從斐波那契數列看兩種常用演算法和優化

xiangjun發表於2020-04-07

歡迎關注我:Github

斐波拉契數列是一個非常經典的數學概念,早在 1202 年就由義大利數學家 Leonardo Fibonacci 提出。它的遞推方法定義為:F(1) = 1, F(2) = 1, F(n) = F(n - 1) + F(n - 2)(n ≥ 3,n ∈ N。本文主要從遞迴、遞推兩種演算法以及記憶化函式尾呼叫優化*兩種優化方式來探討它的解法。

遞迴演算法

const fib = function(N) {
  if (N <= 1) return N
  return fib(N - 1) + fib(N - 2);
};
複製程式碼

遞迴演算法英文為 recursion algorithm,是一種直接或者間接呼叫自身函式或者方法的演算法。遞迴演算法的實質是把問題分解成規模縮小的同類問題的子問題,然後遞迴呼叫方法來表示問題的解。

遞迴演算法解決問題的特點:

  • 遞迴就是方法裡呼叫自身。
  • 在使用遞增歸策略時,必須有一個明確的遞迴結束條件,稱為遞迴出口。
  • 遞迴演算法解題通常顯得很簡潔,但遞迴演算法解題的執行效率較低。所以一般不提倡用遞迴演算法設計程式。
  • 在遞迴呼叫的過程當中系統為每一層的返回點、區域性量等開闢了棧來儲存。遞迴次數過多容易造成棧溢位等,所以一般不提倡用遞迴演算法設計程式。

上面的演算法的遞迴出口是 if (N <= 1) return N,表示 fib 的第一個和第二位分別是從0和1開始計算。假設我們使用此演算法求解 N 為3時 fib 的值,則遞迴過程如下:

// fib(3)返回fib(2)和fib(1)相加的結果
fib(3) = fib(2) + fib(1);
// fib(2)返回fib(1)和fib(0)相加的結果
fib(2) = fib(1) + fib(0);
// fib(1)和fib(0)觸發遞迴出口的條件,分別返回1和0
fib(1) = 1; fib(0) = 0;
複製程式碼

通過上面的遞迴過程,fib(3) 最終轉換為fib(1) + fib(0) + fib(1)的求解。

遞迴的效率並不是最優的,也可能導致棧溢位的問題。

下面是 leetcode 執行用時和記憶體消耗:

執行用時 記憶體消耗
80 ms 34.7 MB

時間複雜度:O(2^N)。

空間複雜度:O(N)。

作者:LeetCode 連結:leetcode-cn.com/problems/fi… 來源:力扣(LeetCode) 著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

函式尾呼叫優化

const fib = function(N) {
  return calc(N, 0, 1);   
};

const calc = function(count, n, m) {
  if (count === 0) return n;
  return calc(count - 1, m, n + m);
}
複製程式碼

尾呼叫(Tail Call)是函數語言程式設計的一個重要概念,是指一個函式裡的最後一個動作是呼叫函式的情形,而函式尾優化就是通過尾呼叫來優化函式的棧空間大小。

原理:函式呼叫時會記憶體形成一個呼叫幀(call frame),儲存呼叫位置和內部變數等資訊,如果函式本身存在遞迴呼叫函式的情況,那麼所有的呼叫記錄就會形成一個呼叫棧(call stack)。複雜的巢狀遞迴會佔用大量的棧空間。當編譯器檢測到一個函式呼叫是尾遞迴的時候,它就覆蓋當前的活動記錄而不是在棧中去建立一個新的。通過覆蓋當前的棧幀而不是在其之上重新新增一個,這樣所使用的棧空間就大大縮減了,這使得實際的執行效率會變得更高。

上面的程式碼都滿足“最後一個動作是呼叫函式”這樣一種情形,所以屬於尾呼叫。區別於遞迴演算法,函式尾呼叫優化將 fib(0)fib(1) 的值作為引數預設值傳遞給了 calc 方法,並且將遞迴演算法返回 fib 前兩項相加的運算放在了函式引數中執行,這樣就做到了函式尾呼叫優化。

下面是 leetcode 執行結果:

執行用時 記憶體消耗
64 ms 34.3 MB

時間複雜度:O(2^N)。

空間複雜度:O(1)。

記憶化

const fib = function(N) {
  return memo(N);
};

function memo(N, arr = []) {
  if (N <= 1) return N;
  if (!arr[N]) arr[N] = memo(N - 1) + memo(N - 2);
  return arr[N];
}
複製程式碼

在遞迴演算法時,存在著重複計算的問題,比如求解 fib(4) 時,會將 fib(2) 重複計算兩次。雖然在 N 很小時不會造成特別大的效能損耗,而且可能還優於記憶化(記憶化要開闢新的空間儲存已計算過的值),不過在處理大資料時記憶化的優勢就顯示出來了。

上面程式碼新增一個 arr 陣列來儲存計算結果,如果 arr 中已經儲存了對應的值,則不再重複計算,直接返回儲存的結果。

其實,相對於記憶化,有一個更優的演算法來求解斐波拉契數列,那就是遞推。

時間複雜度:O(N)。

空間複雜度:O(N)。

遞推

const fib = function(N) {
  if (N <= 1) return N;
  
  const arr = [];
  arr[0] = 0;
  arr[1] = 1;
  for (let i = 2; i <= N; i++) {
    arr[i] = arr[i - 1] + arr[i - 2];
  }
  return arr[N];
};
複製程式碼

遞推(The Recursive)是給定一組初始值,然後根據規定運算,並最終得到所需結果。如果說遞迴是從未知到已知,那麼遞推就是從已知到未知

遞推更加符合人類的思維習慣,從 fib(0)fib(1) 可以計算出 fib(2) 的值,而知道了 fib(1)fib(2) 的值,又可以計算出 fib(3) 的值。以此類推,可以計算出所有值的結果。相對於遞迴,遞推不會出現重複計算的問題,在執行效率上更優。

下面是 leetcode 執行結果:

執行用時 記憶體消耗
56 ms 33.5 MB

時間複雜度:O(N)。

空間複雜度:O(1)

參考

遞迴演算法詳解

相關文章