SICP 递归的种类与变换

递归本质上就是一串延迟操作的计算。根据被延迟操作的结构区别,可以分为线性递归和树形递归;线性递归又有一种特例形式叫做尾递归;线性递归和循环是等价的;线性递归比起树形递归又具有好的多的空间效率,因此树形递归到线性递归的转换也是一个知识点。

线性递归

“In the computation of n!, the length of the chain of deferred multiplications, and hence the amount of information needed to keep track of it, grows linearly with n (is proportional to n), just like the number of steps. Such a process is called a linear recursive process.”

线性递归即表达式树的节点数随深度线性增加的进程。表现在(procedure 的)语法上就是每次返回的表达式里至多只有一次递归调用。即形如:

define f(...):
	...
	return f(...)

这样调用栈的空间占用就会线性增长。我们以等差数列为例,假设这样一个以 0 起始,等差 2 的数列,我们要写一个函数求其第 n 位的值:

#lang racket
(define (f n)
  (if (= n 0)
      0
      (+ (f (- n 1)) 2)))
      
#lang python
def f(n):
    if n == 0:
        return 0
    else:
        return f(n - 1) + 2

上面函数里的递归调用返回值虽然是一个多项表达式,却是“一元”的(在这里称递归调用,且不存在高次的说法,高次解释为多元)。我们说线性递归,字面上的意思就是这个一元

尾递归

One reason that the distinction between process and procedure may be confusing is that most implementations of common languages (including Ada, Pascal, and C) are designed in such a way that the interpretation of any recursive procedure consumes an amount of memory that grows with the number of procedure calls, even when the process described is, in principle, iterative. As a consequence, these languages can describe iterative processes only by resorting to special-purpose “looping constructs” such as do, repeat, until, for, and while. The implementation of Scheme we shall consider in Chapter 5 does not share this defect. It will execute an iterative process in constant space, even if the iterative process is described by a recursive procedure. An implementation with this property is called tail-recursive. With a tail-recursive implementation, iteration can be expressed using the ordinary procedure call mechanism, so that special iteration constructs are useful only as syntactic sugar.

这段话里提到了尾递归,即以常量栈空间执行线性递归过程的特性。并解释了为什么他说的 procedure 和 process 的区别对很多人来说听起来那么别扭。因为其他流行语言的解释器(编译器)都会把其实可以线性执行的 process 按照字面意思递归执行了。这些语言都使用循环来代替表达 iterative process。在作者看来,有尾递归就够了,你们的循环语句不过是些语法糖,并把尾递归缺失称为一种*“defect”*。

从尾递归的定义来看,为了完全复用当前调用帧,递归返回的表达式必须正好是一次函数调用(称为尾调用),用上节的描述就是 “一元单项式”。显然上一节的代码是不能尾递归的,它必须改成这样的形式:

#lang racket
(define (foo cur head n)
  (if (= head n)
      cur
      (foo (+ cur 2) (+ head 1) n)))

(define (f n)
  (if (= n 0)
      0
      (foo 0 0 n)))


#lang python
def foo(cur, head, n):
    if head == n:
        return cur
    else:
        return foo(cur + 2, head + 1, n)

def f(n):
    if n == 0:
        return 0
    else:
        return foo(0, 0, n)

另外,python 本身是不支持尾递归消除的,所以即使把代码写成上面这样,它依然会占用 n 倍的栈空间。但是 Scheme 就可以,对比两版函数,第二版在 n > 500,000 时能肉眼看到速度更快。而因为我限制了 racket 解释器的最大使用内存,当 n > 1,000,000 时程序就跑不了了。

至于尾递归和循环之间的比较,我个人还是倾向使用循环。因为它更直观,可读性更好,也因为我主要用 python。至于 python 不支持尾递归的原因,某人还专门做过解释.

树形递归

上面解释的都是最简单的 “一元” 线性递归。对于 “多元” 的情况,容易想象,展开来就是一棵表达式树。这种树形递归最可怕的地方在于其空间占用的增长率,是次方级的。而其好处在于代码的可读性,树形递归在某些场景下是最直观的编码方式。

将树形递归转换为线性递归因此成了一件有意义的事。从递归的定义来看:

  1. 递归需要定义一个基准情形
  2. 每次递归调用都要朝着基准情形逼近

要想把树形递归转换成线性递归,就需要逆转定义的过程2,并在此过程中保存足够的变量以记录状态。即不是去逼近基准情形,而是从基准情形出发去逼近求解的情形。说白了就是思考如何把递归变成循环。

Exercise 1.11 为例:

recursive process

#lang racket
(define (f n)
 (if (< n 4) 
  n
  (+ (* 1 (f (- n 1)))
   (* 2 (f (- n 2)))
   (* 3 (f (- n 3))))))

iterative process

#lang racket
(define (foo x y z)
 (+ (* 3 x) (* 2 y) z))

(define (next x y z head n)
 (if (= head n)
 (foo x y z)
 (next y z (foo x y z) (+ head 1) n)))

(define (f n)
 (if (< n 4)
 n
 (next 1 2 3 4 n)))
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章