递归

递归在程序设计中属于较难理解的部分,程序员新手往往感觉递归无从入手,这主要在于递归的过程并不直观,有别于我们通常的思考方式。在这篇文章中我将从最基本的概念开始,逐步阐述递归的思想,递归程序的设计方法,理解递归程序的执行过程,以及怎么运用递归解决问题。

基本概念

对于递归有一个最简单的定义:递归即函数直接或者间接调用自身。但递归并不是简单的对自身的调用,递归算法的核心思想是将问题分解为规模更小的同类的子问题,这些同类的子问题能够采用和初始问题同样的方式分解,直到分解出的子问题足够小以至于能够很容易的得出答案,至此,递归则开始逐层回溯将子问题的答案汇总从而最终得到初始问题的解。因此,我们可以将递归分解为两个步骤:“递”和“归”,“递”就是向下不断分解的过程,而“归”则是不断回溯汇总的过程,通过两个过程的结合,从而得到问题的解。
由此,我们可以得到递归算法的几个要点:
1)问题能够被分解为一个或者多个规模更小的子问题;、
2)分解得到的子问题和初始问题是同类的问题;
3)当问题分解到足够小的时候能够很容易计算出问题的解。
下面我们用数据表达式表示,如果用f(n)表示初始问题的解,并且f(n)可以被分解为同类的多个子问题,则f(n)可以被表述为:
f(n) = a1 * f(n-1) + a2 * f(n-2) + ... + ak * f(n-k) + b
这里f(n-i)即为f(n)的子问题,f(n-i)和f(n)是同类的问题,因此对于每个f(n-i),又可以再次运用该表达式来继续分解,当问题分解到足够小的时候,我们希望能够直接计算问题的解(即递归的出口),因此我们需要:
f(1) = s1
f(2) = s2
......
f(k) = sk
这里si为常量或者简单表达式。我们将两个阶段结合在一起,得到完整的递归表达式:
f(1) = s1
f(2) = s2
......
f(k) = sk
f(n) = a1 * f(n-1) + a2 * f(n-2) + ... + ak * f(n-k) + b n > k
看起来是不是有点过于抽象,接下来我们就用一个一个的实例来说明怎么分解问题得到递归表达式,以及怎么确定递归出口。首先,我们从一个经典的递归问题出发。

汉诺塔


汉诺塔问题来自于一个传说:上帝创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按大小顺序摞着64片黄金圆盘。上帝命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上。并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。有预言说,这件事完成时宇宙会在一瞬间闪电式毁灭。有人相信婆罗门至今还在一刻不停地搬动着圆盘。
如果我们假定移动一个圆盘需要1秒钟的话,那么宇宙会在什么时候毁灭呢?
为了在世界末日来临之前做好准备,我们现在就开始着手解决这个问题吧。首先我们将问题做一些抽象,假定现在存在n个圆盘,需要将这n个圆盘全部从一根柱子移动到另一根柱子,我们将需要移动圆盘的次数描述为f(n)。
直接来解f(n)可能会让你感觉无从下手,因此,我们可以先从简单的场景入手。首先考虑n为0和1的场景,可以很轻松地得到:
f(0) = 0 //0个圆盘不需要移动
f(1) = 1 //1个圆盘仅需要移动一次
目前为止非常顺利,但我们并不能从0和1的场景中得到有用的信息,我们需要更为复杂一些的场景。继续看n为2时场景(下面简称场景2),可以通过下面的操作步骤来移动圆盘:

我们从第0步出发,首先将小的圆盘移动到中间的柱子上,然后将大的圆盘移动到右边的柱子上,最后将小的圆盘移动到大的圆盘上面,总共需要3步。因此得到:
f(2) = 3
在场景2下我们任然无法得出结论,我们需要更多的信息,因此,继续。当n为3时(简称场景3),可以使用下面的操作步骤来移动圆盘:

通过7个步骤,移动完成,因此:
f(3) = 7
目前为止,我们已经有了两个较为复杂的场景了,是时候停下来分析一下了。通过观察场景3的整个步骤,我们可以发现场景3的操作步骤如果做一些简化,就可以得到:

通过简化后的场景,我们很容易就可以归纳出:
f(3) = 2 * f(2) + 1
再将简化后的场景3的步骤和场景2的步骤对比,场景3简化后的步骤和场景2的步骤非常相似,如果我们将这些相似之处提炼出来(这才是我们真正想要的),就可以得到:
1)将除最大的圆盘外的圆盘移动到中间的柱子上;
2)将最大的圆盘移动到右边的柱子上;
3)将中间柱子上的圆盘移动到最大的圆盘上。
这样就形成了一个抽象的流程,如果我们将整个流程运用到有n的圆盘的场景,就可以描述为:首先将n-1个圆盘移开,然后将最大的圆盘移到右边的柱子上,再将n-1个圆盘移到最大的圆盘上面,这样,就能表述为:
f(n) = 2 * f(n-1) + 1
加上我们的结束条件,最终,我们就可以得到:
f(0) = 0
f(n) = 2 * f(n-1) + 1
这正是我们想要的递归表达式。通过解该递归式,我们就能得到我们想要的解,下面描述求解的过程。
依然,从小的场景出发,利用该递归式,我们从n为0开始依次得到:0,1,3,7,15,31,63,......。从这个序列我们可以大胆的猜测:f(n) = 2^n - 1。接下来,我们只需要证明我们的猜想是正确的。
证明:使用数学归纳法。
1)当n = 0时,等式成立。
2)假定当n = k - 1时,f(k-1) = 2^(k-1) - 1成立,则f(k) = 2 * f(k-1) + 1 = 2 * (2^(k-1) - 1) + 1 = 2^k - 2 + 1 = 2^k - 1,得证。
至此,我们就可以得到宇宙会在经历了2^64-1秒之后毁灭了,你准备好了吗?:)。

Fibonacci数列

Fibonacci数列是一个经典的组合数列,它的代表问题是意大利著名数学家Fibonacci于1202年提出的兔子繁殖问题:一对兔子从出生后第三个月开始,每月生一对小兔子。小兔子到第三个月又开始生下一代小兔子。假若兔子只生不死,一月份抱来一对刚出生的小兔子,问一年中每个月各有多少只兔子。
我们知道第0月有0对兔子,第一个月抱来一对兔子,第二个月兔子不会繁殖,任然只有一对兔子,第三个月第一对兔子开始生小兔子,因此有了两对兔子,第四个月任然只有一对兔子生小兔子,因此有3对兔子,到了第五个月,第二对兔子也开始生小兔子,就有两对兔子生小兔子,因此将有5对兔子,......。从而,我们就可以得到下面的数列:
0,1,1,2,3,5,8,13,21,34,......
从队列很容易就能看出,每个月的兔子数量等于前两个月的兔子数量之和,通过对场景的分析,我们很容易就能证明我们的结论。下面,我们将其表述为递归表达式。
假定,在第n个月时,有F(n)只兔子,则得到:
F(n) = F(n-1) + F(n-2) n >= 2
结合第0月和第一个月的场景,我们就可以得到完整的递归表达式:
F(0) = 0;
F(1) = 1;
F(n) = F(n-1) + F(n-2) n >= 2
我们很容易的就能将该表达式转换为Python代码:
def fib(n) :
	if n == 0 or n == 1: 
		return n
	return fib(n - 1) + fib(n - 2)
到这里,就可以开始计算第n个月的兔子数量了,但别高兴的太早,很快你就会遇到麻烦了。麻烦出现在第40个月之后(更早或更晚,和你的机器性能相关),你会发现等待的时间开始让你有点不爽了(在我的机器上需要80秒钟),而随着n越来越大,程序运行的越来越慢,直到最后,经过漫长的等待之后你不得不中止程序。如果你是一名员工,你可不想在老板等待的不耐烦的时候告诉他“我不知道为什么,它就是这么慢”(当然,如果你是老板,你肯定不希望把时间浪费在漫长的等待上),老板总是希望知道为什么,因此,我们需要在老板发火之前搞清楚为什么。
要搞清楚为什么,首先,我们需要了解程序的整个运行过程:当计算fib(n)(假定n>1)时,则首先需要计算fib(n-1)和fib(n-2),而计算fib(n-1),我们需要计算fib(n-2)和fib(n-3),计算fib(n-2),我们需要计算fib(n-3)和fib(n-4),......,最终我们就可以得到下面的递归树:


通过充分的发挥我们的想象力,我们了解到了整个运行的过程,下面,我们可以计算出程序具体执行了多少步骤,如果用T(n)表示计算过程中fib(n)执行了多少步骤,我们可以得到:
T(0) = 1
T(1) = 1
T(n) = T(n-1) + T(n-2) + 3   n > 1
这里的3表示2次比较和一次加法,由此,我们能看出T(n) > F(n),由我计算的结果f(50)的值为12586269025,也就是说在n为50时,就需要计算超过125亿次以上的运算才能得到结果,而且,执行步骤的增长速度是指数级的,因此增长的非常快。
了解到了慢的原因,我们就可以看看有没有好的解决办法了。想要提高程序运行的效率,最好的办法自然是减少执行的步骤,重新观察递归树,我们发现F(n)的左子树和右子树非常相似,左子树会计算F(n-2),而整个右子树实际上就是计算F(n-2),也就是计算出现了重复,,继续观察,则会发现这样的重复大量存在,如果能够消除这些重复的步骤,执行的步骤就可以大量的减少,从而提高效率。
我们可以考虑将已经计算过的值保留下来,不再做重复的计算,通过修改上面的代码,我们得到:
def fib2(n, valueMap):
    if n in valueMap:
        return valueMap[n]
    if n == 0 or n == 1: 
        return n
    valueMap[n] = fib2(n - 1, valueMap) + fib2(n - 2, valueMap)
    return valueMap[n]
在函数中我们传入一个map,计算时首先看map中是否已经存在需要计算的值,如果不存在,则计算并保存到map中。
经过我们的改造,当n等于40时,整个计算过程仅需要1毫秒,也就是说,我们将时间从80s降到了1ms。回顾整个过程,在使用了map之后,每天的兔子数量就仅需要计算一次了,也就是说,我们将指数级的时间复杂度降低到了O(n)。这么快的执行效率,还等什么呢,赶快去向老板汇报吧,:)。

Josephus问题

Josephus是一个著名的犹太历史学家,他有过这样的故事:在罗马人占领乔塔帕特后,39个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3个人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。Josephus想了一个办法,帮助自己和朋友逃过了这场死亡游戏。
而今天,我并不想去拯救Josephus和他的朋友,我想做的只是利用这个故事来阐述怎么使用递归思想来解决问题,因此,我将问题做了一些简化:
假定有n个人,编号为1到n,由第1个人开始报数,每报数到2的人就出列,直到剩下最后一个人,求最后一个人的编号。
也许你首先想到的是使用循环来解决这个问题,但使用循环并不简单(你可以尝试一下),并且使用循环来解决这个问题的效率也很低,我们总是希望能够找到最简单的方法来解决问题。
到目前为止,我们还没有好的思路,所以,老办法,从小的问题开始,假定有n个人,f(n)表示最后剩下的那个人的编号,很快,我们就能得到:
f(1) = 1
f(2) = 1
f(3) = 3
f(4) = 1
f(5) = 3
到这里,我们任然无法看出什么规律,继续:
f(6) = 5
f(7) = 7
f(8) = 1
到这里,我们好像发现些什么了,经过多次证实后,我们找到:
f(16) = 1
f(32) = 1
呵呵,因此,我们就大胆的假设:f(2^n) = 1,而这个假设我们可以很容易的通过数学归纳法来证明(略)。
那么,这个结论对我们有什么用呢?我们是否能利用这个结论来解决我们的问题?
我们都知道,n可以被表述为2^m + l(2^m < n,l = n - 2^m,且l < 2^m),因此,如果我们将n个人中首先去除掉l个人,那么就剩下2^m个人,就可以利用我们上面的结论了。由于遍历中每2个人就会淘汰一个人,因此要减少l个人,需要遍历2l个人,并且:
     由于:l < 2^m
     所以:2l < 2^m + l,即2l < n
     因此,2l + 1 <= n
由此,我们得到:
f(n) = 2l + f(2^m) = 2l + 1
将整个表达式联合起来,就得到:
f(n) = 2l + 1 (l = n - 2^m,并且l >= 0且l < 2^m)
将其用Python代码表述为:
def josepus(n):
    m = floor(log2(n))
    l = n - pow(2, m)
    return 2 * l + 1
是不是非常简单,如果你是一个极端主义者,你甚至可以将它表述为一行代码。如果你想对该问题有更深入的了解,在 这里可以看到完整的解答。

总结

到这里,我们的例子就讲完了,但还没有结束,我们还必须来做一些总结,学习后的总结总是一个好的习惯。通过观察上面的例子,我们可以看出在解决问题的过程中我们总是会经历下面的步骤:
1)通过小的问题,找到问题的突破口;
2)发现并找到递归表达式,并证明该递归表达式是正确的;
3)简化表达式,得出最终结论。
有时候第3步非常重要,简化往往可以让我们写出更简洁的代码,甚至直接得出答案。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值