date: 2016-08-18 9:10:00
title: 理解递归
categories: 数据结构
版权声明:本站采用开放的[知识共享署名-非商业性使用-相同方式共享 许可协议]进行许可
所有文章出现的代码,将会出现在我的github中,名字可以根据类全名来找,我在github中的文件夹也会加目录备注。
在接下来的更新列表中,会接触到树,图相关知识点,又因为树、图是非线性结构,并且树是递归定义的,很多关于树和图的问题,可以用递归很好的解决。所以想要学好树和图,先要对递归有所了解。
定义:函数对其本身直接或间接的调用叫做递归。
那么什么叫直接和间接?
public class RecursionDemo {
// directly call
public static void a(int n) {
a(20);
}
// indirectly call
public static void b(int m) {
g(m);
}
public static void g(int k) {
b(k);
}
}
在上述的代码中,可以知道,在调用函数体中调用“自己”叫做直接调用,通过“第三方”函数来调用“自己”叫做间接调用。但是上面所示的代码,并没有给递归调用设置一个出口,也就是说,会抛出StackOverFlowException。
递归的实现
在C语言版数据结构(第二版)中,对于递归的实现是如下解释的:
一个递归函数,在函数的执行过程中,需要多次进行自我调用,那么,这个递归函数是如何执行的?先看任意两个函数之间进行调用的情形。
与汇编程序设计中主程序和子程序之间的链接及信息交换相类似,在高级语言编制的程序中,调用函数和被调用函数之间的链接及信息交换通过栈来进行,
通常,当在一个函数的运行期间调用另一个函数时,在运行被调用函数之前,系统需先完成3件事:
将所有的实参、返回地址等信息传递给被调用函数保存;
为被调用函数的局部变量分配存储区;
将控制转移到被调用函数入口。
而从被调用函数返回调用函数之前,系统也应完成3件工作:
保存被调函数的计算结果;
释放被调函数的数据区;
依照被调函数保存的返回地址将控制转移到调用函数
当有多个函数构成嵌套调用时,按照“后调用先返回”的原则,上述函数之间的信息传递和控制转移必须通过“栈”来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数时,就为它在栈顶分配一个存储区,每当从一个函数推出时,就释放它的存储区,则当前正运行的函数的数据区必在栈顶。
解读:在函数运行期间调用另外一个函数时,系统需要先完成3件事中,返回地址指的是调用其它函数时,当前代码执行完成后,下一句要执行的代码的地址,比如:
public class ComputeFactorial {
// get n's factorial
public static int count(int n) {
if (n == 1)
return 1;
int val = count(n - 1);
return val * n;
}
}
上面的例子是求n的阶乘例子,在int val=count(n-1);这一行中进行了递归调用,在计算机中,我们调用自己的函数跟调用别的函数没有区别。而上面所说的地址信息指的是 return val*n; 这一行代码的地址信息,把这个地址信息传递给被调用函数的目的是为了保持调用的连续性,即当被调用函数执行完之后,可以直接返回下一步要执行的代码,使程序继续执行下去。
递归调用函数
想要了解怎样“自己”调用“自己”首先要知道怎样调用别的函数:
public class CallOthers {
// call others
public static void a() {
System.out.println("aaaaaaaaa");
b();
}
public static void b() {
System.out.println("bbbbbbbbbb");
}
}
上面的代码中,是a函数调用b的过程,运行的结果是输出a和b各一行。而调用“自己”就跟上面的直接调用中所示代码一样,直接在函数体中调用“自己”,但是递归调用要注意函数的出口,否则会抛异常。
下面通过几个例子来强化对递归的理解:
求阶乘、求和
在刚刚学习程序设计语言的时候,我们都会接触到循环,其中求阶乘和求和是最常见的例子,下面我们也用这两个例子
求n的阶乘:
思路:
1、在学习循环的时候,要求一个数的阶乘,就是把前面几个数的积乘以最后一个数,在递归中,也是这个道理,不过,递归强调的是把一个问题分解成好几个相类似的小问题,通过解决小问题从而解决大问题。
2、要求n的阶乘,首先要把前面n-1的阶乘的结果得到,然后再跟n相乘,就是n的阶乘,要得到n-1的阶乘,就要先得到(n-1)-1的阶乘,同理,要求(n-1)-1的阶乘,就要先得到(n-2)-1的阶乘。知道要求的阶乘为1时,暂停嵌套调用。
3、因为1的阶乘就是1,这时候,最先要得到的数的阶乘已经得到了(1的阶乘是1),然后再往后面推回来,即1的阶乘乘以2,再用2的阶乘乘以3,一直到n-1的阶乘,然后再跟n相乘,得到n的阶乘。
代码实现:
public class ComputeFactorial {
// get n's factorial
public static int count(int n) {
if (n == 1)
return 1;
// else get the n-1's factorial
int val = count(n - 1);
return val * n;
}
}
求前n项之和也是同样的道理,只不过把乘号变成加号即可。
汉诺塔问题
问题描述:
汉诺塔(港台:河内塔)是根据一个传说形成的数学问题:
有三根杆子A,B,C。A杆上有N个(N>1)穿孔圆盘,盘的尺寸由下到上依次变小。要求按下列规则将所有圆盘移至C杆:
每次只能移动一个圆盘;大盘不能叠在小盘上面。`
思路:
由于每次只能移动一个盘子,并且保证大的盘子在小的盘子下面,所以不能把第二根柱子当作栈来使用。
这个问题的解决,可以把它分解成两部分(把三根柱子分别称为a,b,c,默认所有盘子开始在a柱子中,并且从上到下编号1……n,并且下面盘子比上面大):
只有1个盘子,直接把这个盘子从a直接移动到c即可。
有n(n>1)个盘子,最重要的是得到最大的盘子,即把前面n-1个盘子从a柱子借助柱子c移动到柱子b。
把a中剩下的最大的盘子直接移动到柱子c中
把柱子b中n-1个盘子借助柱子a移动到柱子c中.
其中在解决前面的n-1个盘子时,依赖于前面n-2个盘子的解决。
图解:用3个盘子做例子
实现代码:
public class HanNuoTa {
public static void hnt(int n, char a, char b, char c) {
//在这里先不考虑n<=0的情况
if (n == 1) {
System.out.println("直接把" + n + "从" + a + "移动到" + c);
} else {
// 首先把n-1个盘子从a借助c移动到b
hnt((n - 1), a, c, b);
// 把a中最后一个盘子移动到c
System.out.println("直接把" + n + "从" + a + "移动到" + c);
// 然后把b中n-1个盘子从b借助a移动到c
hnt((n - 1), b, a, c);
}
}
}
测试代码:
package com.xinpaninjava.recursion;
public class HanNuoTaTest {
public static void main(String[] args) {
char a = 'A';
char b = 'B';
char c = 'C';
HanNuoTa.hnt(3, a, b, c);
}
}
运行结果:
总结
使用递归的条件
1、为递归调用设置一个出口,避免无限递归而导致的栈溢出
2、使用递归时,确保问题的规模是逐渐变小,比如以上求和,求阶乘,汉诺塔问题,都是通过解决前面的n-1的问题来解决后面的n的问题
递归与循环的比较
递归:
缺点:递归由栈实现,并且递归涉及到调用函数,如上面函数调用函数要完成的几件事中可以看到,递归调用需要消耗空间,即函数调用与调用完成的压栈弹栈,为函数分配存储空间,由于上面步骤而导致的程序运行速度变慢
优点:使用递归写的代码比较容易理解,能用循环实现的都可以用递归实现
循环:
缺点:能用递归实现的,用循环实现可能很复杂,比如树和图的遍历及相关的运算
优点:消耗存储空间小,速度快
【全文完】
参考资料:
C语言版数据结构(第二版)p57 3.3.2 递归过程与递归工作栈