《JavaScript函數式編程思想》——遞歸

第7章  遞歸

王二、張三和趙四一日無聊,決定玩擊鼓傳花講冷笑話的遊戲。王二和張三圍成一圈傳花,趙四負責擊鼓。張三接連講了幾個諸如小菜、狐狸狡猾的笑話。花停在了王二的手中。
王二:這個笑話很短。你要保證聽完後不生氣我就說。
張三:你說吧。
王二:張三。
張三:怎麼了?
王二:笑話說完了,就兩個字。
張三欲發怒。
王二:欸,你剛纔說好了不會生氣的。
張三隻好作罷。新一輪開始,花又停在王二的手中。
王二:張三不是個笑話。
張三再次欲發怒。
王二:別生氣,我說的是冷笑話,就表示不好笑啊。
花又一次停在王二的手中。
王二:[張三不是個笑話]不是個笑話。
第四次花停在王二的手中。
王二:[[[張三不是個笑話]不是個笑話]不是個笑話]。
……

7.1  調用自身
7.1.1  遞歸的思路
7.1.2  帶累積參數的遞歸函數
7.2  遞歸的數據結構
7.2.1  構建列表
7.2.2  樹
7.3  遞歸與迭代
7.3.1  名稱
7.3.2  理念和對比
7.3.3  迭代協議
7.3.4  遞歸協議
7.3.5  搜索樹
7.4  尾部遞歸
7.4.1  調用堆棧
7.4.2  尾部調用優化
7.4.3  怎樣算是尾部調用
7.4.4  尾部遞歸

7.5  遞歸的效率

我們來計算經典的斐波那契數列。菲波那契數列的通項公式爲,當n=0和1時,A(n)=n;當n>=2時,A(n)=A(n-1)+A(n-2)。如果讓一個數學不錯又剛學習編程的高中生來寫計算斐波那契項的函數,結果可能會是這樣。

function fibonacci1(n) {
    const phi = (1 + Math.sqrt(5)) / 2;
    if (n < 2) {
        return n;
    }
    return (Math.pow(phi, n) + Math.pow(1 - phi, n)) / (phi * (3 - phi));
}
f.log(fibonacci1(10))
//=> 55.007272246494842705

他的思路如下:將等式A(n)=A(n-1)+A(n-2)變形爲A(n)+x*A(n-1)=(1+x)*[A(n-1)+1/(1+x)*A(n-2)]。令x=1/(1+x),可得1元2次方程x^2+x-1=0,解出x=[-1+sqrt(5)]/2或[-1-sqrt(5)]/2。因爲A(n)+x*A(n-1)構成一個等比數列,再加上初始兩項的值,可求得A(n)+x*A(n-1)的值。再利用這個公式遞歸地消去A(n-1),計算出通項A(n)的值。

這樣的解法會讓數學老師高興,計算機老師難過。計算機被當成計算器來用。另外,由於運算中涉及到小數,計算結果與本應是整數的精確值相比有微小的誤差,如上面的fibonacci1(10)精確值是55。

正常的計算方法可以採用迭代。

function fibonacci2(n) {
    if (n < 2) {
        return n;
    }
    let a = 0, b = 1, c;
    for (let i = 2; i <= n; i++) {
        c = a + b;
        a = b;
        b = c;
    }
    return c;
}
f.log(fibonacci2(10))
//=> 55

也可以採用遞歸。

function fibonacci3(n) {
    if (n < 2) {
        return n;
    }
    return fibonacci3(n - 1) + fibonacci3(n - 2);
}
f.log(fibonacci3(10))
//=> 55

三個版本中,採用遞歸的版本最簡短,它只是將斐波那契數列的數學定義用編程語言寫出來。到現在爲止,三個函數表現都還基本不錯。但當我們求更大的斐波那契項時,情況開始有變化了。

f.log(fibonacci1(100))
//=> 354224848179261800000
f.log(fibonacci2(100))
//=> 354224848179261800000
f.log(fibonacci3(100))
//=> 一覺醒來還是沒有結果

 fibonacci1和fibonacci2都很快得出了一致的結果(因爲數字太大,fibonacci1返回值中的小數被忽略了),而fibonacci3則永遠都得不出結果。出了什麼問題呢?

考察fibonacci3的計算過程,可以讓我們找出原因。本章所有此前出現的遞歸函數有一個共同點,返回語句只包含一次遞歸調用,用數列的語言來說就是,當前項的值只依賴於前一項。而fibonacci3的遞歸算法在求第n項A(n)時,不僅要利用前一項A(n-1),還要依賴更前一項A(n-2),這導致對此前項的大量重複計算,項數越小,重複的次數越多。令B(i)爲第i項被計算的次數,則有

B(i) = 1;  i = n, n - 1

B(i) = B(i + 1) + B(i + 2);  i < n - 1

這樣,B(i)形成了一個有趣的逆的斐波那契數列。求A(n)時有:

B(i) = A(n + 1 - i)

換一個角度來看,令C(i)爲求A(i)時需要做的加法的次數,則有

C(i) = 0;  i = 0, 1

C(i) = 1 + C(i - 1) + C(i - 2);  i > 1

令D(i) = C(i) + 1,有

D(i) = 1;  i = 0, 1

D(i) = D(i - 1) + D(i - 2)

所以D(i)又形成一個斐波那契數列。並可因此得出:

C(n) = A(n + 1) - 1

A(n)是以幾何級數增長,所以fibonacci3在n較大時所做的重複計算量會變得十分驚人。與它相對應的採用迭代的程序fibonacci2,有

B(n) = 1;  n爲任意值

C(n) = 0;  n = 0, 1

C(n) = n - 1;  n > 1

因而當n增長時,一直能保持很快的速度。

聰明的讀者也許已經想到了解決的方法,本書之前介紹的“記憶化”模式的功用正是避免以同樣的參數多次調用函數時的重複計算。記憶化普通函數很簡單,只需將其傳遞給memoize函數,返回的就是記憶化的版本。這種方法對遞歸函數卻不適用,因爲遞歸函數體內有對自身的調用,無法利用記憶化的版本,要想記住對某個參數的計算結果,只有用memoize函數類似的寫法,修改遞歸函數。

const fibonacci4 = function () {
    const memory = new Map();
    return function fibonacci4(n) {
        if (m.has(n, memory)) {
            return m.get(n, memory);
        }
        if (n < 2) {
            m.set(n, n, memory);
        } else {
            m.set(n, fibonacci4(n - 1) + fibonacci4(n - 2), memory);
        }
        return m.get(n, memory);
    }
}();

因爲這裏的參數限定爲非負整數,所以用於記憶計算結果的Map,可以換成數組,這樣函數可以改寫得更簡潔,運行速度也更快。 

const fibonacci5 = function () {
    const memory = [0, 1];
    return function fibonacci5(n) {
        if (memory.length <= n) {
            memory[n] = fibonacci5(n - 1) + fibonacci5(n - 2);
        }
        return memory[n];
    }
}();

在這兩個版本的遞歸算法中,雖然形式上在計算第n項時,仍然包含兩次遞歸調用,但實際上對於每個n,函數都只計算了一次,其他對第n項的引用,都是從記憶中讀取的,所以求第n項過程中進行的加法運算次數與迭代算法相同,具有同樣的可伸縮性。

仔細的讀者會發現,迄今爲止的三個遞歸版本,都不算是尾部調用。所以當n很大時,還是會出現調用堆棧耗盡的問題。

fibonacci5(10**8)
//=> Maximum call stack size exceeded

 上一節已經介紹了,可以利用累積參數將函數轉換成尾部遞歸。在返回語句只包含一次遞歸調用的情況下,轉換的方法是一目瞭然的。而對fibonacci3這樣返回語句包含兩次遞歸調用的函數,以前的方法就無效了。思路的突破口是,一次遞歸調用需要一個參數來累積,多次遞歸調用時,每次調用都需要一個參數來累積。這樣就得到fibonacci3尾部遞歸的版本。

function fibonacci6(n) {
    return _fibonacci(n, 0, 1);

    function _fibonacci(n, a, b) {
        if (n === 0) {
            return a;
        }
        return _fibonacci(n - 1, b, a + b);
    }
}

最後,我們來比試一下各種版本算法的速度。

export function doUnto(...args) {
    return function (fn) {
        return fn(...args);
    }
}

const cTookTime = f.unary(f.curry(f.tookTime, 2));
let fns = f.map(cTookTime, [fibonacci1, fibonacci2, fibonacci4, fibonacci4,
        fibonacci5, fibonacci5, fibonacci6]);
fibonacci5,fibonacci6]);
f.forEach(f.doUnto(1000), fns);
//=> 4.346655768693734e+208
//=> fibonacci1(1000): 1.828857421875ms
//=> 4.346655768693743e+208
//=> fibonacci2(1000): 0.243896484375ms
//=> 4.346655768693743e+208
//=> fibonacci4(1000): 3.918212890625ms
//=> 4.346655768693743e+208
//=> fibonacci4(1000): 0.126953125ms
//=> 4.346655768693743e+208
//=> fibonacci5(1000): 0.372802734375ms
//=> 4.346655768693743e+208
//=> fibonacci5(1000): 0.156005859375ms
//=> 4.346655768693743e+208
//=> fibonacci6(1000): 0.223876953125ms

多次測試,每個函數花費的時間會有波動,但總體上的排名沒有多少出入。從這個結果能讀出很多有趣的信息。fibonacci1直接根據斐波那契數列項的公式來計算,因爲涉及開方和小數的乘方等運算,並不算快。fibonacci2的迭代算法,名列前茅。fibonacci3沒有參賽資格。fibonacci4用映射數據結構作緩存,第一次計算時速度最慢,再次計算時讀取緩存,速度最快。fibonacci5用數組作緩存,第一次計算時,速度已經和不需緩存的最快算法在一個數量級上;第二次計算時,依靠讀取緩存,速度和fibonacci4差不多。fibonacci6的尾部遞歸算法,與迭代算法不相上下。

7.6  小結

更多內容,請參看拙著:

《JavaScript函數式編程思想》(京東)

《JavaScript函數式編程思想》(噹噹)

《JavaScript函數式編程思想》(亞馬遜)

《JavaScript函數式編程思想》(天貓)

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