AC自动机
创建时间:2025-07-24
AC自动机的概念
AC自动机(Aho-Corasick automaton)是一种可以实现多模式串匹配的算法,和 KMP 有点像,但 KMP 是能实现单一模式串匹配。
对于模式串 \(s_i\) 和文本串 \(t\),\(s_i\) 在 \(t\) 中出现的充要条件显然是 \(s_i\) 是 \(t\) 的某个前缀的后缀,例如 \(s_i=\textup{aab},t=\textup{cbaabdd}\),\(s\) 在 \(t\) 中出现且 \(s_i\) 是 \(t\) 的前缀 \(\textup{cbaab}\) 的后缀 \(\textup{aab}\)。在 \(\{s\}\) 中找 \(t\) 的前缀很简单,只需要建一个字典树,将每一个 \(s_i\) 插入字典树,然后从根节点到 \(t\) 向下走就行了。
不妨为每个节点设 \(fail\) 指针指向字典树中以该节点结尾的字符串的后缀的结束节点。那么显然以 \(fail_i\) 为结尾的字符串是以 \(i\) 结尾的字符串的后缀,\(fail_{fail_i}\) 同样是,\(fail_{fail{fail...i}}\) 也是,故只需要顺着 \(fail\) 指针一直走就能找到所有后缀。为了不遗漏,我们记 \(fail_i\) 为字典树中存在的以 \(i\) 节点为结尾的字符串的最长后缀的结束节点编号,若没有则 \(fail_i=0\)。
现在的问题是如何求出 \(fail_i\)。容易发现,\(fail_i\) 在字典树中的深度一定小于 \(i\) 的深度,且 \(i\) 的 \(fail\) 是可以从 \(i\) 的父节点的 \(fail\) 转移的。故可以通过 BFS 得到 \(fail_i\)。当 \(trie[cur][i]\) 存在时,\(trie[cur][i]\) 的 \(fail\) 一定是 \(cur\) 代表的字符串的某个后缀的结束节点 \(j\) 的孩子 \(trie[j][i]\)。显然,当 \(trie[fail_{cur}][i]\) 存在时,有 \(trie[cur][i]\) 的 \(fail\) 就是 \(trie[fail_{cur}][i]\);当 \(trie[fail_{cur}][i]\) 不存在时,可以在 BFS 到 \(fail_{cur}\) 时提前将 \(trie[fail_{cur}][i]\) 设为 \(trie[fail_{fail_i}][i]\),如果 \(trie[fail_{fail_i}][i]\) 也不存在则继续嵌套。故有:
若 \(trie[cur][i] \neq 0\):
若 \(tire[cur][i] = 0\):
由此写出求 \(fail\) 指针的函数 build_fail() :
void build_fail() {
queue<int> q;
for (int i = 0; i < 26; i++)
if (trie[0][i])
q.push(trie[0][i]);
while (!q.empty()) {
int cur = q.front();
q.pop();
for (int i = 0; i < 26; i++)
if (trie[cur][i]) {
fail[trie[cur][i]] = trie[fail[cur]][i];
q.push(trie[cur][i]);
} else
trie[cur][i] = trie[fail[cur]][i];
}
}
有了 \(fail\) 指针,就可以按照之前的逻辑先枚举前缀,再通过 \(fail\) 指针找后缀在文本串 \(t\) 中寻找模式串 \(s_i\)。以P3808 AC 自动机(简单版)
,求在文本串中出现过的模式串的个数总和实现 AC自动机:
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 1e6 + 50;
int n, trie[MAX_N][26], tot;
int cnt[MAX_N], fail[MAX_N];
string s[MAX_N], t;
void insert(string s) {
int cur = 0;
for (char ch : s) {
int i = ch - 'a';
if (!trie[cur][i])
trie[cur][i] = ++tot;
cur = trie[cur][i];
}
cnt[cur]++;
}
void build_fail() {
queue<int> q;
for (int i = 0; i < 26; i++)
if (trie[0][i])
q.push(trie[0][i]);
while (!q.empty()) {
int cur = q.front();
q.pop();
for (int i = 0; i < 26; i++)
if (trie[cur][i]) {
fail[trie[cur][i]] = trie[fail[cur]][i];
q.push(trie[cur][i]);
} else
trie[cur][i] = trie[fail[cur]][i];
}
}
int query(string t) {
int cur = 0, ans = 0;
for (char ch : t) {
int i = ch - 'a';
cur = trie[cur][i];
for (int j = cur; j && ~cnt[j]; j = fail[j])
ans += cnt[j], cnt[j] = -1;
}
return ans;
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> s[i];
insert(s[i]);
}
build_fail();
cin >> t;
cout << query(t) << '\n';
return 0;
}
由于这道题只需要确定每个模式串是否在文本串中出现,所以可以通过剪枝优化使时间复杂度达到 \(O(\sum|s|+|t|)\)。然而一般题目不可以使用剪枝,这么在字典树上跳来跳去的做法复杂度很大,容易超时,此时一般使用下面提到的 \(fail\) 树解决问题。
AC自动机的应用
AC自动机最大的特点就在于它的 \(fail\) 指针,注意到每节点有且仅有一个 \(fail\) 指针,所有 \(fail\) 最终都指向 \(0\),正如树中每个节点有且仅有一个父节点,所有父节点最终汇集在根节点。故可以以 \(fail_i\) 为父亲,\(i\) 为儿子建出一颗以 \(0\) 为根的 \(fail\) 树。树形结构是很容易维护、操作的结构,为各种问题提供了很大的便利,\(fail\) 树也是 AC自动机最核心的应用。
以P5357 【模板】AC 自动机为例,题目要求每个模式串在文本串中的出现次数。按照上面的每次暴力枚举后缀显然会 TLE。
显然的是,一旦某个节点 \(i\) 的出现次数 \(cnt\) 被更新了,\(fail_i\) 的 \(cnt\) 也一定会被更新。在 \(fail\) 树中,相当于一旦某个子节点被更新,父节点也一定会被更新。故在匹配后缀是并不需要将每个后缀都更新,只需要先更新第一个,后面的可以在最后通过树形 DP 递推得到并还原。
代码:
#include <bits/stdc++.h>
using namespace std;
const int MAX_N = 2e5 + 50;
int n, trie[MAX_N][26], tot;
int cnt[MAX_N], fail[MAX_N];
int mp[MAX_N];
vector<int> e[MAX_N];
string s[MAX_N], t;
int insert(string s) {
int cur = 0;
for (char ch : s) {
int i = ch - 'a';
if (!trie[cur][i])
trie[cur][i] = ++tot;
cur = trie[cur][i];
}
return cur;
}
void build_fail() {
queue<int> q;
for (int i = 0; i < 26; i++)
if (trie[0][i])
q.push(trie[0][i]);
while (!q.empty()) {
int cur = q.front();
q.pop();
for (int i = 0; i < 26; i++)
if (trie[cur][i]) {
fail[trie[cur][i]] = trie[fail[cur]][i];
q.push(trie[cur][i]);
} else
trie[cur][i] = trie[fail[cur]][i];
}
}
void work(string t) {
int cur = 0;
for (char ch : t) {
cur = trie[cur][ch - 'a'];
cnt[cur]++;
}
}
void dfs(int u) {
for (int v : e[u]) {
dfs(v);
cnt[u] += cnt[v];
}
}
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> s[i];
mp[i] = insert(s[i]);
}
build_fail();
for (int i = 1; i <= tot; i++)
e[fail[i]].push_back(i);
cin >> t;
work(t);
dfs(0);
for (int i = 1; i <= n; i++)
cout << cnt[mp[i]] << '\n';
return 0;
}
除了树形 DP,\(fail\) 树也常常与树上差分、树上倍增等树形结构处理技巧,或者 \(dfs\) 序/树链剖分+树状数组/线段树等数据结构结合。下面是 AC自动机的一些其他应用:
P3796 AC 自动机(简单版 II);
P3041 [USACO12JAN] Video Game G;
CF547E Mike and Friends;
CF163E e-Government;
P5840 [COCI 2015] Divljak;

浙公网安备 33010602011771号