分治與遞迴樹
把問題切成幾個小一號的同樣問題,解完再合起來。畫出遞迴樹,每一層做多少事、有幾層,就是它的 Big O。
演算法
n
T(n) = 2T(n/2) + n切兩半各自排好,再花 n 步合併:子問題越切越多,但每層加起來永遠是 n。
1/6
| 層 | 子問題數 | 大小 | 每個做的事 | 這層合計 |
|---|---|---|---|---|
| 0 | 1 | 16 | 16 | 16 |
這一層已經加進合計還沒到
層數
5
目前累計
16
主定理
O(n log n)
第 0 層:1 個大小 16 的子問題,每個在遞迴之外做 16 單位,這一層共 16。
亮起來的是這一步執行的程式碼
// T(n) = T(n/2) + O(1)function binarySearch(a: number[], t: number, lo = 0, hi = a.length - 1): number { if (lo > hi) return -1; const mid = (lo + hi) >> 1; if (a[mid] === t) return mid; return a[mid] < t ? binarySearch(a, t, mid + 1, hi) : binarySearch(a, t, lo, mid - 1);} // T(n) = 2T(n/2) + O(n)function mergeSort(a: number[]): number[] { if (a.length <= 1) return a; const mid = a.length >> 1; const left = mergeSort(a.slice(0, mid)); const right = mergeSort(a.slice(mid)); const out: number[] = []; let i = 0, j = 0; while (i < left.length && j < right.length) { out.push(left[i] <= right[j] ? left[i++] : right[j++]); } return out.concat(left.slice(i), right.slice(j));} // T(n) = 3T(n/2) + O(n): three half-size products instead of fourfunction karatsuba(x: number, y: number): number { if (x < 10 || y < 10) return x * y; const m = Math.floor(Math.max(String(x).length, String(y).length) / 2); const p = 10 ** m; const a = Math.floor(x / p), b = x % p; const c = Math.floor(y / p), d = y % p; const ac = karatsuba(a, c); const bd = karatsuba(b, d); const mid = karatsuba(a + b, c + d) - ac - bd; return ac * p * p + mid * p + bd;}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 問題可以切成幾個互相獨立、形狀一樣但小一號的問題,而且小問題的答案能便宜地拼回來:排序、搜尋、大數乘法、最近點對。
- 要估一個遞迴演算法的 Big O:寫出 T(n) = aT(n/b) + f(n),畫遞迴樹,或直接套主定理。
- 子問題互相獨立時可以平行處理:每個分支丟給不同的核心或機器(MapReduce 就是這個形狀)。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 二分搜尋 T(n) = T(n/2) + 1 | O(log n) | O(log n) |
| 合併排序 T(n) = 2T(n/2) + n | O(n log n) | O(n log n) |
| Karatsuba T(n) = 3T(n/2) + n 1.585 = log₂3;小學直式乘法是 O(n²) | O(n^1.585) | O(n^1.585) |
| 主定理 T(n) = aT(n/b) + n^c a < b^c:O(n^c);a = b^c:O(n^c log n);a > b^c:O(n^(log_b a)) | — | — |
空間:O(log n) – O(n),遞迴深度是 log n;合併排序另外要 O(n) 的暫存陣列
Big O 實測:n 變大時步數怎麼長
數的是:遞迴樹的總工作量,以及實際的比較次數
| Big O | n = 1,024 | n = 8,192 | n = 65,536 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 二分搜尋:最多看幾格(實測) | O(log n) | 11 | 14 | 17 | ×1.5 (×1.6) |
| 合併排序:遞迴樹總工作量 | O(n log n) | 11,264 | 114,688 | 1,114,112 | ×99 (×102) |
| 合併排序:實際比較次數 | O(n log n) | 8,937 | 96,123 | 965,329 | ×108 (×102) |
Karatsuba 的 n^1.585 不在這張表的選項裡:它的遞迴樹總量從 n = 1,024 到 65,536 長了 737 倍,n^1.585 預測 729 倍。
和其他做法比
| 層數 | 最上層 | 樹葉層 | 總工作量 | 主定理 | |
|---|---|---|---|---|---|
| 二分搜尋 | 11 | 1 | 1 | 11 | O(log n) |
| 合併排序 | 11 | 1,024 | 1,024 | 11,264 | O(n log n) |
| Karatsuba 乘法 | 11 | 1,024 | 59,049 | 175,099 | O(n^1.585) |
n = 1,024 的遞迴樹。三者的層數幾乎一樣,差別在每層有多重:二分搜尋每層 1、合併排序每層 1,024、Karatsuba 越往下越重,光樹葉就有 59,049 個。對照實際跑一次合併排序:1,024 個隨機數比較了 8,937 次、寫入 10,240 次,和樹上每層 n、共 10 層合併的 10,240 同一個量級。
真實世界裡的它
- 合併排序、快速排序、二分搜尋。
- 大數運算函式庫(例如 Python 的 int、Java 的 BigInteger)在位數夠多時改用 Karatsuba 乘法。
- FFT(Fast Fourier Transform,快速傅立葉轉換):T(n) = 2T(n/2) + n,和合併排序同一個形狀。
取捨與陷阱
- 子問題如果會重複(例如費氏數列的遞迴),分治會一再重算同樣的東西:那是動態規劃該上場的時候。
- 切得不平均就會失去 log n:快速排序遇到已排序的輸入,每次只切掉一個元素,就退化成 n 層。
- n 很小時,遞迴本身的成本比省下的還多:實務上切到幾十個元素以下就改用插入排序這類簡單做法。