📘 教案 17:Aho--Corasick 自动机(AC 自动机 · 完整版)
一、问题动机(为何需要 AC)
给定一组模式串 ( \mathcal{P} = {p_1, p_2, \dots, p_k} ) 与一段文本 (T),目标是:
在 (T) 中同时找出所有模式串的出现位置(多模式匹配)
对比三种思路:
- 逐个用 KMP:总复杂度约为 (O(|T|\cdot k))(不理想)
- Trie:只能做前缀匹配,遇到失配无法高效"跳转"
- AC 自动机 :在 Trie 上引入"失配转移",实现线性时间多模式匹配
二、总体结构(Trie + 失配指针)
AC 自动机由三部分组成:
- Trie(字典树):存储所有模式串
- fail 指针(失配指针):类似 KMP 的"回退信息"
- 自动机转移函数:定义在任意字符上的"下一状态"
核心思想:
当匹配失败时,不回退文本指针,而是沿 fail 指针跳转到"最长可复用前缀"继续匹配
三、形式化定义
设自动机状态集合为 Trie 上的节点集合 (V),根为 (root)。
- (goto(u, c)):从状态 (u) 读入字符 © 的转移(Trie 边)
- (fail(u)):失配指针,指向当前节点所表示字符串的最长"真后缀",且该后缀是某个模式串前缀对应的节点
- (output(u)):在状态 (u) 结束的所有模式串(可能多个)
四、构建流程
4.1 构建 Trie
逐个插入模式串,记录每个单词结束的节点(可在节点上维护 output 列表)。
4.2 构建 fail 指针(BFS)
从根出发按层构建:
- 根的子节点:
fail = root - 其余节点 (u)(由父节点 § 经字符 © 到达):
fail(u) = goto\\big(fail§, c\\big) \\quad \\text{(若不存在则继续沿 fail 链回退,直到 root)}
同时:
output(u) ; \\mathrel{+}= ; output\\big(fail(u)\\big)
含义:如果在 (u) 失配后跳到 (fail(u)),那么在 (fail(u)) 上结束的模式同样应被报告
4.3 完整转移(可选优化)
为了实现"自动机"形式,可把所有缺失的 (goto) 转移补成:
goto(u, c) = \\begin{cases} \\text{原有子节点} \& \\text{若存在} goto\\big(fail(u), c\\big) \& \\text{否则(一直回退直到 root)} \\end{cases}
这样匹配阶段无需 while 回退,直接 O(1) 转移。
五、匹配过程
初始化 (state = root),逐字符扫描文本 (T):
对每个字符 (c = T[i]):
- 若无显式转移,沿 fail 指针回退直到存在转移或到达 root
- 执行转移:( state = goto(state, c) )
- 若 (output(state)) 非空,则报告所有匹配(这些模式串在位置 (i) 结束)
整个过程中文本指针从不回退
六、复杂度分析
- 构建 Trie:(O(\sum |p_i|))
- 构建 fail(BFS):(O(\Sigma \cdot |V|))(若补全转移;或按边数计 (O(\sum |p_i|)))
- 匹配:(O(|T| + \text{匹配输出总数}))
总体近似线性。
七、关键直觉(对标 KMP)
- KMP:在单个模式串上构建前缀函数(lps),失配时跳到"最长可复用前缀"
- AC:在多模式串的 Trie上构建 fail,失配时跳到"最长可复用前缀对应的节点"
AC = KMP 思想在 Trie 上的推广
八、代码骨架(Python)
python
from collections import deque, defaultdict
class Node:
__slots__ = ("next", "fail", "output")
def __init__(self):
self.next = {} # char -> Node
self.fail = None # fail pointer
self.output = [] # list of pattern ids (or lengths)
class AhoCorasick:
def __init__(self):
self.root = Node()
self.root.fail = self.root
def insert(self, word, pid):
node = self.root
for ch in word:
if ch not in node.next:
node.next[ch] = Node()
node = node.next[ch]
node.output.append(pid)
def build(self):
q = deque()
# 初始化 root 的直接子节点
for ch, nxt in self.root.next.items():
nxt.fail = self.root
q.append(nxt)
# BFS 构建 fail
while q:
u = q.popleft()
for ch, v in u.next.items():
f = u.fail
# 沿 fail 链寻找可用转移
while f is not self.root and ch not in f.next:
f = f.fail
v.fail = f.next[ch] if ch in f.next else self.root
# 继承输出
v.output.extend(v.fail.output)
q.append(v)
def search(self, text):
node = self.root
results = [] # (end_index, pattern_id)
for i, ch in enumerate(text):
while node is not self.root and ch not in node.next:
node = node.fail
if ch in node.next:
node = node.next[ch]
else:
node = self.root
if node.output:
for pid in node.output:
results.append((i, pid))
return results
说明:若需要更快的常数,可将
next改为定长数组(如 26/128),并在build中补全转移表。
九、典型应用
- 敏感词过滤(同时匹配上万关键词)
- 日志/流式文本多关键字扫描
- 生物序列匹配(多模式)
- 入侵检测(特征串匹配)
十、实现要点与坑
- fail 的定义必须正确:基于父节点的 fail 逐级回退寻找
- output 的合并不可遗漏:否则会漏报"后缀模式"
- 字符集选择:小字符集用数组(快),大字符集用字典(省空间)
- 内存占用:节点数约为所有模式串总长度之和
- 去重/计数 :根据需求处理
output(可能同一位置命中多个模式)
十一、与 Trie / KMP 的关系总结
- Trie:组织多字符串的前缀结构
- KMP:单字符串的失配跳转
- AC:在 Trie 上引入 KMP 式失配 → 多模式线性匹配
教学式结论
AC 自动机通过在字典树上引入失配指针,将"前缀共享"和"失配跳转"结合,使多模式匹配在一次线性扫描中完成,是字符串处理中的基础工程算法。