递归——初学者学习语言的最大障碍之一
递归是真的难懂,我每次都是绕进去,然后绕不出来,一个程序能做到这样,我服!!!
我们看一段文字先欣赏一下递归的轮廓,如果看完不懂,那就看完全文后再看这段文字!
对递归最恰当的比喻,就是查词典。我们查词典的过程,本身就是递归。想象用一本纯英文词典查单词,要查某一个单词的意思,翻到这个单词时,看解释,发现解释中有一个单词不认识,所以,无法明白这个要查的单词是什么意思;这时,再用这本词典(函数本身)查那个不认识的单词,又发现查的第2个单词的解释中又有一个单词不认识,那么,又再用这本词典查第3个不认识的单词,这样,一个一个查下去,直到解释中所有单词都认识,这样就到底了,就明白了最后一个单词是什么意思,然后一层一层倒回来,就知道我最初想查的第1个单词是什么意思了,问题就解决了。
为了理解透这个知识点,我打算分几个角度讲述这一知识点,力争面面俱到,图文并茂。
首先,我们先理解一下递归的概念。
递归(Recursion):函数调用自己。
递归算法解决问题的特点:
- 递归就是方法里调用自身。
- 在使用递增归策略时,必须有一个明确的递归结束条件,称为递归出口。
- 递归算法解题通常显得很简洁,但递归算法解题的运行效率较低。所以一般不提倡用递归算法设计程序。
- 在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储。递归次数过多容易造成栈溢出等,所以一般不提倡用递归算法设计程序。
好吧,一下子说这些概念也没意思,我们来讲解一下递归的过程吧。
具体地说,如果递归函数调用自己,则被调用的函数也将调用自己,这将无限循环下去,除非代码中包含终止调用链的内容。通常的方法将递归调用放在if语句中。例如,void类型的递归函数recurs()的代码如下:
void recurs(argumentlist){
statements1
if(test) //test最终为false,调用链将断开
recurs(arguments)
statements2
}
下面我们用文字再现这段代码块的内容。只要if语句为true,每个recurs()调用都将执行statement1,然后再调用recurs(),而不会执行statements2 。当前调用结束后,程序控制权将返回给调用它的recurs(),而该recurs()将执行其statements2部分,然后结束,并将控制权返回给前一个调用,依次类推。
光有文字说服力不够,我们来看一个类似此递归的过程图:
因此,如果recurs()进行了5次递归调用,则第一个statements1部分将按函数调用的顺序执行5次,然后statements2部分将以与函数调用相反的顺序执行5次。进入5层递归后,程序将沿进入的路径返回。下面这段程序演示了这种行为:
// recur.cpp -- using recursion
#include <iostream>
void countdown(int n);
int main()
{
countdown(4); // call the recursive function
return 0;
}
void countdown(int n)
{
using namespace std;
cout << "Counting down ... " << n << endl;
if (n > 0)
countdown(n-1); // function calls itself
cout << n << ": Kaboom!\n";
}
该程序的输出如下:
Counting down ... 4 < level 1;adding levels of recursion
Counting down ... 3 < level 2
Counting down ... 2 < level 3
Counting down ... 1 < level 4
Counting down ... 0 < level 5;final recursive call(调用)
0: Kaboom! < level 5;beginning to back out
1: Kaboom! < level 4
2: Kaboom! < level 3
3: Kaboom! < level 2
4: Kaboom! < level 1
注意,每个递归调用都创建自己的一套变量,因此当程序到达第5次调用时,将有5个独立的n变量,其中每个变量的值都不同。
我们修改一下上段的程序来验证这一观点。
![](https://i-blog.csdnimg.cn/blog_migrate/8f900a89c6347c561fdf2122f13be562.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/961ddebeb323a10fe0623af514929fc1.gif)
// recur.cpp -- using recursion #include <iostream> void countdown(int n); int main() { countdown(4); // call the recursive function return 0; } void countdown(int n) { using namespace std; cout << "Counting down ... " << n << " (n at " << &n << ")" << endl; if (n > 0) countdown(n-1); // function calls itself cout << n << ": Kaboom!" << " (n at " << &n << ")" <<endl; }
程序输出:
注意,在一个内存单元(内存地址为0x6cfed0),存储的n值为4;在另一个内存单元(内存地址为0012FD34),存储的n值为3;等等。另外,注意到Counting down阶段和Kaboom阶段的相同层级,n地址相同。
上面的程序中只有一个递归,而我们在创建二叉树或其他操作时往往用到两个递归,那么我们再介绍一下包含多个递归调用的递归。
在需要将一项工作不断分为两项较小的、类似的工作时,递归非常有用。例如,请考虑使用这种方法来绘制标尺的情况。标出两端,找到中点并将其标出。然后将同样的操作作用于标尺的左半部分和右半部分。如果要进一步细分,可将同样的操作用于当前的每一部分。递归方法有时被称为分而治之策略。
下面这段程序使用递归函数subdivide()演示了上面讲的方法,该函数使用一个字符串,该字符串除两端为 | 字符外,其他全部为空格。main函数使用循环调用subdivide()函数6次,每次将递归层编号加1,并打印得到的字符串。这样,每行输出表示一层递归。
// ruler.cpp -- using recursion to subdivide a ruler
#include <iostream>
using namespace std;
const int Len = 66;
const int Divs = 6;
void subdivide(char ar[], int low, int high, int level);
int main()
{
char ruler[Len];
int i;
for (i = 1; i < Len - 2; i++)
ruler[i] = ' ';
ruler[Len - 1] = '\0';
int max = Len - 2;
int min = 0;
ruler[min] = ruler[max] = '|';
cout << ruler << endl;
for (i = 1; i <= Divs; i++){
subdivide(ruler,min,max, i);
cout << ruler << endl;
for (int j = 1; j < Len - 2; j++)
ruler[j] = ' '; // reset to blank ruler
}
return 0;
}
void subdivide(char ar[], int low, int high, int level)
{
if (level == 0)
return;
int mid = (high + low) / 2;
ar[mid] = '|';
subdivide(ar, low, mid, level - 1);
subdivide(ar, mid, high, level - 1);
}
subdivide()函数使用变量level来控制递归层。函数调用自身时,将把level减1,当level为0时,该函数将不再调用自己。注意,subdivide()调用自己两次,一次针对左半部分,另一次针对右半部分。最初的中点被用作一次调用的右端点和另一次调用的左端点。请注意,调用次数将呈几何级数增长,也就是说,调用一次导致两个调用,然后导致4个调用,再导致8个调用,依次类推。这就是6层调用能够填充64个元素的原因(26=64)。这将不断导致函数调用数(以及存储的变量数)翻倍,因此如果要求的递归层次很多,这种递归方式将是一种糟糕的选择;然而,如果递归层次较少,这将是一种精致的而简单的选择。
程序输出:
上面具体讲解了递归的过程和实现细节,我们似乎有一种似懂非懂的feeling,这就对了,递归这么难理解的东西,休想这么快掌握。
下面,我们再探讨递归的细节。
常常有书本将递归定义为死循环,比如像这样的定义:
递归:
参见“递归”。
虽然我们能明白这个定义的暗含出口是“我们懂了递归的定义不再看了”,但是文字上还是一个死循环,当然,这也算递归的一种——无限递归。
虽然上面说是死循环,但是循环和递归完全不是一个概念:递归是要层层嵌套,而循环只是一次次的重复而已。它们的层次不一样。
我们来讨论一般的递归求解思路:
- 写出递归函数也就是要处理好递归的3个主要的点:
a)出口条件,即递归“什么时候结束”,这个通常在递归函数的开始就写好;
b) 如何由"情况n" 变化到"情况n+1",也就是非出口情况,也就是一般情况——"正在"递归中的情况;
c) 初始条件,也就是这个递归调用以什么样的初始条件开始
可以说,上述a,b,c三个条件组成了我们的递归函数。解决好上述3点,也就很容易地写出一个递归函数。剩下的就是去学习学习“数学归纳法”,请自己google之,不需要你成为归纳法专家,但只需要认证体会它的思路,对于你理解和创造递归函数有很大帮助。
下面引用一个大牛对学习递归的理解:
首先是思想方法上要转变,不要试图解决问题(这是常规的思考方式),而应该“鼠目寸光”地只想解决一点点,要点是,解决一点点之后,剩下来的问题还是原来的问题,但规模要比原问题小了。
思想和语言是密切相关的,所以问题的提法也很重要。一个问题这样提可能感觉很难写出递归,换种提法,可能就写出来了。
平时就要注意用递归的方式思考,譬如什么是链表?以递归来看就是一个指针,指向一个含有链表的指针。一旦你用这种方式看待链表,你会发现写链表的代码非常容易。反之,则非常容易拖泥带水。
从代码层面看,递归就是函数的循环,所以只要透彻理解函数,写递归代码没什么难的。
如果到了现在,你还是不能吃透递归的过程,那么只能出绝招了:
在用递归的时候,将它当成一个黑盒子,不要老是想着递归的方法,而是只是把递归函数当成一个普通的调用函数,对这个函数,你需要知道他的输入与输出。
比如最简单的n的阶乘的递归实现,代码如下:
function factorial(n){
if(n === 1){
return n;
}
else{
return n * factorial(n-1);
}
}
你在写这个函数的时候,把函数定义部分代码蒙起来,主要看这部分代码:
if(n == 1){ // 如果求1的阶乘,那么就直接返回
return n;
}
else{ // n! = n * (n-1)! 这个数学等式
return n * factorial(n-1);
}
那这样看代码,你懂了吗?
如果你觉得还不懂的话,且听我下面说完。
不谈性能上面的影响,递归的使用能让你的代码变得简单清晰,逻辑变得易懂。就比如下面这段代码,用非递归的方式实现阶乘:
function factorial (n){
var result = 1;
for (var i = n; i > 0; i--){
result *= i;
}
return result;
}
在递归函数里面,运用了一部分的数学逻辑在里面,能清晰看见数学等式,而非递归实现里面数学逻辑其实是隐藏起来的,是编码人员在知道数学逻辑的情况下实现,而没有体现在代码层面上。这样在数学逻辑变动的时候,维护起来是不太好的。
小结:C语言用调用栈来描述函数之间的调用关系。调用栈由栈帧组成,每个栈帧对应着一个未运行完的函数。在gdb中可以用backtrace(简称bt)命令打印所有栈帧信息。若要用p命令打印一个非当前栈帧的局部变量,可以用frame命令选择另一个栈帧。//gdb是一个功能强大的源码级调试器,虽然是基于命令的文本界面,但运用熟练后非常方便。
![](https://i-blog.csdnimg.cn/blog_migrate/8f900a89c6347c561fdf2122f13be562.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/961ddebeb323a10fe0623af514929fc1.gif)
#include<stdio.h> void swap(int a, int b){ int t = a; a = b; b = t; } int main(){ int a = 3, b = 4; swap(3,4); printf("%d %d\n",a,b); return 0; }
我们调试该程序时,除了关心“当前代码行”和变量的变化之外,再看看调用栈的变化。特别是在执行完swap函数的主体但还没有返回main函数之前,先看一下swap和mian函数所对应的栈帧中a和b的值。
下面给出用gdp完成上述操作的命令和结果:
第一步:编译程序。
gcc swap.c -std=c99 -g
生成可执行程序a.exe。编译选项-g告诉编译器生成调试信息。编译选项-std=c99告诉编译器按照C99标准编译代码。
第二步:运行gdb。
gdb a.exe
这样,gdb在运行时会自动装入刚才生成的可执行程序。
第三步:查看源码。
(gdb) l
1 #include<stdio.h>
2 void swap(int a, int b){
3 int t = a; a = b; b = t;
4 }
5
6 int main(){
7 int a = 3, b = 4;
8 swap(3,4);
9 printf("%d %d\n",a,b);
10 return 0;
这里(gdb)是gdb的提示符,字母l是输入的命令,为list(列出程序清单)的缩写。正如代码所示,swap函数的最后一行是第4行,当执行到这一行时,swap函数的主体已经结束,但函数还没有返回。之所以要详细地解释调用栈和栈帧,是因为理解它们对今后的学习和编程是至关重要的,特别是递归——初学者学习语言的最大障碍之一,调用栈将有助于理解。
![](https://i-blog.csdnimg.cn/blog_migrate/8f900a89c6347c561fdf2122f13be562.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/961ddebeb323a10fe0623af514929fc1.gif)
#include<stdio.h> void swap(int* a, int* b){ int t = *a; *a = *b; *b = t; } int main(){ int a = 3, b = 4; swap(&a,&b); printf("%d %d\n",a,b); return 0; }
![](https://i-blog.csdnimg.cn/blog_migrate/fe50225283e539c82670d090f8990b2c.jpeg)
#include<stdio.h>
int f(int n){
return n == 0 ? 1 : f(n-1)*n;
}
int main(){
printf("%d\n",f(3));
return 0;
}
最后补充:调用栈并不储存在可执行文件中,而是在运行时创建。调用栈所在的段称为堆栈段,它有自己的大小,如果调用次数多了,就会产生若干个栈帧,便会发生越界,这种情况称为栈溢出。由于局部变量也是放在堆栈段的,所以局部变量太大也会造成栈溢出,这就是为什么要把较大的数组放在main函数外的原因。
![](https://i-blog.csdnimg.cn/blog_migrate/d68050053f7a515777982138d093c58f.jpeg)
现在有n个圆盘从上往下从小到大叠在第一根柱子上,每次移动一个圆盘,现要把这些圆盘全部移动到第三根柱子(移动的时候始终只能小圆盘在大圆盘上面),请找出需要移动次数最少的方案。
【解决过程】
我们可以将问题简化描述为:n个盘子和3根柱子:A(源)、B(备用)、C(目的),盘子的大小不同且中间有一孔,可以将盘子“串”在柱子上,每个盘子只能放在比它大的盘子上面。起初,所有盘子在A柱上,问题是将盘子一个一个地从A柱子移动到C柱子。移动过程中,可以使用B柱,但盘子也只能放在比它大的盘子上面。
因此我们得出汉诺塔问题的以下几个限制条件:
- 在小圆盘上不能放大圆盘;
- 在三根柱子之间一回只能移动一个圆盘;
- 只能移动在最顶端的圆盘。
首先,我们从简单的例子开始分析,然后再总结出一般规律。
当n = 1的时候,即此时只有一个盘子,那么直接将其移动至C即可。移动过程就是 A -> C
当n = 2的时候,这时候有两个盘子,那么在一开始移动的时候,我们需要借助B柱作为过渡的柱子,即将A柱最上面的那个小圆盘移至B柱,然后将A柱底下的圆盘移至C柱,最后将B柱的圆盘移至C柱即可。那么完整移动过程就是A -> B , A -> C , B -> C
当n = 3的时候,那么此时从上到下依次摆放着从小到大的三个圆盘,根据题目的限制条件:在小圆盘上不能放大圆盘,而且把圆盘从A柱移至C柱后,C柱圆盘的摆放情况和刚开始A柱的是一模一样的。所以呢,我们每次移至C柱的圆盘(移至C柱后不再移到其他柱子上去),必须是从大到小的,即一开始的时候,我们应该想办法把最大的圆盘移至C柱,然后再想办法将第二大的圆盘移至C柱......然后重复这样的过程,直到所有的圆盘都按照原来A柱摆放的样子移动到了C柱。
那么根据这样的思路,问题就来了:
如何才能够将最大的盘子移至C柱呢?
那么我们从问题入手,要将最大的盘子移至C柱,那么必然要先搬掉A柱上面的n-1个盘子,而C柱一开始的时候是作为目标柱的,所以我们可以用B柱作为"暂存"这n-1个盘子的过渡柱,当把这n-1的盘子移至B柱后,我们就可以把A柱最底下的盘子移至C柱了。
而接下来的问题是什么呢?
我们来看看现在各个柱子上盘子的情况,A柱上无盘子,而B柱从上到下依次摆放着从小到大的n-1个盘子,C柱上摆放着最大的那个盘子。
所以接下来的问题就显而易见了,那就是要把B柱这剩下的n-1个盘子移至C柱,而B柱作为过渡柱,那么我们需要借助A柱,将A柱作为新的"过渡"柱,将这n-1个盘子移至C柱。
根据上面的分析,我们可以抽象得出这样的结论:
汉诺塔函数原型:
void Hanio(int n,char start_pos,char tran_pos,char end_pos)
那么我们把n个盘子从A柱移动至C柱的问题可以表示为:
Hanio(n,A,B,C);
那么从上面的分析得出:
该问题可以分解成以下子问题:
第一步:将n-1个盘子从A柱移动至B柱(借助C柱为过渡柱)
第二步:将A柱底下最大的盘子移动至C柱
第三步:将B柱的n-1个盘子移至C柱(借助A柱为过渡柱)
因此完整代码如下所示:
![](https://i-blog.csdnimg.cn/blog_migrate/8f900a89c6347c561fdf2122f13be562.gif)
![](https://i-blog.csdnimg.cn/blog_migrate/961ddebeb323a10fe0623af514929fc1.gif)
#include<cstdio> int i; //记录步数 //i表示进行到的步数,将编号为n的盘子由from柱移动到to柱(目标柱) void move(int n,char from,char to){ printf("第%d步:将%d号盘子%c---->%c\n",i++,n,from,to); } //汉诺塔递归函数 //n表示要将多少个"圆盘"从起始柱子移动至目标柱子 //start_pos表示起始柱子,tran_pos表示过渡柱子,end_pos表示目标柱子 void Hanio(int n,char start_pos,char tran_pos,char end_pos){ if(n==1){ //很明显,当n==1的时候,我们只需要直接将圆盘从起始柱子移至目标柱子即可. move(n,start_pos,end_pos); } else{ Hanio(n-1,start_pos,end_pos,tran_pos); //递归处理,一开始的时候,先将n-1个盘子移至过渡柱上 move(n,start_pos,end_pos); //然后再将底下的大盘子直接移至目标柱子即可 Hanio(n-1,tran_pos,start_pos,end_pos); //然后重复以上步骤,递归处理放在过渡柱上的n-1个盘子 //此时借助原来的起始柱作为过渡柱(因为起始柱已经空了) } } int main(){ int n; while(scanf("%d",&n)==1&&n){ i = 1; //全局变量赋初始值 Hanio(n,'1','2','3'); printf("最后总的步数为%d\n",i-1); } return 0; }
运行结果如下:
![](https://i-blog.csdnimg.cn/blog_migrate/697b75d9bed37aa7da196d57b4e34822.jpeg)
![](https://i-blog.csdnimg.cn/blog_migrate/0150fb9774653e7f9f6a7fae3f583ed3.jpeg)
从运行后的结果我们也可以发现:对于n个盘子,移动的总步数为2n - 1
体会:递归问题是个抽象的问题,因为人大脑堆栈是有限的,想象不出来运行效果,因此我们只需要枚举前几个实例,然后寻找其中规律即可。当n值增大时,只是复杂度发生了改变,实际上函数的递归调用还是一样的。慢慢来,说不定哪天自己理解透了呢。
我首先在知乎上发现了下面两个例子,对比了递归和循环。
递归:你打开面前这扇门,看到屋里面还有一扇门(这门可能跟前面打开的门一样大小(静),也可能门小了些(动)),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,你继续打开,..., 若干次之后,你打开面前一扇门,发现只有一间屋子,没有门了。你开始原路返回,每走回一间屋子,你数一次,走到入口的时候,你可以回答出你到底用这钥匙开了几扇门。
循环:你打开面前这扇门,看到屋里面还有一扇门,(这门可能跟前面打开的门一样大小(静),也可能门小了些(动)),你走过去,发现手中的钥匙还可以打开它,你推开门,发现里面还有一扇门,(前面门如果一样,这门也是一样,第二扇门如果相比第一扇门变小了,这扇门也比第二扇门变小了(动静如一,要么没有变化,要么同样的变化)),你继续打开这扇门,...,一直这样走下去。 入口处的人始终等不到你回去告诉他答案。
该用户这么总结到:递归就是有去(递去)有回(归来)。
具体来说,为什么可以”有去“?
这要求递归的问题需要是可以用同样的解题思路来回答除了规模大小不同其他完全一样的问题。
为什么可以”有回“?
这要求这些问题不断从大到小,从近及远的过程中,会有一个终点,一个临界点,一个baseline,一个你到了那个点就不用再往更小,更远的地方走下去的点,然后从那个点开始,原路返回到原点。
上面的解释几乎回答了我已久的疑问:为什么我老是有递归没有真的在解决问题的感觉?
因为递是描述问题,归是解决问题。而我的大脑容易被递占据,只往远方去了,连尽头都没走到,何谈回的来。
《漫谈递归:递归的思想》这篇文章将递归思想归纳为:
递归的基本思想是把规模大的问题转化为规模小的相似的子问题来解决。在函数实现时,因为解决大问题的方法和解决小问题的方法往往是同一个方法,所以就产生了函数调用它自身的情况。另外这个解决问题的函数必须有明显的结束条件,这样就不会产生无限递归的情况了。
需注意的是,规模大转化为规模小是核心思想,但递归并非是只做这步转化,而是把规模大的问题分解为规模小的子问题和可以在子问题解决的基础上剩余的可以自行解决的部分。而后者就是归的精髓所在,是在实际解决问题的过程。
我试图把我理解到递归思想用递归用程序表达出来,确定了三个要素:递 + 结束条件 + 归。
function recursion(大规模)
{
if(end_condition){
end;
}
else{ //先将问题全部描述展开,再由尽头“返回”依次解决每步中剩余部分的问题
recursion(小规模); //go;
solve; //back;
}
}
function recursion(大规模)
{
if(end_condition){
end;
}
else{ //在将问题转换为子问题描述的每一步,都解决该步中剩余部分的问题。
solve; //back;
recursion(小规模); //go;
}
}
总结到这里,我突然发现递归是为了最能表达这种思想,所以用“递归”这个词,其实递归可以是“有去有回”,也可以是“有去无回”。但其根本是“由大往小地去,由近及远地去”。“递”是必需,“归”并非必需,依赖于要解决的问题,有的需要去的路上解决,有的需要回来的路上解决。有递无归的递归其实就是我们很容易理解的一种分治思想。
其实理解递归可能没有“归”,只有去(分治)的情况后,我们应该想到递归也许可以既不需要在“去”的路上解决问题,也不需要在“归”的路上解决问题,只需在路的尽头解决问题,即在满足停止条件时解决问题。递归的分治思想不一定是要把问题规模递归到最小,还可以是将问题递归穷举其所有的情形,这时通常递归的表达力体现在将无法书写的嵌套循环(不确定数量的嵌套循环)通过递归表达出来。
将这种递归情形用递归程序描述如下:
recursion()
{
if(end_condition){
solve;
}
else{ //在将问题转换为子问题描述的每一步,都解决该步中剩余部分的问题。
for(){
recursion(); //go
}
}
}
由这个例子,可以发现这种递归对递归函数参数出现了设计要求,即便递归到尽头,组合的字符串规模(长度)也没有变小,规模变小的是递归函数的一个参数。可见,这种变化似乎一下将递归的灵活性大大地扩展了,所谓的大规模转换为小规模需要有一个更为广义的理解了。
对递归的理解就暂时到这里了,可以看出文章中提到关于“打开一扇门”的递归例子来解释递归并不准确,例子只描述了递归的一种情况。而“递归就是有去(递去)有回(归来)”的论断同样不够准确。要为只读了文章前半部分的读者惋惜了。
我也给出自己对递归思想的总结吧:
递归的基本思想是广义地把规模大的问题转化为规模小的相似的子问题或者相似的子问题集合来解决。广义针对规模的,规模的缩小具体可以是指递归函数的参数,也可以是其参数之一。相似是指解决大问题的方法和解决小问题的方法往往是同一个方法,还可以是指解决子问题集的各子问题的方法是同一个方法。解决大问题的方法可以是由解决次规模问题的方法和解决剩余部分的方法组成,也可以是由一系列解决次规模问题的方法组成。
光理解递归没用,还需做大量的题目加强自己的认知。主要题目类型为DFS/BFS/树/动规。