跳到主要內容

演算法

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

主題 · 動態規劃

動態規劃

把大問題拆成重疊的小問題,每個小問題只算一次、填進表格。以編輯距離為例,看表格一格一格填滿。

各最多 10 個 Unicode code point
1/57

字元和索引以 Unicode code point 計算;組合字與連接的 emoji 可能占多格。

∅sitting
∅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 On = 8n = 16n = 32n = 64成長倍數:實測(理論)
填表的格數O(n²)812891,0894,225×52 (×64)

不記表的遞迴沒辦法放進這張表:同樣的隨機字,長度 6、8、10 就分別要呼叫 2,076、16,968、221,631 次。

和其他做法比

編輯距離填表:格數不記憶的遞迴:呼叫次數
flaw → lawn225194
kitten → sitting3563,032
intention → execution51001,709
algorithms → logarithms3121159
abcdefghij → klmnopqrst1012112,146,179

格數就是 (m+1)(n+1)。遞迴的呼叫次數是精確值:同一條遞迴式,用另一張表去數它會呼叫幾次(測試裡會和真的跑一遍的遞迴比對)。最後一列十個字母全部不同,是最壞情況。

真實世界裡的它

  • 拼字檢查和搜尋引擎的「您是不是要找」用編輯距離找最接近的詞。
  • diff 和 git 用最長共同子序列(同一種表格)找出兩份檔案的差異。
  • 比對 DNA 序列的 Needleman–Wunsch 演算法,就是加了權重的編輯距離。

取捨與陷阱

  • 不記住結果的遞迴會爆炸:同一格被重算很多次,呼叫次數隨字長指數成長;表格只要 (m+1)(n+1) 格。
  • 表格可以只留兩列,記憶體從 m×n 降到 n——但這樣就沒辦法回溯出編輯腳本。
  • 填表順序必須保證需要的格子已經算好;這裡是由左上往右下,換一條遞迴式順序就可能不同。
  • 子問題不重疊時(例如合併排序的兩半),記住結果沒有任何好處,那叫分治,不叫動態規劃。

LeetCode 練習