跳到主要內容

演算法

同一個問題,不同的解題思路

主題 · 考題:費氏數列

考題:費氏數列

同一個數列四種算法:直接遞迴是指數時間,記憶化和迴圈是線性,矩陣快速冪只要 log n 次乘法。

解法
6

題目:算出 F(n),其中 F(0) = 0、F(1) = 1、F(n) = F(n−1) + F(n−2)。例如 F(10) = 55。

1/50
f6
正在呼叫正在回傳從快取直接回答

呼叫 fib(6),它要再呼叫 fib(5) 和 fib(4)。目前 1 次呼叫。

四種解法算 n = 6
F(n)工作量額外記憶體Big O
直接遞迴825 次呼叫堆疊深度 6O(φⁿ)
記憶化811 次呼叫快取 7、堆疊 6O(n)
迴圈86 次加法2 個變數O(n)
快速倍增89 次乘法2 個變數O(log n)
大的 n

F(90) = 2880067194370816120(19 位數)

  • 直接遞迴:9.32×10^18 次呼叫,跑不完
  • 記憶化:179 次呼叫,遞迴深度 90
  • 迴圈:90 次加法
  • 快速倍增:21 次乘法,和迴圈算出的值相同

這裡數的是「大數運算」的次數,而 F(90) 有 19 位數:數字越大,每一次運算本身就越貴。大數乘法比加法慢得多,所以快速倍增實際上贏的幅度比次數看起來小,但 n 大時仍然遠遠領先。

亮起來的是這一步執行的程式碼
function fibNaive(n: number): bigint {
if (n < 2) return BigInt(n);
return fibNaive(n - 1) + fibNaive(n - 2);
}
function fibMemo(n: number, memo = new Map<number, bigint>()): bigint {
const hit = memo.get(n);
if (hit !== undefined) return hit;
const value = n < 2 ? BigInt(n) : fibMemo(n - 1, memo) + fibMemo(n - 2, memo);
memo.set(n, value);
return value;
}
function fibLoop(n: number): bigint {
let a = 0n, b = 1n;
for (let i = 0; i < n; i++) [a, b] = [b, a + b];
return a;
}
function fibDoubling(n: number): bigint {
let a = 0n, b = 1n; // F(k) and F(k + 1), from k = 0
for (let bit = 31 - Math.clz32(n); bit >= 0; bit--) {
const c = a * (2n * b - a); // F(2k)
const d = a * a + b * b; // F(2k + 1)
if ((n >> bit) & 1) [a, b] = [d, c + d];
else [a, b] = [c, d];
}
return a;
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。

什麼時候用

  • 面試時從直接遞迴講起,自己指出重複計算:畫出呼叫樹,fib(n−2) 被算了兩次、fib(n−3) 三次……這就是動態規劃的起點。
  • 接著給記憶化(由上而下)和迴圈(由下而上)兩個 O(n) 版本,並說明迴圈只需要 O(1) 空間。
  • 被問到「n 是 10¹⁸ 怎麼辦」時,才拿出矩陣快速冪或快速倍增。

和其他主題的關係

時間與空間複雜度(Big O)

操作平均最差
直接遞迴
φ ≈ 1.618;同一個 fib(k) 被重算很多次
O(φⁿ)O(φⁿ)
記憶化遞迴
空間 O(n):快取加上 n 層的呼叫堆疊
O(n)O(n)
迴圈
空間 O(1)
O(n)O(n)
快速倍增/矩陣快速冪
以運算次數計;大數乘法本身的成本另計
O(log n)O(log n)

空間:O(1) – O(n),迴圈與快速倍增只要兩個變數,遞迴版本要堆疊

Big O 實測:n 變大時步數怎麼長

數的是:大數運算次數(呼叫、加法或乘法)

Big On = 64n = 256n = 1,024成長倍數:實測(理論)
記憶化:呼叫O(n)1275112,047×16 (×16)
迴圈:加法O(n)642561,024×16 (×16)
快速倍增:乘法O(log n)212733×1.6 (×1.7)

直接遞迴是指數成長,不在這張表裡;見上方的比較表。

和其他做法比

直接遞迴:呼叫記憶化:呼叫迴圈:加法快速倍增:乘法
n = 10177191012
n = 2021,891392015
n = 302,692,537593015
n = 40331,160,281794018
n = 909.32×10^181799021

直接遞迴的呼叫次數是 2·F(n+1) − 1(小的 n 有實際跑過驗證),每多 1 就乘上約 1.618 倍:指數成長,所以不放進下面的 Big O 實測表。記憶化每個 n 只算一次、另外多一次查快取,所以是 2n − 1 次呼叫。

真實世界裡的它

  • 爬樓梯(一次走 1 或 2 階有幾種走法)就是費氏數列換個說法。
  • 「答案對 10⁹+7 取餘數」的變形:快速倍增每一步都取餘數,數字就不會變大。
  • 任何線性遞迴(例如 F(n) = F(n−1) + 2F(n−3))都能寫成矩陣,用同樣的快速冪在 O(log n) 算出來。

取捨與陷阱

  • 用 32 或 64 位元整數:F(47) 就超過 32 位元、F(94) 超過 64 位元,會默默溢位。
  • 記憶化遞迴的深度是 n:Python 預設上限 1,000 層,n 一大就 RecursionError。
  • 用浮點數的比內公式(φⁿ/√5):n 超過 70 左右就因為精度不足而算錯。

LeetCode 練習