字串比對
在一大段文字裡找一個字串:暴力法最差 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 片段),滾動雜湊可以一次比一整段。
和其他主題的關係
- 由這些組成
- 資料結構 · 雜湊表(dict/set)
時間與空間複雜度(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 O | n = 1,000 | n = 2,000 | n = 4,000 | n = 8,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 暴力法:比較 | O(n²) | 90,100 | 360,200 | 1,440,400 | 5,760,800 | ×64 (×64) |
| KMP:比較 | O(n) | 1,901 | 3,801 | 7,601 | 15,201 | ×8.0 (×8.0) |
| Rabin-Karp:雜湊 | O(n) | 1,100 | 2,200 | 4,400 | 8,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 = 8 | 10,402 | 10,388 | 8 + 10,008 | 99 |
| 隨機 DNA(4 種字),n = 10,000、m = 8 | 13,369 | 12,466 | 8 + 10,008 | 100 |
| 最差情況 aaa…a 找 aaaaaaab | 79,944 | 19,993 | 0 + 10,008 | 0 |
同一份輸入給三種做法實際跑出來的次數。在一般文字上,暴力法每個位置通常比一兩個字就失敗,三者差不多;遇到最差情況,暴力法每個位置都比到最後一個字才失敗,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 練習
- 28.Find the Index of the First Occurrence in a StringEasy本頁的題目:找子字串第一次出現的位置(在新分頁開啟 LeetCode)
- 459.Repeated Substring PatternEasyKMP 的失敗函數找出最小週期(在新分頁開啟 LeetCode)
- 187.Repeated DNA SequencesMedium滾動雜湊找出重複的長度 10 片段(在新分頁開啟 LeetCode)
- 1392.Longest Happy PrefixHard就是 KMP 的失敗函數本身(在新分頁開啟 LeetCode)
- 214.Shortest PalindromeHard用 KMP 找最長的回文前綴(在新分頁開啟 LeetCode)