js 實現斐波那契數列(數組緩存、動態規劃、尾調用優化)

斐波那契數列是如下一系列數字:javascript

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, ...

在種子數字 0 和 1 以後,後續的每個數字都是前面兩個數字之和。html

斐波那契數列的一個有趣的性質是,數列的當前數字與前一個數字的比值無限趨近於黃金分割數, 1.61803398875…
你可使用斐波那契數列來生成各類各樣有趣的東西,好比黃金螺旋 (Golden Spiral),天然界中存在許多黃金螺旋。
黃金螺旋、黃金矩形前端

斐波那契數列(意大利語:Successione di Fibonacci),又譯爲費波拿契數、費氏數列、黃金分割數列。java

在數學上,斐波那契數列是以遞歸的方法來定義:git

F(0)=0, F(1)=1, n>1時,F(n)=F(n-1)+F(n-2)。

根據該規則,返回第n個斐波那契數。程序員

遞歸法

function fibonacci(n) {
    if(n === 0 || n === 1) {
        return n;
    }
    console.log(`fibonacci(${n-1}) + fibonacci(${n-2})`)
    return fibonacci(n - 2) + fibonacci(n - 1)
}
fibonacci(5)

思路:不斷調用自身方法,直到n爲1或0以後,開始一層層返回數據。
問題:使用遞歸計算大數字時,性能會很是低;另外,遞歸形成了大量的重複計算(不少函數執行了屢次)。es6

數組緩存

從上面代碼的 console 中能夠看出,執行了許多相同的運算。若是咱們對中間求得的變量值,進行存儲的話,就會大大減小函數被調用的次數。
這是典型的以空間換時間。很明顯,函數被調用的次數大大減小,耗時明顯縮減。github

let fibonacci = function() {
    let temp = [0, 1];
    return function(n) {
        let result = temp[n];
        if(typeof result != 'number') {
            result = fibonacci(n - 1) + fibonacci(n - 2);
            temp[n] = result; // 將每次 fibonacci(n) 的值都緩存下來
        }
        return result;
    }
}(); // 外層當即執行

遞推法(動態規劃)

動態規劃:從底部開始解決問題,將全部小問題解決掉,而後合併成一個總體解決方案,從而解決掉整個大問題;
遞歸:從頂部開始將問題分解,經過解決掉全部分解的小問題來解決整個問題;算法

function fibonacci(n) {
    let current = 0;
    let next = 1;
    let temp;
    for(let i = 0; i < n; i++) {
        temp = current;
        current = next;
        next += temp;
    }
    console.log(`fibonacci(${n}, ${next}, ${current + next})`);
    return current;
}

思路:從下往上計算,首先根據f(0)和f(1)算出f(2),再根據f(1)和f(2)算出f(3),依次類推咱們就能夠算出第n項了。
而這種算法的時間複雜度僅爲O(n),比遞歸函數的寫法效率要大大加強。編程

  1. 寫法一:
function fib(n) {
    let current = 0;
    let next = 1;
    for(let i = 0; i < n; i++) {
        [current, next] = [next, current + next];
    }
    return current;
}

[[解構賦值]](https://developer.mozilla.org...。所以咱們能夠用解構賦值,省略temp中間變量。

  1. 寫法二:
function fib(n) {
    let current = 0;
    let next = 1;
    while(n --> 0) { //     while(n>0) {n--} n--的返回值是n
        [current, next] = [next, current + next];
    }
    return current;
}
fib(10)

尾調用優化

// 在ES6規範中,有一個尾調用優化,能夠實現高效的尾遞歸方案。
// ES6的尾調用優化只在嚴格模式下開啓,正常模式是無效的。
'use strict'
function fib(n, current = 0, next = 1) {
    if(n == 0) return 0;
    if(n == 1) return next; // return next
    console.log(`fibonacci(${n}, ${next}, ${current + next})`);
    return fib(n - 1, next, current + next);
}

=======下面是科普======

  1. 什麼是尾調用(函數式編程的一個重要概念)

一句話,就是指某個函數的最後一步是調用另外一個函數。

// 用代碼來講,就是B函數的返回值被A函數返回了。
function B() {
    return 1;
}
function A() {
    return B();  // return 1
}

// 尾調用不必定出如今函數尾部,只要是最後一步操做便可
function f(x) {
  if (x > 0) {
    return m(x)
  }
  return n(x);
}

// 下面不屬於尾調用:調用函數g以後,還有別的操做
function f(x){
  let y = g(x);
  return y;
}

function f(x){
  return g(x) + 1;
}

function f(x) {
  g(x); // 這一步至關於g(x) return undefined
}
  1. 尾調用優化

瞭解 js 的調用棧咱們知道,當腳本要調用一個函數時,解析器把該函數添加到棧中而且執行這個函數,並造成一個棧幀(調用幀),保存調用位置和內部變量等信息。

若是在函數A的內部調用函數B,那麼在A的調用幀上方,還會造成一個B的調用幀。等到B運行結束,將結果返回到A,B的調用幀纔會銷燬。此時若是函數B內部還調用函數C,那就還有一個C的調用幀,以此類推。例如遞歸操做,一個調用棧中若是保存了大量的棧幀,調用棧很是長,消耗了巨大的內存,會致使爆棧(棧溢出,stack overflow)。後入先出的結構。

當函數內部調用函數時,會在棧頂壓入一個調用幀,該函數執行完後,彈出棧,接着再執行棧頂的調用幀。

尾調用之因此與其餘調用不一樣,就在於它的特殊的調用位置。

那麼如今,咱們使用尾調用的話,將函數B放到了函數A的最後一步調用,內層函數不引用外層函數的變量,就不用保留外層變量的調用幀了。這樣的話內層函數的調用幀,會直接取代外層函數的調用幀。調用棧的長度就會小不少。

固然,若是內層函數B使用了外層函數A的變量,那麼就仍然須要保留函數A的棧幀,典型例子便是閉包。

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

這應當是一次尾調用。由於m+n的值是經過參數傳入函數g內部,並無直接引用,所以也不能說保存 f 內部的變量的值。

下面這種狀況內層函數會引用外層函數的值,因此當執行到內層函數時外層函數的調用幀還會存在與當前調用棧中。

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

總得來講,若是全部函數的調用都是尾調用,即只保留內層函數的調用幀,作到每次執行時(例如遞歸操做),一個調用棧中調用幀只有一項,那麼調用棧的長度就會小不少,這樣須要佔用的內存也會大大減小。這就是尾調用優化的含義。

  1. 何時會開啓尾調用優化呢?

在ES5中,尾調用和其餘形式的函數調用同樣:腳本引擎建立一個新的函數棧幀而且壓在當前調用的函數的棧幀上面。也就是說,在整個函數棧上,每個函數棧幀都會被保存,這有可能形成調用棧佔用內存過大甚至溢出。

在ES6中,strict模式下,知足如下條件,尾調用優化會開啓,此時引擎不會建立一個新的棧幀,而是清除當前棧幀的數據並複用:

  • 尾調用函數不須要訪問當前棧幀中的變量;
  • 尾調用返回後,函數沒有語句須要繼續執行;
  • 尾調用的結果就是函數的返回值;

ES6的尾調用優化只在嚴格模式下開啓,正常模式是無效的。
這是由於在正常模式下,函數內部有兩個變量,能夠跟蹤函數的調用棧。

arguments:返回調用時函數的參數。
func.caller:返回調用當前函數的那個函數。

尾調用優化發生時,函數的調用棧會改寫,所以上面兩個變量就會失真。嚴格模式禁用這兩個變量,因此尾調用模式僅在嚴格模式下生效。

  1. 尾遞歸

函數調用自身,稱爲遞歸。若是尾調用自身,就稱爲尾遞歸。

遞歸很是耗費內存,由於須要同時保存成千上百個調用幀,很容易發生"棧溢出"錯誤(stack overflow)。但對於尾遞歸來講,因爲只存在一個調用幀,因此永遠不會發生"棧溢出"錯誤。

因而可知,"尾調用優化"對遞歸操做意義重大。

  1. 遞歸函數的改寫

尾遞歸的實現,每每須要改寫遞歸函數,確保最後一步只調用自身。作到這一點的方法,就是把全部用到的內部變量改寫成函數的參數。

例如實現 fibonacci 函數須要用到兩個中間變量 current 和 next,那就把這個中間變量改寫成函數的參數。

// 實現階乘 複雜度 O(n)
function factorial(n) {
  if (n === 1) return 1;
  return n * factorial(n - 1);
}

// 尾遞歸 只保留一個調用幀,複雜度 O(1) 
function factorial(n, total = 1) {
  if (n === 1) return total;
  return factorial(n - 1, n * total);
}
  1. 問題
  • 在V8引擎層面消除尾遞歸是一個隱式的行爲,程序員寫代碼時可能意識不到本身寫了死循環的尾遞歸,而出現死循環後又不會報出stack overflow的錯誤,難以辨別。
  • 堆棧信息會在優化的過程當中丟失,開發者調試很是困難。

References

一個前端眼中的斐波那契數列
JAVASCRIPT解斐波那契(FIBONACCI)數列的實用解法
JavaScript 調用棧、尾遞歸和手動優化
尾調用優化
【譯】我從用 JavaScript 寫斐波那契生成器中學到的使人驚訝的 7 件事

相關文章
相關標籤/搜索