递归的理解与设计
递归算法:是一种直接或者间接地调用自身的算法。在计算机编写程序中,递归算法对解决一大类问题是十分有效的,它往往使算法的描述简洁而且易于理解。
1,参考于书籍中的讲解:
例: 求n的阶乘
- int fac(n){
- if(n == 0 || n == 1){
- return 1;
- }
- else{
- return n*fac(n-1); //自己调用自己,求n-1的阶乘
- }
- }
2,个人的经验性总结
设计一个递归算法,我认为主要是把握好如下四个方面:
1.函数返回值如何构成原问题的解
其实最先应该明了自己要实现的功能,再来设计函数的意义,特别是这个函数的返回值,直接关系到函数是否存在正确结果,函数返回什么递归子程序调用就会返回什么,而递归子程序调用的返回值会影响到最终结果,因此必须关注函数的返回值,子程序返回的结果被调用者所使用(也可以不使用),调用者又会返回,也就是说函数返回值是一致性的。
关键问题是如何由递归子程序构成原问题的解呢?很重要的问题,但是这里不能一概而论,
比如我们需要的是遍历的这个过程而不是递归子函数的返回的解,那么我们就可以不接收返回值或者直接写成void函数,典型的就是二叉树的三大遍历方式。
我们的解也有可能是由子问题的解组合而成(添加各种运算),无论如何这里应该试着从子问题和原文题的关系入手。
2.递归的截止条件。
截止条件就是可以判断出结果的条件,是递归的出口啊,最好总是先设计递归出口。
3.总是重复的递归过程。
一般简单的递归可以显式的用一个数学公式表达出来,比如前面的求阶乘问题。但是很多问题都不是简单的数学公式问题,我们需要把原问题分解成各种子问题,而子问题使用的是同样的方法,获取的是同样的返回值。
4.控制递归逻辑。
有的时候为了能实现目的,我们需要控制边界啊什么的,下面有具体介绍。
二,LeeCode实战理解
例子1:
判断两个二叉树是否一样?
原文地址,<LeetCode OJ>
100. Same Tree
1),函数返回值如何构成原问题的解
明确函数意义,
判断以节点p和q为根的二叉树是否一样,获取当前以p和q为根的子树的真假情况
bool isSameTree(TreeNode* p, TreeNode* q){
函数体.....
}
解的构成,
每一个节点的左子树和右子树同时一样才能组合成原问题的解。原问题接收来自所有子问题的解,只要有一个假即可所有为假(与运算)
2),递归的截止条件
截止条件就是可以得出结论的条件。
如果p和q两个节点是叶子,即都为NULL,可以认为是一样的,return true
如果存在一个为叶子而另一个不是叶子,显然当前两个子树已经不同,return false
如果都不是叶子,但节点的值不相等,最显然的不一样,return false
3)总是重复的递归过程
当2)中所有的条件都“躲过了”,即q和p的两个节点是相同的值,那就继续判断他们的左子树和右子树是否一样。
即,isSameTree(p->left,q->left)和isSameTree(p->right,q->right)
4)控制重复的逻辑
显然只有两个子树都相同时,才能获取最终结果,否则即为假。
如下所示
return (isSameTree(p->left,q->left))&&(isSameTree(p->right,q->right));
最终代码
- class Solution {
- public:
- bool isSameTree(TreeNode* p, TreeNode* q) {
- if(p==NULL&&q==NULL)
- return true;
- else if(p==NULL&&q!=NULL)
- return false;
- else if(p!=NULL&&q==NULL)
- return false;
- else if(p!=NULL&&q!=NULL && p->val!=q->val)
- return false;
- else
- return (isSameTree(p->left,q->left))&&(isSameTree(p->right,q->right));
- }
- };
例子2
镜像反转二叉树,
原文地址,<LeetCode OJ> 226. Invert Binary Tree
1),函数返回值如何构成原问题的解
明确函数意义,
将根节点root的左右子树镜像反转,并获取翻转后该根节点的指针
TreeNode* invertTree(TreeNode* root) {
函数体.....
}
解的构成,
原问题的解总是由已经解决的左子问题和已经解决的右子问题调换一下即可。
2),递归的截止条件
截止条件就是可以得出结论的条件。
如果root不存在,即NULL,显然此时不用再反转,返回NULL即可
3)总是重复的递归过程
当2)中所有的条件都“躲过了”,即root存在(当然左右子可能不存在)
我们就总是
先获取将root的左子树镜像翻转后的根节点,
再获取将root的右子树镜像翻转后的根节点,
交换两者,并返回root即可。
TreeNode* newleft = invertTree(root->right);//先获取翻转后的左右子树的根节点
TreeNode* newright = invertTree(root->left);
root->left = newleft;//实现翻转
root->right = newright;
return root;//返回结果
4)控制重复的逻辑
以上已完成
最终代码:
- class Solution {
- public:
- //将根节点反转,并获取翻转后该根节点的指针
- TreeNode* invertTree(TreeNode* root) {
- if(root == NULL){
- return NULL;
- }else{
- //这样做将:树的底层先被真正交换,然后其上一层才做反转
- TreeNode* newleft = invertTree(root->right);
- TreeNode* newright = invertTree(root->left);
- root->left = newleft;
- root->right = newright;
- return root;
- }
- }
- };
例子3
获取前序遍历结果
原文地址,<LeetCode OJ> 144/145/94 Binary Tree (Pre & In & Post) order Traversal
1),函数返回值如何构成原问题的解
明确函数意义,
获取以当前节点root为根的前序遍历结果
vector<int> preorderTraversal(TreeNode* root) {
函数体....
}
解的构成,
在这里递归子程序的返回值并不是函数的解,我们只关心遍历顺序即可,而递归子程序的解并不关心,所以递归子程序的返回值我们并不需要(递归子函数不接受即可,但是还是要返回结果哈)。
2),递归的截止条件
截止条件就是可以得出结论的条件。
如果root为NULL,说明已经没有子树了,显然就截止了
立刻返回结果(这个结果返回给递归进来的上一层函数,上一层函数并不接受即可)
3)总是重复的递归过程
当2)中的条件都“躲过了”,
则即刻获取当前根节点的元素值,接着先访问以左子为根的子树,接着右....
4)控制重复的逻辑
前序遍历的基本规则,总是先访问根节点,再左节点,最后右节点
完整代码:
- class Solution {
- public:
- vector<int> result; //将保存遍历的所有结果
- vector<int> preorderTraversal(TreeNode* root) {
- if(root){
- result.push_back(root->val);
- preorderTraversal(root->left); //递归子函数不接受解
- preorderTraversal(root->right);
- }
- return result;
- }
- };
递归程序设计心得与体会
用递归设计出来的程序总是简洁易读,极具美感。但是对于刚入门的学者来说,当遇到递归场景时,自己却难以正确的设计出合理的递归程序。博主曾经也是困惑不已,写的多了,也就渐渐的熟悉了递归设计。特谈一下自己的感受,有些术语是博主自己总结,有可能有不合理之处。
学习递归程序设计,建议首先应该从小规模的递归开始研究,小规模就是说自己可以调试跟踪代码,且自己不会晕。这个过程完成之后,才能熟练掌握递归层次之间的转换,明白递归的执行过程。在这里推荐一篇文章:http://blog.chinaunix.net/uid-20196318-id-31175.html,文章的第一个案例有一定的参考价值,第二个案例是全排列,将在后面讨论到。
在平时的程序设计中,遇到的递归问题一般可以分为两类,一类是过程递归,就是说执行的过程有明显的递归性,典型的就是求阶乘,斐波拉契数列,矩阵染色。。。大多数问题可以归结为第一类;第二类是结构递归,比如二叉树的各序遍历,链表逆转,反向打印等问题。两类问题的设计考虑是有所不同的,如果采用同样的思路去考虑两类不同问题,一定得不到正确的代码。建议从过程递归设计开始学习。
一、过程递归性
不管是过程递归还是结构递归首先要明确的就是一定要抛弃程序设计的细节,如果在设计过程中扣住细节,试图弄清楚每一步执行过程,你就失败了。递归设计的设计者首先要明确的是你的递归函数的功能,比如阶乘int fun(int n),他的功能就是返回n的阶乘,设计过程中要时时记住自己递归函数的设计目的。其次就是递归程序的出口设计,这一点是比较灵活的,不同问题有不同的设计;最后就是一定要有规模的递减。在整个递归设计过程中,一定要严格注意和把握这几点,缺一不可。
案例1:阶乘
阶乘基本就是递归入门级案例,现在将用上面的思路来设计。
1、设计出函数原型,明确其功能;
- int fun(int n) //函数功能,返回n的阶乘结果
- {
- /*设计递归出口,在这个程序中,出口明显是根据n的变化来确定的,而0!=0,1!=1,所以我们就以0或者1来结束递归*/
- if(n==0||n==1)
- return 1;
- /*注意不要做任何的细节处理,明确你函数的功能,*/
- //函数的功能是返回n的阶乘,那么直接就return fun(n);这样做可以吗?这样的话只做到了两点,没有规模的递减
- /*要规模递减,只需做简单的处理*/
- return n*fun(n-1);
- /*那下面这个语句可以吗*/
- /*return n*fun(n-1)*fun(n-2),这样肯定是不可以的,时刻记住函数的功能,fun(n-1)代表n-1的阶乘,在乘以n-2的阶乘就不对了,可以这样写return n*(n-1)*fun(n-2),出口条件在改一下if(n<=1) return 1即可,因为1-2=-1判断条件出不来*/
- }
2、矩阵染色
上面的案例其实我们并没有明显的感觉到过程的递归性,但这个下面的案例我们可以感觉到过程是有明显的递归性的。这个案例是小米2016招聘的一个笔试题,同时在2016中兴捧月比赛初赛中出现了一个极其类似的题。题目描述如下:对于一个矩阵,例如:
0 1 1 0
2 1 2 1
1 1 2 1
0 1 1 0
这个矩阵代表了一个图像,现在要给这个图像的指定位置染色,函数原型如下void fillwithcolor(int i,int j,int c),表示在图像的i,j位置和i,j位置临近的同色区域染色为c,注意:对角元素不算临近,对于上图如果调用fillwithcolor(1,1,5)的话,将得到下面的矩阵。
0 5 5 0
2 5 2 1
5 5 2 1
0 5 5 0
由于 对角不算临近,所以第二排的最后一个1没有被染色。
对于这个问题,函数原型已经给出,void fillwithcolor(int i,int j,int c)把i,j位置以及相邻近位置染色为c,我们试想,由于不考虑对角的位置,所以我们只需要考虑上下左右的位置,对于满足要求的上下左右的位置是不是成为了新的i,j位置呢。只需要在新的位置调用函数即可.
- void fillwithcolor(int ** map,int i,int j,int c,int m,int n)//函数的功能是给i,j位置及其临近位置染色c,m,n表示矩阵的行数和列数。
- {
- /*出口设计,出口设计是i,j位置总不可能跑到矩阵外面去了吧*/
- if (i > m-1 || j > n-1)
- return;
- int temp = arr[i][j]; //先保存初始色
- arr[i][j] = c;//染色
- /*考虑往上面走的情况*/
- if (i-1 >= 0)//不能走到图片外面去
- {
- if(map[i-1][j] == temp)
- fillwithcocor(arr,i-1,j,m,n);//如果上面位置同色的话,也将上面的点染色c
- }
- /*考虑向下走的情况*/
- if (i+1 <= m-1)//不能走到图片外面去
- {
- if(map[i+1][j] == temp)
- fillwithcocor(arr,i+1,j,m,n);//如果上面位置同色的话,也将上面的点染色c
- }
- /*向左和向右是同理的,在这里不做处理了*/
- /*.........................................................*/
这个问题的过程是哟明显的递归性的,依次用同样的方法处理其上下左右的位置。
3、非波拉契数列
斐波拉契数列在这里不做介绍。
首先同样明确函数的目的
- int fei(int n)//返回非波拉契数列中第n个位置的元素
- {
- /*设计出口,当位置为1或者2的时候,这两个位置上的数字都是1*/
- if(n == 1|| n==2)
- return 1;
- /*同样做到规模有减小,不考虑任何细节,明确 递归函数的目的,fei(n-1)+fei(n-2)就是第n个位置的数,直接返回即可*/
- return fei(n-1)+fei(n-2);
- /*和阶乘的设计比起来,这个就更加的顺理成章,因为n位置上的数等于n-1位置上的加上n-2位置上的数,理所当然的同时也做到了 规模的递减*/
- }
4、全排列问题
全排列递归程序设计是一个很好的理解递归设计的例子,有一定的难度。但是是很典型的递归程序设计案例,其过程有明显的过程递归性,下面将用上面的步骤来设计。
1、明确函数功能
我们假定传入的是一个数组,长度为n,递归函数初步设计成这样void permutation(int * arr,int n),然后我们要明确的是全排列的具体递归过程,例如我们考虑1 2 3的全排列。
首先将1固定在排列首,然后求2 3的全排列,然后将2固定在排列首,求13的全排列,最后将3固定在排列首,求12的全排列。这是第一层递归,将1固定在排列首时候,对于2,3两个数构成的全全排列依然要重复上面的过程,即把2固定在首和把3固定在首,明显是一个递归的过程。如果我们所求的数组较长,加入1,2, 3,.....,n个数,我们就会依次求取2~n的排列,3~n的全排列,i~n的全排列,所以我们在设计函数的时候,需要加上一个参数m,代表m到n的全排列,所以函数的定义就如下void permutation(int *arr,int m,int n)函数的功能是求取数组第m个数到第n个数的全排列。
2、由于是求全排列,所以不建议用一个二维数组去保存所有的排列,打印出所有的全排列即可,为了便于理解,我们可以先不考虑出口,先来写代码。
- void permutation(int * arr,int m,int n)
- {
- /*我们要一次完成让每一个数都当做一次排列的首,怎么做呢?当然是用循环*/
- for(int i = 0;i < n;i++)
- {
- /*首先完成交换,交换完成之后就是求1,,,n的全排列了*/
- swap(arr[i],arr[0]);
- /*所以i+1*/
- permutation(arr,i+1,n);
- /*值得注意的是,我们在上面做了交换,试想一下,如果我们不把数组还原回来,还能不能做到让每个数都做一次排列首,显然 是不行的,交换完成之后,必须还原回来才行,所以有*/
- swap(arr[i],arr[0]);
- }
- }
上面的代码已经初出具雏形,但是是不对的,为什么呢?因为毕竟是一个递归的过程,比如1234我们将1固定在排列首之后,后面的234依然要重复前面的过程,针对234也要做将2,3,4依次固定在234构成排列的排列首。所以上面的代码只考虑了第一层递归的交换,后面的都没考虑了,始终都是在和arr[0]座交换。上面函数中我们还有个参数m没用到,所以考虑用上。
- void permutation(int * arr,int m,int n)
- {
- /*循环的目的是用于交换的*/
- for (int i = m;i < n;i++)
- {
- /*k从0开始,表示第一个位置上的数,依次完成和k之后的数交换*/
- swap(arr[m],arr[i]);
- /*求除了第1,2,3,..,n个之后的数的全排列,m+1也存在了规模的递减*/
- permutation(arr,m+1,n);
- /*同样需要换回来*/
- swap(arr[m],arr[i]);
- }
- /*考虑设计出口问题*/
- /*m是不停的在向后游走的,n是长度,所以最后的位置是n-1,m不可能游到n-1之外去吧。所以其实m游走到n-1的位置时,恰好代表了完成了一次全排列的求解,再次声明,不要去考虑细节,整体上是合理的就是正确的*/
- if(m > n-1)
- {
- /*这就是出口,当m到了n-1的位置时,表明以某个数为首的全排列计算完成,直接打印即可*/
- for (int j = 0;j < n;j++)
- cout << arr[j]<<" ";
- cout << endl;
- }
- /*所以对于上面循环的代码,肯定是else分支执行,改动一下即可*/
- }
/***************************************************************************************************************************************************/
//最终结果
- void permutation(int * arr,int m,int n){
- if (m > n-1){
- for (int j = 0;j < n;j++)
- cout << arr[j]<<" ";
- cout << endl;
- }
- else{
- for (int i = k;i < n;i++){
- swap(arr[m],arr[i]);
- permutation(arr,m+1,n);
- swap(arr[m],arr[i]);
- }
- }
- }
对于过程递归来说,在函数的设计过程中只要觉得这个位置所需要实现的功能就是这个函数的功能,就可以理所当然的递归调用自己。不要考虑任何的细节,满足条件即可。
二,结构上具有递归性
递归还有一种设计思路是按照本身结构上具有递归性,用上面的思路解释不通。例如逆向打印,链表逆转,二叉树遍历等,很多带有逆向操作的都可以递归,为什么呢?因为递归是按层执行,每一层函数调用产生的变量,结果等都会压入函数栈中,知道遇到出口,才依次弹栈,所以我们利用其弹栈的特性,在逐渐弹的过程中,去执行我们需要的代码,就可以实现逆向效果。
案例1:用递归逆向打印一个数组
逆向打印数组其实简单的不要不要的了,但是考虑过用递归去打印吗?逆向打印我们在过程上完全感觉不到存在什么递归性,但是由于这种线性结构,使得其具有递归结构性,所以也是可以逆向打印的。
递归逆向打印的设计思路就是让递归一直下去,知道达到最后的位置,然后设定为出口条件,此时就会依次弹出,在弹出的位置依次打印每一层的值。
和过程递归相同的是同样要时刻明确自己函数的功能。
- void reprint(int * arr,int i,int n)//用一个i来描述当前的位置,便于递归退出,如果没有i,就无法退出
- {
- /*出口条件*/
- if (i > n-1)
- return;
- /*这里在不停的递归,知道当i == n的时候就开始弹他会弹到上一层调用的地方,并且开始执行调用函数下面的语句*/
- reprint(i+1)
- /*弹出后就会到这个位置来开始执行下面的语句,所以我们只需要在这里打印即可*/
- cout << arr[i];
- }
案例2:单向链表逆转
单向链表的逆转也是用递归来实现了,有了上面的案例一,是否会有些启发呢。单向链表只能从后面开始逆转,从前面开始逆转的话,由于没有前指针,所以会断掉,只能从后开始逆转。所以可以考虑用递归先将节点位置定位到最后,然后利用其弹栈的过程实现逆转。
- void /*到最后的时候node肯定为空,就相当于遇到了出口*/
- (node && node->next){
- }
案例3:二叉树中序遍历
- static travel(BSTREE_NODE * root){
- (root){
- }
二叉树的前序遍历和后序遍历同理。理解二叉树各序遍历组好找个二叉树图,然后结合代码来理解。
递归算法详细分析
C通过运行时堆栈支持递归函数的实现。递归函数就是直接或间接调用自身的函数。
许多教科书都把计算机阶乘和菲波那契数列用来说明递归,非常不幸我们可爱的著名的老潭老师的《C语言程序设计》一书中就是从阶乘的计算开始的函数递归。导致读过这本经书的同学们,看到阶乘计算第一个想法就是递归。但是在阶乘的计算里,递归并没有提供任何优越之处。在菲波那契数列中,它的效率更是低的非常恐怖。
这里有一个简单的程序,可用于说明递归。程序的目的是把一个整数从二进制形式转换为可打印的字符形式。例如:给出一个值4267,我们需要依次产生字符‘4’,‘2’,‘6’,和‘7’。就如在printf函数中使用了%d格式码,它就会执行类似处理。
我们采用的策略是把这个值反复除以10,并打印各个余数。例如,4267除10的余数是7,但是我们不能直接打印这个余数。我们需要打印的是机器字符集中表示数字‘7’的值。在ASCII码中,字符‘7’的值是55,所以我们需要在余数上加上48来获得正确的字符,但是,使用字符常量而不是整型常量可以提高程序的可移植性。‘0’的ASCII码是48,所以我们用余数加上‘0’,所以有下面的关系:
‘0’+ 0 =‘0’
‘0’+ 1 =‘1’
‘0’+ 2 =‘2’
...
从这些关系中,我们很容易看出在余数上加上‘0’就可以产生对应字符的代码。接着就打印出余数。下一步再取商的值,4267/10等于426。然后用这个值重复上述步骤。
这种处理方法存在的唯一问题是它产生的数字次序正好相反,它们是逆向打印的。所以在我们的程序中使用递归来修正这个问题。
我们这个程序中的函数是递归性质的,因为它包含了一个对自身的调用。乍一看,函数似乎永远不会终止。当函数调用时,它将调用自身,第2次调用还将调用自身,以此类推,似乎永远调用下去。这也是我们在刚接触递归时最想不明白的事情。但是,事实上并不会出现这种情况。
这个程序的递归实现了某种类型的螺旋状while循环。while循环在循环体每次执行时必须取得某种进展,逐步迫近循环终止条件。递归函数也是如此,它在每次递归调用后必须越来越接近某种限制条件。当递归函数符合这个限制条件时,它便不在调用自身。
在程序中,递归函数的限制条件就是变量quotient为零。在每次递归调用之前,我们都把quotient除以10,所以每递归调用一次,它的值就越来越接近零。当它最终变成零时,递归便告终止。
/*接受一个整型值(无符号0,把它转换为字符并打印它,前导零被删除*/
#include<iostream>
using
namespace
std;
void
binary_to_ascii(unsigned
int
value)
{
//unsigned int quotient;
//quotient=value/10;
if
(value%10!=0)
binary_to_ascii(value/10);
cout<<value%10+
'0'
;
}
int
main()
{
binary_to_ascii(3);
cout<<endl;
binary_to_ascii(3747);
}
递归是如何帮助我们以正确的顺序打印这些字符呢?下面是这个函数的工作流程。
1. 将参数值除以10
2. 如果quotient的值为非零,调用binary-to-ascii打印quotient当前值的各位数字
3. 接着,打印步骤1中除法运算的余数
注意在第2个步骤中,我们需要打印的是quotient当前值的各位数字。我们所面临的问题和最初的问题完全相同,只是变量quotient的值变小了。我们用刚刚编写的函数(把整数转换为各个数字字符并打印出来)来解决这个问题。由于quotient的值越来越小,所以递归最终会终止。
一旦你理解了递归,阅读递归函数最容易的方法不是纠缠于它的执行过程,而是相信递归函数会顺利完成它的任务。如果你的每个步骤正确无误,你的限制条件设置正确,并且每次调用之后更接近限制条件,递归函数总是能正确的完成任务。
但是,为了理解递归的工作原理,你需要追踪递归调用的执行过程,所以让我们来进行这项工作。追踪一个递归函数的执行过程的关键是理解函数中所声明的变量是如何存储的。当函数被调用时,它的变量的空间是创建于运行时堆栈上的。以前调用的函数的变量扔保留在堆栈上,但他们被新函数的变量所掩盖,因此是不能被访问的。
当递归函数调用自身时,情况于是如此。每进行一次新的调用,都将创建一批变量,他们将掩盖递归函数前一次调用所创建的变量。当我追踪一个递归函数的执行过程时,必须把分数不同次调用的变量区分开来,以避免混淆。
程序中的函数有两个变量:参数value和局部变量quotient。下面的一些图显示了堆栈的状态,当前可以访问的变量位于栈顶。所有其他调用的变量饰以灰色的阴影,表示他们不能被当前正在执行的函数访问。
假定我们以4267这个值调用递归函数。当函数刚开始执行时,堆栈的内容如下图所示:
执行除法之后,堆栈的内容如下:
接着,if语句判断出quotient的值非零,所以对该函数执行递归调用。当这个函数第二次被调用之初,堆栈的内容如下:
堆栈上创建了一批新的变量,隐藏了前面的那批变量,除非当前这次递归调用返回,否则他们是不能被访问的。再次执行除法运算之后,堆栈的内容如下:
quotient的值现在为42,仍然非零,所以需要继续执行递归调用,并再创建一批变量。在执行完这次调用的出发运算之后,堆栈的内容如下:
此时,quotient的值还是非零,仍然需要执行递归调用。在执行除法运算之后,堆栈的内容如下:
不算递归调用语句本身,到目前为止所执行的语句只是除法运算以及对quotient的值进行测试。由于递归调用这些语句重复执行,所以它的效果类似循环:当quotient的值非零时,把它的值作为初始值重新开始循环。但是,递归调用将会保存一些信息(这点与循环不同),也就好是保存在堆栈中的变量值。这些信息很快就会变得非常重要。
现在quotient的值变成了零,递归函数便不再调用自身,而是开始打印输出。然后函数返回,并开始销毁堆栈上的变量值。
每次调用putchar得到变量value的最后一个数字,方法是对value进行模10取余运算,其结果是一个0到9之间的整数。把它与字符常量‘0’相加,其结果便是对应于这个数字的ASCII字符,然后把这个字符打印出来。
接着函数返回,它的变量从堆栈中销毁。接着,递归函数的前一次调用重新继续执行,她所使用的是自己的变量,他们现在位于堆栈的顶部。因为它的value值是42,所以调用putchar后打印出来的数字是2。
接着递归函数的这次调用也返回,它的变量也被销毁,此时位于堆栈顶部的是递归函数再前一次调用的变量。递归调用从这个位置继续执行,这次打印的数字是6。在这次调用返回之前,堆栈的内容如下:
现在我们已经展开了整个递归过程,并回到该函数最初的调用。这次调用打印出数字7,也就是它的value参数除10的余数。
然后,这个递归函数就彻底返回到其他函数调用它的地点。
如果你把打印出来的字符一个接一个排在一起,出现在打印机或屏幕上,你将看到正确的值:4267
汉诺塔问题递归算法分析:
一个庙里有三个柱子,第一个有64个盘子,从上往下盘子越来越大。要求庙里的老和尚把这64个盘子全部移动到第三个柱子上。移动的时候始终只能小盘子压着大盘子。而且每次只能移动一个。
1、此时老和尚(后面我们叫他第一个和尚)觉得很难,所以他想:要是有一个人能把前63个盘子先移动到第二个柱子上,我再把最后一个盘子直接移动到第三个柱子,再让那个人把刚才的前63个盘子从第二个柱子上移动到第三个柱子上,我的任务就完成了,简单。所以他找了比他年轻的和尚(后面我们叫他第二个和尚),命令:
① 你丫把前63个盘子移动到第二柱子上
② 然后我自己把第64个盘子移动到第三个柱子上后
③ 你把前63个盘子移动到第三柱子上
2、第二个和尚接了任务,也觉得很难,所以他也和第一个和尚一样想:要是有一个人能把前62个盘子先移动到第三个柱子上,我再把最后一个盘子直接移动到第二个柱子,再让那个人把刚才的前62个盘子从第三个柱子上移动到第三个柱子上,我的任务就完成了,简单。所以他也找了比他年轻的和尚(后面我们叫他第三和尚),命令:
① 你把前62个盘子移动到第三柱子上
② 然后我自己把第63个盘子移动到第二个柱子上后
③ 你把前62个盘子移动到第二柱子上
3、第三个和尚接了任务,又把移动前61个盘子的任务依葫芦话瓢的交给了第四个和尚,等等递推下去,直到把任务交给了第64个和尚为止(估计第64个和尚很郁闷,没机会也命令下别人,因为到他这里盘子已经只有一个了)。
4、到此任务下交完成,到各司其职完成的时候了。完成回推了:
第64个和尚移动第1个盘子,把它移开,然后第63个和尚移动他给自己分配的第2个盘子。
第64个和尚再把第1个盘子移动到第2个盘子上。到这里第64个和尚的任务完成,第63个和尚完成了第62个和尚交给他的任务的第一步。
从上面可以看出,只有第64个和尚的任务完成了,第63个和尚的任务才能完成,只有第2个和尚----第64个和尚的任务完成后,第1个和尚的任务才能完成。这是一个典型的递归问题。 现在我们以有3个盘子来分析:
第1个和尚命令:
① 第2个和尚你先把第一柱子前2个盘子移动到第二柱子。(借助第三个柱子)
② 第1个和尚我自己把第一柱子最后的盘子移动到第三柱子。
③ 第2个和尚你把前2个盘子从第二柱子移动到第三柱子。
很显然,第二步很容易实现(哎,人总是自私地,把简单留给自己,困难的给别人)。
其中第一步,第2个和尚他有2个盘子,他就命令:
① 第3个和尚你把第一柱子第1个盘子移动到第三柱子。(借助第二柱子)
② 第2个和尚我自己把第一柱子第2个盘子移动到第二柱子上。
③ 第3个和尚你把第1个盘子从第三柱子移动到第二柱子。
同样,第二步很容易实现,但第3个和尚他只需要移动1个盘子,所以他也不用在下派任务了。(注意:这就是停止递归的条件,也叫边界值)
第三步可以分解为,第2个和尚还是有2个盘子,命令:
① 第3个和尚你把第二柱子上的第1个盘子移动到第一柱子。
② 第2个和尚我把第2个盘子从第二柱子移动到第三柱子。
③ 第3个和尚你把第一柱子上的盘子移动到第三柱子。
分析组合起来就是:1→3 1→2 3→2 借助第三个柱子移动到第二个柱子 |1→3 自私人留给自己的活| 2→1 2→3 1→3借助第一个柱子移动到第三个柱子|共需要七步。
如果是4个盘子,则第一个和尚的命令中第1步和第3步各有3个盘子,所以各需要7步,共14步,再加上第1个和尚的1步,所以4个盘子总共需要移动7+1+7=15步,同样,5个盘子需要15+1+15=31步,6个盘子需要31+1+31=64步……由此可以知道,移动n个盘子需要(2的n次方)-1步。
从上面整体综合分析可知把n个盘子从1座(相当第一柱子)移到3座(相当第三柱子):
(1)把1座上(n-1)个盘子借助3座移到2座。
(2)把1座上第n个盘子移动3座。
(3)把2座上(n-1)个盘子借助1座移动3座。
下面用hanoi(n,a,b,c)表示把1座n个盘子借助2座移动到3座。
很明显: (1)步上是 hanoi(n-1,1,3,2)
(3)步上是 hanoi(n-1,2,1,3)
用C语言表示出来,就是:
#include <stdio.h>
int
method(
int
n,
char
a,
char
b)
{
printf
(
"number..%d..form..%c..to..%c.."
n",n,a,b);
return
0;
}
int
hanoi(
int
n,
char
a,
char
b,
char
c)
{
if
( n==1 ) move (1,a,c);
else
{
hanoi(n-1,a,c,b);
move(n,a,c);
hanoi(n-1,b,a,c);
};
return
0;
}
int
main()
{
int
num;
scanf
(
"%d"
,&num);
hanoi(num,
'A'
,
'B'
,
'C'
);
return
0;
}