3.9 理解递归 (Understanding Recursion)
学生在学习递归时,有时候是被鼓励在纸上追踪 (trace)递归程序调用 (invocation)的过程。 (288页「译注:附录 A 追踪与回溯」可以看到一个递归函数的追踪过程。)但这种练习可能会误导你:一个程序员在定义一个递归函数时,通常不会特别地去想函数的调用顺序所导致的结果。
如果一个人总是需要这样子思考程序,递归会是艰难的、没有帮助的。递归的优点是它精确地让我们更抽象地来设计算法。你不需要考虑真正函数时所有的调用过程,就可以判断一个递归函数是否是正确的。
要知道一个递归函数是否做它该做的事,你只需要问,它包含了所有的情况吗?举例来说,下面是一个寻找列表长度的递归函数:
> (defun len (lst)
(if (null lst)
0
(+ (len (cdr lst)) 1)))
我们可以借由检查两件事情,来确信这个函数是正确的:
- 对长度为
0
的列表是有效的。 - 给定它对于长度为
n
的列表是有效的,它对长度是n+1
的列表也是有效的。
如果这两点是成立的,我们知道这个函数对于所有可能的列表都是正确的。
我们的定义显然地满足第一点:如果列表( lst
) 是空的( nil
),函数直接返回 0
。现在假定我们的函数对长度为 n
的列表是有效的。我们给它一个 n+1
长度的列表。这个定义说明了,函数会返回列表的 cdr
的长度再加上 1
。 cdr
是一个长度为 n
的列表。我们经由假定可知它的长度是 n
。所以整个列表的长度是 n+1
。
我们需要知道的就是这些。理解递归的秘密就像是处理括号一样。你怎么知道哪个括号对上哪个?你不需要这么做。你怎么想像那些调用过程?你不需要这么做。
更复杂的递归函数,可能会有更多的情况需要讨论,但是流程是一样的。举例来说, 41 页的 our-copy-tree
,我们需要讨论三个情况: 原子,单一的 Cons 对象, n+1
的 Cons 树。
第一个情况(长度零的列表)称之为基本用例( base case )。当一个递归函数不像你想的那样工作时,通常是处理基本用例就错了。下面这个不正确的 member
定义,是一个常见的错误,整个忽略了基本用例:
(defun our-member (obj lst)
(if (eql (car lst) obj)
lst
(our-member obj (cdr lst))))
我们需要初始一个 null
测试,确保在到达列表底部时,没有找到目标时要停止递归。如果我们要找的对象没有在列表里,这个版本的 member
会陷入无穷循环。附录 A 更详细地讨论了这种问题。
能够判断一个递归函数是否正确只不过是理解递归的上半场,下半场是能够写出一个做你想做的事情的递归函数。 6.9 节讨论了这个问题。