递归详解

来源:互联网 发布:阿里私有云 编辑:程序博客网 时间:2024/06/06 05:17

博主大二在学习二叉树的时候,经常遇到递归相关的算法,一知半解、强行记忆。网上关于递归的文章不少,但是一般都是关于斐波拉切数列、数乘、汉诺塔等问题上,过于具体,直接给代码,缺少了思想,换个问题我就没法活学活用了。
网上看到篇文章,js递归实现阶乘,直接调用

n*f(n-1);

而没有

return n*f(n-1);

作者经历种种蛋疼终于找出问题得到答案时,怪罪js编译器,而没有发现自己递归理解不到位,生搬硬套代码,怨不得IDE。
博主很菜,但是不能接受自己太水,所以专门花心思把递归理解一番,在这里做个总结,和大家分享。
本文要感谢算法中的递归分析和分治法的原理、递归算法详解、Recursion and the Return Keyword和《编程之美》,书中的例子,让我对算法在实际中的应用有了更直观的理解。
本文分这么几个部分:

  1. 什么是递归?
  2. 递归解决什么样的问题?
  3. 递归的调用流程是什么?

1.什么是递归

递归:在函数或子过程的内部,直接或间接地调用自己的算法。

递归算法解决问题的特点:

  • 递归就是在过程或函数里调用自身。
  • 在使用递归策略时,必须有一个明确的递归结束条件,称为递归出口。
  • 递归算法解题通常显得很简洁,但递归算法解题的运行效率较低。所以一般不提倡用递归算法设计程序。
  • 在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储,递归次数过多容易造成栈溢出等。所以一般不提倡用递归算法设计程序。

虽说从栈溢出和运行效率考虑,不提倡递归设计程序,但是由于递归程序代码清晰、简洁,值得我们好好学习。

2.递归解决的问题

递归算法所体现的“调用自己”的情况一般有三个要求:

  1. 每次调用在规模上都有所缩小(通常是减半);
  2. 相邻两次重复之间有紧密的联系,前一次要为后一次做准备(通常前一次的输出就作为后一次的输入);
  3. 在问题的规模极小时必须用直接给出解答而不再进行递归调用,因而每次递归调用都是有条件的(以规模未达到直接解答的大小为条件),无条件递归调用将会成为死循环而不能正常结束。

在解决问题的规模上,每次要缩小,这和分治法的思想很像,分治法一般来说分为三个阶段:

  1. 划分:把问题规模为n的原问题划分为k个规模较小的子问题,并尽量使这k个字问题规模相等。
  2. 求解子问题,子问题经常与原问题解法相同
  3. 把各个子问题的解合并起来

可见在原问题和子问题求解时,可以使用递归函数完成套路。我们常见的二分排序等都可以用递归函数解决。有时候,编写递归程序时确实难以获得更简单的子问题。 不过,使用归纳定义的数据集可以清楚划分出数据集。比如:树、链表等。
说了这么多,大家应该对递归多少有点概念了。具体的例子,比如数乘我这儿就不举例了。我想大家对递归迷茫的不是简单的使用,而是递归具体的执行流程是怎样的,这样在遇到具体问题的时候才清楚到底该如何调适bug、解决问题。

3.递归的调用流程

简单来说,递归函数的调用流程可以用下面这幅图表示,从主函数入口开始执行,嵌套调用子函数直至退出条件满足,从最里面一层的子函数不断向外返回。
引用自知乎问题
举个例子:

public class Recursion{public static void main(String[] args){    up_and_down(1);//调用递归函数    return 0;}void static up_and_down(int n){    if (n < 4)    {        up_and_down(n + 1);    }}}

对这样一个极其简单的程序,我们看下它的调用过程,up_and_down(1)->up_and_down(2)->up_and_down(3)->up_and_down(4),这时候n==4,不满足n<4的要求,压入栈的这四个函数从栈顶开始出栈返回。
但是我们平时使用递归显然不会这么简单,经常是执行回调函数的前后句都有代码,我们把这个例子再复杂一下。

public class Recursion{public static void main(String[] args){    up_and_down(1);//调用递归函数    return 0;}void static up_and_down(int n){    System.out.println("front: "+n);    if (n < 4)    {        up_and_down(n + 1);    }    System.out.println("end: "+n);}}

效果如图:
这里写图片描述
结合上面的流程图,明白了没?up_and_down(1)内部调用up_and_down(2)之前执行了

System.out.println(“front: “+n);//此时n==2

然后同理调用up_and_down(3)和up_and_down(4)。up_and_down(4)执行到

if (n < 4){
up_and_down(n + 1);
}

不符合继续递归的条件,up_and_down(4)执行

System.out.println(“end: “+n);//n==4

会开始返回up_and_down(3),执行

System.out.println(“end: “+n);//n==3

依次类推,执行完

System.out.println(“end: “+n);//n==1

后,main函数退出,程序执行完毕。递归嵌套的过程如图所示:
这里写图片描述
注意,该图仅仅表示递归时子函数和父函数的嵌套执行关系,实际存储是栈的形式保存了每一次递归的函数。
递归函数自己一层层地往深处调用自己,然后一层层返回,每到一层,就执行接下来的语句(故调用开始的地址和返回的地址一样),每一级递归都是用自己的局部变量。这样子,函数逐步往深调用然后逐步返回直到main函数。
说完递归函数具体的嵌套逻辑,再说下开篇的时候提到的return的问题。自己写过递归的同学可能多少有这样的感觉:有的时候return recursion();有的时候直接recursion();这两种有什么区别呢?或者说什么时候return,什么时候不return呢?比如上文给的例子就没有return,但是在阶乘的时候,代码一般是这样的:

int f(int n){    if(n==1){        return 1;    }else{        return n*f(n-1);    }}

return在递归函数中起什么样的作用呢?不管是否在递归函数中,return 的作用都是从当前函数中退出,并将响应的结果赋值给某变量(如果有的话)。我们先分析阶乘:n!=n*(n-1)!,n>1;1!==1,用函数f(n)表示n!的话,可以表达为:f(n)=n*f(n-1),n>1;f(1)=1。也就是说,要求(n),可以由f(n-1)得到,于是我去计算f(n-1),同理递推求到f(1),只要知道f(1),就可以一路计算到f(n)。而return的作用就很明显了,把f(n-1)的值返回给f(n)=f(n-1)*n使用。还记得我们说分治法的第三步吗?合并子问题的解,return就可以理解成合并子问题的解。如果还不明白,我们换个写法来看。

int f(int n){    if(n==1){        return 1;    }else{        int result=f(n-1)*n;        return result;    }}

还记得我们上文分析的递归嵌套结构图吗?

int result=f(n-1)*n;// *n操作也是在返回的时候开始执行,从1开始返回,然后 *2,结果存放到result
return result;//返回result给父函数,当前递归函数退出。

结合图大家能否构思出来从f(4)开始的执行流程呢?想必很简单,博主就不画了。
分析了递归的嵌套以及return的作用,可能觉得举的例子太简单了,不清楚自己到底理解了没,我们换个二叉树的例子,带大家理一遍。
二叉树的中序遍历,递归的实现只有四行核心代码,我们就分析这个。其他的例子代码偏长,相信有中序做例子,大家能自己看懂。

void inOrder(BiTree bt){    if(bt!=NULL){        inOrder(bt->lchild);        cout<<bt->data<<endl;        inOrder(bt->rchild);    }}

中序遍历就是先访问左子树,再访问跟根节点,最后访问右子树。左、右子树的访问顺序同上,明显的递归,可能从设计上我们能理解这个写法,但是我们知道为什么这么写就可以保证每颗子树先访问左,再访问根,最后访问右呢?
我们借助一个简单的二叉树分析:
这里写图片描述
先访问1,不为空,递归1的左孩子2,不为空,递归2的左孩子4,不为空,递归4的左孩子,为空,回退,执行cout 4,4的右孩子为空,回退,4回退到2,执行cout 2,2的右孩子为5,5的左孩子为空,回退,执行cout 5,右孩子为空,回退,结点5回退,结点2回退到1,1的右孩子3,左孩子为空,回退,执行cout 3,3的右孩子空,回退,3回退到1,递归函数退出。
这里写图片描述
这么一分析,大家能体会到上述三行代码的含义了吗?递归左子树,返回的时候输出根结点,再递归右子树,返回的条件是结点为空。每一次递归函数完成的都是对一个结点的左子树、自身、右子树的完整访问。对于二叉树这种由递归定义出来的数据结构,几乎都可以用递归实现处理。
分享下《编程之美》作者总结的递归问题三体会:

  1. 先弄清楚递归的顺序,递归的实现中往往要假设后续的调用已经完成。(比如说,中序的时候就是假设子树的中序遍历已经完成,后面就可以进行子树的根结点的遍历)
  2. 分析清楚递归体的逻辑,然后写出来。(比如说,中序的时候逻辑就是左子树访问完了之后,结点输出,再访问右子树)
  3. 考虑清楚递归退出的条件。(可借助return或者条件判断)

总结

说了这么多,大家对递归的流程应该有了进一步的了解,分享一个链接http://stackoverflow.com/questions/2247063/need-help-in-returning-from-a-recursive-method,借助这道题,再让自己练练手吧。
很惭愧,做了一点微小的贡献!

0 0