字串演算法完全指南 — KMP、Rabin-Karp、Z-Algorithm 與 Aho-Corasick | 資料結構與演算法

2026/07/15
字串演算法完全指南 — 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) 的概念,這是所有字串演算法的數學基石
  • 實作四大演算法:KMPRabin-KarpZ-AlgorithmAho-Corasick
  • 分析各演算法的時間與空間複雜度,依場景選擇最適合的方案
  • 運用字串演算法解決 LeetCode 高頻面試題

核心概念

模式匹配問題(Pattern Matching Problem)

模式匹配是字串演算法中最基本的問題:給定一段 文本(Text) t(長度 n)和一個 模式(Pattern) p(長度 m),找出 pt 中所有出現的位置。

文本 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::comparestring::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)極短字串,快速驗證
KMPO(m)O(n)O(m)單模式匹配首選,線上處理
Rabin-KarpO(m)O(n) 平均O(1)多模式等長匹配,抄襲偵測
Z-AlgorithmO(n + m)含在預處理中O(n + m)字串自相似、前綴匹配
Aho-CorasickO(Σ|pi|)O(n + 匹配數)O(Σ|pi| x Σ)多模式匹配首選
Suffix ArrayO(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 模式背後的演算法

常見面試考點

面試中字串演算法的常見問法:

  1. KMP Failure Function — 這是最高頻的考點。面試官可能要求你手動畫出 f[] 陣列,或解釋為什麼文本指標不需要後退。要能清楚說明 f[i] 的定義:模式 p[0..i] 中最長「真前綴 = 真後綴」的長度

  2. 滾動雜湊的碰撞處理 — 為什麼需要雙重雜湊?碰撞後為什麼還要做字串比對?如何選擇模數和底數?回答要點:選擇大質數作為模、底數大於字母表大小、雙重雜湊將碰撞率降到 1/(p1 x p2)

  3. Z-Algorithm 與 KMP 的等價性 — 兩者在搜尋時間上都是 O(n + m),但 Z-Algorithm 透過拼接字串一步到位,而 KMP 分為預處理和搜尋兩階段。面試中可能要求用其中一個解決另一個通常解決的問題

  4. Aho-Corasick 的建構過程 — 面試可能要求你解釋失敗連結是如何透過 BFS 建立的,以及為什麼搜尋時間與模式數量無關。關鍵:失敗連結讓自動機在匹配失敗時跳到最長後綴的對應節點,而不是回到根節點

  5. 字串演算法選型 — 給定一個實際場景,選擇最適合的演算法。例如:「同時搜尋 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 練習

題號題目難度核心技巧提示
28Find the Index of the First Occurrence in a StringEasyKMP 基礎應用直接套用 KMP,返回第一個匹配位置即可
459Repeated Substring PatternEasyKMP Failure Function 性質n % (n - f[n-1]) === 0f[n-1] > 0,則為重複子字串
1392Longest Happy PrefixHardKMP / Z-Algorithmf[n-1] 就是答案;或用 Z-Array 找 i + z[i] === n 的最大 z[i]
214Shortest PalindromeHardKMP + 反轉技巧拼接 s + '#' + reverse(s),求 Failure Function 的最後一個值
686Repeated String MatchingMediumKMP / Rabin-Karpa 重複至長度 >= 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 頁面 與我聯繫!

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。