Header

别急!

别急!
我们先别急着切入正文,而是先看看一道算法题:

圆角骑士魔理沙:数组多次取第X大元素

大家在评论区可以看到,这道题有一定的难度:不少人给出了错误,或者超时的答案。

尽管最高赞给出的答案的确是正确的,其实有一个简单很多的做法:

对N生成二叉搜索树,然后用M索引之。

等下,这不是O(n log n)的吗?

所以才说别急!我们想一想,当M < N的时候,这个二叉树,并不是所有节点,都会被索引。

那,我们为什么要去把不会被用的二叉树部分构建出来捏?

但是,我们怎么知道‘树的那一部分会被用上’?

答案是,我们可以同时交叉构建树跟索引树的过程 - 默认情况下,树是没构建的。当我们索引没被构建过的树,我们会构建这里的最小一部分,然后保存下结果,再进行构建。

class LazyBST:
    def __init__(self, arr):
        self.constructed = False
        self.arr = arr
        self.size = len(arr)
    def idx(self, index):
        if not self.constructed:
            self.constructed = True
            self.head = self.arr[0]
            self.left = LazyBST([x for x in self.arr[1:] if x <= self.head])
            self.right = LazyBST([x for x in self.arr[1:] if x > self.head])
        if index < self.left.size:
            return self.left.idx(index)
        elif index == self.left.size:
            return self.head
        else:
            return self.right.idx(index - self.left.size - 1)

有了这个数据结构以后,我们只需要用N构造一下,然后遍历M索引N,就可以了!

B = LazyBST(N)
print([B.idx(m) for m in M])

而这时候,我们整个算法是符合O(n log m)的性能需求的!

以下是证明:

这棵树有log n层,而我们每次对这棵树索引,至多对每层新增一个被构建节点。换句话说,这棵树的每层最多有m个节点。

这时候,每层L的节点平均大小是n / 2^L,于是工作是min(n, m * n / 2^L)。之所以有min,是因为每层只是对N这个列表进行简单变换,总体时间是线性的。

于是,所有工作=

$\sum_{L=0}^{log(n)}min(n, m * \frac{n}{2^L})$

而当L < log(m)时, n < $m * \frac{n}{2^L}$,否则>=,于是工作=

$\sum_{L=0}^{log(m)}n + \sum_{L=log(m)}^{log(n)} m * \frac{n}{2^L}$ =

$n * log(m) + \sum_{L=0}^{log(n)-log(m)} m * \frac{n}{2^{L + log(m)}}$ =

$n * log(m) + \sum_{L=0}^{log(n)-log(m)} m * \frac{n}{2^{L} * m}$ =

$n * log(m) + n$ =

$n * log(m)$

这个‘我再用一个X的时候,才生成最低限度的可以完成工作的X’的思想,叫做延迟求值, lazy evalution。

是不是很有趣捏,一个看上去有难度的问题,在lazy evaluation下,平A下去就好了。

这正是Lazy Evaluation的核心优势:组合性。

更具体的说,在Lazy Evaluation下,你可以大手大脚的构建任意数据结构,而不需要担心支出。只有指定要用的部分,计算机才会花时间对之进行求值。只有在这时候,我们可以无顾虑地,从程序组合出更多的程序。

举一些实际例子:

可以用map后reduce实现Any: (a -> Bool) -> List a -> Bool,而不需要担心对整个列表进行求值

sort后对数组索引,在Lazy Evaluation下是quick select

sort后选取数组头M个元素,在Lazy Evaluation下复杂度为O(N log M)

一些高端例子:

给定一个游戏(比如国际象棋),我们可以直接生成整个游戏的game tree,然后再用各种算法(比如minmax)遍历这个game tree(why functional programming matter)

如果我们有一个A -> B的函数,我们可以生成一个键为A,值为B的,包含所有A的trie。使用这个trie,相当于调用原函数,但是会记录下中间值(Elegant memoization with functional memo tries)

tying the knot。一个数据可以在构建的过程引用自身,比如

fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

或者用这来建循环数据结构,如

repeat a = a : repeat a

或者

nat = 0 : map (+1) nat

除了上面这种‘炫技’用途,你可以用tying the knot达到‘本来应该遍历两次的数据结构,只遍历一次’。

假如我们有

data Tree = Tip Int | Fork Tree Tree

一般来说,我们可以用一次遍历,找出树里面的最小值,再遍历一次,用最小值替代树里面的所有元素。

但是,如果我们用tying the knot,我们可以把这两个操作,同时进行!

repmin (Tip n) m = (Tip m, n)
repmin (Fork l r) m = (Fork tl t2, min ml m2)
  where
    (tl, ml) = repmin l m
    (t2, m2) = repmin r m

更进一步,你可以用tying the knot玩出‘操控时间’的工作,具体见Reverse State Monad跟Tardis Monad,由于篇幅关系(我 累 了)就不详细展开了。

Lazy Evaluation额外阅读:

Using circular programs to eliminate multiple traversals of data

Algebraic Graphs with Class

Using Circular Programs for Higher-Order Syntax

Selection Functions, Bar Recursion, and Backward Induction