1. 字符串匹配问题与朴素匹配
1.1 问题定义
字符串匹配:给定文本串 text(长度 n)与模式串 pattern(长度 m),在 text 中查找所有与 pattern 相等的位置。匹配算法的核心目标是减少字符比较次数,将最坏情况从 O(n·m) 降到 O(n+m)。
输入:text = "ABABABCABAB"
pattern = "ABABC"
输出:匹配位置 = [2] (0 起始下标)
1.2 朴素匹配(Naive Match)
朴素匹配从每个位置 i 开始,逐字符与 pattern 比较。一旦失配就整体后移一位重新比较:
def naive_match(text, pattern):
n, m = len(text), len(pattern)
res = []
for i in range(n - m + 1):
j = 0
while j < m and text[i + j] == pattern[j]:
j += 1
if j == m:
res.append(i)
return res
print(naive_match("ABABABCABAB", "ABABC")) # [2]
朴素匹配在退化输入(如 text = “AAAA…AB”,pattern = “AAA…AB”)下每轮几乎比完整个模式串,复杂度为 O(n·m)。大量重叠的字符信息被白白丢弃——这正是 KMP 要解决的问题。
1.3 匹配算法总览
| 算法 | 平均复杂度 | 最坏复杂度 | 空间 | 核心思想 |
|---|---|---|---|---|
| 朴素匹配 | O(n·m) | O(n·m) | O(1) | 暴力移位 |
| KMP | O(n+m) | O(n+m) | O(m) | 前缀函数跳转 |
| Boyer-Moore | O(n/m) 亚线性 | O(n·m) | O(σ+m) | 坏字符 + 好后缀 |
| Rabin-Karp | O(n+m) | O(n·m) | O(1) | 滚动哈希 |
| AC 自动机 | O(n+m+k) | O(n+m+k) | O(m·σ) | Trie + fail 指针 |
σ 表示字符集大小,k 为多模式匹配命中的次数。工程上文本搜索常用 Boyer-Moore 及其变体(如 GNU grep、Go 的
strings包),而 KMP 更适合模式串短、字符集小的场景。
2. KMP 算法:前缀函数与 next 数组
2.1 前缀函数(Prefix Function)
KMP 的核心是前缀函数 pi[i]:子串 pattern[0..i] 中,既是其前缀又是其真后缀的最长长度。
例:pattern = “ABABCABAB”,计算 pi:
- pi[6]:前缀 “ABABCAB”,最长公共前后缀为 “AB”(长度 2)
- pi[7]:前缀 “ABABCABA”,最长公共前后缀为 “ABA”(长度 3)
- pi[8]:前缀 “ABABCABAB”,最长公共前后缀为 “ABAB”(长度 4)
| i | 子串 | 最长公共前后缀 | pi[i] |
|---|---|---|---|
| 0 | A | - | 0 |
| 1 | AB | - | 0 |
| 2 | ABA | A | 1 |
| 3 | ABAB | AB | 2 |
| 4 | ABABC | - | 0 |
| 5 | ABABCA | A | 1 |
| 6 | ABABCAB | AB | 2 |
| 7 | ABABCABA | ABA | 3 |
| 8 | ABABCABAB | ABAB | 4 |
2.2 前缀函数计算
def compute_pi(pattern):
m = len(pattern)
pi = [0] * m
j = 0
for i in range(1, m):
while j > 0 and pattern[i] != pattern[j]:
j = pi[j - 1] # 回退到次长候选前缀
if pattern[i] == pattern[j]:
j += 1
pi[i] = j
return pi
print(compute_pi("ABABCABAB")) # [0, 0, 1, 2, 0, 1, 2, 3, 4]
2.3 KMP 匹配主流程
def kmp_match(text, pattern):
n, m = len(text), len(pattern)
if m == 0:
return []
pi = compute_pi(pattern)
res = []
j = 0 # 已匹配的模式串长度
for i in range(n):
while j > 0 and text[i] != pattern[j]:
j = pi[j - 1] # 失配时按 next 跳转,不回退 i
if text[i] == pattern[j]:
j += 1
if j == m:
res.append(i - m + 1)
j = pi[j - 1] # 找下一个匹配
return res
print(kmp_match("ABABABCABAB", "ABABC")) # [2]
关键性质:文本指针 i 永不回退,只有模式串指针 j 通过 pi 数组跳跃,因此总比较次数 ≤ 2n,匹配与预处理的整体复杂度为 O(n+m)。
3. Boyer-Moore 算法
3.1 逆向比较 + 坏字符规则
Boyer-Moore 从模式串末尾开始向前比较,失配时利用两条启发式规则大幅跳过位置:
坏字符规则(Bad Character):失配字符 c 在模式串中最后一次出现的位置为 last[c],则模式串至少右移
j - last[c]位(j 为失配处下标)。
好后缀规则(Good Suffix):已匹配的后缀在模式串中能否再次出现,若能则对齐到该位置,否则跳过整个已匹配部分。
# 坏字符表:记录每个字符在 pattern 中最后一次出现的下标
def build_bad_char(pattern):
last = {}
for i, ch in enumerate(pattern):
last[ch] = i # 后出现的覆盖前面的,得到最右位置
return last
def bm_search(text, pattern):
last = build_bad_char(pattern)
n, m = len(text), len(pattern)
i = 0
res = []
while i <= n - m:
j = m - 1
while j >= 0 and text[i + j] == pattern[j]:
j -= 1
if j < 0:
res.append(i)
i += 1 if m == 1 else (m - last.get(text[i + m], -1) if i + m < n else 1)
else:
shift = j - last.get(text[i + j], -1)
i += max(1, shift)
return res
3.2 复杂度分析
| 特性 | 说明 |
|---|---|
| 平均复杂度 | O(n/m),亚线性(模式串越长跳得越快) |
| 最坏复杂度 | O(n·m)(朴素坏字符规则),加好后缀规则可达 O(n+m) |
| 应用 | GNU grep、Golang bytes/strings 内部匹配、文本编辑器 |
工程上 BM 的坏字符表只对 ASCII 等小字符集有效;Unicode 下常用 Sunday 简化变体。对于 DNA(字符集 σ=4)与普通英文文本,BM 通常快于 KMP。
4. Rabin-Karp:滚动哈希匹配
4.1 哈希思想
Rabin-Karp 把长度为 m 的子串映射为一个哈希值,先比较哈希(O(1)),相等时才逐字符确认(防哈希冲突)。
取 base = 131、mod = 2^64 时哈希碰撞概率极低。用滚动哈希在 O(1) 内由
hash(s[i..i+m-1])推出hash(s[i+1..i+m]):h' = ((h - s[i]·base^(m-1)) · base + s[i+m]) mod M
BASE, MOD = 131, 2**64
def rabin_karp(text, pattern):
n, m = len(text), len(pattern)
if m > n:
return []
# 预计算 base^(m-1)
power = pow(BASE, m - 1, MOD)
# 计算 pattern 与 text[0:m] 的哈希
target = 0
for ch in pattern:
target = (target * BASE + ord(ch)) % MOD
h = 0
for ch in text[:m]:
h = (h * BASE + ord(ch)) % MOD
res = []
for i in range(n - m + 1):
if h == target:
if text[i:i + m] == pattern: # 哈希相等,逐字符确认
res.append(i)
if i + m < n:
h = ((h - ord(text[i]) * power) * BASE + ord(text[i + m])) % MOD
return res
print(rabin_karp("ABABABCABAB", "ABABC")) # [2]
4.2 特性
| 特性 | 说明 |
|---|---|
| 平均复杂度 | O(n+m)(冲突极少的随机化哈希) |
| 最坏复杂度 | O(n·m)(构造大量哈希冲突) |
| 应用 | 海量字符串去重、重复子串检测、生物序列比对、单词自动补全候选 |
在「检测一篇文章中是否出现任一敏感词」这类场景,先用 RK 哈希在文档级快速过滤,命中再用 KMP/AC 精确匹配,是工程中常见的两级策略。
5. Manacher 算法:最长回文子串
5.1 中心扩展的困境
回文既可以长度为奇数(中心一个字符)也可以为偶数(中心两个字符)。朴素中心扩展对每个中心做 O(n) 扩展,总复杂度 O(n²)。
Manacher 利用已计算回文的镜像对称性,把每个中心扩展到 O(1) 均摊,总复杂度降到 O(n)。
预处理:在字符间与两端插入特殊分隔符
#,把偶回文统一为奇回文。如 “abba” → “#a#b#b#a#",中心为中间的#。
def manacher(s):
# 插入分隔符,统一奇偶
t = "#" + "#".join(s) + "#"
n = len(t)
p = [0] * n # p[i] 为以 i 为中心的回文半径(含中心)
center, right = 0, 0
for i in range(n):
mirror = 2 * center - i
if i < right:
p[i] = min(right - i, p[mirror]) # 借用镜像回文半径
# 中心扩展
while i - p[i] - 1 >= 0 and i + p[i] + 1 < n and \
t[i - p[i] - 1] == t[i + p[i] + 1]:
p[i] += 1
if i + p[i] > right:
center, right = i, i + p[i] # 更新最右边界
# 还原最长回文(分隔符不计入)
best, best_c = 0, 0
for i in range(n):
if p[i] > best:
best, best_c = p[i], i
start = (best_c - best) // 2
return s[start:start + best]
print(manacher("babad")) # "bab"("aba" 亦可)
print(manacher("cbbd")) # "bb"
5.2 关键性质
| 性质 | 说明 |
|---|---|
| 时间复杂度 | O(n),每个字符最多被访问常数次 |
| 空间复杂度 | O(n),p 数组 |
| 应用 | 最长回文子串、回文计数、回文半径统计(可配合差分做计数) |
核心洞见:当中心 i 位于已知回文
[center-p, center+p]内部时,其初始半径至少等于其镜像点的半径,且不能超出右边界——这保证总扩展次数 O(n)。
6. Trie 字典树
6.1 结构定义
Trie(前缀树)用树状结构存储一组字符串,每条边代表一个字符,根到某个标记节点的路径即一个完整单词。共享前缀是其空间与查询优势的根本。
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 插入 | O(m) | m 为单词长度 |
| 查询 | O(m) | 逐字符沿边走 |
| 删除 | O(m) | 自底向上清除无子节点的链 |
| 前缀匹配/统计 | O(m) | 常用于自动补全 |
class TrieNode:
__slots__ = ("children", "is_end", "count")
def __init__(self):
self.children = {}
self.is_end = False # 是否为一个完整单词的结尾
self.count = 0 # 以该节点为前缀的单词数
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.count += 1
node.is_end = True
def search(self, word):
node = self.root
for ch in word:
node = node.children.get(ch)
if node is None:
return False
return node.is_end
def starts_with(self, prefix):
node = self.root
for ch in prefix:
node = node.children.get(ch)
if node is None:
return False
return True
6.2 应用场景
Trie 的典型应用:自动补全(前缀匹配 + DFS 收集候选)、拼写检查(编辑距离剪枝)、IP 路由最长前缀匹配、词频统计。相比哈希表,Trie 天然支持有序遍历与前缀查询,且无哈希碰撞;缺点是空间开销大(可用压缩 Trie / Patricia Trie 优化)。
7. AC 自动机:多模式匹配
7.1 原理
AC 自动机(Aho–Corasick)= Trie + fail 指针。对所有模式串建 Trie,BFS 为每个节点求出 fail 指针(指向"当前匹配串的最长真后缀对应节点”),匹配时失配沿 fail 链跳转,一次扫描文本即可命中所有模式串。
fail 指针本质是把 KMP 的 pi 函数推广到多模式:
fail[u]表示从根到 u 对应字符串的最长真后缀所对应的 Trie 节点。
#include <bits/stdc++.h>
using namespace std;
struct Node {
int next[26]; // 子节点
int fail; // 失配指针
int end; // 以该节点结尾的模式串个数
Node() { memset(next, -1, sizeof next); fail = end = 0; }
};
vector<Node> tr;
void insert(const string& s) {
int u = 0;
for (char c : s) {
int id = c - 'a';
if (tr[u].next[id] == -1) {
tr[u].next[id] = tr.size();
tr.emplace_back();
}
u = tr[u].next[id];
}
tr[u].end++;
}
void build_automaton() {
queue<int> q;
for (int i = 0; i < 26; i++)
if (tr[0].next[i] != -1) q.push(tr[0].next[i]);
else tr[0].next[i] = 0; // 空边指向根
while (!q.empty()) {
int u = q.front(); q.pop();
for (int i = 0; i < 26; i++) {
int v = tr[u].next[i];
if (v != -1) {
tr[v].fail = tr[tr[u].fail].next[i];
tr[v].end += tr[tr[v].fail].end; // 累加后缀的命中数
q.push(v);
} else {
tr[u].next[i] = tr[tr[u].fail].next[i]; // 压缩失配转移
}
}
}
}
int query(const string& text) {
int u = 0, ans = 0;
for (char c : text) {
u = tr[u].next[c - 'a'];
ans += tr[u].end; // 加上所有以该状态结尾的模式串
}
return ans;
}
7.2 复杂度与应用
| 特性 | 说明 |
|---|---|
| 构建复杂度 | O(模式串总长度 × σ),σ 为字符集大小 |
| 匹配复杂度 | O(n + k),n 为文本长度,k 为总命中次数 |
| 应用 | 敏感词过滤、入侵检测(Snort 规则集)、病毒特征扫描、生物序列多模式比对 |
示例:模式串集合 {“he”, “she”, “his”, “hers”} 构建 AC 自动机后,扫描文本 “ushers” 一次即可命中 “she” 与 “hers”。fail 链压缩后每个字符只做一次数组跳转,故匹配严格线性。
8. 字符串哈希与经典题解
8.1 字符串哈希的工程应用
除匹配外,字符串哈希还广泛用于判重与相似检测:
- 单哈希去重:文件指纹(Git blob 的 SHA-1)、内容寻址存储。
- 双哈希 / 布隆过滤器:极大集合的存在性判断(拼写候选、URL 去重)。
- SimHash / MinHash:网页去重与近似文本判重(垃圾内容检测)。
# 使用 Python 内置哈希做单词频次统计
from collections import Counter
def word_frequency(words):
return Counter(words)
# 双哈希布隆过滤器示意:k 个哈希位都被置位则认为"可能存在"
class BloomFilter:
def __init__(self, size, k=3):
self.bits = [0] * size
self.k = k
def _positions(self, item):
# 用两个基础哈希推导 k 个独立位置
h1, h2 = hash(item), hash(item + "salt")
return [(h1 + i * h2) % len(self.bits) for i in range(self.k)]
def add(self, item):
for p in self._positions(item):
self.bits[p] = 1
def contains(self, item):
return all(self.bits[p] for p in self._positions(item))
8.2 经典题解表
| 题目类型 | 常用算法 | 复杂度 |
|---|---|---|
| 查找文本中模式串所有出现 | KMP / BM | O(n+m) |
| 多敏感词同时命中 | AC 自动机 | O(n+m+k) |
| 最长回文子串 | Manacher | O(n) |
| 最长重复子串 | 后缀数组 + 二分 | O(n log n) |
| 前缀自动补全 | Trie | O(m) |
| 判断两字符串是否循环同构 | 哈希 / 最小表示法 | O(n) |
| 最长公共前缀(多查询) | 后缀数组 + RMQ / 二分+哈希 | O(log n) |
9. 复杂度对比与应用选型
9.1 综合对比
| 算法 | 构建 | 匹配 | 最坏 | 适用场景 |
|---|---|---|---|---|
| 朴素 | - | O(n·m) | O(n·m) | m 极小、教学 |
| KMP | O(m) | O(n+m) | O(n+m) | 字符集小、模式稳定 |
| Boyer-Moore | O(σ) | O(n/m) 均摊 | O(n·m) | 文本长、模式长、字符集大 |
| Rabin-Karp | O(m) | O(n+m) 平均 | O(n·m) | 多模式哈希、去重 |
| Manacher | O(n) | O(n) | O(n) | 回文问题专用 |
| Trie | O(Σm) | O(n·m) 单次 | O(n·m) | 前缀查询、补全 |
| AC 自动机 | O(Σm·σ) | O(n+k) | O(n+k) | 多模式在线匹配 |
9.2 选型决策树
- 单模式 + 模式很短 → KMP 或朴素;
- 单模式 + 文本很长、字符集大 → Boyer-Moore / Sunday;
- 多模式 + 需要在线过滤 → AC 自动机;
- 需要前缀统计 / 补全 → Trie;
- 回文类问题 → Manacher;
- 海量判重 → 字符串哈希 + 布隆过滤器。
实战要点:多数语言标准库内部已经选好了最优匹配算法(如 Rust memmem 使用双端 Two-Way 算法),工程中优先复用标准库;只有当你需要同时匹配多模式或统计回文等标准库未覆盖的能力时,才自行实现上述算法。
参考文章
- Wikipedia — Knuth–Morris–Pratt algorithm
- Wikipedia — Boyer–Moore string-search algorithm
- Wikipedia — Aho–Corasick algorithm
- Wikipedia — Manacher’s algorithm
- CP-Algorithms — String Hashing
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。