JavaScript專題之遞迴

冴羽發表於2017-09-13

JavaScript 專題系列第十八篇,講解遞迴和尾遞迴

定義

程式呼叫自身的程式設計技巧稱為遞迴(recursion)。

階乘

以階乘為例:

function factorial(n) {
    if (n == 1) return n;
    return n * factorial(n - 1)
}

console.log(factorial(5)) // 5 * 4 * 3 * 2 * 1 = 120複製程式碼

示意圖(圖片來自 wwww.penjee.com):

階乘
階乘

斐波那契數列

《JavaScript專題之函式記憶》中講到過的斐波那契數列也使用了遞迴:

function fibonacci(n){
    return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
}

console.log(fibonacci(5)) // 1 1 2 3 5複製程式碼

遞迴條件

從這兩個例子中,我們可以看出:

構成遞迴需具備邊界條件、遞迴前進段和遞迴返回段,當邊界條件不滿足時,遞迴前進,當邊界條件滿足時,遞迴返回。階乘中的 n == 1 和 斐波那契數列中的 n < 2 都是邊界條件。

總結一下遞迴的特點:

  1. 子問題須與原始問題為同樣的事,且更為簡單;
  2. 不能無限制地呼叫本身,須有個出口,化簡為非遞迴狀況處理。

瞭解這些特點可以幫助我們更好的編寫遞迴函式。

執行上下文棧

《JavaScript深入之執行上下文棧》中,我們知道:

當執行一個函式的時候,就會建立一個執行上下文,並且壓入執行上下文棧,當函式執行完畢的時候,就會將函式的執行上下文從棧中彈出。

試著對階乘函式分析執行的過程,我們會發現,JavaScript 會不停的建立執行上下文壓入執行上下文棧,對於記憶體而言,維護這麼多的執行上下文也是一筆不小的開銷吶!那麼,我們該如何優化呢?

答案就是尾呼叫。

尾呼叫

尾呼叫,是指函式內部的最後一個動作是函式呼叫。該呼叫的返回值,直接返回給函式。

舉個例子:

// 尾呼叫
function f(x){
    return g(x);
}複製程式碼

然而

// 非尾呼叫
function f(x){
    return g(x) + 1;
}複製程式碼

並不是尾呼叫,因為 g(x) 的返回值還需要跟 1 進行計算後,f(x)才會返回值。

兩者又有什麼區別呢?答案就是執行上下文棧的變化不一樣。

為了模擬執行上下文棧的行為,讓我們定義執行上下文棧是一個陣列:

    ECStack = [];複製程式碼

我們模擬下第一個尾呼叫函式執行時的執行上下文棧變化:

// 虛擬碼
ECStack.push(<f> functionContext);

ECStack.pop();

ECStack.push(<g> functionContext);

ECStack.pop();複製程式碼

我們再來模擬一下第二個非尾呼叫函式執行時的執行上下文棧變化:

ECStack.push(<f> functionContext);

ECStack.push(<g> functionContext);

ECStack.pop();

ECStack.pop();複製程式碼

也就說尾呼叫函式執行時,雖然也呼叫了一個函式,但是因為原來的的函式執行完畢,執行上下文會被彈出,執行上下文棧中相當於只多壓入了一個執行上下文。然而非尾呼叫函式,就會建立多個執行上下文壓入執行上下文棧。

函式呼叫自身,稱為遞迴。如果尾呼叫自身,就稱為尾遞迴。

所以我們只用把階乘函式改造成一個尾遞迴形式,就可以避免建立那麼多的執行上下文。但是我們該怎麼做呢?

階乘函式優化

我們需要做的就是把所有用到的內部變數改寫成函式的引數,以階乘函式為例:

function factorial(n, res) {
    if (n == 1) return res;
    return factorial2(n - 1, n * res)
}

console.log(factorial(4, 1)) // 24複製程式碼

然而這個很奇怪吶……我們計算 4 的階乘,結果函式要傳入 4 和 1,我就不能只傳入一個 4 嗎?

這個時候就要用到我們在《JavaScript專題之柯里化》中編寫的 curry 函式了:

var newFactorial = curry(factorial, _, 1)

newFactorial(5) // 24複製程式碼

應用

如果你看過 JavaScript 專題系列的文章,你會發現遞迴有著很多的應用。

作為專題系列的第十八篇,我們來盤點下之前的文章中都有哪些涉及到了遞迴:

1.《JavaScript 專題之陣列扁平化》

function flatten(arr) {
    return arr.reduce(function(prev, next){
        return prev.concat(Array.isArray(next) ? flatten(next) : next)
    }, [])
}複製程式碼

2.《JavaScript 專題之深淺拷貝》

var deepCopy = function(obj) {
    if (typeof obj !== 'object') return;
    var newObj = obj instanceof Array ? [] : {};
    for (var key in obj) {
        if (obj.hasOwnProperty(key)) {
            newObj[key] = typeof obj[key] === 'object' ? deepCopy(obj[key]) : obj[key];
        }
    }
    return newObj;
}複製程式碼

3.JavaScript 專題之從零實現 jQuery 的 extend

// 非完整版本,完整版本請點選檢視具體的文章
function extend() {

    ...

    // 迴圈遍歷要複製的物件們
    for (; i < length; i++) {
        // 獲取當前物件
        options = arguments[i];
        // 要求不能為空 避免extend(a,,b)這種情況
        if (options != null) {
            for (name in options) {
                // 目標屬性值
                src = target[name];
                // 要複製的物件的屬性值
                copy = options[name];

                if (deep && copy && typeof copy == 'object') {
                    // 遞迴呼叫
                    target[name] = extend(deep, src, copy);
                }
                else if (copy !== undefined){
                    target[name] = copy;
                }
            }
        }
    }

    ...

};複製程式碼

4.《JavaScript 專題之如何判斷兩個物件相等》

// 非完整版本,完整版本請點選檢視具體的文章
// 屬於間接呼叫
function eq(a, b, aStack, bStack) {

    ...

    // 更復雜的物件使用 deepEq 函式進行深度比較
    return deepEq(a, b, aStack, bStack);
};

function deepEq(a, b, aStack, bStack) {

    ...

    // 陣列判斷
    if (areArrays) {

        length = a.length;
        if (length !== b.length) return false;

        while (length--) {
            if (!eq(a[length], b[length], aStack, bStack)) return false;
        }
    }
    // 物件判斷
    else {

        var keys = Object.keys(a),
            key;
        length = keys.length;

        if (Object.keys(b).length !== length) return false;
        while (length--) {

            key = keys[length];
            if (!(b.hasOwnProperty(key) && eq(a[key], b[key], aStack, bStack))) return false;
        }
    }

}複製程式碼

5.《JavaScript 專題之函式柯里化》

// 非完整版本,完整版本請點選檢視具體的文章
function curry(fn, args) {
    length = fn.length;

    args = args || [];

    return function() {

        var _args = args.slice(0),

            arg, i;

        for (i = 0; i < arguments.length; i++) {

            arg = arguments[i];

            _args.push(arg);

        }
        if (_args.length < length) {
            return curry.call(this, fn, _args);
        }
        else {
            return fn.apply(this, _args);
        }
    }
}複製程式碼

寫在最後

遞迴的內容遠不止這些,比如還有漢諾塔、二叉樹遍歷等遞迴場景,本篇就不過多展開,真希望未來能寫個演算法系列。

專題系列

JavaScript專題系列目錄地址:github.com/mqyqingfeng…

JavaScript專題系列預計寫二十篇左右,主要研究日常開發中一些功能點的實現,比如防抖、節流、去重、型別判斷、拷貝、最值、扁平、柯里、遞迴、亂序、排序等,特點是研(chao)究(xi) underscore 和 jQuery 的實現方式。

如果有錯誤或者不嚴謹的地方,請務必給予指正,十分感謝。如果喜歡或者有所啟發,歡迎 star,對作者也是一種鼓勵。

相關文章