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)))
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章