AC 自动机
AC 自动机
AC 自动机(Aho-Corasick Automaton)是以 Trie 结构为基础,融合 KMP 失配思想构建的确定性有限状态自动机,是多模式串匹配问题的标准算法。它将所有模式串统一预处理,实现文本串单次扫描即可完成全部模式串的匹配,复杂度与模式串数量无关。
一、核心定位:从 KMP / Trie 到 AC 自动机
1.1 知识衔接
在学习 AC 自动机前,需掌握:
-
Trie:高效存储字符串集合,支持前缀查询,但无法直接完成子串匹配;
-
KMP:单模式串线性匹配,通过 next 指针实现失配回退,避免重复匹配。
AC 自动机本质是二者的结合:
AC 自动机 = Trie 树 + fail 失配指针
fail 指针就是 KMP 的 next 思想在 Trie 树上的推广。
1.2 KMP 与 AC 自动机对比
| 维度 | KMP 算法 | AC 自动机 |
|---|---|---|
| 处理场景 | 1 个模式串 + 1 个文本串 | 多个模式串 + 1 个文本串 |
| 核心结构 | 线性 next 数组 | Trie 树结构 |
| 失配指针 | \(\mathit{next}[i]\):当前前缀的最长 Border | \(\mathit{fail}[u]\):当前状态的最长后缀状态 |
| 匹配复杂度 | \(O\left(n+m\right)\) | O\left(\sum |
1.3 解决的核心问题
给定 \(n\) 个模式串 \(s_1,s_2,\dots,s_n\) 和一个文本串 \(T\),常见问题:
-
哪些模式串在 \(T\) 中出现过?
-
每个模式串在 \(T\) 中出现了多少次?
\(T\) 中有多少个子串是某个模式串的前缀?
暴力方案是对每个模式串单独跑 KMP,总复杂度 \(O\left(n\cdot|T|\right)\);AC 自动机可一次性处理所有模式串,文本串仅需扫描一次。
二、核心概念:fail 失配指针
2.1 严格定义
状态 \(u\) 的 fail 指针指向另一个状态 \(v\),满足:
\(v\) 对应的字符串是 \(u\) 对应字符串的最长真后缀;
\(v\) 对应的字符串同时是某个模式串的前缀。
通俗理解:匹配到状态 \(u\) 时若下一个字符失配,无需从头开始,直接跳转到 \(\mathit{fail}[u]\),复用已匹配的后缀信息,与 KMP 失配回退的思想完全一致。
2.2 fail 与 KMP next 的本质区别
| 维度 | KMP next 指针 | AC fail 指针 |
|---|---|---|
| 作用域 | 单个模式串内部 | Trie 上所有状态 |
| 跳转范围 | 始终在同一个模式串内 | 可跳到另一个模式串的前缀状态 |
| 本质 | 单个串的最长相等前后缀 | 所有模式串前缀中,当前状态的最长后缀 |
关键差异:KMP 只有一个模式串,next 只在串内部跳转;AC 自动机有多个模式串,fail 可能跨串跳转,这是它能同时匹配多模式的核心原因。
三、构建流程
AC 自动机构建分为两步:插入所有模式串建 Trie → BFS 层序构建 fail 指针并补边形成字典图。
3.1 第一步:构建基础 Trie
与普通字典树完全一致,将所有模式串逐字符插入。
const int MAXN = 2e6 + 10;
const int SIGMA = 26;
int ch[MAXN][SIGMA];
int fail[MAXN];
int cnt[MAXN]; // 以该节点结尾的模式串数量/编号
int tot; // 节点分配器,根为0
void insert(string &s, int id = 1) {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!ch[u][c]) ch[u][c] = ++ tot;
u = ch[u][c];
}
cnt[u] += id;
}
3.2 第二步:BFS 构建 fail 指针 + 字典图
构建思想
设节点 \(u\) 的父节点为 \(p\),\(p\) 经字符 \(c\) 指向 \(u\)。假设深度小于 \(u\) 的所有节点 fail 已求出:
-
若 \(\mathit{ch}[\mathit{fail}[p]][c]\) 存在,则 \(\mathit{fail}[u] = \mathit{ch}[\mathit{fail}[p]][c]\);
-
若不存在,继续跳 \(\mathit{fail}[\mathit{fail}[p]]\) 重复判断,直到根节点;
-
若始终不存在,\(\mathit{fail}[u] = 0\)(指向根)。
字典图优化(Trie Graph)
上述朴素实现需要 while 循环跳 fail,效率较低。标准做法是补边优化:
若 \(\mathit{ch}[u][c]\) 不存在,直接令 \(\mathit{ch}[u][c] = \mathit{ch}[\mathit{fail}[u]][c]\)。
BFS 按层处理,处理 \(u\) 时 \(\mathit{fail}[u]\) 的所有转移已计算完毕,因此可直接赋值,无需循环跳转。
补边后 Trie 升级为确定性有限状态自动机(DFA),每个状态对每个字符都有唯一出边,匹配时无需手动跳 fail,一步转移到位。
完整 build 代码
void build() {
queue<int> q;
// 根的直接子节点先入队,它们的fail天然指向根0
for (int i = 0; i < SIGMA; i++) {
if (ch[0][i]) q.push(ch[0][i]);
}
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = 0; i < SIGMA; i++) {
if (ch[u][i]) {
// 子节点存在:fail指向fail[u]的对应转移
fail[ch[u][i]] = ch[fail[u]][i];
q.push(ch[u][i]);
} else {
// 子节点不存在:补边,直接继承fail的转移
ch[u][i] = ch[fail[u]][i];
}
}
}
}
注:不能将根节点入队,否则根的子节点 fail 会指向自身,造成逻辑错误。
四、多模式匹配
4.1 基础匹配写法
构建完成后,在字典图上逐字符扫描文本串即可。每到达一个状态,需要沿 fail 链向上遍历,统计所有匹配的模式串(因为后缀也可能是完整模式串)。
int query(string &t) {
int u = 0, res = 0;
for (char ch : t) {
int c = ch - 'a';
u = ch[u][c]; // 字典图一步转移,无需手动跳fail
// 沿fail链统计所有匹配
for (int j = u; j && cnt[j] != -1; j = fail[j]) {
res += cnt[j];
cnt[j] = -1; // 标记已统计,避免重复计数
}
}
return res;
}
4.2 复杂度分析
| 阶段 | 时间复杂度 | 说明 |
|---|---|---|
| 建 Trie | $O\left(\sum | s_i |
| build 构建 | $O\left(\sum | s_i |
| 基础匹配 | $O\left( | T |
当模式串数量多、嵌套匹配频繁时,内层 fail 链跳转成为性能瓶颈,需要拓扑排序优化。
五、效率优化:fail 树与拓扑排序
5.1 fail 树的核心性质
仅保留所有 fail 边,得到的图一定是一棵以根为根的树(fail 树)。
-
每个节点有且仅有一个 fail 指针;
-
fail 指针始终指向深度更浅的节点,不可能成环。
在 fail 树上,父节点是子节点的最长后缀状态。若文本串匹配到了子节点状态,则其所有祖先状态对应的后缀也被匹配到。
5.2 优化思路
-
匹配阶段:只在到达的状态上打计数标记,不跳 fail 链;
-
统计阶段:在 fail 树上按拓扑序(从叶子到根)将子节点的计数累加到父节点,一次性得到所有状态的匹配次数。
该优化将匹配复杂度降为严格 \(O\left(|T|\right)\),总复杂度与匹配次数无关,是竞赛大数据的标准写法。
5.3 完整实现
int in[MAXN]; // fail树的入度
int val[MAXN]; // 状态被经过的次数
int ans[MAXN]; // 每个模式串的最终出现次数
void build() {
queue<int> q;
for (int i = 0; i < SIGMA; i++) {
if (ch[0][i]) q.push(ch[0][i]);
}
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = 0; i < SIGMA; i++) {
if (ch[u][i]) {
fail[ch[u][i]] = ch[fail[u]][i];
in[fail[ch[u][i]]]++; // 统计fail树入度
q.push(ch[u][i]);
} else {
ch[u][i] = ch[fail[u]][i];
}
}
}
}
void query(string &t) {
int u = 0;
for (char ch : t) {
int c = ch - 'a';
u = ch[u][c];
val[u]++; // 只打标记,不跳fail
}
}
void topo_sort() {
queue<int> q;
for (int i = 0; i <= tot; i++) {
if (in[i] == 0) q.push(i);
}
while (!q.empty()) {
int u = q.front();
q.pop();
ans[u] = val[u];
int v = fail[u];
val[v] += val[u]; // 子节点计数累加到父节点
if (--in[v] == 0) q.push(v);
}
}
六、完整模板
6.1 基础版:统计出现过的模式串总数
对应题目:洛谷 P3808 AC 自动机(简单版)
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e6 + 10;
const int SIGMA = 26;
int ch[MAXN][SIGMA];
int fail[MAXN];
int cnt[MAXN];
int tot;
void insert(string &s) {
int u = 0;
for (char c : s) {
int v = c - 'a';
if (!ch[u][v]) ch[u][v] = ++tot;
u = ch[u][v];
}
cnt[u]++;
}
void build() {
queue<int> q;
for (int i = 0; i < SIGMA; i++)
if (ch[0][i]) q.push(ch[0][i]);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = 0; i < SIGMA; i++) {
if (ch[u][i]) {
fail[ch[u][i]] = ch[fail[u]][i];
q.push(ch[u][i]);
} else ch[u][i] = ch[fail[u]][i];
}
}
}
int query(string &t) {
int u = 0, res = 0;
for (char c : t) {
u = ch[u][c - 'a'];
for (int j = u; j && cnt[j] != -1; j = fail[j]) {
res += cnt[j];
cnt[j] = -1;
}
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n; cin >> n;
for (int i = 1; i <= n; i++) {
string s; cin >> s;
insert(s);
}
build();
string t; cin >> t;
cout << query(t) << endl;
return 0;
}
6.2 进阶版:每个模式串出现次数(拓扑优化)
对应题目:洛谷 P5357【模板】AC 自动机
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e6 + 10;
const int SIGMA = 26;
int ch[MAXN][SIGMA];
int fail[MAXN];
int id[MAXN]; // 该节点对应第几个模式串,0表示无
int in[MAXN];
int val[MAXN];
int res[MAXN]; // 第i个模式串的答案
int tot, n;
void insert(string &s, int idx) {
int u = 0;
for (char c : s) {
int v = c - 'a';
if (!ch[u][v]) ch[u][v] = ++tot;
u = ch[u][v];
}
id[u] = idx;
}
void build() {
queue<int> q;
for (int i = 0; i < SIGMA; i++)
if (ch[0][i]) q.push(ch[0][i]);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int i = 0; i < SIGMA; i++) {
if (ch[u][i]) {
fail[ch[u][i]] = ch[fail[u]][i];
in[fail[ch[u][i]]]++;
q.push(ch[u][i]);
} else ch[u][i] = ch[fail[u]][i];
}
}
}
void query(string &t) {
int u = 0;
for (char c : t) {
u = ch[u][c - 'a'];
val[u]++;
}
}
void topo() {
queue<int> q;
for (int i = 0; i <= tot; i++)
if (in[i] == 0) q.push(i);
while (!q.empty()) {
int u = q.front();
q.pop();
if (id[u]) res[id[u]] = val[u];
int v = fail[u];
val[v] += val[u];
if (--in[v] == 0) q.push(v);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
for (int i = 1; i <= n; i++) {
string s; cin >> s;
insert(s, i);
}
build();
string t; cin >> t;
query(t);
topo();
for (int i = 1; i <= n; i++) cout << res[i] << '\n';
return 0;
}
七、进阶应用:AC 自动机上 DP
AC 自动机是 DFA,天然支持动态规划,典型问题:求长度为 \(m\)、不包含任何给定模式串的字符串数量。
核心思路
- 标记「危险状态」:自身是模式串结尾,或 fail 链上存在模式串结尾;
DP 状态:\(f\left[i\right][j]\) 表示长度为 \(i\)、当前在状态 \(j\) 的合法字符串方案数;
- 转移:枚举下一个字符,若目标状态不是危险状态则累加方案数。
危险状态标记
void mark_danger() {
queue<int> q;
for (int i = 0; i < SIGMA; i++)
if (ch[0][i]) q.push(ch[0][i]);
while (!q.empty()) {
int u = q.front();
q.pop();
if (cnt[fail[u]]) cnt[u] = 1; // fail链有标记则自身也危险
for (int i = 0; i < SIGMA; i++)
if (ch[u][i]) q.push(ch[u][i]);
}
}
八、多方案对比与选型
| 方案 | 预处理复杂度 | 匹配复杂度 | 适用场景 | 核心特点 |
|---|---|---|---|---|
| KMP × n | $O\left(\sum | s_i | \right)$ | $O\left(n\cdot | ||
| AC 自动机 | $O\left(\sum | s_i | \cdot \text{SIGMA}\right)$ | s_i | ||
| 后缀自动机 SAM | $O\left( | T | \right)$ | T | ||
| 后缀数组 SA | $O\left( | T | \log | T |
| 字符串哈希 | $O\left(\sum | s_i | + | T |
九、总结
AC 自动机的核心可以用一句话概括:
以 Trie 为载体,用 KMP 失配思想构建 fail 指针,通过补边形成 DFA,实现文本单次扫描完成多模式匹配。
-
基础流程:建 Trie → BFS 建 fail + 补边 → 文本扫描匹配
-
优化核心:利用 fail 树拓扑排序,实现严格线性复杂度
-
进阶延伸:作为 DFA 可扩展 DP、可持久化等高级应用
-
选型建议:模式串数量少时用 KMP,多模式标准场景用 AC 自动机,文本固定查询多用 SAM
注:部分内容由 AI 润色
原创·转载请注明出处
✦
浙公网安备 33010602011771号