对于阶乘问题,如果我们完全用递归思想来描述,那一个数的阶乘就等于这个数乘以这个数减一的阶乘(特别地,规定数0的阶乘为1)。容易把这样的描述写为scheme代码,长下面这样:
(define fact (lambda (n)
(if (= n 1)
1
(* n (fact (- n 1))))))
使用代换模型,这个程序的执行过程如下:
这种代换出来是“大肚子”形状的便是递归过程。可以从代换出的形状看出,当运行到中间部分时,所需空间达到峰值,这个峰值空间大小和所要计算的数n之间为线性关系。而整体执行步骤步数是n的两倍,和n之间也呈线性关系。因此,这段代码的空间复杂度和时间复杂度都是 O ( n ) O(n) O(n)
那事实上,对于一些递归问题,如果我们能步进式地描述其得到答案的过程,我们就较为容易写出迭代版本的程序。
就比如对于这个阶乘过程,其实我们人真正第一反应的描述应当不是一个数的阶乘,等于这个数乘以这个数减一的阶乘吧,至少我不会上来这么描述,因为这样根本不好算呀。事实上,我们更自然地会将之描述为,从一开始乘,一乘以下一个数字二的结果再乘以下下个数字三,一直乘到我们要求的那个数就能算出答案了,那显然,每次获得“下个数”的规则就是“+1”。有了上面的描述,能写出下面的迭代版本的代码:
(define ifact-helper (lambda (product count n)
(if (> count n) product
(ifact-helper (* product count)
(+ count 1) n))))
(define ifact (lambda (n) (ifact-helper 1 1 n)))
如果对上述过程使用代换模型,可以得到如下结果:
(所以虽然代码形式上是自己调用了自己,但本质上却是个迭代过程)
那上面的过程的执行步数是n+1,与n呈线性关系因而时间复杂度为 O ( 1 ) O(1) O(1),因为每次都只需要两个位置来存每一轮的两个参数,所需空间为常数,因此空间复杂度为 O ( n ) O(n) O(n)
其实,上一例子中的递归程序的时间复杂度能在 O ( n ) O(n) O(n)只是因为,每次递归时函数只需调用一次自己。接下来我们来看另一个经典例子,斐波那契数列。
斐波那契数列除最前两项,剩下的每一项的值等于其前两项的和,上代码:
(define fib
(lambda (n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1))
(fib (- n 2)))))))
因为每次递归发生时,斐波那契数列函数要调用两次自己,因而可以自然地用一个二叉树来描述算法执行过程:
假如我们用 t n t_n tn描述获取第n项斐波那契数所需的执行步数,我们可以列出下面的式子:
t n = t n − 1 + t n − 2 = 2 t n − 2 = 4 t n − 4 = 8 t n − 6 = 2 n / 2 t_n = t_{n-1} + t_{n - 2} = 2t_{n-2} = 4t_{n-4} = 8t_{n-6} = 2^{n/2} tn=tn−1+tn−2=2tn−2=4tn−4=8tn−6=2n/2因而所需步数同n呈指数关系,算法时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
看过了斐波那契数列的例子,我们来看一个递归版本代码和斐波那契数列很相近的例子,打印杨辉三角。
上为杨辉三角形,图中规律呈现为每一行除最左和最右两个数字为1外,其余项的大小均等于它们各自肩上两数的和。如果用再严谨些的数学语言描述,即,每一行中列数为1和列数等于该行行数的两个数是一,剩余的每个数都等于其对应各自的行数减一、列数减一处的数字与行数减一、列数不变处的数字的和。
递归版本代码:
(define 杨辉三角
(lambda (列 行)
(cond ((= 列 0) 1)
((= 列 行) 1)
(else (+ (杨辉三角 (- 列 1) (- 行 1)
(杨辉三角 列 (- 行 1))))))))
同样还是每次递归时要调用两次自己,时间复杂度 O ( n 2 ) O(n^2) O(n2)
那怎么写出迭代版本的代码?
我们除了直接在这张图上直接看到过杨辉三角,还有什么我们所学过的知识同杨辉三角有直接联系?二项式系数!二项式系数同杨辉三角中的数字是一一对应的,而n次二项式的系数的计算公式是:
n ! k ! ( n − k ) ! \frac{n!}{k!(n-k)!} k!(n−k)!n!
其中k表示是展开式中的第几项。
因而对于输出杨辉三角中的项的过程,我们可以直接这么定义:
(define (行 列)
(lambda (行 列)
(/ (阶乘 行)
(* (阶乘 列) (阶乘 (- 行 列)))))
那因为我们之前已经定义过空间复杂度为 O ( 1 ) O(1) O(1),时间复杂度为O(n)的阶乘计算的迭代过程,这里每次得出结果不过是要算三次阶乘罢了,因而新的这个过程的空间复杂度仍为 O ( 1 ) O(1) O(1),时间复杂度也仍为 O ( n ) O(n) O(n)
这个例子中我们要计算形如 a b a^b ab的值。
若完全用递归思想描述幂运算,即: a b = a ⋅ a b − 1 a^b= a\cdot a^{b - 1} ab=a⋅ab−1,并规定 a 0 = 1 a^0 = 1 a0=1
可以写成递归版本代码,如下:
(define my-expt
(lambda (a b)
(if (= b 0)
1
(* a (my-expt a (- b 1))))))
但同样,我觉得这和我们人对幂运算的计算过程的第一反应并不一致,我们人考虑幂运算,应该会将之理解为一个累乘过程,以 a b a^b ab为例,即b个a相乘,具体算的话,就是算出a*a的结果,将结果再乘a……一直乘b次。那如我上面所说,如果对于一个过程,我们能“步进式”地描述它的运算执行流程,就相对容易写出迭代版本的代码:
(define exp-i (lambda (a b) (exp-i-help 1 b a)))
(define exp-i-help
(lambda (prod count a)
(if (= count 0)
prod
(exp-i-help (* prod a) (- count 1) a))))
其实和上面第二个例子阶乘的递归改迭代很类似,只不过在幂运算这里count计数器就不用参与运算了,只负责计数即可。
同样的,幂运算的递归版本空间复杂度是O(n),时间复杂度是O(n),改为迭代版本,空间复杂度是O(1),时间复杂度O(n),和第二个例子阶乘一致。
那接下来我们再来看一种将幂运算进一步优化的方法:
我们知道当指数是偶数时, a b = a b 2 ⋅ a b 2 a^b = a^{\frac b 2} \cdot a^{\frac b 2} ab=a2b⋅a2b,当指数是奇数时, a b = a ⋅ a b − 1 2 ⋅ a b − 1 2 a^b = a \cdot a^{\frac {b - 1} 2} \cdot a^{\frac {b - 1} 2} ab=a⋅a2b−1⋅a2b−1。
若把上面的思想变为代码,则为如下:
如此优化以后,时间复杂度其实降为了 O ( l o g ( n ) ) O(log(n)) O(log(n)),是一个极快的程序。
但是,笔者结合自身所学,可能要斗胆说一句,在一般的工程项目中,可能反而不要贸然提前就直接进行这种程度的代码优化,上面的算法虽然快,但相比我们对于幂运算这个概念所能比较直接想到的计算形式已有较大距离,并且阅读这寥寥几行的代码时,脑中就要进行比较复杂的代换与跳转,可读性并不强,容易出错,一旦出错也不易调试。可能你这里留下的原本令你自己自豪的trick就会变成他日的trap。
[唯一编程神作SICP slide版] 计算机程序的构造和解释(2004版)