数据结构 递归讲解

来源:互联网 发布:风险控制矩阵图 编辑:程序博客网 时间:2024/06/11 03:59

1. 递归的定义

           定义好简单了, 如果1个函数直接或间接地调用自己本身, 我们就说它是1个递归.

           



2. 不同函数是怎样互相调用的.

           严蔚敏编写的的数据结构教材中写过这段话:

          

           当1个函数(A)的运行期间 调用 另1个函数(B), 在调用函数B之前, 系统必须先完成3件事

           1. 将所有的实参, 返回地址等信息传递给被调用的函数(B)保存.

           2. 为被调用的函数(B)的局部变量分配存储区域.

           3. 将控制权限转移到被调用函数(B)的入口.


             而当被调用函数(B)执行完全 返回调用函数(A) 之前, 系统也必须成3件事

            1. 保存被调用函数(B)的结算结果.

            2. 释放被调用函数所占的存储区域

            3. 依照被调用函数(B)保存的返回地址, 将控制权返回给调用函数(A)


            当有多个函数构成嵌套调用时, 按照后调用, 先返回的原则(先入后出),   上述函数的传递与控制转移必须借助栈来实现.

            即系统将整个程序运行所需的数据空间安排在1个栈中,  每当调用1个函数时, 就在栈顶为其分配1个存储区域, 当完成调用1个函数时, 就在栈顶释放对应的存储区域,   则当前正在运行的函数存储区域必须在栈顶!


    

           这段话有点复杂啊..  下面我们借助1个小程序来讲解下:

           代码如下:

#include <stdio.h>void f1();void g();void k();void f1(){printf("it's f1\n");g();}void g(){printf("it's g\n");k();}void k(){printf("it's k\n");}int recur_main(void){f1();printf("recur_main done!\n");return 0;}


2.1 入口函数recur_main()

         分析下, 这个程序入口是recur_main()函数,    那么系统就会把这个函数所需的变量和内存压入函数栈内, 这个函数就是栈底(假设没有其他函数调用recur_main).  而这个函数就位于栈顶开始执行.


如下图:



2.2 recur_main() 调用 f1()


      好了当, recur_main() 执行后第一句就是调用函数f1() , 那么系统会把f1() 所需的参数变量(这里没有), 和返回地址放入栈中.

      实参变量容易理解, 关键是那个返回地址是什么呢?  就是当f1()执行完成, 系统返回到哪里啊?  就是返回到recur_main()函数的第1行啊.  具体来讲, 实际上会返回到函数栈中 recur_main()节点的第1行所对应的内存地址!

      总之就会将上述信息和函数f1() 压入栈中, 这时f()在函数栈内就成为栈顶了! 同时系统会转移到f1()函数. 如下图:



2.3 f1() 调用g(), g()再调用k()

      同理, f1() 第2行, 里调用了函数g() 那么系统会把f1()第2行在栈里的内存地址和相关信息和g()压入栈中执行.

      g() 里面又调用了k(), 所以执行到k() 时,    内存栈里的状态如下图:

      


     可以见到, 每调用1个新的函数, 都会把这个函数压入栈顶执行, 所以应验了上面那句话, 当前被执行的函数在内存栈中必须位于栈顶啊.


2.4 总的来说, 计算机利用栈技术来实现函数的各级调用

    当执行玩1个函数, 系统会在函数栈中把该函数从栈顶出栈, 而且会根据之前的保存的返回地址, 返回到栈中下个函数(变成了新栈顶)的对应行继续执行.  


      也就是说当k()执行完, 就直接执行g(),  ---> f1() ---> recur_main().


       实际上, 我们可过gdb调试来查看函数栈的当前状态哦:

如下图:



可以观察出, 实际上是main() 才是真正的程序入口, 所以main() 是栈底啦. 除了栈顶k(), 它下面的节点都有1个地址, 那个就是返回地址啊!


      好了. 根据图解, 估计可以大概理解到, 系统中是利用入栈和出栈来实现函数的各种调用的, 毕竟1个函数不能同时调用2个函数,  而且遵循先进后出的原则啊!



3. 递归的本质是函数栈中出现两个相同的函数节点.

       看回上面的例子,   当程序执行到k() 时, k()里面没有再调用其他函数了, 所以这个栈长度最大时, k()就是栈顶, 然后k() , g() , f1() , recur_main(),逐个执行完出栈, 程序就执行完毕了.

        我们说这个过程没有出现递归. 


        那么怎么才能出现递归了,  我们修改下这个程序.

改成:

#include <stdio.h>void f1();void g();void k();void f1(){printf("it's f1\n");g();}void g(){printf("it's g\n");k();}void k(){printf("it's k\n");f1(); //call back f1()}int recur_main(void){f1();printf("recur_main done!\n");return 0;}

     改动只有1处, 就是令 k() 调用f1().,  我们就说这3个函数 f1() / g() /k()都是递归函数了, 何解呢.

     我们看看当第一次调用到k()时的函数栈, 如下图:



然后k() 调用了 f1(), 那么系统会再次将f1() 压入栈, 如下图:



             可以见到函数栈里有两个f1() 节点, 这两个节点实际上的函数是相同的啊, 都是f1()同1个函数啊, 但是, 他们的返回地址是不同的, 栈顶的f1() 函数返回地址是k()的第2行, 而下面的f1()返回地址似乎 recur_main()的第一行啊, 所以这就是为什么要保存返回地址的原因了!  当然实际上1个栈内两个相同函数节点的实参也有可能不相同的.

             

              当1个栈中出现2个相同的函数节点时, 如上图我们认为f1()通过g() 和 k()简介调用了自己本身, 所以f1()是1个递归函数啊, 而f1()会调用g(), 马上g(0)也会再次入栈, 所以g()也是1个递归函数啊...


             当然了, 这个是1个没有出口的递归函数地址, 它们会被执行到栈溢出时程序才会错误地退出..



4. 1个函数直接调用本身的简单例子:

上面的例子是1个间接调用本身的例子啦, 下面写个直接调用本身的例子,

也很简单, 代码如下:

void f3(){printf("it's f3\n");f3();}int recur_main(void){f3();printf("recur_main done!\n");return 0;}


看上面的函数f3(),  它的函数体内就调用了自己本身.  但是这个函数跟再上面的f1()一样, 都是1个"死递归"..

那么我们可以不可以控制递归执行的次数, 也就是说执行到1定条件后 就不再执行递归呢?

答案就是递归函数的出口


5. 递归函数的出口.

实际上, 对于任何1个递归函数, 都必须定义它的出口, 否则就成为上面两个例子, 是1个死递归, 程序一旦调用那个递归函数, 就回不来了, 实际情况就是递归函数不断地重复入栈, 知道栈溢出...


什么是递归函数的出口?  这个实际上就是1个条件, 当递归函数执行若干次后, 这个条件为真, 那么递归函数就不再调用自己, 就能出栈返回到上一级函数了!


举个例子, 我们修改上面的f3() 函数, 为其添加1个出口:

代码如下:

void f3(int * i){printf("it's f3, i is %d\n",*i);*i = *i +1;if (*i < 10){f3(i);}}int recur_main(void){int i=0;f3(&i);printf("recur_main done!\n");return 0;}

见到了吗? 我为f3() 传入1个参数i, 每当f3()执行1次, i的值都会加1,   当i=10时, f3()就会停止调用自己, 这就是递归函数的出口啊!

输出如下:

gateman@TFPC c_start $ ./mainit's f3, i is 0it's f3, i is 1it's f3, i is 2it's f3, i is 3it's f3, i is 4it's f3, i is 5it's f3, i is 6it's f3, i is 7it's f3, i is 8it's f3, i is 9recur_main done!

也可以写成如下形式哦,

void f4(int i){printf("it's f4, i is %d\n",i);if (i<10){f4(i+1);}}int recur_main(void){//int i=0;//f3(&i);f4(1);printf("recur_main done!\n");return 0;}

由这个两个写法大概可以看出,  因为递归函数本质上是同1个函数, 如果要有出口, 那么根据什么来判断出口呢?

其中一种方法就是判断实参, 如上面两个例子, 每一次调用递归函数, 它们实参都不一样啊.

另一种方法就是判断静态变量或全局变量了, 这个不难理解..



6. 实现1个成功递归函数的连个必要条件

所谓成功递归函数就是非死递归函数.

条件1: 明确的出口, 也就是中止条件.

条件2: 递归函数处理的数据规模必须在递减


条件1, 容易理解啊.

问题是条件2, 怎样理解.

通俗点来讲,  就是用于判断出口的条件或参数, 与出口的距离必须不断再减少.


void f4(int i){printf("it's f4, i is %d\n",i);if (i<10){f4(i+1);}}


就如上的里例子  用于判断出口的就是实际参数i, 而没执行1次 , 参数i的值都会加1   f(i+1),  出口就是i<10 , 所以参数i 跟出口的距离是不断在减少的,  加入我写成f(i-1),  那么每调用一次, i的值减1, 离出口越来越远, 这个就只能是i个死递归了.



7. 求阶乘的递归实现.

阶乘公式 f(int n) = 1 * 2 * 3 ......*n


用循环来实现就是

int fac(int n){int i;int f=1;for (i=1; i!=(n +1); i++){f= f *i;}return f;}

可以看到对于循环体来讲, 入口是1, 出口就是 i == (n+1)  或 i<=n 而每执行1次, 条件i的值加1, 向出口靠近.

我们观察i, 它的起点是 1, 而终点是就是n了.

一般习惯上我们会以终点(n) 作为出口的.


但是对于递归来讲, 因为对应终点的值不确定, 而对应起点的值是已知的( 1!=1)

所以递归函数应该已 起点作为出口.

就得出应该已终点(n)作为入口了,  既然n是入口, 所以每执行1次递归函数, 所以条件必须向出口(1)靠近, 所以条件值必须减一啊.


可以借助与下面的公式

n! = n* (n-1)!    

明显出口就是1!嘛

代码如下:

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





8. 实现1+2+3...+100 的递归函数.

如果用循环来计算上面的值, 就十分简单了:

代码如下:

void sum100(){int i;int s;for (i=1; i != 101; i++){s+=i;}printf("sum of 1 ~ 100 is %d\n", s);}


跟上面的例子很相似嘛,   这个就不分析了

代码如下:

int recur_100(int n){if (n==1){return 1;}return n + recur_100(n-1);}int recur_main(void){//int i=0;//f3(&i);//f4(1);//sum100();//int s=0;//int i=1;//recur_100(i, &s);//printf("f is %d\n", fac_rec(10));printf("sum of 1 ~ 100 is %d\n", recur_100(100));printf("recur_main done!\n");return 0;}


9. 汉诺塔

这个是1个经典的专题了, 可以参考这里:

http://blog.csdn.net/nvd11/article/details/8865683



10. 递归和循环的比较

其实第8点和第7点的问题也可以利用循环来解决,

例如

int i, sum;for (i=1; i<=100; i++){        sum+=i;} return sum;

那么用递归和循环究竟有有什么区别呢?

理论上所有循环都可以转化为递归来实现, 但是递归就不一定能转化成循环了

它们的区别是:


1. 递归比循环更容易理解和编写代码.

2. 递归的执行时间更长

3. 递归所需的内存空间比循环大很多.


咋一看递归的缺点很大啊, 为什么呢,  因为上面说了啊, 函数每一次调用就需要执行很多动作啊, 而且要在函数栈里分配空间, 而递归就是循环地调用函数本身啊

所以递归函数执行起来肯定会比循环花费更大的内存和更长的时间!

所以如果追求代码的优化, 很多时候能用循环就用循环.


但现实上很多问题, 出了用递归很难实现.


就是因为递归的优点, 易与理解和实现,  也许有人有意见, 例如求1~100的和 或 求阶乘, 怎么看都是循环更容易理解啊.

没错, 线性结构的确看不出来, 但是以后更深的非线性结构, 例如树的遍历, 不用递归的话很难很难写出来的,  又如汉诺塔, 非递归解法也比递归复杂得多.

这就是递归的意义啊!