Jonathan Bartlett, 技术主管, New Media Worx
命令式语言开发人员并不经常使用递归这一工具,因为他们认为这样会较慢而且浪费空间,不过,作者通过示例表明,可以使用一些技术来尽量减少或者避免这些问题。他介绍了递归以及递归程序设计模式的概念,研究了如何使用它们来编写保证正确的程序。示例是使用 Scheme 和 C 编写的。
计算机科学的新学生通常难以理解递归程序设计的概念。递归思想之所以困难,原因在于它非常像是循环推理(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 (recurse (cdr internal-list))))))) (recurse the-list))))
|
这个版本的程序稍微难以理解。它定义了一个名为 recurse 的闭包,能够只使用一个参数来调用它,而不是两个。(要获得关于闭包的更多资料,请参阅 参考资料。)我们不必将 the-string 传递给 recurse,因为它已经在父环境(parent environment)中,而且从一个调用到另一个调用时不会改变。由于 recurse 是在 is-in-list2 的 内部 定义的,所以它可以访问所有当前定义的变量,因而不必重新传递它们。这就避免了在每一次迭代时都要传递一个变量。
在这个微不足道的示例中,使用闭包来代替参数传递并没有太多区别,不过,在更为复杂的函数中,这样可以减少很多键盘输入、很多错误以及传递参数中引入的很多开销。
这个示例中所使用的生成递归闭包的方法有些冗长。在递归程序设计中,要一次又一次地以相同的模式使用 letrec 创建递归闭包,并使用一个初始种子值来调用它。
为了让编写递归模式更为简单,Scheme 使用一个名为 命名 let(named let) 的快捷方法。这种快捷方法看起来非常像是一个 let,只是整个程序块会被给定一个名称,这样可以将它作为一个递归闭包去调用。使用命名 let 所构建的函数的参数定义与普通的 let 中的变量类似;初始种子值的设置方式也与普通的 let 中初始变量值的设置方式相同。从那里开始,每一次后续的递归调用都使用那些参数作为新的值。
命名 let 的内容讨论起来相当费解,所以来看下面的代码,并将其与清单 7 中的代码相比较。
清单 8. 命名 let 示例
(define is-in-list2 (lambda (the-list the-string) ;;Named Let ;;This let block defines a function called "recurse" that is the ;;body of this let. The function's parameters are the same as ;;the variables listed in the let. (let recurse ;;internal-list is the first and only parameter. The ;;first time through the block it will be primed with ;;"the-list" and subsequent calls to "recurse" will ;;give it whatever value is passed to "recurse" ( (internal-list the-list) )
;;Body of function/named let block (if (null? internal-list) #f (if (equal? the-string (car internal-list)) #t ;;Call recursive function with the ;;rest of the list (recurse (cdr internal-list)))))))
|
在编写递归函数时,命名 let 能够相当程度地减少键盘输入以及出现错误的数量。如果您理解命名 let 的概念仍有困难,那么我建议您对上面的两个程序中的每一行进行全面的比较(另外参阅本文 参考资料 部分中的一些文档)。
我们的下一个基于列表的递归函数示例要稍微复杂一些。它将检查列表是否以升序排序。如果列表是以升序排序,则函数返回 #t;否则,它将返回 #f。这个程序稍有不同,因为除了必须要考查当前的值以外,我们还必须记住处理过的最后一个值。
对列表中第一项的处理必须与其他项不同,因为没有在它之前的任何项。对于其余的项,我们需要将先前考查的值传递到函数调用中。函数类似如下:
清单 9. 确定列表是否以升序排序的 Scheme 程序
(define is-ascending (lambda (the-list) ;;First, Initialize the algorithm. To do this we ;;need to get the first value, if it exists, and ;;use it as a seed to the recursive function (if (null? the-list) #t (let is-ascending-recurse ( (previous-item (car the-list)) (remaining-items (cdr the-list)) ) ;;Base case #1 - end of list (if (null? remaining-items) #t (if (< previous-item (car remaining-items)) ;;Recursive case, check the rest of the list (is-ascending-recurse (car remaining-items) (cdr remaining-items)) ;;Base case #2 - not in ascending order #f))))))
|
这个程序首先检查边界条件 —— 列表是否为空。空列表被认为是升序的。然后程序以列表中的第一项及其余部分列表为种子开始递归函数。
然后检查基线条件。能到达列表末尾的惟一情形是此前所有项都按顺序排列,所以,如果某个列表为空,则列表为升序。否则,我们去检查当前项。
如果当前项是升序的,那么我们接下来只需要解决问题的一个子集 —— 列表的其余部分是否为升序。所以我们递归处理列表其余部分,并再次尝试。
注意在函数中我们是如何通过向前传递程序来保持函数调用过程中的状态的。以前我们每次只是传递了列表的剩余部分。不过,在这个函数中,我们需要了解关于计算状态的稍多些内容。当前计算的结果依赖于之前的部分结果,所以,在每次后续递归调用中,我们向前传递那些结果。对更复杂的递归过程来说这是一个通用的模式。
编写保证正确的程序
bug 是每位程序员日常生活的一部分,因为就算是最小的循环和最简单的函数调用之中也会有 bug。尽管大部分程序员能够检查代码并测试代码的 bug,但他们并不知道如何证明他们的程序将会按他们所设想的那样去执行。出于此方面的考虑,我们接下来研究 bug 的一些常见来源,然后阐述如何编写正确的程序以及如何证明其正确性。
bug 来源:状态改变
变量状态改变是产生 bug 的一个主要来源。您可能会认为,程序能敏锐地确切知道变量何时如何改变状态。有时在简单的循环中的确如此,不过在复杂的循环中通常不是这样。通常在循环中给定的变量能够以多种方式改变状态。
例如,如果您拥有一个复杂的 if 语句,有些分支可能会修改某个变量,而其他分支可能会修改其他变量。此外,顺序通常很重要,但是难以绝对保证在所有情形下编码的次序都是正确的。通常,由于这些顺序问题,为某一情形修改某个 bug 会为其他情形引入 bug。
为了预防此类错误,开发人员需要能够:
- 预先断定每个变量如何获得其当前值。
- 确保变量都没有双重用途。(很多程序员经常使用同一变量来存储两个相关但稍有不同的值。)
- 当循环重新开始时,确保所有的变量都处于它们应该处于的状态。(在极少使用和测试的不重要情形中疏忽了为循环变量设置新值,这是常见的程序设计错误。)
为了达成这些目标,我们只需要在程序设计中制定一个规则:一个变量只赋值一次,而且永远不再修改它!
什么?(您说得不可信!)这个规则对很多人来说不可接受,他们所熟知的是命令式、过程式和面向对象程序设计 —— 变量赋值与修改是这些程序设计技术的基础!尽管如此,对命令式语言程序员来说,状态的改变依然是程序设计错误的主要原因。
那么,编程时如何才能不修改变量?让我们来研究一些经常要修改变量的情形,并研究我们是否能够不修改变量而完成任务:
- 重新使用某个变量。
- 变量的条件修改(conditional modification)。
- 循环变量。
我们先来研究第一种情形,重新使用某个变量。通常会出于不同的(但是类似的)目的而重新使用某个变量。例如,有时候,循环的某个部分中,在循环的前半部分需要一个指向当前位置的索引,而循环的其余部分需要一个恰在此索引之前或之后的索引,很多程序员为这两种情况使用同一变量,而只是根据情况对其进行增量处理。当前程序被修改时,这无疑会令程序员难以理解这两种用途。为了预防这一问题,最好的解决方案是创建两个单独的变量,并以同样的方法根据第一个变量得出第二个变量,就像是写入同一变量那样。
第二种情形,即变量的条件修改,是重新使用的问题的一个子集,只是有时我们会保持现有的值,有时需要使用一个新值。同样,最好创建一个新的变量。在大部分语言中,我们可以使用三元运算符 ? : 来设置新变量的值。例如,如果我们需要为新变量赋一个新值,条件是它不大于 some_value,我们可以这样写 int new_variable = old_variable > some_value ? old variable : new_value;。
(我们将在本文中稍后讨论循环变量。)
当我们解决了所有变量状态改变的问题后,就可以确信,当我们第一次定义变量时,变量的定义在函数整个生存期间都会保持。这使得操作的顺序简单了很多,尤其是当修改已有代码时。您不必关心变量被修改的顺序,也不必关心在每一个时刻关于其状态要做什么假定。
当变量的状态不能改变时,在声明它的时刻和地方就给出了其起源的完全定义。您再也不用搜索全部代码去找出不正确的或者混乱的状态。
什么是循环变量?
现在,问题是如何不通过赋值来进行循环?答案是 递归函数。在表 1 中了解循环的特性,看它们可以如何与递归函数的特性相对比。
表 1. 对比循环与递归函数
| 特性 |
循环 |
递归函数 |
| 重复 |
为了获得结果,反复执行同一代码块;以完成代码块或者执行 continue 命令信号而实现重复执行。 |
为了获得结果,反复执行同一代码块;以反复调用自己为信号而实现重复执行。 |
| 终止条件 |
为了确保能够终止,循环必须要有一个或多个能够使其终止的条件,而且必须保证它能在某种情况下满足这些条件的其中之一。 |
为了确保能够终止,递归函数需要有一个基线条件,令函数停止递归。 |
| 状态 |
循环进行时更新当前状态。 |
当前状态作为参数传递。 |
可见,递归函数与循环有很多类似之处。实际上,可以认为循环和递归函数是能够相互转换的。区别在于,使用递归函数极少被迫修改任何一个变量 —— 只需要将新值作为参数传递给下一次函数调用。这就使得您可以获得避免使用可更新变量的所有益处,同时能够进行重复的、有状态的行为。
将一个常见的循环转化为递归函数
让我们来研究一个打印报表的常见循环,了解如何将它转化为一个递归函数。
- 这个循环将在每一个分页处打印出页编号和页眉。
- 假定报告的行依照某个数字标准分组,并假定有用来了解这些组的一个总数。
- 在每一组的最后,打印出组的总数。
出于演示目的,我们略去了所有次要的函数,假定它们存在而且按预期执行。下面是我们的报告打印程序的代码:
清单 10. 用普通循环实现的报告打印程序
void print_report(struct report_line *report_lines, int num_lines) { int num_lines_this_page = 0; int page_number = 1; int current_line; /* iterates through the lines */ int current_group = 0; /* tells which grouping we are in */ int previous_group = 0; /* tells which grouping was here on the last loop */ int group_total = 0; /* saves totals for printout at the end of the grouping */
print_headings(page_number);
for(current_line = 0; current_line < num_lines; current_line++) { num_lines_this_page++; if(num_lines_this_page == LINES_PER_PAGE) { page_number++; page_break(); print_headings(page_number); }
current_group = get_group(report_lines[current_line]); if(current_group != previous_group) { print_totals_for_group(group_total); group_total = 0; }
print_line(report_lines[current_line]);
group_total += get_line_amount(report_lines[current_line]); } }
|
程序中故意留了一些 bug。看您是否能够找出它们。
由于我们要不断地修改状态变量,所以难以预见在任意特定时刻它们是否正确。下面是递归实现的同一程序:
清单 11. 使用递归实现的报告打印程序
void print_report(struct report_line *report_lines, int num_lines) { int num_lines_this_page = 0; int page_number = 1; int current_line; /* iterates through the lines */ int current_group = 0; /* tells which grouping we are in */ int previous_group = 0; /* tells which grouping was here on the last loop */ int group_total = 0; /* saves totals for printout at the end of the grouping */
/* initialize */ print_headings(page_number);
/* Seed the values */ print_report_i(report_lines, 0, 1, 1, 0, 0, num_lines); }
void print_report_i(struct report_line *report_lines, /* our structure */ int current_line, /* current index into structure */ int num_lines_this_page, /* number of lines we've filled this page */ int page_number, int previous_group, /* used to know when to print totals */ int group_total, /* current aggregated total */ int num_lines) /* the total number of lines in the structure */ { if(current_line == num_lines) { return; } else { if(num_lines_this_page == LINES_PER_PAGE) { page_break(); print_headings(page_number + 1); print_report_i( report_lines, current_line, 1, page_number + 1, previous_group, group_total, num_lines); } else { int current_group = get_group(report_lines[current_line]); if(current_group != previous_group && previous_group != 0) { print_totals_for_group(group_total); print_report_i( report_lines, current_line, num_lines_this_page + 1, page_number, current_group, 0, num_lines); } else { print_line(report_lines[current_line]); print_report_i( report_lines, current_line + 1, num_lines_this_page + 1, page_number, current_group, group_total + get_line_amount(report_lines[current_line]), num_lines); } } } }
|
注意,我们所使用的所有数字都是始终一致的。几乎在任何情形下,只要修改多个状态,在状态改变过程中就会有一些代码行中将不能拥有始终一致的数字。如果以后向程序中此类改变状态的代码中添加一行,而对变量状态的判断与实际情况不相匹配,那么将会遇到很大的困难。这样修改几次以后,可能会因为顺序和状态问题而引入难以捉摸的 bug。在这个程序中,所有状态改变都是通过使用完全前后一致的数据重新运行递归程序而实现的。