目录
递归程序设计方法(简称递归)是指函数直接或者间接调用自己的现象。递归是一种有趣的程序设计方法,往往区区几行代码就能达到神奇的效果。递归同时是一种非常数学化的方法,其背后有着深刻的数学原理。递归是程序设计方法王冠上的明珠,学会使用和优化递归是一个有灵魂的软件工程师与一般软件工程师之间最重要的区别之一。
1. 递归的本质
递归的本质就是数学归纳法。什么是数学归纳法?比如,如果我们要证明前n个奇数的和等于,例如:1+3+5=
,1+3+5+7=
,也就是说1+3+……+(2n-1)=
。那么怎么证明呢?
第一步,简称为确定归纳边界。当n=1时看看定理是否成立。当然,定理显然是成立的,因为1=。
第二步,称为归纳假设。假设n=k时定理成立。即前k个奇数的和等于。
第三步,称为递归推导,看看n=k+1时定理是否成立。因为1+3+……+(2k-1)+(2(k+1)-1)= ,可见,n=k+1时定理也是成立的。
综合上述,我们认为前n个奇数的和的确等于,这就是数学归纳法。递归实际上也是按照这三步来的,分别简称为确定递归边界、递归假设和递归推导。不同之处仅仅在于,数学归纳法试图证明一个定理成立,而递归的目的是根据输入生成输出。
下面我们通过例子来说明如何正确地使用递归。
2. 河内塔问题
递归程序的一个著名例子就是Hanoi塔(中文翻译为河内塔或汉诺塔)问题。
有三根插在地上的杆子A、B、C。A杆上套有n个中间有孔的碟子,碟子的直径从上到下分别是1、2、……、n(参见上图)。现在我们试图把所有碟子从A杆移到C杆,条件是:
- 任意一个杆子上只有最上面的碟子可以移动;
- 任何情况下大碟子都不能放在小碟子的上面;
- B杆可以作为中转用。
比如,当n=4时,移动步骤是这样的:
- Move 1 from A ==> B
- Move 2 from A ==> C
- Move 1 from B ==> C
- Move 3 from A ==> B
- Move 1 from C ==> A
- Move 2 from C ==> B
- Move 1 from A ==> B
- Move 4 from A ==> C
- Move 1 from B ==> C
- Move 2 from B ==> A
- Move 1 from C ==> A
- Move 3 from B ==> C
- Move 1 from A ==> B
- Move 2 from A ==> C
- Move 1 from B ==> C
解决问题的第一件事情就是确定问题的输入和输出。河内塔问题的输出很明确。输入是什么呢?可能有人认为输入是碟子的个数,比如n。这个想法是错误的,因为我们并不清楚这n个碟子在哪个杆子上。所以输入参数除了n之外,应该还有a、b、c三个参数,分别表示来源、中转和目的地。注意,a、b、c的初值分别是字符串"A"、"B"和"C"。
确定了输入和输出之后,下面按照递归的三个步骤解决Hanoi塔问题。
第一步,确定递归边界。所谓递归边界就是输入参数要满足的一个条件,在这个条件下,原问题可以很容易得到解决。河内塔问题在什么情况下最容易解决?显然,当输入参数n=1时,问题最好解决。因为此时a上仅有一个碟子,直接把这个碟子从a移到c即可,这就是递归边界。注意,该递归边界只与n有关,与其他参数无关。
第二步,递归假设。这里可以假设n-1个碟子可以从a、b、c中的任意一个杆子移到另外任意一个杆子上。可能你会问:“怎么才能把n-1个碟子从一个杆子移到另一个杆子上?”这个问题不用回答,因为它是假设成立的。编程序也可以假设?是的,可以假设,这正是递归程序设计神奇而有魅力的地方。
关于递归假设,你要注意两点:第一,递归假设时所用到的参数应该比原问题的参数更靠近边界。比如上面分析中,n-1就比n更靠近边界;第二,参数的个数、每个参数的类型和作用都应该与原参数一一对应。违反了这两点中的任何一点都会导致递归的失败。这是因为,递归的本质是把一个问题转化为一个规模更小一点的问题解决。所谓规模小,表现在函数参数上,就是指比原参数更靠近边界。如果相反操作,就会导致问题离边界越来越远,最终导致递归陷入死循环,问题求解失败。
n-1个碟子先从A杆移到B杆
第三步,递归推导。就是利用河内塔问题在n-1个碟子上的解来推导问题在n个碟子上的解。既然n-1个碟子可以从任意一个杆子移到另外任意一个杆子上,那么我们可以这样移:以c作为中转,先把a最上面的n-1个碟子移到b杆上(参见上图),然后再把a杆剩下的直径为n的那个碟子直接移到c杆上(参见下图)。
最后把b上的n-1个碟子移到c上(参见下图)。这样问题就得到了解决。
递归程序设计的原则就是:首先用一个if语句判断当前满不满足递归边界条件。如果满足就按递归边界处理,否则利用递归假设和递归推导进行编程即可。河内塔问题的递归程序如下所示:
解决河内塔问题的Python递归程序
从本质上讲,递归程序设计方法仍然是一种自顶向下的程序设计方法。因为主程序调用的子程序就是主程序自己。唯一区别在于一般自顶向下调用时不用考虑是否更靠近边界,而递归调用必须要考虑这个问题。
2. 兔子问题和斐波那契数列
一对刚出生的小白兔两个月后就成熟并生下一对小兔子,之后每个月都能生一对小兔子。刚生下的小兔子过两个月以后也成熟了,也能每个月生一对小兔子,……,以此类推。假设兔子永远不会翘辫子,现在给你一对刚出生的小白兔,问n个月后有几对兔子?
显然,输入参数是n。递归边界是n=0或1,此时小兔子还没有出生,所以兔子数目都是1对。递归假设是任意n-1,n-2,......个月的兔子数目都是已知的。
当n>2时,每个月的兔子由上个月的兔子和本月新出生的兔子组成。而本月新出生的兔子数与两个月前的兔子总数相同,因为那时即使是刚出生的兔子现在也成熟了,也能在本月生出一对新的小兔子。所以,本题的解就是著名的斐波那契(Fibnacci)数列:F(n) = F(n-1) + F(n-2), F(1) = F(0) = 1。
解决兔子问题的递归程序
def get_rabbits(months):
if months <= 1: # 递归边界
return 1
# 每个月的兔子数等于上个月兔子数+这个月新增兔子数
# 而这个月新增兔子数=两个月前的兔子数,因为那时即使刚出生的兔子现在也成熟了
return get_rabbits(months - 1) + get_rabbits(months - 2)
if __name__ == '__main__':
for months in range(11):
print(months, ':%6d' % get_rabbits(months), 'pairs')
运行结果如下:
0 : 1 pairs
1 : 1 pairs
2 : 2 pairs
3 : 3 pairs
4 : 5 pairs
5 : 8 pairs
6 : 13 pairs
7 : 21 pairs
8 : 34 pairs
9 : 55 pairs
10 : 89 pairs
3. 通配符匹配问题
如果说上节的兔子问题还可以用非递归的方法实现的话,那么下面这个例子就很难用非递归方法来实现了。
假设“*”可以匹配0个或0个以上的字符,“?”可以匹配且仅匹配一个字符。请写一个递归函数match(pattern, str)判断字符串str是否与模式pattern匹配。
比如,在操作系统里寻找一个文件时,不必写全文件的名字。可以使用*和?这两个通配符。比如AB*C?.doc表示任何以AB打头,倒数第二个字符是C的doc文档。
我们仍然按照递归三部曲来考虑这个问题的解法。
第一,确定边界条件。
在什么情况下我们可以直接判断str是否与pattern匹配?显然,如果str是个空字符串(即长度为0的字符串)的话,那pattern也必须是个空字符串两者才能匹配。反过来,如果pattern是个空字符串的话,那它也只能和空字符串匹配。所以这个边界条件是str或pattern是空字符串。
第二&