尾調用優化——記一道面試題的思考

前言

面某東,有一道題目是javascript

實現一個斐波拉契數列, 已知第一項爲0,第二項爲1,第三項爲1,後一項是前兩項之和,即 f(n) = f(n - 1) + f(n -2)

拿到這個題目,二話沒想就寫了java

function f(n) {
    if(n === 0) return 0;
    if(n === 1) return 1;
    return f(n - 1) + f(n -2);
}

後來回想,後悔死了,這明顯沒這麼簡單,每次遞歸調用都會呈指數往調用棧裏增長記錄「調用幀「,這樣作,當項比較多,就會出現「棧溢出」的!!!也就是,這個答案是不及格的,正確姿式應該用尾遞歸優化,」調用幀「保持只有一個。正解也就是:算法

function f(n, prev, next) {
    if(n <= 1) {
        return next;
    }
    return f(n - 1, next, prev + next);
}

下面來複習一下知識點:尾調用和尾遞歸。PS:更好的方案請繼續往下看。app

尾調用

尾調用是指某個函數的最後一步是調用另外一個函數。函數

如下三種狀況都不是尾調用:性能

// 狀況一
function f(x) {
    let y = g(x);
    return y;
}

// 狀況二
function f(x) {
    return g(x) + 1;
}

// 狀況三
function f(x) {
    g(x);
}

狀況一是調用函數g以後,還有賦值操做,因此不屬於尾調用,即便語義徹底同樣。狀況二也是屬於調用後還有操做。狀況三等同於:優化

g(x);
return undefined;

函數調用會在內存造成一個「調用記錄」,又稱「調用幀」,保存調用位置和內存變量等信息。若是在函數A的內部調用函數B,那麼在A的調用幀上方,還會造成一個B的調用幀。等到B運行結束,將結果返回到AB的調用幀纔會消失。若是函數B內部還調用函數C,那就還有一個C的調用幀,依次類推。全部的調用幀,就造成一個「調用棧」。prototype

尾調用因爲是函數的最後一步操做,全部不須要保留外層函數的調用幀,由於調用位置、內部變量等信息都不會再用到了,只要直接用內層函數的調用幀,取代外層函數的調用幀就能夠了。code

function f() {
    let m = 0;
    let n = 2;
    return g(m + n);
}
f();

// 等同於
function f() {
    return g(3);
}
f();

// 等同於
g(3);

若是全部函數都是尾調用,那麼徹底能夠作到每次執行時,調用幀只有一項,這將大大節省內存。這就是「尾調用優化」。遞歸

注意,只有再也不用到外層函數的內部變量,內層函數的調用幀纔會取代外層函數的調用幀,不然就沒法進行「尾調用優化」。

function addOne(a) {
    var one = 1;
    function inner(b) {
        return b + one;
    }
    return inner(a);
}

尾遞歸

函數調用自身,稱爲遞歸。若是尾調用自身,就稱爲尾遞歸。遞歸很是耗費內存,由於須要同時保存成百上千調用幀,很容易發生「棧溢出」錯誤。但對於尾遞歸來講,因爲只存在一個調用幀,因此永遠不會發生「棧溢出」錯誤。

function factorial(n) {
    if (n === 1) return 1;
    return n * factorial(n - 1);
}
console.log(factorial(5)); // 120

上面最多保存n個調用記錄,複雜度是O(n)

若是改爲尾遞歸,只保留一個調用記錄,複雜度O(1)

function factorial(n, total) {
    if (n === 0) return total;
    return factorial(n - 1, n * total);
}
console.log(factorial(5, 1)); // 120

下面回到咱們的主題,計算Fibonacci數列。

function fibonacci(n) {
    if(n <= 1) return 1;
    return fibonacci(n -1) + fibonacci(n -2);
}
console.log(fibonacci(10)); // 89
console.log(fibonacci(50)); // stack overflow

上面不使用尾遞歸,項數稍大點就發生」棧溢出「了。

function fibonacci(n, prev, next) {
    if(n <= 1) return next;
    return fibonacci(n-1, next, prev + next);
}
console.log(fibonacci(10, 1, 1)); // 89
console.log(fibonacci(100, 1, 1)); // 573147844013817200000
console.log(fibonacci(1000, 1, 1)); // 7.0330367711422765e+208

上面項數再大都狀態良好。

柯理化改寫

尾遞歸的實現,每每須要改寫遞歸函數,確保最後一步只調用自身。作到這一點的方法,就是把全部用到的內部變量改寫成函數的參數。可是這樣的話就會增長初始入參,好比fibonacci(10, 1, 1),後面的兩個參數11意思不明確,直接用fibonacci(100)纔是習慣用法。因此須要在中間預先設置好初始入參,將多個入參轉化成單個入參的形式,叫作函數柯理化。通用方式爲:

function curry(fn) {
    var args = Array.prototype.slice.call(arguments, 1);
    return function () {
        var innerArgs = Array.prototype.slice.call(arguments);
        var finalArgs = innerArgs.concat(args);
        return fn.apply(null, finalArgs);
    }
}

用函數柯理化改寫階乘

function tailFactorial(n, total) {
    if(n === 1) return total;
    return tailFactorial(n - 1, n * total);
}

var factorial = curry(tailFactorial, 1); 
console.log(factorial(5)); // 120

一樣改寫斐波拉契數列

function tailFibonacci(n, prev, next) {
    if(n <= 1) return next;
    return tailFibonacci(n - 1, next, prev + next);
}

var fibonacci = curry(fibonacci, 1, 1);
console.log(fibonacci(10)); // 89
console.log(fibonacci(100)); // 573147844013817200000
console.log(fibonacci(1000)); // 7.0330367711422765e+208

ES6改寫

柯理化的過程實際上是初始化一些參數的過程,在ES6中,是能夠直接函數參數默認賦值的。

用ES6改寫階乘

const factorial = (n, total = 1) => {
    if(n === 1) return total;
    return factorial(n - 1, n * total);
}
console.log(factorial(5)); // 120

用ES6改寫斐波拉契數列

const fibonacci = (n, prev = 1, next = 1) => {
    if(n <= 1) return next;
    return fibonacci(n - 1, next, prev + next);
}
console.log(fibonacci(10)); // 89
console.log(fibonacci(100)); // 573147844013817200000
console.log(fibonacci(1000)); // 7.0330367711422765e+208

ps:用ES6極大方便了算法運用!

總結

綜上,這個問題解決的思路是:

  1. 尾遞歸+函數柯理化;
  2. 尾遞歸+ES6默認賦值;

算法題永遠要想到性能問題,不能只停留到表面,默哀三秒鐘,[悲傷臉.gif]。

相關文章
相關標籤/搜索