歡迎關注我: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)