面某東,有一道題目是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
運行結束,將結果返回到A
,B
的調用幀纔會消失。若是函數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)
,後面的兩個參數1
和1
意思不明確,直接用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改寫階乘
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極大方便了算法運用!
綜上,這個問題解決的思路是:
算法題永遠要想到性能問題,不能只停留到表面,默哀三秒鐘,[悲傷臉.gif]。