递归算法详细分析

Posted AlanTu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了递归算法详细分析相关的知识,希望对你有一定的参考价值。

递归的理解与设计

递归算法:是一种直接或者间接地调用自身的算法。在计算机编写程序中,递归算法对解决一大类问题是十分有效的,它往往使算法的描述简洁而且易于理解。

 

1,参考于书籍中的讲解:

递归的原理,其实就是一个栈(stack), 比如求5的阶乘,要知道5的阶乘,就要知道4的阶乘,4又要是到3的,以此类推,所以递归式就先把5的阶乘表示入栈, 在把4的入栈,直到最后一个,之后呢在从1开始出栈, 看起来很麻烦,确实很麻烦,他的好处就是写起代码来,十分的快,而且代码简洁,其他就没什么好处了,运行效率出奇的慢.
例: 求n的阶乘
  1. int fac(n){  
  2.  if(n == 0 || n == 1){  
  3.         return 1;  
  4.   }  
  5.  else{  
  6.         return n*fac(n-1); //自己调用自己,求n-1的阶乘  
  7.   }  
  8. }  

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));


最终代码

  1. class Solution {    
  2. public:    
  3.     bool isSameTree(TreeNode* p, TreeNode* q) {    
  4.         if(p==NULL&&q==NULL)      
  5.             return true;      
  6.         else if(p==NULL&&q!=NULL)      
  7.             return false;      
  8.         else if(p!=NULL&&q==NULL)      
  9.             return false;      
  10.         else if(p!=NULL&&q!=NULL && p->val!=q->val)      
  11.             return false;      
  12.         else      
  13.             return (isSameTree(p->left,q->left))&&(isSameTree(p->right,q->right));      
  14.     }    
  15. };  

 

例子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)控制重复的逻辑

以上已完成


最终代码:

  1. class Solution {    
  2. public:    
  3. //将根节点反转,并获取翻转后该根节点的指针    
  4.      TreeNode* invertTree(TreeNode* root) {    
  5.         if(root == NULL){       
  6.              return NULL;    
  7.         }else{    
  8.             //这样做将:树的底层先被真正交换,然后其上一层才做反转    
  9.             TreeNode* newleft = invertTree(root->right);    
  10.             TreeNode* newright = invertTree(root->left);    
  11.             root->left = newleft;    
  12.             root->right = newright;    
  13.             return root;    
  14.         }    
  15.     }     
  16. };   


例子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)控制重复的逻辑

前序遍历的基本规则,总是先访问根节点,再左节点,最后右节点


完整代码:

    1. class Solution {    
    2. public:    
    3.     vector<int> result;  //将保存遍历的所有结果  
    4.     vector<int> preorderTraversal(TreeNode* root) {    
    5.         if(root){    
    6.             result.push_back(root->val);    
    7.             preorderTraversal(root->left);  //递归子函数不接受解  
    8.             preorderTraversal(root->right);    
    9.         }    
    10.         return result;    
    11.     }    
    12. };  

 

 

递归程序设计心得与体会

用递归设计出来的程序总是简洁易读,极具美感。但是对于刚入门的学者来说,当遇到递归场景时,自己却难以正确的设计出合理的递归程序。博主曾经也是困惑不已,写的多了,也就渐渐的熟悉了递归设计。特谈一下自己的感受,有些术语是博主自己总结,有可能有不合理之处。


学习递归程序设计,建议首先应该从小规模的递归开始研究,小规模就是说自己可以调试跟踪代码,且自己不会晕。这个过程完成之后,才能熟练掌握递归层次之间的转换,明白递归的执行过程。在这里推荐一篇文章:http://blog.chinaunix.net/uid-20196318-id-31175.html,文章的第一个案例有一定的参考价值,第二个案例是全排列,将在后面讨论到。


在平时的程序设计中,遇到的递归问题一般可以分为两类,一类是过程递归,就是说执行的过程有明显的递归性,典型的就是求阶乘,斐波拉契数列,矩阵染色。。。大多数问题可以归结为第一类;第二类是结构递归,比如二叉树的各序遍历,链表逆转,反向打印等问题。两类问题的设计考虑是有所不同的,如果采用同样的思路去考虑两类不同问题,一定得不到正确的代码。建议从过程递归设计开始学习。


一、过程递归性

不管是过程递归还是结构递归首先要明确的就是一定要抛弃程序设计的细节,如果在设计过程中扣住细节,试图弄清楚每一步执行过程,你就失败了。递归设计的设计者首先要明确的是你的递归函数的功能,比如阶乘int fun(int n),他的功能就是返回n的阶乘,设计过程中要时时记住自己递归函数的设计目的。其次就是递归程序的出口设计,这一点是比较灵活的,不同问题有不同的设计;最后就是一定要有规模的递减。在整个递归设计过程中,一定要严格注意和把握这几点,缺一不可。

案例1:阶乘

阶乘基本就是递归入门级案例,现在将用上面的思路来设计。

1、设计出函数原型,明确其功能;

 

  1. int fun(int n)  //函数功能,返回n的阶乘结果  
  2. {  
  3.     /*设计递归出口,在这个程序中,出口明显是根据n的变化来确定的,而0!=0,1!=1,所以我们就以0或者1来结束递归*/  
  4.     if(n==0||n==1)  
  5.         return 1;  
  6.     /*注意不要做任何的细节处理,明确你函数的功能,*/  
  7.     //函数的功能是返回n的阶乘,那么直接就return fun(n);这样做可以吗?这样的话只做到了两点,没有规模的递减  
  8.     /*要规模递减,只需做简单的处理*/  
  9.     return n*fun(n-1);  
  10.     /*那下面这个语句可以吗*/  
  11.     /*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判断条件出不来*/  
  12. }  

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位置呢。只需要在新的位置调用函数即可.

 

  1. void fillwithcolor(int ** map,int i,int j,int c,int m,int n)//函数的功能是给i,j位置及其临近位置染色c,m,n表示矩阵的行数和列数。  
  2. {  
  3.     /*出口设计,出口设计是i,j位置总不可能跑到矩阵外面去了吧*/  
  4.     if (i > m-1 || j > n-1)  
  5.         return;  
  6.     int temp = arr[i][j];  //先保存初始色  
  7.     arr[i][j] = c;//染色  
  8.    /*考虑往上面走的情况*/  
  9.     if (i-1 >= 0)//不能走到图片外面去  
  10.    {  
  11.         if(map[i-1][j] == temp)  
  12.         fillwithcocor(arr,i-1,j,m,n);//如果上面位置同色的话,也将上面的点染色c  
  13.    }  
  14.     /*考虑向下走的情况*/  
  15.     if (i+1 <= m-1)//不能走到图片外面去  
  16.    {  
  17.         if(map[i+1][j] == temp)  
  18.             fillwithcocor(arr,i+1,j,m,n);//如果上面位置同色的话,也将上面的点染色c  
  19.    }  
  20. /*向左和向右是同理的,在这里不做处理了*/  
  21. /*.........................................................*/  

这个问题的过程是哟明显的递归性的,依次用同样的方法处理其上下左右的位置。

3、非波拉契数列

斐波拉契数列在这里不做介绍。

首先同样明确函数的目的

 

  1. int fei(int n)//返回非波拉契数列中第n个位置的元素  
  2. {  
  3.     /*设计出口,当位置为1或者2的时候,这两个位置上的数字都是1*/  
  4.     if(n == 1|| n==2)  
  5.         return 1;  
  6.     /*同样做到规模有减小,不考虑任何细节,明确 递归函数的目的,fei(n-1)+fei(n-2)就是第n个位置的数,直接返回即可*/  
  7.     return fei(n-1)+fei(n-2);  
  8.     /*和阶乘的设计比起来,这个就更加的顺理成章,因为n位置上的数等于n-1位置上的加上n-2位置上的数,理所当然的同时也做到了     规模的递减*/  
  9. }  

 

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、由于是求全排列,所以不建议用一个二维数组去保存所有的排列,打印出所有的全排列即可,为了便于理解,我们可以先不考虑出口,先来写代码。

 

  1. void permutation(int * arr,int m,int n)  
  2. {  
  3.     /*我们要一次完成让每一个数都当做一次排列的首,怎么做呢?当然是用循环*/  
  4.     for(int i = 0;i < n;i++)  
  5.    {  
  6.         /*首先完成交换,交换完成之后就是求1,,,n的全排列了*/  
  7.         swap(arr[i],arr[0]);  
  8.                /*所以i+1*/  
  9.         permutation(arr,i+1,n);  
  10.         /*值得注意的是,我们在上面做了交换,试想一下,如果我们不把数组还原回来,还能不能做到让每个数都做一次排列首,显然          是不行的,交换完成之后,必须还原回来才行,所以有*/  
  11.         swap(arr[i],arr[0]);  
  12.     }  
  13. }  

上面的代码已经初出具雏形,但是是不对的,为什么呢?因为毕竟是一个递归的过程,比如1234我们将1固定在排列首之后,后面的234依然要重复前面的过程,针对234也要做将2,3,4依次固定在234构成排列的排列首。所以上面的代码只考虑了第一层递归的交换,后面的都没考虑了,始终都是在和arr[0]座交换。上面函数中我们还有个参数m没用到,所以考虑用上。

 

  1. void permutation(int * arr,int m,int n)  
  2. {  
  3.     /*循环的目的是用于交换的*/  
  4.     for (int i = m;i < n;i++)  
  5.    {  
  6.         /*k从0开始,表示第一个位置上的数,依次完成和k之后的数交换*/  
  7.         swap(arr[m],arr[i]);   
  8.         /*求除了第1,2,3,..,n个之后的数的全排列,m+1也存在了规模的递减*/  
  9.         permutation(arr,m+1,n);  
  10.         /*同样需要换回来*/  
  11.         swap(arr[m],arr[i]);  
  12.    }  
  13.      /*考虑设计出口问题*/  
  14.      /*m是不停的在向后游走的,n是长度,所以最后的位置是n-1,m不可能游到n-1之外去吧。所以其实m游走到n-1的位置时,恰好代表了完成了一次全排列的求解,再次声明,不要去考虑细节,整体上是合理的就是正确的*/  
  15.        if(m > n-1)  
  16.        {  
  17.        /*这就是出口,当m到了n-1的位置时,表明以某个数为首的全排列计算完成,直接打印即可*/  
  18.            for (int j = 0;j < n;j++)  
  19.                cout << arr[j]<<" ";  
  20.            cout << endl;  
  21.        }  
  22.       /*所以对于上面循环的代码,肯定是else分支执行,改动一下即可*/  
  23. }  

/***************************************************************************************************************************************************/

//最终结果

 

  1. void permutation(int * arr,int m,int n){  
  2.     if (m > n-1){  
  3.         for (int j = 0;j < n;j++)  
  4.             cout << arr[j]<<" ";  
  5.         cout << endl;  
  6.       }  
  7. else{  
  8.     for (int i = k;i < n;i++){  
  9.         swap(arr[m],arr[i]);  
  10.         permutation(arr,m+1,n);  
  11.         swap(arr[m],arr[i]);  
  12.         }  
  13.     }  
  14. }  

对于过程递归来说,在函数的设计过程中只要觉得这个位置所需要实现的功能就是这个函数的功能,就可以理所当然的递归调用自己。不要考虑任何的细节,满足条件即可。

二,结构上具有递归性

递归还有一种设计思路是按照本身结构上具有递归性,用上面的思路解释不通。例如逆向打印,链表逆转,二叉树遍历等,很多带有逆向操作的都可以递归,为什么呢?因为递归是按层执行,每一层函数调用产生的变量,结果等都会压入函数栈中,知道遇到出口,才依次弹栈,所以我们利用其弹栈的特性,在逐渐弹的过程中,去执行我们需要的代码,就可以实现逆向效果。


案例1:用递归逆向打印一个数组

逆向打印数组其实简单的不要不要的了,但是考虑过用递归去打印吗?逆向打印我们在过程上完全感觉不到存在什么递归性,但是由于这种线性结构,使得其具有递归结构性,所以也是可以逆向打印的。

递归逆向打印的设计思路就是让递归一直下去,知道达到最后的位置,然后设定为出口条件,此时就会依次弹出,在弹出的位置依次打印每一层的值。

和过程递归相同的是同样要时刻明确自己函数的功能。

 

  1. void reprint(int * arr,int i,int n)//用一个i来描述当前的位置,便于递归退出,如果没有i,就无法退出  
  2. {  
  3.     /*出口条件*/  
  4.     if (i > n-1)  
  5.         return;  
  6.     /*这里在不停的递归,知道当i == n的时候就开始弹他会弹到上一层调用的地方,并且开始执行调用函数下面的语句*/  
  7.     reprint(i+1)  
  8.     /*弹出后就会到这个位置来开始执行下面的语句,所以我们只需要在这里打印即可*/  
  9.     cout << arr[i];  
  10. }  

 

案例2:单向链表逆转

单向链表的逆转也是用递归来实现了,有了上面的案例一,是否会有些启发呢。单向链表只能从后面开始逆转,从前面开始逆转的话,由于没有前指针,所以会断掉,只能从后开始逆转。所以可以考虑用递归先将节点位置定位到最后,然后利用其弹栈的过程实现逆转。

 

  1. void     /*到最后的时

    以上是关于递归算法详细分析的主要内容,如果未能解决你的问题,请参考以下文章

    数据结构与算法递归全流程详细剖析 | 详解图的深度优先遍历

    数据结构与算法递归全流程详细剖析 | 详解图的深度优先遍历

    算法 | 递归+缓存值=动态规划?

    详细实例说明+典型案例实现 对递归法进行全面分析 | C++

    详解二叉树的遍历问题(前序后序中序层序遍历的递归算法及非递归算法及其详细图示)

    算法分析之递归与分治策略