空间
探讨递归过程如何消耗空间,栈溢出的成因,以及尾递归如何避免空间无限增长。
递归过程
为了初步探索过程如何使用空间,先看下面这个过程:
(define (factorial n)
(if (= n 0)
1
(* n (factorial (- n 1)))))
如果我们手工求值 (factorial 5),把每一步都写出来,会得到:
start with (factorial 5)
replaced by (* 5 (factorial 4))
replaced by (* 5 (* 4 (factorial 3)))
replaced by (* 5 (* 4 (* 3 (factorial 2))))
replaced by (* 5 (* 4 (* 3 (* 2 (factorial 1)))))
replaced by (* 5 (* 4 (* 3 (* 2 (* 1 (factorial 0))))))
replaced by (* 5 (* 4 (* 3 (* 2 (* 1 1)))))
replaced by (* 5 (* 4 (* 3 (* 2 1))))
replaced by (* 5 (* 4 (* 3 2)))
replaced by (* 5 (* 4 6))
replaced by (* 5 24)
replaced by 120
每一行都描述了计算的一个新步骤——即为了继续求值,我们在那一刻需要记住的内容。这里有一个关键观察:如果我们给 factorial 选一个足够大的输入,比如 10000,那么在某一步,我们就不可能把整行装进脑子里了。
计算机求值过程调用的方式也是如此。每次函数调用都存储在计算机的工作内存(working memory)中——一个存放中间未完成计算结果的地方。这个空间是有限的,可能被耗尽。要记住的重点是:这个问题只在非常大的输入下才会发生。这种「工作内存」称为「调用栈」(call stack)。当一个程序把这个空间用尽时,就称为「栈溢出」(stack overflow)。
迭代过程
有没有办法改造 factorial,使它在处理大输入时不会让计算机耗尽空间?看看下面这个版本:
(define (factorial n)
(fact-iter 1 1 n))
(define (fact-iter product counter max-count)
(if (> counter max-count)
product
(fact-iter (* counter product)
(+ counter 1)
max-count)))
检验一下你的理解。为什么我们必须写 (> counter max-count)?如果我们调用 (fact-iter 1 1 3) 会发生什么?现在,如果用上面的代码来描绘 (factorial 5),我们会得到:
start with (factorial 5)
replaced by (fact-iter 1 1 5)
replaced by (fact-iter 1 2 5)
replaced by (fact-iter 2 3 5)
replaced by (fact-iter 6 4 5)
replaced by (fact-iter 24 5 5)
replaced by (fact-iter 120 6 5)
replaced by 120
在这种情况下,随着求值的推进,我们需要在脑中保留的未完成计算并没有一步步增长。相反,我们通过实参把计算的未完成答案一路传递下去,从而节省了空间。换句话说,计算的完整状态都保存在递归调用的实参中——调用 (fact-iter 2 3 5) 会产生与 (fact-iter 1 1 5) 相同的结果,而调用 (factorial 3) 则会产生与 (factorial 5) 不同的结果。
注意,这个迭代过程仍然使用了递归(recursion),但这与说它是一个「递归过程」不是一回事。
比较迭代过程与递归过程
把这些函数并排来看,我们可以找出这两个过程之间的联系。
| 递归过程 | 迭代过程 |
|---|---|
(define (factorial n) (if (= n 0) 1 (* n (factorial (- n 1))))) | (define (factorial n) (fact-iter 1 1 n)) (define (fact-iter product counter max-count) (if (> counter max-count) product (fact-iter (* counter product) (+ counter 1) max-count))) |
关于两者差异的重要观察:
- 递归版本中作为基本情况返回的值,在迭代版本中充当
product的起始值(1)。注意,调用迭代版的(factorial 1)会触发fact-iter中的基本情况,返回 1。 - 在递归版本中,
*过程是在递归调用之外调用的;而在迭代版本中,所有实参都在递归调用之前(或之中)完成转换。也就是说,先执行(* counter product),然后才进行递归调用。这正是迭代版本更节省空间的关键。 - 由此可得,这意味着递归调用是过程调用中最后被求值的表达式(而不是某次乘法)。
- 由于迭代版本需要跟踪更多实参,它需要一个辅助过程,让递归真正在其中发生。迭代过程往往都是这样。
尾递归与编写尾递归过程
用正式的话说,迭代版 factorial 更节省空间,是因为 Racket 解释器实现了尾调用消除(Tail Call Elimination)。在其他编程语言和未做尾调用优化的解释器中,递归版和迭代版运行时会占用同样多的空间。那么,我们为什么还要关心这个问题呢?
- 介绍这些主题能让我们更深入地理解递归、求值和编程语言,为学习其他主题打下坚实基础。
- 这是一个练习批判性思考资源使用与权衡的机会,这在软件工程和计算机科学中总体而言都很重要。
要写出尾递归(tail-recursive)的过程,这里有几点建议。
- 弄清楚你是否需要额外的实参来跟踪计算的状态,以及是否需要一个辅助过程。几乎总是需要某个实参来跟踪「到目前为止的答案」。在
factorial中,这个实参就是product。 - 事实上,一般规律是:过程的递归版本会让实参逐渐变小,而迭代版本则是逐步构建出结果。因此,编写迭代过程时,不妨想想「构建」答案所需的初始值。在
factorial中,就是1、1和n,即(fact-iter 1 1 n)中的三个值。 - 确保递归调用发生在「最后一刻」。实践中,这意味着实参要先经过处理(相加、相减、相乘、
butfirst等),再发起递归调用。
练习
以下问题用于加深理解,不计成绩。你可以找课程组成员核对答案。
- 迭代版
factorial在fact-iter中跟踪三样东西。是哪三样?我们能不能再次改写factorial,让它只跟踪两样东西? - 如果使用迭代版本,我们调用
factorial时还会耗尽空间吗?