1. 前言
递归【recursion】和迭代【iteration】是计算过程生成的两种“形状”
深入的理解对于我们理解程序的计算至关重要
(能够通过观察想象计算所产生的结果)
注:这篇文章是参考《SICP》后而写的,里面可能会出现一些lisp代码,不过后面附加了等价的c语言实现
2. 线性递归
首先,我们考虑计算一个数
n
n
n的阶乘:
n
!
=
n
∗
(
n
−
1
)
∗
(
n
−
2
)
∗
.
.
.
∗
2
∗
1
n! = n*(n-1)*(n-2)*...*2*1
n!=n∗(n−1)∗(n−2)∗...∗2∗1
这是最普通也是最直观的计算方法了,但是我们从简单的式子中也可以发现一些不一样的想法:从
n
n
n往后的所有项
(
n
−
1
)
∗
(
n
−
2
)
∗
.
.
.
∗
2
∗
1
(n-1)*(n-2)*...*2*1
(n−1)∗(n−2)∗...∗2∗1可以写成
(
n
−
1
)
!
(n-1)!
(n−1)!
那么,我们就可以将原式改写为
n
!
=
n
∗
[
(
n
−
1
)
∗
(
n
−
2
)
∗
.
.
.
∗
2
∗
1
]
=
n
∗
(
n
−
1
)
!
n! = n*[(n-1)*(n-2)*...*2*1] = n*(n-1)!
n!=n∗[(n−1)∗(n−2)∗...∗2∗1]=n∗(n−1)!
可以看见,原来的问题(
n
!
n!
n!)被我们转化成了一个更小规模的问题(
(
n
−
1
)
!
(n-1)!
(n−1)!),这便是递归的思想,这样不断地进行下去,最终一定会收敛到一个最简单的情况【base case】,即
1
!
=
1
1! = 1
1!=1
代码的实现
lisp
(define (factorial n)
(if (= n 1)
1
(* n (factorial (- n 1)))))
c
int factorial (int n){
if(n == 1) return 1;
return n * factorial(n-1);
}
观察上面的代码,两种语言实现的递归算法都非常简单直观,十分符合我们之前的分析思路,接下来看一下一个实现阶乘的递归算法具体的运行轨迹
(以计算
6
!
6!
6!为例)
运行轨迹跟踪
3. 迭代
现在,我们换一个视角来思考阶乘
n
n
n的计算
n
!
=
1
∗
2
∗
.
.
.
∗
(
n
−
2
)
∗
(
n
−
1
)
∗
n
n! = 1*2*...*(n-2)*(n-1)*n
n!=1∗2∗...∗(n−2)∗(n−1)∗n
首先设置一个累加器product,初始值为1;
再设置一个计数器counter,初始值也为1,然后将按照如下的规则进行反复执行:
(1)product := product * counter
(2)counter := counter + 1
直到counter的值超过了给定的n,我们停止执行,这时候的product的值就自然为
n
!
n!
n!了
代码的实现
lisp
(define (factorial n)
(fact-iter 1 1 n))
(define (fact-iter product counter max-count)
(if (> counter max-count)
product
(fact-iter (* counter product)
(+ counter 1)
max-count)))
可能有人会说,这明明调用了fact-iter函数自身啊,怎么会是迭代呢?这其实是lisp语言的特性,这里的尾递归【tail-recursion】实现在c语言中可以等价地写成迭代的形式
c
int factorial(int max-count){
int product,counter;
product = 1;
counter = 1;
while(counter <= max-count){
product = product * counter;
counter = counter + 1;
}
return product;
}
运行轨迹跟踪
3. 两者的区别
3.1
首先,我们考虑线性递归的运行轨迹
不难看出,递归的过程是一个不断扩张【expansion】和收缩【contraction】的过程。在扩张过程中,不断地延迟乘法操作;在收缩过程中,则不断执行乘法操作(这也正是“递”和“归”的体现)
由于递归过程中会有一些“隐藏”的信息被解释器(或编译器)暂存,因此随着递归的深度越深,被保存的信息也就越多。而递归时栈是由系统管理的,如果递归深度太深,就会导致爆栈问题
3.2
接下来考虑迭代的运行轨迹
迭代的过程和递归有很大不同,它并没有扩张和收缩的现象
在迭代中,我们要做的就是跟踪所有的中间变量的值,不断更新它们,直到满足停止条件后,我们将得到结果,迭代过程也会结束
从另一种角度来说,这些中间变量完全地提供了计算过程的状态描述(如阶乘计算中,product和counter的更新体现了当前的累加器和计数器的变化),而整个运行过程是在一个固定的空间中完成的
4. 树形递归
不同于线性递归,另一种递归的形式叫做树形递归【tree recursion】
一个最常见的树形递归的例子就是斐波那契数列
F
i
b
o
n
a
c
c
i
(
n
)
=
{
0
,
n
=
0
1
,
n
=
1
F
i
b
o
n
a
c
c
i
(
n
−
1
)
+
F
i
b
o
n
a
c
c
i
(
n
−
2
)
,
n
>
1
Fibonacci(n) = \begin{cases}0, &n=0 \cr 1, &n = 1 \cr Fibonacci(n-1) + Fibonacci(n-2),&n>1\end{cases}
Fibonacci(n)=⎩⎪⎨⎪⎧0,1,Fibonacci(n−1)+Fibonacci(n−2),n=0n=1n>1
代码的实现
lisp
(define (fib n)
(cond ((= n 0) 0)
((= n 1) 1)
(else (+ (fib (- n 1))
(fib (- n 2))))))
c
int fib(int n){
if(n == 0) return 0;
else if(n == 1) return 1;
else
return fib(n-1) + fib(n-2);
}
运行轨迹跟踪
从图中我们可以清楚地看见,计算
f
i
b
5
fib\ 5
fib 5的过程中
要重复计算:2次
f
i
b
3
fib\ 3
fib 3、3次
f
i
b
2
fib\ 2
fib 2
这样的重复计算必然会带来高额的开销,特别是n非常大的时候
虽然树形递归在时间效率上不那么尽如人意,但却很容易阐述其思想并理解
一种优化树形递归的方法就是利用一张表来暂存计算的中间值,这样每次要用的时候就可以查表获取,而不用每次都再重复计算