|
|
51CTO旗下网站
|
|
移动端

基本递归

《算法精解:C语言描述》第3章 递归,本章介绍递归,这是一种在许多数据结构和算法中都会用到的技术。本节为大家介绍基本递归。

作者:肖翔/陈舸 译来源:机械工业出版社|2012-12-06 10:14

第3章 递归

递归是一种强大的方法,它允许一个对象以其自身更小的形式来定义自己。恐怕没有什么比观察神秘的自然界中出现的递归现象更好的方法来体会递归的重要意义了。想想蕨类植物的叶子,每片叶子叶脉中的小分支都是整片叶子的较小缩影;又或者两个反光的物体,相互映射对方渐远的影像。这样的例子使我们明白尽管大自然的力量是强大的,在许多方面它那种出乎意料的简洁更让我们觉得优美。同样的道理也可用在递归算法上,从很多方面来说递归算法都是简洁而优美的,而且还非常强大。

在计算机科学领域中,递归是通过递归函数来实现的。递归函数是一种可以调用自身的函数。每次成功的调用都使得输入变得更加精细,使我们越来越接近问题的答案。大多数开发者都喜欢将大型的问题分解成一些小型的问题,然后分别编写不同的函数去处理它们。然而,许多开发者却并不习惯于用单一函数递归的方式解决大型的问题。诚然,以这种方式看待问题需要一定的适应过程。本章探讨递归是如何工作的并展示了如何以递归的方式去定义问题。本书中的一些递归方面的例子有树的遍历(见第9章),图中的广度优先和深度优先查找(见第11章),以及排序(见第12章)。

本章内容包括:

基本递归

一种强大的方法,允许一个问题以其自身越来越小的形式来定义自己。在计算机科学领域中,我们通过使用递归函数来解决带有递归性质的问题,也就是用函数调用自身。

尾递归

递归的一种形式,这里编译器会为此产生优化的代码。大多数现代的编译器能够识别尾递归。为此,只要条件允许我们都应该利用这个特性。

基本递归

开始之前,首先来看一个通常我们不会以递归的形式思考的问题。假设我们想计算整数n的阶乘。n的阶乘可写作n!,其结果是1~n之间的各数之积。比如,4!=4×3×2×1。一种计算法方法是循环遍历其中的每一个数,然后与它之前的数相乘作为结果再参与下一次计算。这种方法称为迭代法,可以正式定义为:

n! = (n)(n-1)(n-2)…(1)

看待这个问题的另一种方式是将n!定义为更小的阶乘形式。为了实现这一步,我们将n!定义为n-1阶乘的n倍。当然,求解(n-1)!的过程同n!一样,只是变小了一些。如果我们再把(n-1)!看做n-1倍的(n-2)!,(n-2)!看做n-2倍的(n-3)!,一直到n=1时,我们就计算完了。这就是递归的方式,可以正式定义为:

 
图3-1展示了利用递归的方法计算的4!过程。它也勾画出了递归过程中的两个基本阶段:递推与回归。在递推阶段,每一个递归调用通过进一步调用自己来记住这次递归过程。当其中有调用满足终止条件时,递推结束。比如,在计算n的阶乘时,终止条件是当n=1和n=0,此时函数只须简单地返回1即可。每一个递归函数都必须拥有至少一个终止条件;否则,递推阶段就永远不会结束了。一旦递推阶段结束,处理过程就进入回归阶段,在这之前的函数调用以逆序的方式回归,直到最初调用的函数返回为止,此时递归过程结束。

 
图3-1:以递归的方式计算4的阶乘

示例3-1展示了一个C函数fact,它接受一个整数n作为参数,以递归的方式计算n的阶乘。该函数按照如下的方式工作:如果n小于0,该函数直接返回0,这代表一个错误。如果n等于0或者1,该函数返回1,这是因为o!和1!都等于1,以上就是终止递归的条件。否则,函数返回n-1的阶乘的n倍。而n-1的阶乘又会以递归的方式再次调用fact来计算,如此继续。注意观察递归实现与我们之前对递归的定义之间的相同点。

示例3-1:以递归方式计算阶乘的函数实现

  1. /* fact.c */   
  2. #include "fact.h"  
  3. /* fact */  
  4. int fact(int n) {  
  5. if (n < 0)  
  6.    return 0;  
  7. else if (n == 0)  
  8.    return 1;  
  9. else if (n == 1)  
  10.    return 1;  
  11. else  
  12.    return n * fact(n - 1);  

为了理解递归究竟是如何工作的,有必要先看看C语言中函数的执行方式。基于这点,我们需要了解一点关于C程序在内存中的组织方式。基本上来说一个可执行程序由4个区域组成:代码段、静态数据区、堆与栈(见图3-2a)。代码段包含程序运行时所执行的机器指令。静态数据区包含在程序生命周期内一直持久的数据,比如全局变量和静态局部变量。堆包含程序运行时动态分配的存储空间,比如用malloc分配的内存。栈包含函数调用的信息。按照惯例,堆的增长方向为从程序低地址到高地址向上增长,而栈的增长方向刚好相反(实际情况可能不是这样,与CPU的体系结构有关)。注意,这里用到的术语堆与第10章介绍的数据结构堆没有什么关系。

当C程序中调用了一个函数时,栈中会分配一块空间来保存与这个调用相关的信息。每一个调用都被当做是活跃的。栈上的那块存储空间称为活跃记录,或者称为栈帧。栈帧由5个区域组成:输入参数、返回值空间、计算表达式时用到的临时存储空间、函数调用时保存的状态信息以及输出参数(见图3-2b)。输入参数是传递到活跃记录中的参数;输出参数是传递给在活跃记录中调用的函数所使用的。一个活跃记录中的输出参数就成为栈中下一个活跃记录的输入参数。函数调用产生的活跃记录将一直存在于栈中直到这个函数调用结束。

回到示例3-1,考虑一下当计算4!时栈中都发生了些什么。初始调用fact会在栈中产生一个活跃记录,输入参数n=4(见图3-3,第1步)。由于这个调用没有满足函数的终止条件,因此fact将继续以n=3为参数递归调用。这将在栈上创建另一个活跃记录,但这次输入参数(见图3-3,第2步)。这里,n=3也是第一个活跃期中的输出参数,因为正是在第一个活跃期内调用fact产生了第二个活跃期。这个过程将一直继续,直到n的值变为1,此时满足终止条件,fact将返回1(见图3-3,第4步)。

 
(点击查看大图)图3-2:a)C程序在内存中的组织形式  b)一份活跃记录
 
(点击查看大图)图3-3:递归计算4!时的C程序的栈

一旦当n=1时的活跃期结束,n=2时的递归计算结果就是2×1=2,因而n=2时的活跃期也将结束,返回值为2(见图3-3,第5步)。结果就是n=3时的递归计算结果表示为3×2=6,因此n=3时的活跃期结束,返回值为6(见图3-3,第6步)。最终,当n=4时的递归计算结果将表示为6×4=24,n=4时的活跃期将结束,返回值为24(见图3-3, 第7步)。此时,函数已经从最初的调用中返回,递归过程结束。

栈是用来存储函数调用信息的绝好方案,这正是由于其后进先出的特点(见第6章)精确满足了函数调用和返回的顺序。然而,使用栈也有一些缺点。栈维护了每个函数调用的信息直到函数返回后才释放,这需要占用相当大的空间,尤其是在程序中使用了许多递归调用的情况下。除此之外,因为有大量的信息需要保存和恢复,因此生成和销毁活跃记录需要耗费一定的时间。如此一来当函数调用的开销变得很大时,我们就需要考虑应该采用迭代的方案。幸运的是,我们可以采用一种称为尾递归的特殊递归方式来避免前面提到的这些缺点。

【责任编辑:book TEL:(010)68476606】

回书目   上一节   下一节
点赞 0
分享:
大家都在看
猜你喜欢

读 书 +更多

Windows Forms 编程实战

本书由浅入深地介绍Windows Forms编程的技巧和各种实用方法。本书先详细介绍了菜单、状态条、可复用类库、文件对话框、文本框、按钮、列表...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊