跳到主要內容

演算法

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

主題 · 分治與遞迴樹

分治與遞迴樹

把問題切成幾個小一號的同樣問題,解完再合起來。畫出遞迴樹,每一層做多少事、有幾層,就是它的 Big O。

演算法
n

T(n) = 2T(n/2) + n切兩半各自排好,再花 n 步合併:子問題越切越多,但每層加起來永遠是 n。

1/6
16884444222222221111111111111111
層子問題數大小每個做的事這層合計
01161616
這一層已經加進合計還沒到
層數
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 four
function 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) + 1O(log n)O(log n)
合併排序 T(n) = 2T(n/2) + nO(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 On = 1,024n = 8,192n = 65,536成長倍數:實測(理論)
二分搜尋:最多看幾格(實測)O(log n)111417×1.5 (×1.6)
合併排序:遞迴樹總工作量O(n log n)11,264114,6881,114,112×99 (×102)
合併排序:實際比較次數O(n log n)8,93796,123965,329×108 (×102)

Karatsuba 的 n^1.585 不在這張表的選項裡:它的遞迴樹總量從 n = 1,024 到 65,536 長了 737 倍,n^1.585 預測 729 倍。

和其他做法比

層數最上層樹葉層總工作量主定理
二分搜尋111111O(log n)
合併排序111,0241,02411,264O(n log n)
Karatsuba 乘法111,02459,049175,099O(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 很小時,遞迴本身的成本比省下的還多:實務上切到幾十個元素以下就改用插入排序這類簡單做法。

LeetCode 練習