跳到主要內容

演算法

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

主題 · 字串比對

字串比對

在一大段文字裡找一個字串:暴力法最差 O(nm);KMP(Knuth-Morris-Pratt)利用已經比對過的資訊從不回頭,Rabin-Karp 用滾動雜湊一次比一整段。

做法
範例
1/31
a0b1a2b3a4b5c6a7b8a9b10a11b12a13b14a15b16c17a18b19
ababcab
0012012
← 失敗表
這一對相同這一對不同目前對齊的範圍已找到

文字第 0 格「a」和樣式第 0 格相同:繼續比下一個。

字元比較次數
1
找到幾處
0
文字長度 n
20
樣式長度 m
7
亮起來的是這一步執行的程式碼
const utf8 = (s: string) => Array.from(new TextEncoder().encode(s));
function bruteForce(text: string, pattern: string): number[] {
const t = utf8(text), p = utf8(pattern), found: number[] = [];
if (p.length === 0) return found; // Empty means no active search.
for (let s = 0; s + p.length <= t.length; s++) {
let j = 0;
while (j < p.length && t[s + j] === p[j]) j++;
if (j === p.length) found.push(s);
}
return found;
}
// fail[q]: length of the longest proper prefix of p[0..q] that is also its suffix.
function failureTable(p: number[]): number[] {
const fail = new Array(p.length).fill(0);
let k = 0;
for (let q = 1; q < p.length; q++) {
while (k > 0 && p[q] !== p[k]) k = fail[k - 1];
if (p[q] === p[k]) k++;
fail[q] = k;
}
return fail;
}
function kmp(text: string, pattern: string): number[] {
const t = utf8(text), p = utf8(pattern), found: number[] = [];
if (p.length === 0) return found;
const fail = failureTable(p);
let j = 0;
for (let i = 0; i < t.length; i++) {
while (j > 0 && t[i] !== p[j]) j = fail[j - 1];
if (t[i] === p[j]) j++;
if (j === p.length) {
found.push(i - p.length + 1);
j = fail[j - 1];
}
}
return found;
}
function rabinKarp(text: string, pattern: string, mod: number): number[] {
const t = utf8(text), p = utf8(pattern), m = p.length, found: number[] = [];
if (m === 0 || m > t.length) return found;
let high = 1;
for (let k = 1; k < m; k++) high = (high * 256) % mod;
let ph = 0, wh = 0;
for (let k = 0; k < m; k++) {
ph = (ph * 256 + p[k]) % mod;
wh = (wh * 256 + t[k]) % mod;
}
for (let s = 0; s + m <= t.length; s++) {
if (wh === ph) {
let j = 0;
while (j < m && t[s + j] === p[j]) j++;
if (j === m) found.push(s);
}
if (s + m < t.length) {
wh = (((wh - (t[s] * high) % mod + mod) % mod) * 256 + t[s + m]) % mod;
}
}
return found;
}

模型假設與範圍

  • Big O 與實測步數以頁面列出的基本操作為單位;視覺化的快照、畫圖、程式高亮和輸出成本另計,不是執行時間 benchmark。
  • 互動輸入限 printable ASCII,展示程式搜尋 UTF-8 byte 位置;空 pattern 在本 app 表示尚未搜尋,回傳空結果。

什麼時候用

  • 日常程式直接用語言內建的搜尋(indexOf、str.find、String.contains):它們內部已經用了比暴力法聰明的做法。
  • KMP:需要保證最差情況也是線性時間,或是文字以串流方式進來、不能回頭重讀。
  • Rabin-Karp:要同時找很多個一樣長的樣式,或找重複出現的片段(抄襲比對、找重複的 DNA 片段),滾動雜湊可以一次比一整段。

和其他主題的關係

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

操作平均最差
暴力法
一般文字很快就比到不同;最差情況每個位置都比 m 次
O(n + m)O(nm)
KMP
建失敗表 O(m),掃描時文字指標從不後退
O(n + m)O(n + m)
Rabin-Karp
最差是每個位置雜湊都撞到
O(n + m)O(nm)

空間:O(m),KMP 的失敗表;暴力法和 Rabin-Karp 只要 O(1)

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

數的是:最差情況下的字元比較或雜湊次數(m = n / 10)

Big On = 1,000n = 2,000n = 4,000n = 8,000成長倍數:實測(理論)
暴力法:比較O(n²)90,100360,2001,440,4005,760,800×64 (×64)
KMP:比較O(n)1,9013,8017,60115,201×8.0 (×8.0)
Rabin-Karp:雜湊O(n)1,1002,2004,4008,800×8.0 (×8.0)

樣式長度跟著文字一起長(m = n / 10),所以暴力法的 O(nm) 在這裡就是 O(n²):文字變 8 倍,比較次數變 64 倍;KMP 和 Rabin-Karp 都只變 8 倍。

和其他做法比

暴力法:比較KMP:比較Rabin-Karp:比較+雜湊模 101 時的假警報
隨機英文字母,n = 10,000、m = 810,40210,3888 + 10,00899
隨機 DNA(4 種字),n = 10,000、m = 813,36912,4668 + 10,008100
最差情況 aaa…a 找 aaaaaaab79,94419,9930 + 10,0080

同一份輸入給三種做法實際跑出來的次數。在一般文字上,暴力法每個位置通常比一兩個字就失敗,三者差不多;遇到最差情況,暴力法每個位置都比到最後一個字才失敗,KMP 仍然不超過 2n。Rabin-Karp 用模 1,000,000,007 時幾乎沒有假警報,每個位置只做一次雜湊;模數小(101)時假警報變多,但答案仍然正確,因為每次都逐字確認。

真實世界裡的它

  • grep 和文字編輯器的搜尋;GNU grep 用的是 Boyer-Moore 的變形,從樣式尾端比起,常常一次跳過好幾個字。
  • rsync 用滾動雜湊找出兩個檔案裡相同的區塊,只傳不同的部分。
  • 生物資訊在基因序列裡找特定片段;入侵偵測系統在封包裡找攻擊特徵(多樣式比對用 Aho-Corasick,就是 KMP 的失敗表加上字首樹)。

取捨與陷阱

  • 暴力法平常很快,所以最差情況常被忽略:像 aaa…ab 這種輸入,或使用者能控制的樣式,會讓它慢上 m 倍。
  • Rabin-Karp 雜湊相同不代表字串相同:省略逐字確認就會回報錯誤的位置。模數太小假警報會很多;模數太大則要注意整數溢位。
  • 字元和位元組不一樣:中文、emoji 在 UTF-8 裡佔好幾個位元組,以位元組為單位回傳的位置,不等於第幾個字。

LeetCode 練習