《Thinking In Algorithm》09.彻底理解递归

时间:2022-12-25 21:26:10

递归真的非常非常重要!!!

我们直接从例子开始吧!

一:简单实例

1.阶乘的实现

写个函数实现   N! = N × (N-1) × (N-2) × ... × 2 × 1

public static int factorial(int N) { 
if (N == 1) return 1;
return N * factorial(N-1);
}
上面的程序虽然简单,但我们要了解他运行的步骤,以factorial(4)为例。

factorial(4)	=	4 * factorial(3)
= 4 * (3 * factorial(2) )
= 4 * (3 * (2 * factorial(1) ) )
= 4 * (3 * (2 * (1 * factorial(0) ) ) )
= 4 * (3 * (2 * (1 * 1) ) )
= 4 * (3 * (2 * 1) )
= 4 * (3 * 2)
= 4 * 6
= 24
也可以表示为

factorial(5) 
factorial(4)
factorial(3)
factorial(2)
factorial(1)
return 1
return 2*1 = 2
return 3*2 = 6
return 4*6 = 24
return 5*24 = 120

2.欧几里得函数的实现

求p和q的最大公约数

首先我们复习一下欧几里得算法

定理:gcd(a,b) = gcd(b,a mod b)

证明:a可以表示成a = kb + r,则r = a mod b 
假设d是a,b的一个公约数,则有 
d|a, d|b,而r = a - kb,因此d|r 
因此d是(b,a mod b)的公约数

假设d 是(b,a mod b)的公约数,则 
d | b , d |r ,但是a = kb +r 
因此d也是(a,b)的公约数

因此(a,b)和(b,a mod b)的公约数是一样的,其最大公约数也必然相等,得证。


下面的程序用了递归和迭代两种方法。(后面会讲到那种类型的递归可以改写成迭代)

public class Euclid {

// recursive implementation
public static int gcd(int p, int q) {
if (q == 0) return p;
else return gcd(q, p % q);
}

// non-recursive implementation
public static int gcd2(int p, int q) {
while (q != 0) {
int temp = q;
q = p % q;
p = temp;
}
return p;
}

public static void main(String[] args) {
int p = Integer.parseInt(args[0]);
int q = Integer.parseInt(args[1]);
int d = gcd(p, q);
int d2 = gcd2(p, q);
System.out.println("gcd(" + p + ", " + q + ") = " + d);
System.out.println("gcd(" + p + ", " + q + ") = " + d2);
}
}

程序执行的顺序如下

Computing the recurrence relation for x = 27 and y = 9:
gcd(27, 9)   = gcd(9, 27% 9)
= gcd(9, 0)
= 9
Computing the recurrence relation for x = 259 and y = 111:
gcd(259, 111)   = gcd(111, 259% 111)
= gcd(111, 37)
= gcd(111, 111% 37)
= gcd(37, 0)
= 37

二:递归的本质

从上面两个简单的例子我们队递归的执行顺序有了一点了解,我们知道递归的本质和栈数据的存取很相似了,都是先进去,但是往往最后处理!再者对于递归函数的局部变量的存储是按照栈的方式去存的,对于每一层的递归函数在栈中都保存了本层函数的局部变量,一边该层递归函数结束时能够保存原来该层的数据!如图:
《Thinking In Algorithm》09.彻底理解递归
可能你看到这里还是一头雾水,递归的本质怎么就和堆栈一样了呢,ok,我们举个例子来详细说明这点,因为上面两个简单的例子不能很清楚说明他的操作顺序。

1.给出一个值4267,我们需要依次产生字符‘4’,‘2’,‘6’,和‘7’。就如在printf函数中使用了%d格式码,它就会执行类似处理。
分析:首先我们会想到用4267取余,然后除以10再区域,如此循环。但这样输出的顺序不会是7,6,2,4吗?于是我们就利用递归的堆栈结构的特性:先进后出
public class Recursion{
public static void main(String args[]){
recursion(4267) ;
}

public static void recursion(int value){
int quotient ;
quotient = value/10 ;
if(quotient!=0){ recursion(quotient) ;}
System.out.println(value%10) ;
}
}

递归是如何帮助我们以正确的顺序打印这些字符呢?下面是这个函数的工作流程。


       1. 将参数值除以10
       2. 如果quotient的值为非零,调用binary-to-ascii打印quotient当前值的各位数字
  3. 接着,打印步骤1中除法运算的余数


  注意在第2个步骤中,我们需要打印的是quotient当前值的各位数字。我们所面临的问题和最初的问题完全相同,只是变量quotient的 值变小了。我们用刚刚编写的函数(把整数转换为各个数字字符并打印出来)来解决这个问题。由于quotient的值越来越小,所以递归最终会终止。
  一旦你理解了递归,阅读递归函数最容易的方法不是纠缠于它的执行过程,而是相信递归函数会顺利完成它的任务。如果你的每个步骤正确无误,你的限制条件设置正确,并且每次调用之后更接近限制条件,递归函数总是能正确的完成任务。
  但是,为了理解递归的工作原理,你需要追踪递归调用的执行过程,所以让我们来进行这项工作。追踪一个递归函数的执行过程的关键是理解函数中所声 明的变量是如何存储的。当函数被调用时,它的变量的空间是创建于运行时堆栈上的。以前调用的函数的变量扔保留在堆栈上,但他们被新函数的变量所掩盖,因此 是不能被访问的。
  当递归函数调用自身时,情况于是如此。每进行一次新的调用,都将创建一批变量,他们将掩盖递归函数前一次调用所创建的变量。当我追踪一个递归函数的执行过程时,必须把分数不同次调用的变量区分开来,以避免混淆。
  程序中的函数有两个变量:参数value和局部变量quotient。下面的一些图显示了堆栈的状态,当前可以访问的变量位于栈顶。所有其他调用的变量饰以灰色的阴影,表示他们不能被当前正在执行的函数访问。
假定我们以4267这个值调用递归函数。当函数刚开始执行时,堆栈的内容如下图所示:
 
《Thinking In Algorithm》09.彻底理解递归


执行除法之后,堆栈的内容如下:
《Thinking In Algorithm》09.彻底理解递归
  
接着,if语句判断出quotient的值非零,所以对该函数执行递归调用。当这个函数第二次被调用之初,堆栈的内容如下:
 
《Thinking In Algorithm》09.彻底理解递归

堆栈上创建了一批新的变量,隐藏了前面的那批变量,除非当前这次递归调用返回,否则他们是不能被访问的。再次执行除法运算之后,堆栈的内容如下:
 
《Thinking In Algorithm》09.彻底理解递归

quotient的值现在为42,仍然非零,所以需要继续执行递归调用,并再创建一批变量。在执行完这次调用的出发运算之后,堆栈的内容如下:
 《Thinking In Algorithm》09.彻底理解递归

此时,quotient的值还是非零,仍然需要执行递归调用。在执行除法运算之后,堆栈的内容如下:
 
《Thinking In Algorithm》09.彻底理解递归
 
  不算递归调用语句本身,到目前为止所执行的语句只是除法运算以及对quotient的值进行测试。由于递归调用这些语句重复执行,所以它的效果 类似循环:当quotient的值非零时,把它的值作为初始值重新开始循环。但是,递归调用将会保存一些信息(这点与循环不同),也就好是保存在堆栈中的 变量值。这些信息很快就会变得非常重要。
  现在quotient的值变成了零,递归函数便不再调用自身,而是开始打印输出。然后函数返回,并开始销毁堆栈上的变量值。
每次调用putchar得到变量value的最后一个数字,方法是对value进行模10取余运算,其结果是一个0到9之间的整数。把它与字符常量‘0’相加,其结果便是对应于这个数字的ASCII字符,然后把这个字符打印出来。
   输出4: 
 
《Thinking In Algorithm》09.彻底理解递归

接着函数返回,它的变量从堆栈中销毁。接着,递归函数的前一次调用重新继续执行,她所使用的是自己的变量,他们现在位于堆栈的顶部。因为它的value值是42,所以调用putchar后打印出来的数字是2。
  输出42: 
 
《Thinking In Algorithm》09.彻底理解递归

接着递归函数的这次调用也返回,它的变量也被销毁,此时位于堆栈顶部的是递归函数再前一次调用的变量。递归调用从这个位置继续执行,这次打印的数字是6。在这次调用返回之前,堆栈的内容如下:
  输出426:
 
《Thinking In Algorithm》09.彻底理解递归

现在我们已经展开了整个递归过程,并回到该函数最初的调用。这次调用打印出数字7,也就是它的value参数除10的余数。
  输出4267:
 
《Thinking In Algorithm》09.彻底理解递归

然后,这个递归函数就彻底返回到其他函数调用它的地点。
如果你把打印出来的字符一个接一个排在一起,出现在打印机或屏幕上,你将看到正确的值:4267 


三:另外

递归的使用条件:

  存在一个递归调用的终止条件;

  每次递归的调用必须越来越靠近这个条件;只有这样递归才会终止,否则是不能使用递归的!

总之,在你使用递归来处理问题之前必须首先考虑使用递归带来的好处是否能补偿

  他所带来的代价!否则,使用迭代算法会比递归算法要高效。 

递归的基本原理:

  1 每一次函数调用都会有一次返回.当程序流执行到某一级递归的结尾处时,它会转移到前一级递归继续执行.

  2 递归函数中,位于递归调用前的语句和各级被调函数具有相同的顺序.如打印语句 #1 位于递归调用语句前,它按照递归调用的顺序被执行了 4 次.

  3 每一级的函数调用都有自己的局部变量.

  4 递归函数中,位于递归调用语句后的语句的执行顺序和各个被调用函数的顺序相反.

           即位于递归函数入口前的语句,右外往里执行;位于递归函数入口后面的语句,由里往外执行。

  5 虽然每一级递归有自己的变量,但是函数代码并不会得到复制.

  6 递归函数中必须包含可以终止递归调用的语句.

一旦你理解了递归(理解递归,关键是脑中有一幅代码的图片,函数执行到递归函数入口时,就扩充一段完全一样的代码,执行完扩充的代码并return后,继续执行前一次递归函数中递归函数入口后面的代码),阅读递归函数最容易的方法不是纠缠于它的执行过程,而是相信递归函数会顺利完成它的任务。如果你的每个步骤正确无误,你的限制条件设置正确,并且每次调用之后更接近限制条件,递归函数总是能正确的完成任务。

不算递归调用语句本身,到目前为止所执行的语句只是除法运算以及对quotient的值进行测试。由于递归调用这些语句重复执行,所以它的效果类似循环:当quotient的值非零时,把它的值作为初始值重新开始循环。但是,递归调用将会保存一些信息(这点与循环不同),也就好是保存在堆栈中的变量值。这些信息很快就会变得非常重要。

斐波那契数是典型的递归案例:

  Fib(0) = 0 [基本情况] Fib(1) = 1 [基本情况]

  对所有n > 1的整数:Fib(n) = (Fib(n-1) + Fib(n-2)) [递归定义]

 递归算法一般用于解决三类问题:

  (1)数据的定义是按递归定义的。(Fibonacci函数)

  (2)问题解法按递归算法实现。(回溯)

  (3)数据的结构形式是按递归定义的。(树的遍历,图的搜索)

 如:

  procedure a;

  begin

  a;

  end;

  这种方式是直接调用.

又如:

  procedure b;

  begin

  c;

  end;

  procedure c;

  begin

  b;

  end;

  这种方式是间接调用.

如何设计递归算法

  1.确定递归公式

  2.确定边界(终了)条件

四:最后

留一个程序给大家去研究研究,看看程序运行的结果。

public class Region{
public static void main(String args[]){
int[] a = {1,2,3,4} ;
System.out.println("final "+region(a,0,0)) ;
}

public static int region(int[] a,int currentSum,int i){
currentSum+=a[i];
System.out.println("out "+ currentSum) ; //按顺序输出:递归式前面
if(i<3){
region(a,currentSum,i+1) ;
System.out.println("in "+ currentSum) ; //先进后出:递归式后面
}
System.out.println("hello ") ;
return currentSum ;
}
}

结果

out  1
out 3
out 6
out 10
hello
in 6
hello
in 3
hello
in 1
hello
final 1
这个例子我希望大家务必去研究研究,递归的先进后出的思想体现的淋漓精致,虽然是没有意义的程序。
由上面的例子我们可以知道:递归式前面的是按顺序执行,递归是后面的则是先进后出的执行。如上面程序中,有标明


还给大家一道题:《程序员面试100题 In Java》04.二元树中和为某一值的所有路径

如果你想更深入的理解递归可以看:Recursion-wiki


Reference:

http://introcs.cs.princeton.edu/java/23recursion/

http://en.wikipedia.org/wiki/Recursion_(computer_science)

http://blog.csdn.net/fightforyourdream/article/details/8671276

http://beckshanling.iteye.com/blog/378483