2026年5月5日预计 12 分钟阅读OI

AC自动机

zbl2012
zbl2012博主 & 创作者

AC自动机是一种基于 Trie树 与 KMP 的解决多模式串匹配问题的算法。其实本质上是在 Trie 上跑 KMP。

先把每个每个模式串建到字典树上,然后手动去模拟一下匹配的过程。发现,当匹配一个串时,如果匹配失败,就做一个类似 KMP 要跳到另一个地方重新匹配,我们要求解的就是要跳到哪里。

定义 failifail_i 表示 ii 这个点失配后应该跳到的点的编号,TiT_i 表示 Trie 上编号为 ii 的节点,faxfa_x 表示节点 xx 的父亲。则如果 ii 的父节点 faifa_ifailfail,也就是 failfaifail_{fa_i},满足 failfaifail_{fa_i} 这个点的子节点中有 ii 这个节点的字符,那么 failifail_i 指向这个子节点,也就是 faili=xfail_i=x 满足 Tx=TiT_x=T_ifax=failfaifa_x=fail_{fa_i};否则 failifail_i 指向根节点,也就是 faili=0fail_i=0。 求 failfail 的过程,可以用 bfs 按字典树的层序遍历。 定义 chp,uch_{p,u} 代表第 pp 个节点的 uu 个儿子是谁。 先把第一层的节点入队,也就是遍历 2626 个字母,满足 ch0,ich_{0,i} 是有节点的,把它入队。 然后跑正常广搜,把队头 pp 取出来,每一个节点,遍历 2626 个字母,如果 chp,ich{p,i} 是有节点的,那么根据定义,得出 failchp,i=chfailp,ifail_{ch_{p,i}}=ch_{fail_p,i},也就是以 pp 为父节点,满足有这个字符就更新 failfail,把当前阶段 chp,ich_{p,i}failfail 更新为父节点 ppfailfailii 的字符;如果没有这个节点,就进行一个记忆化的操作,让 chp,i=chfailp,ich_{p,i}=ch_{fail_p,i} 把这个点连上。

把每个点的 failfail 处理出来之后,就是查询部分了。

查询的方法很简单,就是跑文本串,每个节点往下跑,匹配就走,没匹配就跳 failfail,最后统计有多少个是单词的结尾。

具体实现如下: 先初始化遍历的指针 p=0p=0 在根节点,然后跑文本串,让 pp 取到这一个节点的数,也就是 chp,xch_{p,x},其中 x=si97x=s_i-97,然后跑这棵树。jjpp 开始,当 jj 没有爬到根,也就是 j0j\neq 0 时,且当前节点的 cntjcnt_j 是没计算过的,就让 ans+cntjans+cnt_j,然后把 cntjcnt_j 标记为 1-1,这里标记为 1-1 是因为如果标记为 00 的话,有时可能会少统计。 最后 ansans 就是答案。

拓扑排序优化

AC自动机有一个非常牛的技巧,叫做拓扑排序优化。

AC自动机如果每次暴力跳 fail 的话,在一些类似“金字塔”的数据,也就是每个字符一样,第一个模式串长度是 11,第二个是 22,以此类推。在这样的数据中,如果每次都暴力跳 fail 的话,就会被卡成 O(n2)O(n^2) 导致超时。

在建 fail 时,把 fail 连成边,变成一棵 fail 树。所以可以将问题转换:在 fail 树上求链的长度。 在这时,有一个优化方法,就是用拓扑排序中的拓扑序来跑这个树。 在建 failfail 的过程中,如果没有这个节点,就进行一个记忆化的操作,让 chp,i=chfailp,ich_{p,i}=ch_{fail_p,i} 把这个点连上。这其实就是一个建字典图的过程。 因为如果跑一个都是一个字符的链的 fail,会发现每次一个节点的结果上传,上面的数都会增加。所以可以从最底部开始向上做一个类似前缀和或者拓扑排序 DP 的操作,将子树的 cnt 向上传递。时间复杂度优化为严格 O(n)O(n)

实现其实非常简单。拓扑排序的入度统计在建 failfail 时,在满足有边时把 failchp,ifail_{ch_{p,i}} 的入度加 11 即可。 注意在建字典树时,在跑完模式串之后不需要更新 cnt[p]cnt[p],只用 edid=ped_{id}=p,其中 edied_i 表示在第 ii 个单词结尾的编号,idid 在建树时把 ii 顺便传进来就可以。 那 cntcnt 在哪更新呢?就要在写一个 queryquery,其实本质上就是一个遍历。跑文本串,把当前这位的字符在字典树上的节点的 cntcnt11 即可。 拓扑排序的过程和板子差不多,先将 idxidx 个节点的字典树(其实是图)上的节点判断是不是入度为 00,如果是就入队。然后就是一个广搜的过程,在取出队头之后,将上面的节点拿到下面的节点的 cntcnt,也就是 cntfailpcnt_{fail_p} 加上 cntpcnt_p,再让 failpfail_p 这个节点的入度减 11。当 cntfailpcnt_{fail_p}00 时,就把它入队。

最后第 ii 个模式串的出现次数就是 cntedicnt_{ed_i}注意点 1.注意在 bfs 时当满足 chp,i0ch_{p,i}\ge 0 时,处理完 failfail 之后要把 chp,ich_{p,i} 入队。 2.查询时 cntjcnt_j 统计完之后一定是标记为 1-1,循环时的条件就是 cntj\sim cnt_j,因为 1-1 的按位取反是 00。 3.注意字典树的建树操作时不要更新 cntcnt。 4.注意建 fail 和拓扑排序时更新入度的点,哪个是 toto,哪个是 fromfrom。 5.注意要先跑 queryquery 再跑拓扑排序。

求主串中模式串出现次数(暴力跳fail)

P3808 AC 自动机(简单版)的代码。

cpp
#include<bits/stdc++.h>
using namespace std;
const int N=1e6+10;
char s[N];
int ch[N][26];
int n;
int cnt[N],idx;
int fail[N];
char c[N];
void insert(char *c){
	int p=0;
	for(int i=0;c[i];i++){
		int x=c[i]-'a';
		if(!ch[p][x])ch[p][x]=++idx;
		p=ch[p][x];
	}
	cnt[p]++;
}
void bfs(){
	queue<int>q;
	for(int i=0;i<26;i++){
		if(ch[0][i]){
			q.push(ch[0][i]);
		}
	}
	while(q.size()){
		int p=q.front();
		q.pop();
		for(int i=0;i<26;i++){
			if(ch[p][i]){
				fail[ch[p][i]]=ch[fail[p]][i];
				q.push(ch[p][i]);
			}
			else {
				ch[p][i]=ch[fail[p]][i];
			}
		}
	}
}
int query(){
	int ans=0;
	int p=0;
	for(int i=0;s[i];i++){
		p=ch[p][s[i]-'a'];
		for(int j=p;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++){
		scanf("%s",c);
		insert(c);
	}
	bfs();
	scanf("%s",s);
	cout<<query();
	return 0;
}

求每个模式串在主串中出现的次数(拓扑排序优化)

P5357 【模板】AC 自动机 的代码。

cpp
#include<bits/stdc++.h>
using namespace std;
const int N=2e6+10;
int ch[N][30];
char s[N];
char str[N];
int n;
int fail[N];
int rd[N];
int cnt[N],ed[N];
int idx;
void insert(char *c,int id){
	int p=0;
	for(int i=0;c[i];i++){
		int x=c[i]-'a';
		if(!ch[p][x])ch[p][x]=++idx;
		p=ch[p][x];
	}
	ed[id]=p;
}
void bfs(){
	queue<int>q;
	for(int i=0;i<26;i++){
		if(ch[0][i]){
			q.push(ch[0][i]);
		}
	}
	while(q.size()){
		int p=q.front();
		q.pop();
		for(int i=0;i<26;i++){
			if(ch[p][i]){
				fail[ch[p][i]]=ch[fail[p]][i];
				rd[fail[ch[p][i]]]++;
				q.push(ch[p][i]);
			}
			else {
				ch[p][i]=ch[fail[p]][i];
			}
		}
	}
}
void query(){
	int p=0;
	for(int i=0;s[i];i++){
		p=ch[p][s[i]-'a'];
		cnt[p]++;
	}
}
void topo(){
	queue<int>q;
	for(int i=1;i<=idx;i++){
		if(rd[i]==0)q.push(i);
	}
	while(q.size()){
		int p=q.front();
		q.pop();
		cnt[fail[p]]+=cnt[p];
		rd[fail[p]]--;
		if(rd[fail[p]]==0){
			q.push(fail[p]);
		}
	}
}
int main(){
	cin>>n;
	for(int i=1;i<=n;i++){
		scanf("%s",str);
		insert(str,i);
	}
	scanf("%s",s);
	bfs();
	query();
	topo();
	for(int i=1;i<=n;i++){
		cout<<cnt[ed[i]]<<'\n';
	}
	return 0;
}

文章留言区

已有 0 条精彩探讨

正在拼命加载留言中...

发表您的见解

※ 提倡客观理性讨论。留言需要经过安全核查,请勿注入恶意链接。
上一篇文章字典树Trie下一篇文章 背包DP