Archive

Archive for the ‘algorithm’ Category

Left-Leaning Red-Black Tree

July 7th, 2008 2 comments

CLRS 上对 Red-Black Tree (RBT) 的介绍简直是一团乱麻,代码又乱,解释又罗嗦,我完全没有实现它的兴趣……

不得不佩服 Robert Sedgewick 啊,他在 Algorithms in C 里先介绍 2-3-4 Tree,把 RBT 作为 2-3-4 Tree 的一种表示方式来介绍 RBT,这样就清楚多了。当然,Sedgewick 本来就是 Red-Black Tree 的发明者之一,他能够讲清楚也就不奇怪了。除了解释清楚以外,Sedgewick 使用递归实现,因此代码很简洁,看起来很舒服。

不过即使如此,Red-Black Tree 还是很复杂,所以即使看了 Sedgewick 的代码以后还是没有自己实现的冲动。好在 Sedgewick 已经考虑过这个问题,其结果就是 Left-Leaning Red-Black Tree。(这份幻灯片做得非常好!Apple 的 Keynote 貌似不错啊。)LLRBT 跟 RBT 的不同之处在于 2-3-4 Tree 的 3-node 在 LLRBT 中只有一种表示方式,因此 LLRBT 减少了 RBT 的插入/删除需要处理的情况数量,从而大大减小了复杂度。RBT 的 Java 迭代实现要用 150 行代码,而递归实现需要 46 行代码,LLRBT 递归则只需要 33 行。删除需要的代码行数也大大减小了,实现难度当然也大大降低了。

我用 Common Lisp 试着实现了 LLRBT,确实很简单。不过我还在想怎样才能迭代实现 LLRBT,找到了 FreeBSD 用头文件实现的 LLRBT,被彻底雷到了,这宏用的太牛了!

Update: 其实 LLRBT 是在好几个月前看到的,最近才想到写出来。FreeBSD 中 LLRBT 实现的作者 Jason Evans 写过一篇文章 “Left-leaning red-black trees are hard to implement“,不过他写文章时候使用的算法中 4-node 对应 RBT 中向左倾斜的 3 个节点,可能因此比较难以实现。Sedgewick 在后来的幻灯片中更新了算法,4-node 变为平衡的三个节点。Evans 还比较了 LLRBT 和其他树实现的性能,在文章的注释中也提到了他将算法改为迭代所采用的办法。

Ternary Search Tree

April 18th, 2008 3 comments

今天谈下技术。介绍一个蛮有趣的数据结构 Ternary Search Tree,我毕设里的关键代码就依赖于这个数据结构。

这个数据结构的思路 1960 年左右就有了,不过其实际使用价值一直被忽略了。Jon Bentley 和 Robert Sedgewick 在 1997 年发表的论文 Fast Algorithms for Sorting and Searching Strings 中利用这个数据结构实现了针对字符串的快速排序和查找算法,网页上除了论文以外还有实现和测试代码。后来他们又专门针对 Ternary Search Tree 发表了一篇文章,Dr. Dobb 上有在线版本。我是在 Robert Sedgewick 的《Algorithms in C, 3rd edition》中 Radix Search Tree 这一章看到这个数据结构的,这本书将 multi-way branching tries 和 TST 放在一起进行介绍,把 TST 作为 multi-way branching tries 的另一种表现形式来看待,另外也将 TST 和 digital search tries 进行了比较。这里只是对这个数据结构进行概述,详细内容还是看作者的论文去。

Ternary Search Tree 的主要应用场景如下:字符串作为 key,每个字符串的长度不定,需要一种支持动态集合操作的数据结构以便能够快速的通过 key 查找到其对应的 value。用 hash table 的话可以做到快速的搜索,但是有两个缺点,一是随着保存的字符串的增多,需要重新构建 hash table 以保证快速的查找,二是 hash table 中不维护 key 之间的顺序关系。使用平衡 Binary Search Tree (BST) 两个问题都能解决,但是查找的速度相对 hash table 变慢了。如果使用 digital search trie 的话查找虽然非常快,但是需要的内存空间太大。

Ternary Search Tree 结合了 BST 和 trie 两者的优点,利用它还可以很容易的实现 partial-match searching, near-neighbor searching 这样的字符串搜索操作。而且 TST 本身非常简单,实现起来也很容易。与 BST 相比,TST 中每个节点存放的不是整个 key,而只是 key 中的一个字符。而对 tries,只需要进行一点小的修改就得到了 TST。(用 trie 描述起来比较方便,如果不知道 tries 的话可以先看下 trie 是什么。)

  1. 把 tries 中的每个节点保存的 bit 换成一个字符
  2. 和 tries 一样,数据都保存在叶子节点
  3. tries 中每个节点最多有两个子节点,TST 有三个,分别称为 left, mid, right

插入 key 的时候,从树根作为当前节点开始(树为空的时候树根为 nil),一个字符一个字符的递归执行下面过程。

  1. 如果当前节点为 nil,则创建一个新的节点,将当前字符保存在节点中,将其设置为当前节点。
  2. 如果当前字符和当前节点保存的字符都为 ‘\0′,将数据存于当前节点,终止调用。
  3. 如果当前字符小于当前节点保存的字符,那么递归将当前字符插入节点的 left 子节点,大于则插入 right 子节点;相等则将下一个字符插入当前节点的 mid 子节点。

搜索的过程和插入类似,把 key 一个字符一个字符的和树中节点保存的字符进行比较,同样从树根开始递归执行。

  1. 如果当前节点为 nil,则查找失败。
  2. 如果当前字符比节点中的小,递归对 left 子节点查找,依然比较当前字符。
  3. 如果当前字符比节点中的大,递归对 right 子节点查找,依然比较当前字符。
  4. 如果当前字符与节点中的相等且都为 ‘\0′,则这个节点中就存着 key 对应的 value,终止调用。如果相等但是不是 ‘\0′,则递归对 mid 子节点查找,比较字符为下一个字符。

对 TST 性能的最坏情况分析在 “The Analysis of Hybrid Trie Structures” 这篇论文中,不过这篇论文下不到。对失败的查找,字符比较次数往往远小于其中包含的字符个数。使用 hash table 的话仅计算 hash 值就需要访问 key 中的所有字符(比如这里的 string hashing function),而且得到 hash value 之后还得检查 search key 与实际保存的 key 是否相等。所以 TST 的不成功的搜索比 hash table 更快。

论文的两位作者提供的 C 源代码里给出了迭代的插入和搜索的实现。对插入还作了优化,通过预先分配内存池的办法避免了内存分配成为性能瓶颈。对于数据的保存使用了一点 hack,通过 type cast 直接用叶节点的 mid 指针保存 value。因为 char 默认为 unsigned (至少 gcc 如此),所以保存 ‘\0′ 的节点的 mid 和 left 肯定不会指向节点(right 节点可能指向树的节点),所以可以利用他们来保存 value。示例代码直接将 key 保存在叶节点中,但实际上 key 保不保存都没关系,除非以后要用到。对示例代码还有一点要说明的是递归版本和迭代版本有一点小差别,递归版本在实现遇到相同的 key 的时候会将新 value 的存入,而迭代的版本则不会。

对于 partial-match search, near-neighbor search 我就不罗嗦了,论文作者肯定比我说的清楚多了。我毕设里面使用的 search 也是标准 search 的一个小变种。我的 TST 中的 key 是文件系统的路径(假设文件系统用 UTF-8 编码,所以树的节点存放 char 就 OK 了),给定一个 search key (path),如果 TST 中存有 search path 的祖先目录,我希望能够找到与之距离最近的祖先目录。用 TST 来实现这个操作也是非常容易的,有兴趣的可以自己想一下 :-)

TST 也有可以改进的地方。插入过程中,往往到了末尾的字符的时候树种的每一个节点都只有一个 mid 字节点,也就是所谓的 one-way branching,这导致了使用多余的空间,而且对于成功的搜索速度也不一定是最优的。一种改进办法是在接近字符串的尾部使用 multi-way branching 来减少需要的 node,不过怎样确定在什么地方开始 multi-way branching 比较麻烦,而且还要处理多种节点类型的情况。另一种方法是使用 PATRICIA 的思想,在每个节点上再存一个索引,表示其存放的字符是 key 中的第几个字符,比较的时候直接将其与 key 中的特定字符进行比较而不是一个一个的往下比较。这样一来减少了使用的节点,二来对成功的查找,如果保存着的两个 key 之间有几个连续的相同字符就可以跳过这些字符,直接跳到能够区分出这两个不同字符串的字符位置。但这样要求树中也保存 key,在找到节点之后还不要比较一下 search key 和 TST 中保存的 key 是否相同才能决定是否是成功的查找。

发现描述数据结构算法之类的东西还是挺麻烦的,看别人的文章的时候不觉得,自己写的时候就发现难了,也不知道写的能不能让人明白了。欢迎提出建议 :-)

Tags:

Fibonacci 数列 续

July 22nd, 2007 No comments

先记录一段流水帐,要不然会不记得自己是怎么找到这些东西的,没兴趣看流水帐的跳过。

我想看看用 Common Lisp 怎么写插入法排序(我对语言本身还不熟悉,所以想看别人怎么写的),于是 Google “common lisp insertion sort”,来到了这里,LiteratePrograms,一个记录了很多程序的 Wiki,很不错的地方。看了一会儿就想也许 Fibonacci 数列的算法也会有,于是 Google “common lisp fibonacci”,这次同样也有 LiteratePrograms 上面的文章出现在结果中,不过位置比较靠后,而且上面的算法也很普通。第三个结果来自 CLiki,文章在这里,这里的算法除了那个 generator 以外我在上一篇文章里面都有给出,包括那两个 Θ(lg(n)) 的算法。而最后在 LiteratePrograms 上逛的时候又看到了一个用矩阵乘法的算法。

利用 generator 来求 Fibonacci 数列

利用 generator 的算法比较特别,有点像 continuation。看过 Wikipedia 的解释以后才知道什么是 generator,而 continuation 可以用来实现 generator。代码如下:

(let ((a 0)
      (b 1))
  (defun fib ()
    (prog1 a (psetf a b b (+ a b)))))

这里的实现使用了 closure,let 返回了一个函数 fib,fib 中使用到的 a, b 在 let 创建的环境中,每一次调用 fib 都会返回 Fibonacci 数列中的下一个值,并且更新环境中的 a, b。prog1 对第一个 form 求值,然后对后面的 form 进行求值,最后返回第一个 form 的值。而 psetf 在赋值之前对所有的需要求值的 form 进行求值,然后在赋值,这样就避免了要使用一个变量来记录 a 的值的需要。

这个算法只是用函数的形式表达迭代而已,除此以外没有什么特别的。但是注意使用 generator 我们相当于得到了一个长度无限的 Fibonacci 数列,我们方便地可以控制迭代的过程,在需要的时候才进行迭代。

关于 closure 再多说两句。这个实现里的 fib 函数实际上带有自己的状态,其状态就是用 let 定义时候的 a,b 变量,注意这两个变量在函数之外是无法访问到的。使用 C++ 当然也可以实现相同的效果,但是你得定义一个类,里面封装这两个状态变量,然后重载括号符,使这个类成为一个 functor,实在是麻烦,有兴趣的可以去 Wikipedia 的 generator 条目里面看看 C++ 的实现。而使用 closure 的话,创建带状态的函数一切都是那么自然,不知道 Java 7 里面最终是否会加入 closure。

Dijkstra 的笔记,In honour of Fibonacci

在 CLiki 上发现我所使用的 Θ(lg(n)) 的算法原来 Dijkstra 也有给出过,在他的一篇名为 In honour of Fibonacci 的笔记中记录着。去看了看他的笔记,原来我用的算法和 Dijkstra 记录下来的一样,不过他在计数的时候从 1 开始而不是从 0 开始。(Dijkstra 是不是也是数学家?)Dijkstra 还把这个关系式泛化了,不过有几处推导过程没有看懂,也不知道泛化以后有什么用。

用矩阵乘法求 Fibonacci 数列

另外在 LiteratePrograms 上看到求 Fibonacci 数列的 Ruby 版本中,有一个使用矩阵的算法。方法是对矩阵 ((1 1) (1 0)) 做 n – 1 次乘方,即可得到 Fibonacci 数列第 n 项。代码如下:

require 'matrix'

FIB_MATRIX = Matrix[[1,1],[1,0]]
def fib(n)
	(FIB_MATRIX**(n-1))[0,0]
end

这个算法的优化就可以交给矩阵乘法去做了,矩阵乘法如果是 Θ(lg(n)) 的时间复杂度那么这个算法也是 Θ(lg(n)) 的时间复杂度了。

后来又到 Wikipedia 上搜了一下,在 Fibonacci number 条目下有关于 Fibonacci 数列的非常之多的数学关系和等式的证明,还列举了非常多的参考资料,包括 Dijkstra 的笔记。矩阵形式在上面也有,而且通过矩阵乘法的性质很容易的推导出了复杂度为 Θ(lg(n)) 算法中利用的等式。众人编辑的力量果然强。

为什么不直接用通式求 Fibonacci 数列的第 n 项

Fibonacci 数列的通式我当然知道,但是直接使用它来求数列的第 n 项在精度上会出现问题。通式中有无理数,因此计算过程中会产生误差。根据 CLiki 上的说明,在 CLISP 2.33.2,32 bit 的机器上,对单精度浮点数,当 n < 32 时,使用 truncate 可以得到正确结果;对双精度浮点数,n < 76 时,使用 rounding 可以得到正确结果。超出这个范围都将在整数级别上出现错误。

除了计算上的问题,我觉得这个问题本身也比较有意思。就像 MIT 的教授在上算法导论时说的,”Speed is fun!”,我也这么觉得。而且,当发现自己做着 Dijkstra 曾经也做过的事情的时候还是会小有成就感的。

求斐波那契 (Fibonacci) 数列第 n 项的算法

July 20th, 2007 7 comments

最近看算法导论的习题时联想到 SICP 的一条习题后想到的,折腾了下,得到了几种算法,不同的算法时间复杂度有 Θ(a^n), Θ(n), Θ(lg(n)) 几种。时间复杂度为 Θ(lg(n)) 的算法 比较有意思。下面一个个说,具体算法用 Common Lisp (CL) 实现,至于为什么用 CL 我在文章最后说明。

约定

符号 ^ 表示乘方运算。F(n) 表示 Fibonacci 数列的第 n 项,n 从 0 开始。由此, Fibonacci 数列可以由如下定义给出:

F(0) = 0
F(1) = 1
F(n) = F(n - 1) + F(n - 2)

这个定义本身是递归的。要是可以用在 WordPress 里面使用 LaTeX 输入数学公式的插件就好了,下文中还会出现 _ 来表示下标,这些习惯跟 LaTeX 中数学公式的输入一样,不过看起来就会比较丑了。

最简单的递归定义和迭代实现

递归算法

根据定义可以很方便的得到一个求 F(n) 的算法:

(defun fibonacci-rec-simple (n)
  (cond ((= n 0) 0)
	((= n 1) 1)
	(t (+ (fibonacci-rec-simple (- n 1))
              (fibonacci-rec-simple (- n 2))))))

defun 定义一个函数,参数是 n,cond 类似于 C 中的 switch,t 表示真,函数体的最后 一个 form 作为返回值返回。另外注意数学表达式都是前缀表达式的形式。就算不懂 CL 应该也能看懂吧。

这个递归的版本应该是最容易写的了,直接翻译定义就行。不过可惜的是这个算法因为太多的重复计算导致时间复杂度非常大,计算 F(n) 时需要计算 F(0), F(1) 共 F(n + 1) 次( 可以用数学归纳法很容易的证明),而 F(n) 是最接近 ((1 + sqrt(5))/2)^n/sqrt(5) 的 整数,所以这时计算 F(n) 的时间复杂度为 Θ(a^n)。在我的笔记本上,当 n = 50 时,我就已经不愿意等待到结果输出了。

迭代算法

如果定义变换 T(a, b) 为:

T(a, b) = (a + b, a)

那么,从 (1, 0) 开始,进行 n 次变换后,序对中的第二个元素就是 F(n) 了。由此可以 得到求 F(n) 的迭代算法:

(defun fibonacci-iter-simple (n)
  (loop repeat (+ n 1)
     for b = 0 then a
     and a = 1 then (+ a b)
     finally (return b)))

这个算法的时间复杂度为 Θ(n),没什么好说的。稍微解释下语法吧,loop 是 CL 中构造循 环的一个宏,原来我是用 do 来进行循环的,但发现用 loop 更容易让人看懂,所以这里就写了个 loop 的版本。

这一段不少内容抄袭了 SCIP 1.2.2 中的分析,尽情鄙视我吧。

对递归定义的变形提高算法性能

抱歉,上面的内容没有什么新意,非原创。下面的应该会有点新意了。

最初的递归定义每次递归都只是将问题规模从 n 变到 n – 1 和 n – 2,我的想法是把递归定义进行变换,使得每次递归能够将问题规模一下子减小许多。因此我不断将 F(n) 进行递归展开,

F(n) = 1 * F(n - 1) + 1 * F(n - 2)
     = 2 * F(n - 2) + 1 * F(n - 3)
     = 3 * F(n - 3) + 2 * F(n - 4)
     = 5 * F(n - 4) + 3 * F(n - 5)
     = 8 * F(n - 5) + 5 * F(n - 6)
     = ...
     = F(p + 1) * F(n - p) + F(p) * F(n - (p + 1))

上面的关系式可以很容易使用数学归纳法证明。很自然的想到希望能够在每一次迭代的时候将问题的规模减小到原来的一半。

当 n 为奇数时,令 p = (n – 1) / 2,则:

F(n) = F((n + 1)/2)^2 + F((n - 1)/2)^2

当 n 为偶数时,令 p = n / 2,则

F(n) = F(n/2 + 1)*F(n/2) + F(n/2)*F(n/2 - 1)
     = F(n/2)^2 + 2*F(n/2 - 1)*F(n/2)

上面两个式子合起来可以写出下面的形式:

F(2*n) = F(n)^2 + 2*F(n)*F(n - 1)
F(2*n + 1) = F(n + 1)^2 + F(n)^2

有了上面的递归定义,可以得到下面的算法:

(defun fibonacci-rec (n)
    (cond ((= n 0) 0)
	  ((= n 1) 1)
	  ((evenp n)
           (let ((tmp (fibonacci-rec (/ n 2))))
             (* tmp (+ tmp (* 2 (fibonacci-rec (- (/ n 2) 1)))))))
	  (t (+ (expt (fibonacci-rec (/ (+ n 1) 2)) 2)
		(expt (fibonacci-rec (/ (- n 1) 2)) 2)))))

这个算法在每次递归调用时都会将问题划分为两个规模为原先一半的子问题,表示该算法运行时间的递归式 (recurence) 为:

T(n) = 2*T(n/2) + c

其中 c 为进行乘法运算和加法运算需要的时间,将其看作常量。而 n/2 代表 (n / 2) 得 到的数的上或下取整得到的整数。由主方法 (master method) 和替换法进行分析都得到这 个算法的时间复杂度应该为 Θ(n),从算法导论上的例子看这个递归式的时间复杂度也确实是 Θ(n),但是从实际的运行情况来看其效率比上面的迭代算法快不少,使用的内存也少许多,与下面的迭代版本的 Θ(lg(n)) 的算法差距不大。(性能比较是在 SBCL 中使用 time 进行的,通过求 F(50000) 进行,这个数值在我的机器上除了 fibonacci-rec-simple 以外 不会进行垃圾回收,因此避免了垃圾回收导致的性能下降。)是不是我的递归式弄错了,请知道的人请告诉我。

避免重复的计算

上面的递归算法中其实还是进行了不少的重复计算,而它的复杂度也因此不能降到 Θ (lg(n))。下面分两种情况考虑上面的递归算法。

如果递归过程中问题划分为求 F(2n) 和 F(2n + 1),F(2n) 会通过 F(n), F(n – 1) 求得, 而 F(2n + 1) 通过 F(n), F(n + 1) 求得。这里的关系如下:

F(2n)     --> F(n - 1), F(n)
F(2n + 1) --> F(n),     F(n + 1)

而如果递归过程中问题划分为求 F(2n – 1) 和 F(2n) 时,有如下关系:

F(2n - 1) --> F(n - 1), F(n)
F(2n)     --> F(n - 1), F(n)

上面的关系式中,F(n) 和 F(n – 1) 都重复出现,但实际上上面的算法都对它们进行了重复计算,减少这些重复计算就可以提高算法的性能。

上面的第二中情况需要计算两个子问题,而第一种情况里面要计算三个子问题,但实际上可以利用 F(n – 1), F(n) 来得到 F(n + 1),因此实际上也只需要计算F(n – 1), F(n) 两个 子问题就可以了。由此我们得到下面的关系式:

F(2n - 1), F(2n) --> F(n - 1), F(n)
F(2n), F(2n + 1) --> F(n - 1), F(n)

注意这里的关系式与前面式子的区别,前面的式子中,每个问题划分为两个子问题,而这里我们将求解两个 Fibonacci 数列中的项看成一个问题,每个问题被划分为一个更小的子问题。利用上面的关系式就可以避免算法执行中的重复计算。由此得到下面的程序:

(defun fibonacci-rec-2 (n)
  (cond ((= n 0) 0)
	(t (multiple-value-bind (x) (fib-two n) x))))
 
;; Returns the n'th and n-1'th fibonacci number.
(defun fib-two (n)
  (cond ((= n 1) (values 1 0))
	((evenp n)
	 (multiple-value-bind (i i-1) (fib-two (/ n 2))
	   (values (* i (+ i (* 2 i-1)))
		   (+ (* i-1 i-1) (* i i)))))
	(t
	 (multiple-value-bind (i i-1) (fib-two (/ (- n 1) 2))
	   (values (+ (* i i) (expt (+ i-1 i) 2))
		   (* i (+ i (* 2 i-1))))))))

fib-two 返回的是 F(n) 和 F(n – 1),利用了 CL 中函数返回多个返回值的特性。这个算法的递归式如下:

T(n) = T(n/2) + c

c 代表加法乘法运算的时间,n/2 的含义与上面的相同。这个递归式的运行时间为 Θ(lg(n))。 实际测试的结果,这个算法的性能是最好的,内存开销也最小(为什么内存开销小不明白,可能跟我使用的 Common Lisp 实现有关),比下面要介绍的复杂度为 Θ(lg(n)) 迭代算法更快。

对迭代的变形提高算法性能

上面的递归算法都是从改变递归关系出发的,通过修改迭代过程也可以得到性能更好的算法。这里的思路是从 SICP 的习题 1.19 中看来的。有兴趣的直接去看原书好了。

考虑之前提到的 T 变换(不是递归式里面的 T),实际上求 F(n) 就是对最初的数值做 n 次 T 变换。

F(n) = T(T(T(...T(1, 0)))) = T^n(1, 0)

改变迭代过程的思路其实使用下面的关系式求乘方是一样的:

a^n = (a^2)^{n/2} = (a^4)^{n/4} = ... = (a^n)^1

具体算法实现的时候,当 n 不是偶数的时老老实实多乘一个 a,是偶数的话就平方。对 Fibonacci 数列,我们希望能够找到一个变换序列 {T_i}(下划线表示下标),使得:

T_1(a, b) = T^2(a, b)
T_2(a, b) = T^4(a, b)
T_3(a, b) = T^8(a, b)
...
T_{k-1}(a, b) = T^{n/2}(a, b)
T_k(a, b) = T^n(a, b)

对任意的 i,下式成立:
T_{i+1}(a, b) = T_i^2(a, b)

SICP 上面用的办法是定义 T_{pq}(a, b) = (bp + aq + ap, bp + aq),然后寻找变换 T_{p’q’},使得:

T_{p'q'}(a, b) = T_{pq}^2(a, b)

通过计算可以得到

p' = p^2 + q^2
q' = q^2 + 2pq

这个式子与之前改变递归定义得到的关系式形式完全相同,不过我没有看出来它们是如何联系起来的。最后给出具体的算法实现,我把书上 Scheme 的实现转换成 CL 实现。

(defun fibonacci-iter (n)
   (fib-iter 1 0 0 1 n))
 
(defun fib-iter (a b p q count)
  (cond ((= count 0) b)
	((evenp count)
	 (fib-iter a b
		   (+ (* p p) (* q q)) ; p
		   (* q (+ q p p))     ; q
		   (/ count 2)))
	(t (fib-iter (+ (* b q) (* a q) (* a p)) ; a
		     (+ (* b p) (* a q))         ; b
		     p q (- count 1)))))

函数 fib-iter 虽然递归调用了自己,但其实是尾递归,实际的思想是迭代。我另外写了一个没有递归调用的版本,但是用了多个 setf 赋值,不够优雅,所以还是给原来的版本。按理说这个算法也是 Θ(lg(n)) 的时间复杂度,但是实际运行中,在求 F(50000) 时仅比 fibonacci-rec 快了没多少,比 fibonacci-rec-2 慢了不少。比后者慢可能是因为这个算法在输入为奇数时只能前进一小步,而 fibonacci-rec-2 不管输入是奇数还是偶数都可以将问题规模缩小一半。

关于递归中的重复计算

为了解决递归中的重复计算问题其实有另外的解决方案,将已经计算过的值存入某个缓存中。函数调用开始计算之前先到缓存中查,如果已经计算过则可以直接利用之前的结果,否则才进行计算。如果实现了这样的缓存,那么即使使用最初的 fibonacci-rec-simple,函数的时间复杂度也会降低到 Θ(lg(n)),而 fibonacci-rec 的时间复杂度也会降到 Θ (lg(n))。这个想法也是看 SICP 时看到的,在 1.2.2 的脚注 34 中提出,练习 3.27 给 出了一个实现。

为什么用 Common Lisp

用 CL 不是为了装 B,我会的语言中用 C/C++, Java 来研究算法显然太麻烦,不支持大整数,性能测试不方便。而 Ruby 的实现会导致一个程序使用不同的语法而导致巨大的性能差异。比如 inject 加 block 的性能比 while 的性能差很多,但是前者在实现累加之类的操作时显然会更优雅。而我对 Scheme 的了解仅限于 SICP 上用到的那些。这样下来就只剩下 Common Lisp 了。

而事实上,用 CL 研究算法真的是非常方便。在 SLIME 和 Emacs 的支持下,我可以使用一个 功能强大的 IDE,编译、调试、代码编辑都非常方便,更重要的是与系统交互式的开发过程。我可以在写完一个函数之后非常方便的测试这个函数,简化测试过程大大的减少了我实现一个算法的时间。另外语言本身的特性也使得算法的实现变得简单,比如让函数返回多个值,CL 本身就有特性支持这个功能。大整数使得我可以使用大的输入来测试性能,time 方便 的让我得到函数运行时间、内存的统计信息。使用其他语言我很难享受到这些好处。

CL 也有不爽的地方:数学式子得转换成前缀表达式,不过这个也可以通过 macro 来解决,Google 到了几个现成的,暂时还没有研究怎么用。