字符串匹配算法 — 从零精通算法与数据结构——Google 面试系统备战 第11篇

第11章:字符串匹配算法

本章目标

读完本章你会:

  • 理解暴力匹配 O(n·m) 的浪费在哪里
  • 手写 KMP 算法(前缀函数 + 匹配),解释为什么是 O(n+m)
  • 掌握 Rabin-Karp 的滚动哈希技巧
  • 了解 Trie(前缀树)与 AC 自动机的基本思想
  • 将字符串匹配模块添加到 algo_toolkit

知识讲解

从一个生活例子开始

你在读一篇文章,要找所有出现"algorithm"的位置。

暴力法: 从文章第一个字母开始,逐个比较。到 'a' 不匹配 → 右移一格重新开始。

问题:"algorithm" 的前五个字母是 algor。如果在 algor 的某个位置匹配失败,我们已经从失败中获得了信息——如果我们知道 algo 不可能匹配到下一个 'a' 的位置,我们就可以跳过多步!

KMP 的精髓就是利用匹配失败时已知的信息,告诉我们应该跳到哪个位置重新开始,避免回退主串指针。

工作原理

11.1 KMP 算法

前缀函数 π[i]: 子串 P[0..i] 的最长真前缀(不能是字符串本身)且也是后缀的长度。

示例:

P = "ababc":
π[0] = 0    ("a" 的真前缀只有空串)
π[1] = 0    ("ab" 的前缀 "a",后缀 "b" → 不匹配)
π[2] = 1    ("aba" 的前缀 "a" = 后缀 "a")  ← 关键的"a"!
π[3] = 2    ("abab" 的前缀 "ab" = 后缀 "ab") ← 2 个字符匹配
π[4] = 0    ("ababc" 没有匹配的前后缀)

匹配过程:

文本 T = "abababc"
模式 P = "ababc",已计算 π = [0,0,1,2,0]

i=0: T[0]='a' vs P[0]='a' ✓
i=1: T[1]='b' vs P[1]='b' ✓
i=2: T[2]='a' vs P[2]='a' ✓
i=3: T[3]='b' vs P[3]='b' ✓
i=4: T[4]='a' vs P[4]='c' ✗ → 失配!j = π[3] = 2
     ↑ 现在 P 向左"滑"了 3-2+1 = 2 个位置
     T 指针 i 没有回退!这是 O(n) 的关键
i=4: T[4]='a' vs P[2]='a' ✓ (从 P[2] 继续比较)
...

为什么 O(n+m): T 的索引 i 从不回退,每次循环要么 i 前进,要么 j 减小(而 j 减小次数 ≤ j 增加次数 ≤ n)。总迭代次数 ≤ 2n。

11.2 Rabin-Karp 与滚动哈希

思想: 把短字符串转换成数字(哈希值),匹配 = 数字比较。关键是滚动哈希——已知 T[i..i+m-1] 的哈希,能在 O(1) 时间算出 T[i+1..i+m] 的哈希。

Hash(T[i+1..i+m]) = (Hash(T[i..i+m-1]) - T[i]·b^{m-1}) · b + T[i+m]
                   ↑ 去掉最左字符的影响    ↑ 右移乘底数  ↑ 加入新字符

冲突处理: 哈希值相同不代表字符串相同(哈希冲突)。当哈希值匹配时,再逐字符验证——期望验证次数极少。选用大质数模数(如 10⁹+7)可将冲突概率降到极低。

与 KMP 的比较:

KMP Rabin-Karp
时间 最坏 O(n+m) 期望 O(n+m),最坏 O(n·m)
空间 O(m) O(1)
多模式匹配 一次一个 多模式天然适用
实现难度 前缀函数较难理解 哈希直观

11.3 Trie(前缀树)

场景: 百万个单词的词典,要快速判断一个前缀是否是某个单词的前缀。

Trie 示例:单词 {"cat", "car", "dog"}

        root
       /    \
      c      d
     /        \
    a          o
   / \          \
  t   r          g
 (cat)(car)     (dog)
  • 插入 O(L),查找 O(L),L 是单词长度
  • 每个节点存 26 或 128 个子节点指针(或 map)
  • 前缀查找天然支持——走到前缀对应的节点,查看其子树

AC 自动机(Aho-Corasick) 是 Trie + KMP 思想的结合:在 Trie 上构造失败指针,实现同时匹配多个模式串。搜索引擎的敏感词过滤常用 AC 自动机。


代码实战

include/algo/string_matching.h

#ifndef ALGO_STRING_MATCHING_H_
#define ALGO_STRING_MATCHING_H_

#include <cstdint>
#include <string>
#include <vector>

namespace algo {

// ========== KMP ==========

// 计算前缀函数 π
std::vector<int> ComputePrefixFunction(const std::string& pattern);

// KMP 匹配:返回所有匹配的起始索引
std::vector<int> KmpSearch(const std::string& text,
                           const std::string& pattern);

// ========== Rabin-Karp ==========

// 返回所有匹配的起始索引
std::vector<int> RabinKarpSearch(const std::string& text,
                                 const std::string& pattern);

// ========== Trie ==========

class Trie {
 public:
  Trie();

  // 插入单词
  void Insert(const std::string& word);

  // 精确查找
  bool Search(const std::string& word) const;

  // 前缀查找(是否存在以 prefix 为前缀的单词)
  bool StartsWith(const std::string& prefix) const;

 private:
  struct Node {
    Node* children[26]{};
    bool is_end = false;
  };
  std::unique_ptr<Node> root_;
};

}  // namespace algo

#endif  // ALGO_STRING_MATCHING_H_

include/algo/string_matching_impl.h(KMP 核心)

namespace algo {

inline std::vector<int> ComputePrefixFunction(const std::string& pattern) {
  int m = static_cast<int>(pattern.size());
  std::vector<int> pi(m, 0);

  for (int i = 1; i < m; ++i) {
    int j = pi[i - 1];
    while (j > 0 && pattern[i] != pattern[j]) {
      j = pi[j - 1];  // 回退——复用已计算的 π 值
    }
    if (pattern[i] == pattern[j]) ++j;
    pi[i] = j;
  }
  return pi;
}

inline std::vector<int> KmpSearch(const std::string& text,
                                  const std::string& pattern) {
  std::vector<int> result;
  if (pattern.empty()) return result;

  auto pi = ComputePrefixFunction(pattern);
  int j = 0;  // pattern 中的位置

  for (int i = 0; i < static_cast<int>(text.size()); ++i) {
    while (j > 0 && text[i] != pattern[j]) {
      j = pi[j - 1];  // KMP 核心:失配时用前缀函数跳转
    }
    if (text[i] == pattern[j]) ++j;
    if (j == static_cast<int>(pattern.size())) {
      result.push_back(i - j + 1);  // 匹配成功
      j = pi[j - 1];                // 继续找下一个匹配
    }
  }
  return result;
}

}  // namespace algo

KMP 代码阅读提示: ComputePrefixFunctionKmpSearch 的结构几乎一样——前者的"文本"是 pattern 自己(从 i=1 开始),后者在整个 text 上运行。理解这一点就理解了 KMP 的统一性。


本章小结

  1. KMP 用前缀函数避免文本指针回退,保证最坏 O(n+m)
  2. 前缀函数 π[i] = P[0..i] 的最长真前后缀长度——是匹配失败时的"回退指引"
  3. Rabin-Karp 用滚动哈希在 O(1) 时间更新窗口哈希值,期望 O(n+m)
  4. Trie 是字符串集合的前缀树,插入/查询 O(L);AC 自动机是 Trie + KMP

关键术语

术语 释义
前缀函数 π[i] = 子串 P[0..i] 的最长真前缀且也是后缀的长度,KMP 的预处理核心
滚动哈希 O(1) 时间从当前窗口哈希值计算下一个窗口哈希值的技术
Trie 前缀树,每个节点代表一个前缀,边代表字符
AC 自动机 Aho-Corasick 算法,Trie + 失败指针 = 多模式同时匹配
posted @ 2026-06-22 01:09  Yobeeo  阅读(13)  评论(0)    收藏  举报