本文极其详细,内容很多,请想学好 AC自动机 的朋友耐心看完
引入
AC自动机是什么
Aho--Corasick automaton,简称AC自动机,著名的多模匹配算法,由Alfred V. Aho和Margaret J.Corasick于1975年在贝尔实验室发明,主要用于多模式串匹配问题。
AC自动机有什么用
AC自动机可以帮助你AC许多字符串匹配的题目
假如有一道题目给你 \(n\) 个模式串去匹配一个文本串,若使用 KMP 算法则需对每个模式串单独跑一次 KMP ,复杂度会乘上 \(n\)
AC自动机则可以将时间复杂度优化到 \(O(|S|+M)\),\(|S|\) 为模式串总长,\(M\) 为文本串长度。
是不是非常厉害?
Trie树:AC自动机的框架
没学过 Trie 的请移步至"Trie树讲解"
我们需要将所有模式串插入 Trie,Trie树中的每个节点代表"从根走到该节点形成的字符串"。
例如模式串:
cpp
he
she
his
hers
对应的 Trie 树:

代码中用数组模拟,insert 函数负责把模式串插入 Trie
cpp
int trie[N][26];//trie[u][c]:节点u通过字符c到达的儿子
int idx; //节点编号,根节点固定为0
inline void insert(string s){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]-'a';
if (!trie[p][c])trie[p][c]=++idx;
p=trie[p][c];
}
//这里根据题目要求记录模式串结尾的信息
}
Fail指针:AC自动机的灵魂
定义
对于 Trie 中的一个节点 \(u\),那么它对应着一个字符串 \(S_u\)(从根到 \(u\) 的路径)。
fail\[u\]表示:\(S_u\) 的最长真后缀 ,并且这个后缀也必须在 Trie 中(即它是某个模式串的前缀),如果不存在这样的真后缀,那么 \(failu=0\)
这个定义确实让初学者难以理解,下面根据图片举例说明。

还是这张图,比如说现在要求"he"中"e"这个节点的 fail 值,那么此时的 fail 指针应指向 0,而不是"she"中的"e",因为"she"中的"e"并不是某个模式串的前缀。
现在理解了吧
作用
现在说一下 fail 指针的功能,加深一下对 fail 指针的理解
模拟一下AC自动机匹配的过程:
匹配文本时,当前节点走到了 \(u\),已经匹配了 \(S_u\)。
如果下一个字符接不上,不是全部全部丢掉从零开始,而是退回到 \(failu\) 继续尝试匹配
- 因为S_fail\[u\] 是 \(S_u\) 的后缀
- 所以已经匹配的这一段的末尾,仍然被保留了一部分
- 然后从这个保留的后缀继续尝试匹配
这就是 AC自动机 与 KMP 的关系
例子
我们手算一下上述模式串的 fail 值
| 节点 | 路径 | fail 指向 | 原因 |
|---|---|---|---|
| 1 | h |
0 | 根的直接孩子 |
| 2 | s |
0 | 根的直接孩子 |
| 3 | he |
0 | 真后缀 e 不在 Trie 中 |
| 4 | sh |
1 (h) |
真后缀 h 在 Trie 中 |
| 5 | she |
3 (he) |
真后缀 he 在 Trie 中,且最长 |
| 6 | hi |
0 | 真后缀 i 不在 Trie 中 |
| 7 | his |
2 (s) |
is 不在,s 在 |
| 8 | her |
0 | er、r 都不在 |
| 9 | hers |
2 (s) |
ers、rs 不在,s 在 |
fail指针的构建
fail 指针的构建用 BFS 递推
节点 \(v\) 是 \(u\) 通过字符 \(c\) 走到的孩子:
\ u \\xrightarrow{c} v \\那么 \(S_v = S_u + c\)。
它的最长可用后缀一定长成:
\ S_{fail\[u} + c \]所以要从 \(failu\) 开始,沿 fail 链找一个trie\[u\]\[c\]!=0 的节点。
构建代码
cpp
inline void build(){
queue<int>q;
for(int c=0;c<26;c++){
int v=trie[0][c];
if (v){
fail[v]=0;
q.push(v);
}
}
while(!q.empty()){
int u=q.front();
q.pop();
for(int c=0;c<26;c++){
int v=trie[u][c];
if (!v)continue;
int f=fail[u];
while(f and !trie[f][c])f=fail[f];
fail[v]=trie[f][c];
q.push(v);
}
}
}
代码优化
上面的 while(f and !trie[f][c]) 在构建时可能反复跳 fail,我们可以在 BFS 中直接把不存在的边补成 fail 链上对应的儿子
即 if (trie[u][c]==0)trie[u][c]=trie[fail[u]][c];
这样在匹配文本时,不需要写 while 反复跳 fail ,直接 p=trie[u][c] 即可,使其真正成为"自动机"。
优化后的代码:
cpp
inline void build(){
queue<int>q;
for(int c=0;c<26;c++){
int v=trie[0][c];
if (v){
fail[v]=0;
q.push(v);
}
}
while(!q.empty()){
int u=q.front();
q.pop();
for(int c=0;c<26;c++){
int v=trie[u][c];
if (v){
fail[v]=trie[fail[u]][c];
q.push(v);
}else{
trie[u][c]=trie[fail[u]][c];
}
}
}
}
匹配文本
进行完上述操作后,匹配变得十分简单:
cpp
inline int query(string s){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]='a';
p=trie[p][c];
//根据题目要求统计
}
}
到此为止,AC自动机的框架已经完整,题目的区别在于:到了节点 p 之后,统计什么?怎么统计?
附上完整框架代码,大家可以做模板收藏
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int trie[N][26],fail[N],n,idx;
string s;
inline void insert(string s){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]-'a';
if (!trie[p][c])trie[p][c]=++idx;
p=trie[p][c];
}
}
inline void build(){
queue<int>q;
for(int c=0;c<26;c++){
int v=trie[0][c];
if (v){
fail[v]=0;
q.push(v);
}
}
while(!q.empty()){
int u=q.front();
q.pop();
for(int c=0;c<26;c++){
int v=trie[u][c];
if (v){
fail[v]=trie[fail[u]][c];
q.push(v);
}else{
trie[u][c]=trie[fail[u]][c];
}
}
}
}
inline int query(string s){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]='a';
p=trie[p][c];
//根据题目要求统计
}
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
cin>>s;
insert(s);
}
build();
cin>>s;
cout<<query(s);
return 0;
}
例题讲解
1.P3808 【模板】AC自动机(简单版)
题意 :给定 \(n\) 个模式串和一个文本串,求有多少个模式串在文本串中出现过。
数据范围:
\ \\sum \\mathrm{length}(\\text{模式串}) \\le 10\^6,\\quad \\mathrm{length}(\\text{文本串}) \\le 10\^6 \\
核心难点:同一个模式串可能出现多次,但题目要求的是"是否出现过",所以每个模式串只能贡献一次。
做法:
insert时在结尾节点val[p] = 1(或val[p]++,但统计时只算一次)。- 匹配文本时,每到一个节点 \(p\),沿 fail 链向上走:
完整代码:
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int n,trie[N][26],fail[N],val[N],idx;
inline void insert(string s){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]-'a';
if (!trie[p][c])trie[p][c]=++idx;
p=trie[p][c];
}
val[p]++;
}
inline void build(){
queue<int>q;
for(int c=0;c<26;c++){
int v=trie[0][c];
if (v){
fail[v]=0;
q.push(v);
}
}
while(!q.empty()){
int u=q.front();
q.pop();
for(int c=0;c<26;c++){
int v=trie[u][c];
if (v){
fail[v]=trie[fail[u]][c];
q.push(v);
}else{
trie[u][c]=trie[fail[u]][c];
}
}
}
}
inline int query(string s){
int len=s.size(),p=0,ans=0;
for(int i=0;i<len;i++){
p=trie[p][s[i]-'a'];
for(int t=p;t and val[t]!=-1;t=fail[t]){
ans+=val[t];
val[t]=-1;
}
}
return ans;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
string s;
cin>>s;
insert(s);
}
build();
string s;
cin>>s;
cout<<query(s);
return 0;
}
2.P3796 【模板】AC 自动机(加强版)
题意 :多组数据。每组给定 \(n\) 个模式串和一个文本串,找出在文本串中出现次数最多的模式串;如果有多个,按输入顺序全部输出。
核心变化:
- 需要分别统计每个模式串的出现次数,而不是总次数。
- 多组数据,需要清空 Trie、fail、计数数组。
做法:
insert时记录第 \(i\) 个模式串的结尾节点:pos[i] = p。- 匹配文本时,不沿 fail 链实时跳,而是:
- 每到一个节点 \(p\),
cnt[p]++; - 匹配结束后,利用 fail 树的拓扑序,把
cnt从叶子向根累加。
- 每到一个节点 \(p\),
- 最后对每个模式串 \(i\),
cnt[pos[i]]就是它的真实出现次数。
为什么不能实时沿 fail 跳?
如果文本中有大量重复字符,实时跳 fail 可能退化为:
\ O(\\text{文本长度} \\times \\text{模式串最大长度}) \\
而"匹配时只打标记,最后统一累加"是线性的。
fail 树与拓扑序:
- 每个节点 \(u\) 向 \(failu\) 连一条边,形成一棵以根为根的树,叫 fail 树。
- 一个节点的
cnt最终值 = 它自身的标记 + 它所有 fail 树子树的标记。 - BFS 队列中,深度浅的节点先入队。所以倒序遍历 BFS 队列,就是在按拓扑序从叶子向根累加
完整代码:
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int trie[N][26],fail[N],cnt[N],pos[N],q[N],idx,n;
string pat[155],s;
inline void insert(string s,int id){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]-'a';
if (!trie[p][c])trie[p][c]=++idx;
p=trie[p][c];
}
pos[id]=p;
}
inline void build(){
int head=0,tail=0;
for(int c=0;c<26;c++){
if (trie[0][c]){
fail[trie[0][c]]=0;
q[tail++]=trie[0][c];
}
}
while(head<tail){
int u=q[head++];
for(int c=0;c<26;c++){
int v=trie[u][c];
if (v){
fail[v]=trie[fail[u]][c];
q[tail++]=v;
}else{
trie[u][c]=trie[fail[u]][c];
}
}
}
}
inline void query(string s){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]-'a';
p=trie[p][c];
cnt[p]++;
}
}
inline void init(){
for(int i=0;i<=idx;i++){
memset(trie[i],0,sizeof(trie[i]));
fail[i]=0;
pos[i]=0;
cnt[i]=0;
q[i]=0;
}
idx=0;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
while(1){
cin>>n;
if (n==0)break;
init();
for(int i=1;i<=n;i++){
cin>>pat[i];
insert(pat[i],i);
}
build();
cin>>s;
query(s);
for(int i=idx-1;i>=0;i--){
int u=q[i];
cnt[fail[u]]+=cnt[u];
}
int maxn=-1;
for(int i=1;i<=n;i++){
maxn=max(maxn,cnt[pos[i]]);
}
cout<<maxn<<'\n';
for(int i=1;i<=n;i++){
if (cnt[pos[i]]==maxn){
cout<<pat[i]<<'\n';
}
}
}
return 0;
}
3.P5357 【模板】AC 自动机
题意 :给定 \(n\) 个模式串和一个文本串,分别求出每个模式串在文本串中出现的次数。
数据范围:
\ n \\le 2 \\times 10\^5,\\quad \\sum \\mathrm{length}(\\text{模式串}) \\le 2 \\times 10\^5,\\quad \\mathrm{length}(\\text{文本串}) \\le 2 \\times 10\^6 \\
和 P3796 的本质区别:
P3796 的模式串数量很少(最多 150),文本串长度 \(10^6\);
P5357 的模式串总长更大,但文本串也更长。
如果仍然用"到达节点后沿 fail 链实时统计",文本串中如果出现大量重复字符,会反复跳 fail,导致超时。
正解 :和 P3796 一样,使用 fail 树 + 倒序 BFS 拓扑累加。
区别只在于:
- 模式串不保证互不相同,所以不能只按节点输出,要按输入顺序 输出
cnt[pos[i]]。 N开到 \(2 \times 10^5 + 5\) 左右。- 不需要保存模式串原文,因为输出的是次数。
完整代码:
cpp
#include<bits/stdc++.h>
using namespace std;
const int N=2e6+5;
int trie[N][26],fail[N],cnt[N],pos[N],q[N],idx,n;
string pat,s;
inline void insert(string s,int id){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]-'a';
if (trie[p][c]==0)trie[p][c]=++idx;
p=trie[p][c];
}
pos[id]=p;
}
inline void build(){
int head=0,tail=0;
for(int c=0;c<26;c++){
if (trie[0][c]){
fail[trie[0][c]]=0;
q[tail++]=trie[0][c];
}
}
while(head<tail){
int u=q[head++];
for(int c=0;c<26;c++){
int v=trie[u][c];
if (v){
fail[v]=trie[fail[u]][c];
q[tail++]=v;
}else{
trie[u][c]=trie[fail[u]][c];
}
}
}
}
inline void query(string s){
int len=s.size(),p=0;
for(int i=0;i<len;i++){
int c=s[i]-'a';
p=trie[p][c];
cnt[p]++;
}
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
cin>>pat;
insert(pat,i);
}
build();
cin>>s;
query(s);
for(int i=idx-1;i>=0;i--){
int u=q[i];
cnt[fail[u]]+=cnt[u];
}
for(int i=1;i<=n;i++){
cout<<cnt[pos[i]]<<"\n";
}
return 0;
}
结语
本文耗时 3 小时制作,图片,代码,思路均为本人原创,文章语言经 deepseek 润色。
都看到这儿了,点个推荐呗,谢谢啦~
祝2026 NOIP RP++