動態規劃
把大問題拆成重疊的小問題,每個小問題只算一次、填進表格。以編輯距離為例,看表格一格一格填滿。
各最多 10 個 Unicode code point
1/57
字元和索引以 Unicode code point 計算;組合字與連接的 emoji 可能占多格。
| ∅ | s | i | t | t | i | n | g | |
|---|---|---|---|---|---|---|---|---|
| ∅ | 0 | |||||||
| k | ||||||||
| i | ||||||||
| t | ||||||||
| t | ||||||||
| e | ||||||||
| n |
正在填的格子/回溯的路它參考的三個格子
空字串變空字串:0 次編輯。
編輯腳本
表格填滿之後(最後一步)才會出現。
編輯距離
3
表格:每格算一次
56
不記憶的遞迴呼叫
3,032
亮起來的是這一步執行的程式碼
function editDistance(textA: string, textB: string): number[][] { const a = Array.from(textA), b = Array.from(textB); const d: number[][] = []; for (let i = 0; i <= a.length; i++) { d.push(new Array(b.length + 1).fill(0)); for (let j = 0; j <= b.length; j++) { if (i === 0 || j === 0) { d[i][j] = i + j; continue; } const same = a[i - 1] === b[j - 1]; const diagonal = d[i - 1][j - 1] + (same ? 0 : 1); const up = d[i - 1][j] + 1; const left = d[i][j - 1] + 1; d[i][j] = Math.min(diagonal, up, left); } } return d;} function editScript(textA: string, textB: string, d: number[][]): string[] { const a = Array.from(textA), b = Array.from(textB); const ops: string[] = []; let i = a.length, j = b.length; while (i > 0 || j > 0) { const same = i > 0 && j > 0 && a[i - 1] === b[j - 1]; if (i > 0 && j > 0 && d[i][j] === d[i - 1][j - 1] + (same ? 0 : 1)) { ops.push(same ? "keep" : "substitute"); i--; j--; } else if (i > 0 && d[i][j] === d[i - 1][j] + 1) { ops.push("delete"); i--; } else { ops.push("insert"); j--; } } return ops.reverse();}模型假設與範圍
- Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
什麼時候用
- 問題能拆成會重複出現的子問題,而且大問題的最佳解由子問題的最佳解組成。
- 常見訊號:「最少幾步」「有幾種方法」「最長的共同…」,而且直接遞迴會一再用同樣的參數呼叫。
- 兩種寫法:由上而下(遞迴+記住算過的)或由下而上(填表)。這裡用填表,因為順序看得到。
和其他主題的關係
- 延伸閱讀
- 最短路徑
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 填表 m、n 是兩個字的長度,每格只算一次 | O(mn) | O(mn) |
| 回溯出編輯腳本 | O(m + n) | O(m + n) |
| 不記表的遞迴 同一個子問題被重算無數次 | O(3^(m+n)) | O(3^(m+n)) |
空間:O(mn),只要距離、不要編輯腳本時,只留上一列就夠:O(min(m, n))
Big O 實測:n 變大時步數怎麼長
數的是:實際填了幾格;n 是兩個隨機字(a、c、g、t 組成)的長度
| Big O | n = 8 | n = 16 | n = 32 | n = 64 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 填表的格數 | O(n²) | 81 | 289 | 1,089 | 4,225 | ×52 (×64) |
不記表的遞迴沒辦法放進這張表:同樣的隨機字,長度 6、8、10 就分別要呼叫 2,076、16,968、221,631 次。
和其他做法比
| 編輯距離 | 填表:格數 | 不記憶的遞迴:呼叫次數 | |
|---|---|---|---|
| flaw → lawn | 2 | 25 | 194 |
| kitten → sitting | 3 | 56 | 3,032 |
| intention → execution | 5 | 100 | 1,709 |
| algorithms → logarithms | 3 | 121 | 159 |
| abcdefghij → klmnopqrst | 10 | 121 | 12,146,179 |
格數就是 (m+1)(n+1)。遞迴的呼叫次數是精確值:同一條遞迴式,用另一張表去數它會呼叫幾次(測試裡會和真的跑一遍的遞迴比對)。最後一列十個字母全部不同,是最壞情況。
真實世界裡的它
- 拼字檢查和搜尋引擎的「您是不是要找」用編輯距離找最接近的詞。
- diff 和 git 用最長共同子序列(同一種表格)找出兩份檔案的差異。
- 比對 DNA 序列的 Needleman–Wunsch 演算法,就是加了權重的編輯距離。
取捨與陷阱
- 不記住結果的遞迴會爆炸:同一格被重算很多次,呼叫次數隨字長指數成長;表格只要 (m+1)(n+1) 格。
- 表格可以只留兩列,記憶體從 m×n 降到 n——但這樣就沒辦法回溯出編輯腳本。
- 填表順序必須保證需要的格子已經算好;這裡是由左上往右下,換一條遞迴式順序就可能不同。
- 子問題不重疊時(例如合併排序的兩半),記住結果沒有任何好處,那叫分治,不叫動態規劃。