计算机科学的新学生通常难以理解递归程序设计的概念。递归思想之所以困难,原因在于它非常像是循环推理(circular
reasoning)。它也不是一个直观的过程;当我们指挥别人做事的时候,我们极少会递归地指挥他们。
对刚开始接触计算机编程的人而言,这里有递归的一个简单定义:当函数直接或者间接调用自己时,则发生了递归。
递归的经典示例
计算阶乘是递归程序设计的一个经典示例。计算某个数的阶乘就是用那个数去乘包括 1
在内的所有比它小的数。例如,factorial(5) 等价于 5*4*3*2*1,而 factorial(3) 等价于 3*2*1。
阶乘的一个有趣特性是,某个数的阶乘等于起始数(starting
number)乘以比它小一的数的阶乘。例如,factorial(5) 与 5
* factorial(4)相同。您很可能会像这样编写阶乘函数:
清单 1. 阶乘函数的第一次尝试
int factorial(int n)
{
return n * factorial(n - 1);
}
不过,这个函数的问题是,它会永远运行下去,因为它没有终止的地方。函数会连续不断地调用 factorial。
当计算到零时,没有条件来停止它,所以它会继续调用零和负数的阶乘。因此,我们的函数需要一个条件,告诉它何时停止。
由于小于 1 的数的阶乘没有任何意义,所以我们在计算到数字 1 的时候停止,并返回 1 的阶乘(即 1)。因此,真正的递归函数
类似于:
清单 2. 实际的递归函数
int factorial(int n)
{
if(n == 1)
{
return 1;
}
else
{
return n * factorial(n - 1);
}
}
可见,只要初始值大于零,这个函数就能够终止。停止的位置称为 基线条件(base
case)。基线条件是递归程序的
最底层位置,在此位置时没有必要再进行操作,可以直接返回一个结果。所有递归程序都必须至少拥有一个基线条件,而且
必须确保它们最终会达到某个基线条件;否则,程序将永远运行下去,直到程序缺少内存或者栈空间。
回页首
递归程序的基本步骤
每一个递归程序都遵循相同的基本步骤:
初始化算法。递归程序通常需要一个开始时使用的种子值(seed
value)。要完成此任务,可以向函数传递参数,或者提供一个入口函数, 这个函数是非递归的,但可以为递归计算设置种子值。
检查要处理的当前值是否已经与基线条件相匹配。如果匹配,则进行处理并返回值。
使用更小的或更简单的子问题(或多个子问题)来重新定义答案。
对子问题运行算法。
将结果合并入答案的表达式。
返回结果。
使用归纳定义
有时候,编写递归程序时难以获得更简单的子问题。
不过,使用 归纳定义的(inductively-defined)数据集
可以令子问题的获得更为简单。归纳定义的数据集是根据自身定义的数据结构 ——
这叫做 归纳定义(inductive definition)。
例如,链表就是根据其本身定义出来的。链表所包含的节点结构体由两部分构成:它所持有的数据,以及指向另一个节点结构体(或者是
NULL,结束链表)的指针。 由于节点结构体内部包含有一个指向节点结构体的指针,所以称之为是归纳定义的。
使用归纳数据编写递归过程非常简单。注意,与我们的递归程序非常类似,链表的定义也包括一个基线条件 —— 在这里是 NULL 指针。
由于 NULL 指针会结束一个链表,所以我们也可以使用 NULL 指针条件作为基于链表的很多递归程序的基线条件。
链表示例
让我们来看一些基于链表的递归函数示例。假定我们有一个数字列表,并且要将它们加起来。履行递归过程序列的每一个步骤,
以确定它如何应用于我们的求和函数:
初始化算法。这个算法的种子值是要处理的第一个节点,将它作为参数传递给函数。
检查基线条件。程序需要检查确认当前节点是否为 NULL 列表。如果是,则返回零,因为一个空列表的所有成员的和为零。
使用更简单的子问题重新定义答案。我们可以将答案定义为当前节点的内容加上列表中其余部分的和。为了确定列表其余部分的和,
我们针对下一个节点来调用这个函数。
合并结果。递归调用之后,我们将当前节点的值加到递归调用的结果上。
下面是这个函数的伪代码和实际代码:
清单 3. sum_list 程序的伪代码
function sum_list(list l)
is l null?
yes - the sum of an empty list is 0 - return that
data = head of list l
rest_of_list = rest of list l
the sum of the list is:
data + sum_list(rest_of_list)
这个程序的伪代码几乎与其 Scheme 实现完全相同。
清单 4. sum_list 程序的 Scheme 代码
(define sum-list (lambda (l)
(if (null? l)
0
(let (
(data (car l))
(rest-of-list (cdr l)))
(+ data (sum-list rest-of-list))))))
对于这个简单的示例而言,C 版本同样简单。
清单 5. sum_list 程序的 C 代码
int sum_list(struct list_node *l)
{
if(l == NULL)
return 0;
return l.data + sum_list(l.next);
}
您可能会认为自己知道如何不使用递归编写这个程序,使其执行更快或者更好。稍后我们会讨论递归的速度和空间问题。
在此,我们继续讨论归纳数据集的递归。
假定我们拥有一个字符串列表,并且想要知道某个特定的字符串是否包含在那个列表中。将此问题划分为更简单的问题的方法是,再次到单个的节点中去
查找。
子问题是这样:“搜索字符串是否与 这个节点
中的字符串相同?”如果是,则您就已经有了答案;如果不是,则更接近了一步。基线条件是什么?
有两个:
如果当前节点拥有那个字符串,则那就是基线条件(返回“true”)。
如果列表为空,则那也是基线条件(返回“false”)。
这个程序不是总能达到第一个基线条件,因为不是总会拥有正在搜索的字符串。不过,我们可以断言,如果程序不能达到第一个基线条件,
那么当它到达列表末尾时至少能达到第二个基线条件。
清单 6. 确定给定的列表中是否包含给定字符串的 Scheme 代码
(define is-in-list
(lambda (the-list the-string)
;;Check for base case of "list empty"
(if (null? the-list)
#f
;;Check for base case of "found item"
(if (equal? the-string (car the-list))
#t
;;Run the algorithm on a smaller problem
(is-in-list (cdr the-list) the-string)))))
这个递归函数能很好地工作,不过它有一个主要的缺点 ——
递归的每一次迭代都要为 the-string
传递 相同的值。传递额外的参数会增加函数调用的开销。
不过,我们可以在函数的起始处设置一个闭包(closure),以使得不再必须为每一个调用都传递那个字符串:
清单 7. 使用闭包的搜索字符串的 Scheme 程序
(define is-in-list2
(lambda (the-list the-string)
(letrec
(
(recurse (lambda (internal-list)
(if (null? internal-list)
#f
(if (equal? the-string (car internal-list))
#t