AC自动机超详细讲解,附带例题详解

本文极其详细,内容很多,请想学好 AC自动机 的朋友耐心看完

前置知识:KMP,Trie

引入

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树中的每个节点代表“从根走到该节点形成的字符串”。
例如模式串:

he
she
his
hers

对应的 Trie 树:
graph
代码中用数组模拟,insert 函数负责把模式串插入 Trie

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 中(即它是某个模式串的前缀),如果不存在这样的真后缀,那么 \(fail[u]=0\)

这个定义确实让初学者难以理解,下面根据图片举例说明。
graph
还是这张图,比如说现在要求“he”中“e”这个节点的 fail 值,那么此时的 fail 指针应指向 0,而不是“she”中的“e”,因为“she”中的“e”并不是某个模式串的前缀。
现在理解了吧

作用

现在说一下 fail 指针的功能,加深一下对 fail 指针的理解
模拟一下AC自动机匹配的过程:
匹配文本时,当前节点走到了 \(u\),已经匹配了 \(S_u\)。
如果下一个字符接不上,不是全部全部丢掉从零开始,而是退回到 \(fail[u]\) 继续尝试匹配

  • 因为\(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 \]

所以要从 \(fail[u]\) 开始,沿 fail 链找一个\(trie[u][c]!=0\) 的节点。

构建代码

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] 即可,使其真正成为“自动机”。

优化后的代码:

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];
		//根据题目要求统计 
	} 
}

到此为止,AC自动机的框架已经完整,题目的区别在于:到了节点 p 之后,统计什么?怎么统计?

附上完整框架代码,大家可以做模板收藏

#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 链向上走:

完整代码:

#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、计数数组。

做法:

  1. insert 时记录第 \(i\) 个模式串的结尾节点:pos[i] = p。
  2. 匹配文本时,不沿 fail 链实时跳,而是:
    • 每到一个节点 \(p\),cnt[p]++;
    • 匹配结束后,利用 fail 树的拓扑序,把 cnt 从叶子向根累加。
  3. 最后对每个模式串 \(i\),cnt[pos[i]] 就是它的真实出现次数。

为什么不能实时沿 fail 跳?

如果文本中有大量重复字符,实时跳 fail 可能退化为:

\[O(\text{文本长度} \times \text{模式串最大长度}) \]

而“匹配时只打标记,最后统一累加”是线性的。

fail 树与拓扑序:

  • 每个节点 \(u\) 向 \(fail[u]\) 连一条边,形成一棵以根为根的树,叫 fail 树。
  • 一个节点的 cnt 最终值 = 它自身的标记 + 它所有 fail 树子树的标记。
  • BFS 队列中,深度浅的节点先入队。所以倒序遍历 BFS 队列,就是在按拓扑序从叶子向根累加

完整代码:

#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\) 左右。
  • 不需要保存模式串原文,因为输出的是次数。

完整代码:

#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++

posted @ 2026-11-07 11:56  Emoji-happy  阅读(3)  评论(0)    收藏  举报