理解递归的代码排布
gcd 与 factorial 都使用了递归,但在编译的时候,它们的不同之处对编译时寄存器的安排影响很大。
(define (factorial n)
(if (= n 1)
1
(* (factorial (- n 1)) n)))
(define (gcd a b)
(if (= b 0)
a
(gcd b (remainder a b))))
gcd 不需要记录每一步的结果,最终的结果就是整个 gcd 计算式的结果。factorial 则需要记录 n 的值与子程序返回值。
factorial 的代码排布如下(SICP 图 5.11):
(controller
(assign continue (label fact-done)) ; set up final return address
fact-loop
(test (op =) (reg n) (const 1))
(branch (label base-case))
;; Set up for the recursive call by saving n and continue.
;; Set up continue so that the computation will continue
;; at after-fact when the subroutine returns.
(save continue)
(save n)
(assign n (op -) (reg n) (const 1))
(assign continue (label after-fact))
(goto (label fact-loop))
after-fact
(restore n)
(restore continue)
(assign val (op *) (reg n) (reg val)) ; val now contains n(n - 1)!
(goto (reg continue)) ; return to caller
base-case
(assign val (const 1)) ; base case: 1! = 1
(goto (reg continue)) ; return to caller
fact-done)
如果我们能知道每一步 stack 都是什么样子的,则更有助于我们理解这段代码。以 (factorial 4) 为例,在 n 为 1 之前的 stack 的样子如下:
+--------------+
| 2 |
+--------------+
| after-fact |
+--------------+
| 3 |
+--------------+
| after-fact |
+--------------+
| 4 |
+--------------+
| done |
+--------------+
当 n 为 1 的时候,程序跳转到 base-case 执行,val 寄存器被赋值 1,然后开始返回到调用者(caller)的位置,不断地把 stack 中的数据恢复到 n 和 continue 寄存器中。
所以,
val = 1
+--------------+
| 2 | n = 2
+--------------+
| after-fact | continue = after-fact
+--------------+
| 3 |
+--------------+
| after-fact |
+--------------+
| 4 |
+--------------+
| done |
+--------------+
一次 restore 之后:
val = 1 * 2 = 2
+--------------+
| |
+--------------+
| |
+--------------+
| 3 | n = 3
+--------------+
| after-fact | continue = after-fact
+--------------+
| 4 |
+--------------+
| done |
+--------------+
第二次 restore:
val = 2 * 3 = 6
+--------------+
| |
+--------------+
| |
+--------------+
| |
+--------------+
| |
+--------------+
| 4 | n = 4
+--------------+
| done | continue = done
+--------------+
最后 val 将为 24,这个时候程序将跳转到 continue 的位置,即 done 的位置:
val = 6 * 4 = 24
+--------------+
| |
+--------------+
| |
+--------------+
| |
+--------------+
| |
+--------------+
| |
+--------------+
| |
+--------------+
计算结束。每一次计算之前压入当前的 label 和 n 的值,计算之后弹出到相应的寄存器中。
双递归
(define (fib n)
(if (< n 2)
n
(+ (fib (- n 1)) (fib (- n 2)))))
双递归的计算过程是一直深度优先计算 (fib (- n 1)) ,到了最后一层时,开始计算 (fib (- n 2)) 。然后依次计算上一层的 (fib (- n 2)) 。所以对于
(controller
(assign continue (label fib-done))
fib-loop
(test (op <) (reg n) (const 2))
(branch (label immediate-answer))
;; set up to compute Fib(n - 1)
(save continue)
(assign continue (label afterfib-n-1))
(save n) ; save old value of n
(assign n (op -) (reg n) (const 1)); clobber n to n - 1
(goto (label fib-loop)) ; perform recursive call
afterfib-n-1 ; upon return, val contains Fib(n - 1)
(restore n)
(restore continue)
;; set up to compute Fib(n - 2)
(assign n (op -) (reg n) (const 2))
(save continue)
(assign continue (label afterfib-n-2))
(save val) ; save Fib(n - 1)
(goto (label fib-loop))
afterfib-n-2 ; upon return, val contains Fib(n - 2)
(assign n (reg val)) ; n now contains Fib(n - 2)
(restore val) ; val now contains Fib(n - 1)
(restore continue)
(assign val ; Fib(n - 1) + Fib(n - 2)
(op +) (reg val) (reg n))
(goto (reg continue)) ; return to caller, answer is in val
immediate-answer
(assign val (reg n)) ; base case: Fib(n) = n
(goto (reg continue))
fib-done)
fib-loop 是 fib 的入口,每一次 fib 的调用都会跳转到 fib-loop,。每一次 (fib (- n 1)) 之后,也即 afterfib-n-1,都应该去计算 (fib (- n 2))。计算过 (fib (- n 2)) 之后,也即 afterfib-n-2,都应该计算两次结果的和,并返回上一层 caller。
这段代码先进入 n 的计算,不断压入 n 的值。到了递归的最后一层,开始回到 afterfib-n-1,然后计算 n - 2 进入 (fib (- n 2)),接下来则跳到 afterfib-n-2 计算。然后返回到上一层,又回到 aftrfib-n-1。
我们使用 (fib 4) 来看。在 fib-loop 快要跳出时(也即,n 为 1时),stack 内的情况如下:
n: 2
continue: afterfib-n-1
+--------------+
| 2 |
+--------------+
| afterfib-n-1 |
+--------------+
| 3 |
+--------------+
| afterfib-n-1 |
+--------------+
| 4 |
+--------------+
| fib-done |
+--------------+
然后 val 被复制为 n 即为 1,返回到 afterfib-n-1 中。为 n 赋值 2,continue 赋值 afterfib-n-1,计算 (- n 2),即计算 n = 2 时候 (fib (- n 2))。在这个 afterfib-n-1 结束的时候,stack 的情况
n: 0
continue: afterfib-n-2
val: 1
+--------------+
| 1 |
+--------------+
| afterfib-n-1 |
+--------------+
| 3 |
+--------------+
| afterfib-n-1 |
+--------------+
| 4 |
+--------------+
| fib-done |
+--------------+
然后程序进入 fib-loop,计算 (fib (- n 2)) 的值,这个时候 val 已经 save 了,所以新的计算并不会影响之前计算的值。fib-loop 这次会因为 n < 2 而直接跳转到 immediate-answer,val = 0,然后跳转到 afterfib-n-2。afterfib-n-2 结束时 stack 的情况:
n: 0
continue: afterfib-n-1
val: 1
+--------------+
| 3 |
+--------------+
| afterfib-n-1 |
+--------------+
| 4 |
+--------------+
| fib-done |
+--------------+
下一步程序会跳转到 afterfib-n-1 执行 n = 3 时的代码。
到这里一轮递归就结束了。之后的可以接下去分析。
尾递归的空间的占用情况
尾递归时候,每一个递归返回的值就是最后计算结果的值。不需要保存额外的空间来保存中间值。
gcd 即为尾递归,使用这样的代码排布方式,不会使用额外的寄存器与额外的栈,空间是常量。