字串演算法完全指南 — KMP、Rabin-Karp、Z-Algorithm 與 Aho-Corasick | 資料結構與演算法
字串演算法(String Algorithms) 是處理文字資料的核心工具,從 KMP 的失配函式到 Rabin-Karp 的滾動雜湊,從 Z-Algorithm 的前綴匹配到 Aho-Corasick 的多模式自動機,每種演算法針對不同問題規模與查詢類型做出精準的時間空間取捨。本文帶你從暴力法的瓶頸出發,逐步掌握四大字串匹配演算法的核心原理與完整實作,搭配 JavaScript/TypeScript 與 C++ 雙語言範例,全面解鎖文字處理的高效演算法。
前言
想像你是一位考古學家,正在一塊數十萬字的古代泥板上尋找某個特定的符號序列。最直覺的方法是:從泥板的第一個字開始,逐字比對你手上的模式圖樣——如果比到一半發現不對,就退回起始點的下一個字重新來過。這就是 暴力匹配法(Brute Force),簡單但效率低落。
現實世界中的「泥板」無處不在:文字編輯器的 Ctrl+F 搜尋、搜尋引擎的全文索引、防毒軟體的特徵碼比對、DNA 序列分析的基因片段搜尋。這些場景動輒處理數百萬甚至數十億字元的文本,暴力法的 O(n x m) 時間複雜度完全無法勝任。
字串演算法的核心智慧在於:利用模式本身的結構資訊,避免不必要的重複比較。KMP 透過失配函式記住「已經知道的匹配」、Rabin-Karp 用雜湊值一次比對整個視窗、Z-Algorithm 利用 Z-Box 跳過已知區間、Aho-Corasick 把多個模式織成一張自動機網路。
學習本文後,你將能夠:
- 理解 模式匹配問題(Pattern Matching) 的本質,以及暴力法為何低效
- 掌握 前綴(Prefix) 與 後綴(Suffix) 的概念,這是所有字串演算法的數學基石
- 實作四大演算法:KMP、Rabin-Karp、Z-Algorithm、Aho-Corasick
- 分析各演算法的時間與空間複雜度,依場景選擇最適合的方案
- 運用字串演算法解決 LeetCode 高頻面試題
核心概念
模式匹配問題(Pattern Matching Problem)
模式匹配是字串演算法中最基本的問題:給定一段 文本(Text) t(長度 n)和一個 模式(Pattern) p(長度 m),找出 p 在 t 中所有出現的位置。
文本 t = "ABABDABABCABABD" (n = 15)
模式 p = "ABABCABAB" (m = 9)
目標:找到 p 在 t 中出現的所有起始位置
答案:位置 5(0-indexed)
暴力法的瓶頸
暴力法(Brute Force)對文本的每個位置 i,嘗試將模式逐字比對:
暴力法的最差情況:
文本 t = "AAAAAAAAB" (n = 9)
模式 p = "AAAAB" (m = 5)
i=0: AAAA≠AAAAB(比到第5字才失敗)
i=1: AAAA≠AAAAB(又比到第5字才失敗)
i=2: AAAA≠AAAAB(同上)
...
每次比較都走了 m 步才失敗 → 總時間 O(n × m)
當 n = 10^6, m = 10^3 時,需要 10^9 次比較——太慢了!
暴力法的核心問題是:匹配失敗後,之前比對成功的資訊全部丟失,一切從頭來過。
前綴與後綴概念
理解 KMP 和 Z-Algorithm 的關鍵在於掌握 前綴(Prefix) 和 後綴(Suffix) 的概念:
字串 s = "ABCAB"
前綴(Prefix):從頭開始的子字串
"A", "AB", "ABC", "ABCA", "ABCAB"
後綴(Suffix):從尾結束的子字串
"B", "AB", "CAB", "BCAB", "ABCAB"
真前綴(Proper Prefix):不含整個字串本身的前綴
"A", "AB", "ABC", "ABCA"
真後綴(Proper Suffix):不含整個字串本身的後綴
"B", "AB", "CAB", "BCAB"
最長「真前綴 = 真後綴」:
"AB"(長度 2)
→ 這就是 KMP 失配函式要計算的值!
這個「最長真前綴等於真後綴」的長度,就是 KMP 演算法能夠「跳著比」的數學基礎——當匹配失敗時,已經知道前綴和後綴相同的部分,不需要重新比對。
JavaScript / TypeScript 實作
KMP 演算法(Knuth-Morris-Pratt)
KMP 的核心洞察:匹配失敗時,已匹配的前綴資訊可以 避免從頭重新匹配。透過 失配函式(Failure Function),又稱部分匹配表(Partial Match Table),記錄「當匹配失敗時,模式指標應回退到哪個位置」。
Failure Function 建構過程(模式 p = "ABABCABAB"):
索引: 0 1 2 3 4 5 6 7 8
字符: A B A B C A B A B
f[i]: 0 0 1 2 0 1 2 3 4
逐步解析:
f[0] = 0 (只有一個字符,無真前綴)
f[1] = 0 ("AB",前綴{"A"},後綴{"B"},無交集)
f[2] = 1 ("ABA",最長交集 = "A",長度 1)
f[3] = 2 ("ABAB",最長「前 = 後」= "AB",長度 2)
f[4] = 0 ("ABABC",無符合的真前綴 = 真後綴)
f[5] = 1 ("ABABCA",最長 "前 = 後" = "A",長度 1)
f[6] = 2 ("ABABCAB",最長 "前 = 後" = "AB",長度 2)
f[7] = 3 ("ABABCABA",最長 "前 = 後" = "ABA",長度 3)
f[8] = 4 ("ABABCABAB",最長 "前 = 後" = "ABAB",長度 4)
以下是 KMP 演算法的完整 TypeScript 實作:
// KMP 字串匹配演算法
// 預處理:O(m),搜尋:O(n),空間:O(m)
// 建構 Failure Function(部分匹配表)
// f[i] = 模式 p[0..i] 中最長「真前綴 = 真後綴」的長度
function buildFailure(pattern: string): number[] {
const m = pattern.length;
const f = new Array(m).fill(0);
// k = 目前已知最長「真前綴 = 真後綴」的長度
let k = 0;
// 注意:從 i = 1 開始,f[0] 永遠是 0(單一字符無真前綴)
for (let i = 1; i < m; i++) {
// 不匹配時,利用已知的 f 值縮短 k(核心優化)
while (k > 0 && pattern[k] !== pattern[i]) {
k = f[k - 1]; // 回退到更短的匹配前綴
}
// 當前字符匹配,延伸長度
if (pattern[k] === pattern[i]) {
k++;
}
f[i] = k;
}
return f;
}
// KMP 搜尋:返回所有匹配的起始位置(0-indexed)
function kmpSearch(text: string, pattern: string): number[] {
const n = text.length;
const m = pattern.length;
if (m === 0) return [0]; // 空模式特殊處理
if (m > n) return [];
const f = buildFailure(pattern);
const results: number[] = [];
let j = 0; // 模式指標(已匹配長度)
for (let i = 0; i < n; i++) {
// 不匹配時,模式指標利用 f 回退(文本指標 i 永遠不後退)
while (j > 0 && text[i] !== pattern[j]) {
j = f[j - 1];
}
// 當前字符匹配
if (text[i] === pattern[j]) {
j++;
}
// 完整匹配!記錄起始位置
if (j === m) {
results.push(i - m + 1);
// 繼續搜尋下一個匹配(允許重疊匹配)
j = f[j - 1];
}
}
return results;
}
// ─── 使用範例 ──────────────────────────────────────────────────
// 基本匹配
console.log(kmpSearch("ABABDABABCABABD", "ABABCABAB"));
// 輸出:[5](0-indexed)
// 重疊匹配
console.log(kmpSearch("AAAAAA", "AAA"));
// 輸出:[0, 1, 2, 3](允許重疊)
// 無匹配
console.log(kmpSearch("ABCDEF", "XYZ"));
// 輸出:[]
KMP 匹配過程的視覺化:
文本 t = "ABABDABABCABABD",模式 p = "ABABCABAB"
t: A B A B D A B A B C A B A B D
↑ ↑ ↑ ↑ ↑
p: A B A B C j=0..4,t[4]='D' ≠ p[4]='C'
失敗!j = f[3] = 2(回退到位置 2,不從頭開始)
t: A B A B D A B A B C A B A B D
↑ ↑ ↑
p: A B A B C j=2 繼續,t[4]='D' ≠ p[2]='A'
失敗!j = f[1] = 0
t: A B A B D A B A B C A B A B D
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
p: A B A B C A B A B j=0..8 全部匹配!
位置 5 = i(13) - m(9) + 1 = 5
關鍵:文本指標 i 永遠不後退 → O(n) 搜尋時間
KMP 應用:判斷重複子字串
KMP 的失配函式還有一個優雅的性質:可以用來判斷字串是否由某個子字串重複多次構成。
// 判斷字串是否為重複子字串構成(LeetCode 459 變體)
function hasRepeatingUnit(s: string): boolean {
const m = s.length;
const f = buildFailure(s);
const repeatLen = m - f[m - 1]; // 最短重複單元長度
// 若 m 能被 repeatLen 整除,且 f[m-1] > 0,說明 s 是重複構成的
return m % repeatLen === 0 && f[m - 1] > 0;
}
console.log(hasRepeatingUnit("abababab")); // 輸出:true(重複單元 "ab")
console.log(hasRepeatingUnit("abcabcabc")); // 輸出:true(重複單元 "abc")
console.log(hasRepeatingUnit("abcdef")); // 輸出:false
Rabin-Karp 演算法(Rolling Hash)
Rabin-Karp 的核心思想完全不同於 KMP——它用 雜湊值(Hash) 代替逐字比較。透過 滾動雜湊(Rolling Hash) 技術,每次視窗滑動一格時,只需 O(1) 時間更新雜湊值,而不需要重新計算整個子字串的雜湊。
Rabin-Karp 的獨特優勢在於:它可以高效處理 多個等長模式 的同時匹配,這是 KMP 做不到的。
// Rabin-Karp Rolling Hash 字串匹配
// 預處理:O(m),搜尋:O(n) 平均,O(nm) 最差(碰撞時)
// 使用雙重雜湊降低碰撞機率
const RK_BASE = 31n;
const RK_MOD1 = 1_000_000_007n; // 第一個質數模
const RK_MOD2 = 998_244_353n; // 第二個質數模(雙重雜湊)
interface RKHash {
h1: bigint;
h2: bigint;
}
// 計算字串區間 [start, end) 的雙重雜湊值
function computeHash(s: string, start: number, end: number): RKHash {
let h1 = 0n, h2 = 0n;
for (let i = start; i < end; i++) {
const c = BigInt(s.charCodeAt(i) - 96); // 'a' → 1, 'b' → 2, ...
h1 = (h1 * RK_BASE + c) % RK_MOD1;
h2 = (h2 * RK_BASE + c) % RK_MOD2;
}
return { h1, h2 };
}
// Rabin-Karp 搜尋(單一模式)
function rabinKarp(text: string, pattern: string): number[] {
const n = text.length;
const m = pattern.length;
if (m === 0 || m > n) return [];
// 預計算 BASE^m(用於滾動時移除最左字符)
let pow1 = 1n, pow2 = 1n;
for (let i = 0; i < m; i++) {
pow1 = (pow1 * RK_BASE) % RK_MOD1;
pow2 = (pow2 * RK_BASE) % RK_MOD2;
}
// 計算模式的雜湊值
const patHash = computeHash(pattern, 0, m);
// 計算文本第一個視窗的雜湊值
let { h1, h2 } = computeHash(text, 0, m);
const results: number[] = [];
const charCode = (s: string, i: number) => BigInt(s.charCodeAt(i) - 96);
for (let i = 0; i <= n - m; i++) {
// 雙重雜湊都命中才進行字串比對(幾乎不會誤判)
if (h1 === patHash.h1 && h2 === patHash.h2) {
if (text.substring(i, i + m) === pattern) {
results.push(i);
}
}
// 滾動更新:移除最左字符,加入右側新字符
// 注意:必須加上 MOD 防止負數(關鍵陷阱!)
if (i < n - m) {
h1 = ((h1 * RK_BASE - charCode(text, i) * pow1 % RK_MOD1 + RK_MOD1)
+ charCode(text, i + m)) % RK_MOD1;
h2 = ((h2 * RK_BASE - charCode(text, i) * pow2 % RK_MOD2 + RK_MOD2)
+ charCode(text, i + m)) % RK_MOD2;
}
}
return results;
}
// ─── 使用範例 ──────────────────────────────────────────────────
console.log(rabinKarp("aababcabcabc", "abc"));
// 輸出:[3, 6, 9]
// Rabin-Karp 的獨特優勢:多模式等長匹配
function multiPatternSearch(
text: string,
patterns: string[]
): Map<string, number[]> {
if (patterns.length === 0) return new Map();
const m = patterns[0].length; // 假設所有模式等長
const n = text.length;
// 建立模式雜湊集合(O(總模式長度))
const patternHashes = new Map<string, string>();
for (const pat of patterns) {
if (pat.length !== m) continue;
const { h1, h2 } = computeHash(pat, 0, m);
patternHashes.set(`${h1},${h2}`, pat);
}
const results = new Map<string, number[]>();
for (const pat of patterns) results.set(pat, []);
if (n < m) return results;
// 預計算 BASE^m
let pow1 = 1n, pow2 = 1n;
for (let i = 0; i < m; i++) {
pow1 = (pow1 * RK_BASE) % RK_MOD1;
pow2 = (pow2 * RK_BASE) % RK_MOD2;
}
let { h1, h2 } = computeHash(text, 0, m);
const charCode = (i: number) => BigInt(text.charCodeAt(i) - 96);
for (let i = 0; i <= n - m; i++) {
const key = `${h1},${h2}`;
if (patternHashes.has(key)) {
const pat = patternHashes.get(key)!;
if (text.substring(i, i + m) === pat) {
results.get(pat)!.push(i);
}
}
if (i < n - m) {
h1 = ((h1 * RK_BASE - charCode(i) * pow1 % RK_MOD1 + RK_MOD1)
+ charCode(i + m)) % RK_MOD1;
h2 = ((h2 * RK_BASE - charCode(i) * pow2 % RK_MOD2 + RK_MOD2)
+ charCode(i + m)) % RK_MOD2;
}
}
return results;
}
// 多模式匹配範例
const multi = multiPatternSearch("thequickbrownfoxjumps", ["qui", "bro", "fox"]);
multi.forEach((positions, pat) => console.log(`"${pat}": [${positions}]`));
// 輸出:
// "qui": [3]
// "bro": [8]
// "fox": [13]
Z-Algorithm(Z 陣列)
Z-Algorithm 計算字串中每個位置 i 開始的子字串與整個字串的 最長公共前綴長度(Z[i])。它透過維護一個 Z-Box(已知的匹配區間 [l, r]),避免重複比較,達到 O(n) 時間。
// Z-Algorithm:計算字串每個位置的 Z 陣列
// 時間:O(n),空間:O(n)
// Z[i] = s[i..] 與 s[0..] 的最長公共前綴長度
function buildZArray(s: string): number[] {
const n = s.length;
const z = new Array(n).fill(0);
z[0] = n; // 慣例:Z[0] = 字串長度本身
// Z-Box [l, r]:目前已知的「延伸最遠的匹配區間」
let l = 0, r = 0;
for (let i = 1; i < n; i++) {
if (i < r) {
// i 在 Z-Box 內:利用已知的 Z[i-l] 初始化 Z[i]
// 取 min 確保不超出 Z-Box 範圍(關鍵!)
z[i] = Math.min(r - i, z[i - l]);
}
// 從 z[i] 已知的長度開始,繼續暴力延伸
while (i + z[i] < n && s[z[i]] === s[i + z[i]]) {
z[i]++;
}
// 若 i + z[i] 超過當前 r,更新 Z-Box
if (i + z[i] > r) {
l = i;
r = i + z[i];
}
}
return z;
}
// 使用 Z-Algorithm 進行字串搜尋
// 技巧:拼接 pattern + '$' + text,Z 值等於 pattern 長度的位置就是匹配點
function zSearch(text: string, pattern: string): number[] {
const m = pattern.length;
const combined = pattern + '$' + text; // '$' 確保不跨越邊界
const z = buildZArray(combined);
const results: number[] = [];
const offset = m + 1; // text 在 combined 中的起始偏移
for (let i = offset; i < combined.length; i++) {
if (z[i] === m) {
// Z 值等於 pattern 長度,表示完全匹配
results.push(i - offset); // 轉換回 text 中的索引
}
}
return results;
}
// ─── 使用範例 ──────────────────────────────────────────────────
console.log(zSearch("ABABDABABCABABD", "ABABCABAB"));
// 輸出:[5]
// Z-Algorithm 應用:找最短循環節(LeetCode 459)
function shortestRepeatingUnit(s: string): string {
const n = s.length;
const z = buildZArray(s);
for (let len = 1; len <= n / 2; len++) {
// 若長度 len 能整除 n,且 Z[len] + len >= n
// 說明 s 是長度 len 的子字串重複而成
if (n % len === 0 && z[len] + len >= n) {
return s.substring(0, len);
}
}
return s; // 無重複單元,整個 s 就是最短循環節
}
console.log(shortestRepeatingUnit("abababab")); // 輸出:"ab"
console.log(shortestRepeatingUnit("abcabcabc")); // 輸出:"abc"
console.log(shortestRepeatingUnit("abcdef")); // 輸出:"abcdef"
Aho-Corasick 多模式匹配
Aho-Corasick 自動機 是 Trie 的延伸,通過添加 失敗連結(Failure Link) 和 輸出連結(Output Link),實現 O(n + 匹配數) 的多模式同時匹配。它的地位就像是「多模式版的 KMP」。
Aho-Corasick 建構(模式集合:{"he", "she", "his", "hers"})
Trie 結構:
root
/ \
h s
/ \ \
e i h
| | \
r s e
|
s
失敗連結(Failure Link):
從 root 出發 BFS,每個節點的失敗連結指向「最長真後綴的 Trie 節點」
例:節點 "she" 的失敗連結 → 節點 "he"
節點 "he" 的失敗連結 → root
匹配過程(文本 "ahishers"):
a → root(無對應子節點)
h → 走到節點 h
i → 從 h 走到 hi
s → 從 hi 走到 his → 匹配 "his"!
h → 回到節點 h
e → 從 h 走到 he → 匹配 "he"!
r → 從 he 走到 her
s → 從 her 走到 hers → 匹配 "hers"!
同時沿失敗連結發現 "she" 也匹配!
以下是完整的 TypeScript 實作:
// Aho-Corasick 多模式匹配自動機
// 建構:O(Σ|pi|),搜尋:O(n + 匹配數)
// 其中 Σ|pi| 為所有模式的總長度
class AhoCorasick {
private goto: Map<string, number>[]; // 轉移函式:goto[node][char] = nextNode
private fail: number[]; // 失敗連結
private output: number[][]; // 輸出列表:output[node] = [patternIdx, ...]
private nodeCount: number;
constructor() {
// 初始化根節點(編號 0)
this.goto = [new Map()];
this.fail = [0];
this.output = [[]];
this.nodeCount = 1;
}
// 插入模式字串到 Trie 中
// patternIdx:模式在模式列表中的索引
insert(pattern: string, patternIdx: number): void {
let cur = 0;
for (const ch of pattern) {
if (!this.goto[cur].has(ch)) {
// 建立新節點
const newNode = this.nodeCount++;
this.goto.push(new Map());
this.fail.push(0);
this.output.push([]);
this.goto[cur].set(ch, newNode);
}
cur = this.goto[cur].get(ch)!;
}
// 標記此節點為模式的結尾
this.output[cur].push(patternIdx);
}
// BFS 建立失敗連結(類似 KMP 的 Failure Function)
build(): void {
const queue: number[] = [];
// 根節點的所有直接子節點的失敗連結指向根
for (const [, child] of this.goto[0]) {
this.fail[child] = 0;
queue.push(child);
}
// BFS 遍歷建立所有節點的失敗連結
let head = 0;
while (head < queue.length) {
const u = queue[head++];
for (const [ch, v] of this.goto[u]) {
// 計算子節點 v 的失敗連結
let f = this.fail[u];
while (f !== 0 && !this.goto[f].has(ch)) {
f = this.fail[f]; // 沿失敗連結回溯
}
this.fail[v] = this.goto[f].has(ch) ? this.goto[f].get(ch)! : 0;
// 如果失敗連結指向根的 ch 子節點就是 v 自己,則指向根
if (this.fail[v] === v) this.fail[v] = 0;
// 合併輸出連結:繼承失敗連結的輸出
this.output[v] = [
...this.output[v],
...this.output[this.fail[v]]
];
queue.push(v);
}
}
}
// 搜尋文本,返回所有匹配結果
search(
text: string,
patterns: string[]
): Array<{ pattern: string; position: number }> {
const results: Array<{ pattern: string; position: number }> = [];
let cur = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
// 沿失敗連結回溯,直到找到有 ch 轉移的節點或回到根
while (cur !== 0 && !this.goto[cur].has(ch)) {
cur = this.fail[cur];
}
cur = this.goto[cur].has(ch) ? this.goto[cur].get(ch)! : 0;
// 收集此節點上的所有匹配模式
for (const patIdx of this.output[cur]) {
results.push({
pattern: patterns[patIdx],
position: i - patterns[patIdx].length + 1
});
}
}
return results;
}
}
// ─── 使用範例 ──────────────────────────────────────────────────
const ac = new AhoCorasick();
const patterns = ["he", "she", "his", "hers"];
// 插入所有模式
patterns.forEach((pat, idx) => ac.insert(pat, idx));
// 建立失敗連結
ac.build();
// 搜尋文本
const matches = ac.search("ahishers", patterns);
matches.forEach(({ pattern, position }) => {
console.log(`"${pattern}" 在位置 ${position}`);
});
// 輸出:
// "his" 在位置 1
// "he" 在位置 4
// "she" 在位置 3
// "hers" 在位置 4
C++ 對照實作
以下是相同演算法的 C++ 實作。C++ 在競賽環境中更常用於字串演算法,其效能優勢在處理大規模文本時尤為明顯。
語言差異重點
- C++ 使用
vector<int>代替 TypeScript 的number[],記憶體佈局更緊湊 - C++ 的
string::compare和string::substr等成員函式讓字串操作更直接 - C++ 使用固定大小陣列
array<int, 26>作為 Trie 的轉移表,效能優於Map - C++ 的
queue來自<queue>標頭檔,而 TypeScript 用陣列模擬
#include <bits/stdc++.h>
using namespace std;
// ===== KMP 演算法 =====
// 預處理 O(m),搜尋 O(n),空間 O(m)
// 建構 Failure Function
vector<int> buildFailure(const string& pattern) {
int m = pattern.size();
vector<int> f(m, 0);
int k = 0;
for (int i = 1; i < m; i++) {
while (k > 0 && pattern[k] != pattern[i]) {
k = f[k - 1]; // 回退到更短的前綴
}
if (pattern[k] == pattern[i]) k++;
f[i] = k;
}
return f;
}
// KMP 搜尋,返回所有匹配起始位置(0-indexed)
vector<int> kmpSearch(const string& text, const string& pattern) {
int n = text.size(), m = pattern.size();
if (m == 0 || m > n) return {};
auto f = buildFailure(pattern);
vector<int> results;
int j = 0;
for (int i = 0; i < n; i++) {
while (j > 0 && text[i] != pattern[j]) {
j = f[j - 1];
}
if (text[i] == pattern[j]) j++;
if (j == m) {
results.push_back(i - m + 1);
j = f[j - 1]; // 繼續搜尋重疊匹配
}
}
return results;
}
// ===== Z-Algorithm =====
// 時間 O(n),空間 O(n)
vector<int> buildZArray(const string& s) {
int n = s.size();
vector<int> z(n, 0);
z[0] = n;
int l = 0, r = 0;
for (int i = 1; i < n; i++) {
if (i < r) {
z[i] = min(r - i, z[i - l]); // 取 min 避免超出 Z-Box
}
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++;
}
if (i + z[i] > r) {
l = i;
r = i + z[i];
}
}
return z;
}
// 使用 Z-Algorithm 搜尋字串
vector<int> zSearch(const string& text, const string& pattern) {
string combined = pattern + "$" + text;
int m = pattern.size();
auto z = buildZArray(combined);
vector<int> results;
int offset = m + 1;
for (int i = offset; i < (int)combined.size(); i++) {
if (z[i] == m) {
results.push_back(i - offset);
}
}
return results;
}
// ===== Aho-Corasick 自動機 =====
// 建構 O(Σ|pi|),搜尋 O(n + 匹配數)
struct AhoCorasick {
static const int ALPHA = 26;
vector<array<int, ALPHA>> go; // 轉移函式
vector<int> fail; // 失敗連結
vector<vector<int>> output; // 輸出列表
AhoCorasick() {
go.push_back({});
go[0].fill(-1);
fail.push_back(0);
output.push_back({});
}
// 插入模式字串
void insert(const string& pattern, int patIdx) {
int cur = 0;
for (char ch : pattern) {
int c = ch - 'a';
if (go[cur][c] == -1) {
go[cur][c] = go.size();
go.push_back({});
go.back().fill(-1);
fail.push_back(0);
output.push_back({});
}
cur = go[cur][c];
}
output[cur].push_back(patIdx);
}
// BFS 建立失敗連結
void build() {
queue<int> q;
// 根的直接子節點:失敗連結指向根
for (int c = 0; c < ALPHA; c++) {
if (go[0][c] == -1) {
go[0][c] = 0; // 無對應子節點時指回根
} else {
fail[go[0][c]] = 0;
q.push(go[0][c]);
}
}
while (!q.empty()) {
int u = q.front(); q.pop();
for (int c = 0; c < ALPHA; c++) {
if (go[u][c] == -1) {
// 無子節點:直接跳到失敗連結的對應子節點
go[u][c] = go[fail[u]][c];
} else {
int v = go[u][c];
fail[v] = go[fail[u]][c];
// 合併輸出:繼承失敗連結的輸出
for (int idx : output[fail[v]]) {
output[v].push_back(idx);
}
q.push(v);
}
}
}
}
// 搜尋文本
vector<pair<int, int>> search(
const string& text,
const vector<string>& patterns
) {
vector<pair<int, int>> results; // {模式索引, 起始位置}
int cur = 0;
for (int i = 0; i < (int)text.size(); i++) {
cur = go[cur][text[i] - 'a'];
for (int patIdx : output[cur]) {
int startPos = i - (int)patterns[patIdx].size() + 1;
results.push_back({patIdx, startPos});
}
}
return results;
}
};
// ===== 主測試函式 =====
int main() {
// KMP 測試
{
auto results = kmpSearch("ABABDABABCABABD", "ABABCABAB");
cout << "KMP 匹配位置: ";
for (int pos : results) cout << pos << " ";
cout << "\n"; // 輸出:5
}
// Z-Algorithm 測試
{
auto results = zSearch("ABABDABABCABABD", "ABABCABAB");
cout << "Z-Search 匹配位置: ";
for (int pos : results) cout << pos << " ";
cout << "\n"; // 輸出:5
}
// Aho-Corasick 測試
{
AhoCorasick ac;
vector<string> patterns = {"he", "she", "his", "hers"};
for (int i = 0; i < (int)patterns.size(); i++) {
ac.insert(patterns[i], i);
}
ac.build();
string text = "ahishers";
auto matches = ac.search(text, patterns);
cout << "Aho-Corasick 匹配:\n";
for (auto [patIdx, startPos] : matches) {
cout << " \"" << patterns[patIdx]
<< "\" 在位置 " << startPos << "\n";
}
}
return 0;
}
複雜度分析
以下是各演算法的時間與空間複雜度比較(n = 文本長度,m = 模式長度,k = 模式數量):
| 演算法 | 預處理時間 | 搜尋時間 | 空間複雜度 | 適用場景 |
|---|---|---|---|---|
| 暴力法(Brute Force) | O(1) | O(n x m) | O(1) | 極短字串,快速驗證 |
| KMP | O(m) | O(n) | O(m) | 單模式匹配首選,線上處理 |
| Rabin-Karp | O(m) | O(n) 平均 | O(1) | 多模式等長匹配,抄襲偵測 |
| Z-Algorithm | O(n + m) | 含在預處理中 | O(n + m) | 字串自相似、前綴匹配 |
| Aho-Corasick | O(Σ|pi|) | O(n + 匹配數) | O(Σ|pi| x Σ) | 多模式匹配首選 |
| Suffix Array | O(n log²n) | O(m log n) | O(n) | 全文索引,多次任意查詢 |
效能比較重點:
- KMP 的搜尋時間嚴格 O(n),文本指標永遠不後退,適合串流式資料處理
- Rabin-Karp 的平均時間 O(n) 但最差 O(nm)(大量碰撞時),雙重雜湊可大幅降低碰撞率
- Z-Algorithm 在拼接字串後一次完成預處理和搜尋,空間需求 O(n + m)
- Aho-Corasick 搜尋時間與模式數量無關(只取決於文本長度和匹配數),這是它的最大優勢
- Suffix Array 建構成本較高,但支援任意子字串的重複查詢,適合「一次建構、多次查詢」的場景(詳見 038 — 後綴結構)
變體與延伸
Suffix Array 簡介
Suffix Array(後綴陣列) 是字串所有後綴按字典序排列後的索引陣列,配合 LCP Array(最長公共前綴陣列) 可以在 O(m log n) 時間回答任意子字串搜尋。它是搜尋引擎全文索引和基因組比對的核心資料結構,將在 038 — 後綴結構 中詳細展開。
字串 s = "banana$"(加入終止符 $ 確保唯一性)
所有後綴按字典序排列:
排名 索引 後綴
0 6 $
1 5 a$
2 3 ana$
3 1 anana$
4 0 banana$
5 4 na$
6 2 nana$
SA = [6, 5, 3, 1, 0, 4, 2]
應用:
- 子字串搜尋:二元搜尋 SA,O(m log n)
- 最長重複子字串:LCP Array 的最大值
- 不同子字串計數:n(n+1)/2 - sum(LCP)
Boyer-Moore 演算法
Boyer-Moore 從模式的右邊開始比對(與 KMP 相反),搭配 壞字符規則(Bad Character Rule) 和 好後綴規則(Good Suffix Rule) 兩種跳躍策略。在實務中,Boyer-Moore 通常比 KMP 更快,因為它每次跳躍的距離更大——特別是在字母表較大(如 ASCII 256 字元)的情況下,平均時間可以達到亞線性 O(n/m)。
Boyer-Moore 的核心優勢:
文本: t h e _ q u i c k _ b r o w n
模式: b r o w n
↑
從右邊開始比對:t[4]='u' ≠ p[4]='n'
壞字符規則:'u' 不在模式中 → 整個模式右移 5 格!
跳躍距離遠大於 KMP 的逐字前進 → 實務上更快
萬用字元匹配(Wildcard Matching)
當模式中包含萬用字元(如 ? 匹配任意一個字元、* 匹配任意長度字串)時,傳統的精確匹配演算法需要調整。常見的解法包括:
- 動態規劃(DP):O(n x m) 時間,適用於包含
*的萬用字元匹配(LeetCode 44) - FFT/NTT 加速:當只有
?時,可以將匹配問題轉化為多項式乘法,用 FFT 在 O(n log n) 時間解決 - Bitap 演算法:支援近似匹配(允許一定數量的錯誤),是
grep的-E模式背後的演算法
常見面試考點
面試中字串演算法的常見問法:
KMP Failure Function — 這是最高頻的考點。面試官可能要求你手動畫出
f[]陣列,或解釋為什麼文本指標不需要後退。要能清楚說明f[i]的定義:模式p[0..i]中最長「真前綴 = 真後綴」的長度滾動雜湊的碰撞處理 — 為什麼需要雙重雜湊?碰撞後為什麼還要做字串比對?如何選擇模數和底數?回答要點:選擇大質數作為模、底數大於字母表大小、雙重雜湊將碰撞率降到 1/(p1 x p2)
Z-Algorithm 與 KMP 的等價性 — 兩者在搜尋時間上都是 O(n + m),但 Z-Algorithm 透過拼接字串一步到位,而 KMP 分為預處理和搜尋兩階段。面試中可能要求用其中一個解決另一個通常解決的問題
Aho-Corasick 的建構過程 — 面試可能要求你解釋失敗連結是如何透過 BFS 建立的,以及為什麼搜尋時間與模式數量無關。關鍵:失敗連結讓自動機在匹配失敗時跳到最長後綴的對應節點,而不是回到根節點
字串演算法選型 — 給定一個實際場景,選擇最適合的演算法。例如:「同時搜尋 1000 個關鍵詞」→ Aho-Corasick;「文字編輯器即時搜尋」→ KMP;「抄襲偵測比對」→ Rabin-Karp
常見陷阱:
// 陷阱 1:KMP Failure Function 的起始索引
// 錯誤:從 i = 0 開始,f[0] 會被錯誤設為 1
function badFailure(pattern: string): number[] {
const f = new Array(pattern.length).fill(0);
let k = 0;
for (let i = 0; i < pattern.length; i++) { // ← 從 0 開始是錯的!
while (k > 0 && pattern[k] !== pattern[i]) k = f[k - 1];
if (pattern[k] === pattern[i]) k++;
f[i] = k; // f[0] = 1,但定義上應為 0
}
return f;
}
// 正確:從 i = 1 開始,f[0] = 0(定義:長度 1 的字串無真前綴)
function goodFailure(pattern: string): number[] {
const f = new Array(pattern.length).fill(0);
let k = 0;
for (let i = 1; i < pattern.length; i++) { // ← 從 1 開始
while (k > 0 && pattern[k] !== pattern[i]) k = f[k - 1];
if (pattern[k] === pattern[i]) k++;
f[i] = k;
}
return f;
}
// 陷阱 2:Rabin-Karp 滾動更新時的模負數
// 錯誤:直接相減,結果可能為負數
h1 = (h1 * BASE - remove * pow) % MOD; // ← 可能為負!
// 正確:先加 MOD 再取餘,確保結果非負
h1 = ((h1 * BASE - remove * pow % MOD + MOD) + add) % MOD;
// 陷阱 3:Z-Algorithm 不取 min 導致越界
// 錯誤:直接使用 z[i-l],可能超出 Z-Box 範圍
z[i] = z[i - l]; // ← 超出 [l, r] 範圍時會錯
// 正確:取 min(r - i, z[i - l]) 確保不超出
z[i] = Math.min(r - i, z[i - l]);
LeetCode 練習
| 題號 | 題目 | 難度 | 核心技巧 | 提示 |
|---|---|---|---|---|
| 28 | Find the Index of the First Occurrence in a String | Easy | KMP 基礎應用 | 直接套用 KMP,返回第一個匹配位置即可 |
| 459 | Repeated Substring Pattern | Easy | KMP Failure Function 性質 | 若 n % (n - f[n-1]) === 0 且 f[n-1] > 0,則為重複子字串 |
| 1392 | Longest Happy Prefix | Hard | KMP / Z-Algorithm | f[n-1] 就是答案;或用 Z-Array 找 i + z[i] === n 的最大 z[i] |
| 214 | Shortest Palindrome | Hard | KMP + 反轉技巧 | 拼接 s + '#' + reverse(s),求 Failure Function 的最後一個值 |
| 686 | Repeated String Matching | Medium | KMP / Rabin-Karp | 將 a 重複至長度 >= b.length,然後做模式匹配 |
LeetCode 28 — Find the Index of the First Occurrence
直接套用 KMP 演算法,時間 O(n + m):
// LeetCode 28 — Find the Index of the First Occurrence in a String
function strStr(haystack: string, needle: string): number {
const n = haystack.length;
const m = needle.length;
if (m === 0) return 0;
if (m > n) return -1;
// 建構 Failure Function
const f = new Array(m).fill(0);
let k = 0;
for (let i = 1; i < m; i++) {
while (k > 0 && needle[k] !== needle[i]) k = f[k - 1];
if (needle[k] === needle[i]) k++;
f[i] = k;
}
// KMP 搜尋:找到第一個匹配立即返回
let j = 0;
for (let i = 0; i < n; i++) {
while (j > 0 && haystack[i] !== needle[j]) j = f[j - 1];
if (haystack[i] === needle[j]) j++;
if (j === m) return i - m + 1;
}
return -1;
}
console.log(strStr("sadbutsad", "sad")); // 輸出:0
console.log(strStr("leetcode", "leeto")); // 輸出:-1
LeetCode 214 — Shortest Palindrome
將問題轉化為「找 s 的最長回文前綴」。拼接 s + '#' + reverse(s) 後,Failure Function 的最後一個值就是最長回文前綴的長度:
// LeetCode 214 — Shortest Palindrome
// 時間:O(n),空間:O(n)
function shortestPalindrome(s: string): string {
const n = s.length;
if (n <= 1) return s;
// 拼接 s + '#' + reverse(s)
// '#' 防止 Failure Function 跨越邊界
const rev = s.split('').reverse().join('');
const combined = s + '#' + rev;
const total = combined.length;
// 計算 KMP Failure Function
const f = new Array(total).fill(0);
let k = 0;
for (let i = 1; i < total; i++) {
while (k > 0 && combined[k] !== combined[i]) k = f[k - 1];
if (combined[k] === combined[i]) k++;
f[i] = k;
}
// f[total-1] = 最長回文前綴的長度
// 在 s 前面補上 rev 中前 (n - f[total-1]) 個字符
const longestPalPrefixLen = f[total - 1];
const prefix = rev.substring(0, n - longestPalPrefixLen);
return prefix + s;
}
console.log(shortestPalindrome("aacecaaa")); // 輸出:"aaacecaaa"
console.log(shortestPalindrome("abcd")); // 輸出:"dcbabcd"
LeetCode 1392 — Longest Happy Prefix
「Happy Prefix」就是 KMP Failure Function 的定義——f[n-1] 就是最長「真前綴 = 真後綴」的長度:
// LeetCode 1392 — Longest Happy Prefix
// 時間:O(n),空間:O(n)
function longestPrefix(s: string): string {
const n = s.length;
// 建構 Failure Function
const f = new Array(n).fill(0);
let k = 0;
for (let i = 1; i < n; i++) {
while (k > 0 && s[k] !== s[i]) k = f[k - 1];
if (s[k] === s[i]) k++;
f[i] = k;
}
// f[n-1] 即為最長「真前綴 = 真後綴」的長度
return s.substring(0, f[n - 1]);
}
console.log(longestPrefix("level")); // 輸出:"l"
console.log(longestPrefix("ababab")); // 輸出:"abab"
console.log(longestPrefix("leetcodeleet")); // 輸出:"leet"
總結
本文涵蓋了字串演算法的四大核心技術:
- KMP 演算法:透過失配函式記錄模式的前後綴結構,保證文本指標永不後退,搜尋時間嚴格 O(n)。失配函式本身也有獨立應用,如判斷重複子字串、找最長回文前綴
- Rabin-Karp 演算法:用滾動雜湊將逐字比對轉化為 O(1) 的雜湊比對,配合雙重雜湊降低碰撞率。獨特優勢是能高效處理多個等長模式的同時匹配,是抄襲偵測的底層技術
- Z-Algorithm:計算每個位置與字串開頭的最長公共前綴,透過 Z-Box 避免重複比較。拼接
pattern + '$' + text的技巧讓它在字串搜尋中同樣實用 - Aho-Corasick 自動機:Trie + 失敗連結 = 多模式匹配神器。搜尋時間 O(n + 匹配數) 與模式數量無關,是防毒軟體、敏感詞過濾的核心演算法
選擇演算法的經驗法則:單模式匹配用 KMP(或 Z-Algorithm)、多個等長模式用 Rabin-Karp、多個任意長模式用 Aho-Corasick、需要建索引的用 Suffix Array。
上一篇:網路流 — Ford-Fulkerson、Dinic 演算法與最大流最小割定理下一篇預告:數論(Number Theory) — 質數篩法、GCD/LCM、模運算與快速冪,解鎖數學在演算法中的核心應用。
希望這篇文章能幫助你深入理解字串演算法的核心原理與實作技巧。如有任何問題或疑惑,歡迎透過 Contact 頁面 與我聯繫!