递归在程序设计中属于较难理解的部分,程序猿新手往往感觉递归无从入手,这主要在于递归的过程并不直观,有别于我们通常的思考方式。在这篇文章中我将从最主要的概念開始,逐步阐述递归的思想,递归程序的设计方法,理解递归程序的运行过程,以及怎么运用递归解决这个问题。
基本概念
因此,我们能够将递归分解为两个步骤:“递”和“归”。“递”就是向下不断分解的过程,而“归”则是不断回溯汇总的过程,通过两个过程的结合,从而得到问题的解。
由此,我们能够得到递归算法的几个要点:
1)问题能够被分解为一个或者多个规模更小的子问题;、
2)分解得到的子问题和初始问题是同类的问题。
3)当问题分解到足够小的时候能够非常easy计算出问题的解。
以下我们用数据表达式表示,假设用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
看起来是不是有点过于抽象。接下来我们就用一个一个的实例来说明怎么分解问题得到递归表达式。以及怎么确定递归出口。
首先,我们从一个经典的递归问题出发。
汉诺塔
而且规定。在小圆盘上不能放大圆盘,在三根柱子之间一次仅仅能移动一个圆盘。有预言说,这件事完毕时宇宙会在一瞬间闪电式毁灭。有人相信婆罗门至今还在一刻不停地搬动着圆盘。
假设我们假定移动一个圆盘须要1秒钟的话,那么宇宙会在什么时候毁灭呢?
为了在世界末日来临之前做好准备,我们如今就開始着手解决这个问题吧。首先我们将问题做一些抽象,假定如今存在n个圆盘,须要将这n个圆盘所有从一根柱子移动到还有一根柱子,我们将须要移动圆盘的次数描写叙述为f(n)。
直接来解f(n)可能会让你感觉无从下手。因此。我们能够先从简单的场景入手。首先考虑n为0和1的场景。能够非常轻松地得到:
f(0) = 0 //0个圆盘不须要移动
f(1) = 1 //1个圆盘仅须要移动一次
眼下为止非常顺利,但我们并不能从0和1的场景中得到实用的信息,我们须要更为复杂一些的场景。继续看n为2时场景(以下简称场景2),能够通过以下的操作步骤来移动圆盘:
f(2) = 3
在场景2下我们任然无法得出结论。我们须要很多其它的信息,因此。继续。
当n为3时(简称场景3)。能够使用以下的操作步骤来移动圆盘:
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数列
假若兔子仅仅生不死。一月份抱来一对刚出生的小兔子,问一年中每一个月各有多少仅仅兔子。
我们知道第0月有0对兔子,第一个月抱来一对兔子,第二个月兔子不会生殖。任然仅仅有一对兔子,第三个月第一对兔子開始生小兔子,因此有了两对兔子。第四个月任然仅仅有一对兔子生小兔子,因此有3对兔子。到了第五个月,第二对兔子也開始生小兔子,就有两对兔子生小兔子。因此将有5对兔子。......。从而,我们就能够得到以下的数列:
0,1,1,2,3,5,8,13,21,34,......
从队列非常easy就能看出。每一个月的兔子数量等于前两个月的兔子数量之和,通过对场景的分析,我们非常easy就能证明我们的结论。以下。我们将其表述为递归表达式。
假定,在第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
我们非常easy的就能将该表达式转换为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(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和他的朋友,我想做的仅仅是利用这个故事来阐述怎么使用递归思想来解决这个问题。因此,我将问题做了一些简化:
假定有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,而这个假设我们能够非常easy的通过数学归纳法来证明(略)。
那么,这个结论对我们有什么用呢?我们能否利用这个结论来解决我们的问题?
我们都知道,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步非常重要,简化往往能够让我们写出更简洁的代码,甚至直接得出答案。