【笔记】AC自动机(ACAM)

这篇文章不仅写了普通的原算法,还附了好几道好题和题解,如果大佬您已经学了 ACAM,不妨做做文后的题目,如果这些题您也都做过了,那就点个赞支持一下吧!

upd 8.29:把之前咕掉的三道题补上了。

前置知识

kmp 模式匹配、Trie。

没了。

介绍:这玩意是干什么的

想必你第一次看到 AC 自动机这个名字,心潮涌动。

其实这和做题 AC 啥关系没有,这个 AC 是Aho-Corasick,我也不知道啥意思。

你一定知道 kmp 是在一个文本串中匹配一个模式串。

AC 自动机(ACam/ACAM:Aho-Corasick automaton)可以让你在一个文本串中匹配一堆模式串,但文本串只要扫一遍,很是NB。

通俗的解释:看了也没有帮助

正经板子

裸T:

1st
2nd
3rd

此处以3rd为例讲解(这就是ACAM最经典的运用):

建立

AC自动机的实现结合 Trie 的结构KMP 的失配指针思想,构造过程可分为建立 Trie 与构造失配指针两个部分:

如何建立 Trie

和普通的 Trie 构建一模一样,把所有模式串插入一个 Trie(此处所用 Trie 数组下文称 \(tr\) )即可,此处不再赘述

为便于下文叙述,对于节点 \(x\) ,将 Trie 的根到 \(x\) 的路径所表示的字符串记为 \(S_{x}\)

构造失配指针

定义

对于节点 \(x\) ,其失配指针(下文称 \(fail\) 指针)指向 Trie 中不为 \(x\) 的一个节点 \(y\) ,满足 \(S_{y}\)\(S_{x}\) 的后缀,且 \(S_{y}\) 最长

思想与步骤

建议使用 OI-wiki 的这个例子帮助理解。

大致参考KMP的思想,在对 Trie 进行搜索(BFS)的过程中,设当前节点为 \(x\)

1.找到 \(x\) 的父亲节点 \(p\)\(x\) 的边上字符 \(c\) (即 \(tr_{p,c}=x\) ),令 \(y=fail_{p}\)

2.若 \(tr_{p,c}\) 存在,则显然 \(S_{tr_{p,c}}\)\(S_{x}\) 后缀,又因每跳一次 \(fail\)\(p\) 的深度就会减小,故此时的 \(p\) 即为 \(fail_{x}\) ;若不存在,则 \(p \leftarrow fail_{p}\) ,并重复此步骤直至 \(p\) 为根。

字典图

一个优化,对每个 \(tr_{x,c}\) ,若不为空则不变,若为空则 \(tr_{x,c} \leftarrow tr_{fail_{x},c}\)

这样,对每个 \(tr_{x,c}\) 都有 \(fail_{tr_{x,c}}=tr_{fail_{x},c}\) ,节省了多余空间,求 \(fail\) 数组和匹配文本串时常数更小了,更容易写了,大家都说好!

code

inline void buildfail(){
	int x;queue q;
	for(re int i=0;i<26;++i)
		if(tr[0][i]) q.push(tr[0][i]);
		while(q.unempty()){
			x=q.front(),q.pop();
			for(re int i=0;i<26;++i) 
				if(tr[x][i]) q.push(tr[x][i]),fail[tr[x][i]]=tr[fail[x]][i];
				else tr[x][i]=tr[fail[x]][i];
		}
}

匹配

步骤

很明显,ACAM建立以后,若匹配字符串 \(T\) ,就一直对节点 \(x \leftarrow tr_{x,T_{i}}\) ,到达的 \(x\) 点就表示 \(S_{x}\)\(T\) 的一个子串,记录 \(x\) 被到达次数即可。

但是还有一个东西:

对于节点 \(x\) ,其失配指针指向Trie中不为 \(x\) 的一个节点 \(y\) ,满足 \(S_{y}\)\(S_{x}\) 的后缀,且 \(S_{y}\) 最长。

也就是说,不止$ S_{x} $ 是 \(T\) 的一个子串, $ S_{fail_{x}} ,S_{fail_{fail_{x}}} ......$ 都是 \(T\) 的子串。

那怎么办?如果在每个节点循环跳 \(fail\) ,肯定会TLE(也可能过去,我没试)。

但是,每个点的 \(fail\) 指针只有一个,并且根节点没有(或者说,在程序中为自己)!

也就是说, \(fail\) 构成一颗树,所以,匹配完以后,在 fail 树上 dfs 或拓扑排序,就可以把 \(x\) 节点的信息传到它的 \(fail\) 上了!

最后对 Trie 图上是模式串结尾的节点统计答案就好了。

代码

inline void query(){
	int i=0,x=0;scanf("%t",t+1);
	while(t[++i]) x=tr[x][t[i]-'a'],++siz[x];
	topo();
}
inline void topo(){
	int x;queue q;
	for(re int i=1;i<=cnt;++i) ++in[fail[i]];
	for(re int i=1;i<=cnt;++i) if(!in[i]) q.push(i);
	while(q.unempty()){
		x=q.front();q.pop();
		if(end[x]) ans[end[x]]+=siz[x];
		siz[fail[x]]+=siz[x];
		if(!(--in[fail[x]])) q.push(fail[x]);
	}
}

一个小细节

此题有重复模式串,开邻接表存一下就是。

code

#include<cstdio>
#include<cstring>
#define re register
const int N=2e6+5,M=2e5+5;
int ans[M],nxt[M];
char s[N];
struct queue{
	int l=1,r=0,a[M];
	inline bool unempty(){return l<=r;}
	inline int front(){return a[l];}
	inline void pop(){++l;}
	inline void push(int x){a[++r]=x;}
};
struct ACam{
	int cnt,tr[M][26],end[M],fail[M],siz[M],in[M];
	inline void ins(int k){
		int i=0,x=0;scanf("%s",s+1);
		while(s[++i]) x=tr[x][s[i]-'a']=(tr[x][s[i]-'a']?tr[x][s[i]-'a']:++cnt);
		nxt[k]=end[x],end[x]=k;
	}
	inline void buildfail(){
		int x;queue q;
		for(re int i=0;i<26;++i)
			if(tr[0][i]) q.push(tr[0][i]);
		while(q.unempty()){
			x=q.front(),q.pop();
			for(re int i=0;i<26;++i) 
				if(tr[x][i]) q.push(tr[x][i]),fail[tr[x][i]]=tr[fail[x]][i];
				else tr[x][i]=tr[fail[x]][i];
		}
	}
	inline void topo(){
		int x;queue q;
		for(re int i=1;i<=cnt;++i) ++in[fail[i]];
		for(re int i=1;i<=cnt;++i) if(!in[i]) q.push(i);
		while(q.unempty()){
			x=q.front();q.pop();
			if(end[x]) ans[end[x]]+=siz[x];
			siz[fail[x]]+=siz[x];
			if(!(--in[fail[x]])) q.push(fail[x]);
		}
	}
	inline void query(){
		int i=0,x=0;scanf("%s",s+1);
		while(s[++i]) x=tr[x][s[i]-'a'],++siz[x];
		topo();
	}
}ac;

int main(){
	int n;scanf("%d",&n);
	for(re int i=1;i<=n;++i) ac.ins(i);
	ac.buildfail();ac.query();
	for(re int i=n;i;--i) ans[nxt[i]]=ans[i];
	for(re int i=1;i<=n;++i) printf("%d\n",ans[i]);
	return 0;
}

一些题目

比较板子的题就没放了啊。

ACAM上的dp

ACAM 上的 dp 比较套路,大多数状态都是设为 \(f[\)串长\(][\)目前节点\(]\)

1.USACO12JAN-Video Game G

本题就是直接按上面的状态枚举出边,进行转移,比较板。

code:

#include<cstdio>
#include<queue>
#define re register
using std::queue;
inline int max(int x,int y){return x>y?x:y;}

const int L=18,N=365,M=1005;
int n,m,tot,ans,end[N],fail[N],tr[N][3],f[M][N];

inline void ins(){
	char c[L];int x=0,i=0,p;
	scanf("%s",c+1);
	while(c[++i]) p=c[i]-'A',x=tr[x][p]?tr[x][p]:(tr[x][p]=++tot);
	end[x]++;
}
inline void buildfail(){
	queue<int> q;int x;
	for(re int k=0;k<3;k++) if(tr[0][k]) q.push(tr[0][k]);
	while(!q.empty()){
		x=q.front(),q.pop();
		for(re int k=0;k<3;k++)
			if(tr[x][k]){
				q.push(tr[x][k]);
				fail[tr[x][k]]=tr[fail[x]][k];
				end[tr[x][k]]+=end[fail[tr[x][k]]];
			}
			else tr[x][k]=tr[fail[x]][k];
	}
}
inline void dp(){
	for(re int i=0;i<=m;i++)
		for(re int j=0;j<=tot;j++)
			f[i][j]=-0x3f3f3f3f;
	f[0][0]=0;
	for(re int i=0;i<=m;i++)
		for(re int j=0;j<=tot;j++)
			for(re int k=0;k<3;k++)
				f[i+1][tr[j][k]]=max(f[i+1][tr[j][k]],f[i][j]+end[tr[j][k]]);
}

int main(){
	scanf("%d %d",&n,&m);
	for(re int i=1;i<=n;i++) ins();
	buildfail(),dp();
	for(re int i=0;i<=tot;i++) ans=max(f[m][i],ans);
	printf("%d",ans);
	return 0;
}

2.JSOI2007-文本生成器

正着算会重,本题目要正难则反。

先算出所有可生成文本数量(快速幂)。

再减去一个点也匹配不到的文本(就是在 Trie 上没经过任何一个串的末尾的文本)数量(dp 求)。

code:

#include<cstdio>
#include<queue>
#define re register
using std::queue;

const int L=105,N=6050,M=105,mod=10007;
int n,m,tot,fail[N],tr[N][26],f[M][N],ans=1;
bool end[N];

inline void ins(){
	char c[L];int i=0,x=0,p;
	scanf("%s",c+1);
	while(c[++i]) p=c[i]-'A',x=(tr[x][p]?tr[x][p]:(tr[x][p]=++tot));
	end[x]=1;
}
inline void buildf(){
	queue<int> q;int x;
	for(re int k=0;k<26;k++) if(tr[0][k]) q.push(tr[0][k]);
	while(!q.empty()){
		x=q.front(),q.pop();
		for(re int k=0;k<26;k++){
			if(tr[x][k]){
				fail[tr[x][k]]=tr[fail[x]][k];
				end[tr[x][k]]|=end[fail[tr[x][k]]];
				q.push(tr[x][k]);
			}
			else tr[x][k]=tr[fail[x]][k];
		}
	}
}
inline void qpow(int d,int z){for(;z;z>>=1,d=d*d%mod) ans=(z&1)?ans*d%mod:ans;}
inline void dp(){
	f[0][0]=1;
	for(re int i=0;i<m;i++)
		for(re int j=0;j<=tot;j++)
			for(re int k=0;k<26;k++)
				if(!end[tr[j][k]])
					f[i+1][tr[j][k]]=(f[i+1][tr[j][k]]+f[i][j])%mod;
	for(re int j=0;j<=tot;j++) ans=(ans-f[m][j])%mod;
}

int main(){
	scanf("%d %d",&n,&m);
	for(re int i=1;i<=n;i++) ins();
	buildf(),qpow(26,m),dp();
	printf("%d\n",ans>=0?ans:(ans+mod));
	return 0;
}

fail树上的问题

1.NOI2011-阿狸的打字机

首先根据输入建出 Trie 树。

利用一个字符串数据结构的常用性质:子串是前缀的后缀。

所以,我们可以对于每个 \(y\) 的前缀所对应的节点看它 fail 树上的祖先是否包括 \(x\) 所对应的节点,如果有就说明 \(x\) 在此位置为 \(y\) 的子串,更新一下答案。

换种说法,就是求每个 \(y\) 的前缀所对应的节点里有多少个节点在 \(x\) 所对应的节点在 fail 树上的子树上。

所以可以处理出 fail 树的 dfs 序,把所有询问离线掉,在 Trie 上 dfs 将点加入树状数组,用树状数组处理以目前节点为 \(y\) 所对应的节点的询问。

注意本题要在 Trie 上 dfs,所以不能建字典图。

code:


#include<cstdio>
#include<queue>
#include<stack>
#define re register
#define fuc(k) for(re int k=0;k<26;k++)
using std::queue;
using std::stack;
inline int win(){
	int x=0;char c=getchar();
	while(c>'9'||c<'0') c=getchar();
	while(c>='0'&&c<='9') x=(x<<1)+(x<<3)+(c^48),c=getchar();
	return x;
}

const int N=100050;
char s[N];
int tot,t,ch[N][26],fail[N],endof[N];
int t2,cnt,h2[N],v2[N],ne2[N],xl[N],xr[N];
int t1,h1[N],v1[N],ne1[N],id[N],ans[N];

struct BIT{
	int f[N];
	inline void add(int x){for(;x<=cnt;x+=(x&-x)) f[x]++;}
	inline void del(int x){for(;x<=cnt;x+=(x&-x)) f[x]--;}
	inline int ask(int l,int r){
		int res=0;l--;
		for(;r;r^=(r&-r)) res+=f[r];
		for(;l;l^=(l&-l)) res-=f[l];
		return res;
	}
}bit;
inline void rem(int y,int x,int p){v1[++t1]=x,ne1[t1]=h1[y],h1[y]=t1,id[t1]=p;}
inline void add(int x,int y){v2[++t2]=y,ne2[t2]=h2[x],h2[x]=t2;}
inline void buildtrie(){
	stack<int> st;int x=0,i=0,p;scanf("%s",s+1),st.push(0);
	while(s[++i]){
		if(s[i]=='B') st.pop(),x=st.top();
		else if(s[i]=='P') endof[++t]=tot;
		else p=s[i]-'a',st.push(x=ch[x][p]=ch[x][p]?ch[x][p]:++tot);
	}
}
inline void buildfail(){
	queue<int> q;int x,j;
	fuc(k) if(ch[0][k]) q.push(ch[0][k]);
	while(!q.empty()){
		x=q.front(),q.pop(),add(fail[x],x);
		fuc(k) if(ch[x][k]){
			j=fail[x];while(!ch[j][k]&&j) j=fail[j];
			fail[ch[x][k]]=ch[j][k],q.push(ch[x][k]);
		}
	}
}
void dfs1(int x){
	xl[x]=++cnt;
	for(re int i=h2[x];i;i=ne2[i]) dfs1(v2[i]);
	xr[x]=cnt;
}
void dfs2(int x){
	bit.add(xl[x]);
	for(re int i=h1[x];i;i=ne1[i]) ans[id[i]]=bit.ask(xl[v1[i]],xr[v1[i]]);
	fuc(k) if(ch[x][k]) dfs2(ch[x][k]);
	bit.del(xl[x]);
}

int main(){
	buildtrie(),buildfail();int m=win();
	for(re int i=1,x;i<=m;i++) x=endof[win()],rem(endof[win()],x,i);
	dfs1(0),dfs2(0);
	for(re int i=1;i<=m;i++) printf("%d\n",ans[i]);
	return 0;
}



其他一些好题

1.POI2000-病毒

考虑用输入的 01 串构造 ACAM 并标记其结尾,本题即是问是否能在 ACAM 上走出一条无限长的路径使它不经过任何一个带有结尾标记的节点。

显然,要无限长的这种路径,必然意味着从根出发不经过带有结尾标记的节点能走到一个环,满足这个环上没有一个带有结尾标记的节点。

所以建出来后 dfs 判环即可,注意本题不可拓扑判环,因为删掉带结尾标记的点后图不一定连通,难以判断是否从根出发能走出环。

code:

#include<cstdio>
#include<queue>
#define re register
using std::queue;

const int N=3e4+5;
int t,tr[N][2],fail[N],lin[N];
bool end[N],vis[N];

inline void add(){
	int x=0,p;char c=getchar();
	while(c!='0'&&c!='1') c=getchar();
	while(c=='0'||c=='1'){
		p=c=='1';
		x=(tr[x][p]?tr[x][p]:tr[x][p]=++t);
		c=getchar();
	}
	end[x]=1;
}
inline void buildfail(){
	int x;queue<int> q;
	q.push(tr[0][0]),q.push(tr[0][1]);
	while(!q.empty()){
		x=q.front(),q.pop();
		for(re int k=0;k<=1;k++)
			if(tr[x][k]) q.push(tr[x][k]),fail[tr[x][k]]=tr[fail[x]][k],end[tr[x][k]]|=end[fail[tr[x][k]]];
			else tr[x][k]=tr[fail[x]][k];
	}
}
inline bool dfs(int x){
	if(vis[x]) return true;
	vis[x]=1;
	int k=(((!end[tr[x][0]])&&dfs(tr[x][0]))||((!end[tr[x][1]])&&dfs(tr[x][1])));
	return vis[x]=0,k;
}
/*
inline void debug(){
	for(re int i=0;i<=t;i++){
			printf("%d: %d %d\n",i,tr[i][0],tr[i][1]);
		}
}
*/
int main(){
	int n;
	scanf("%d",&n);
	while(n--) add();
	if(!(tr[0][0]&&tr[0][1])) return puts("TAK"),0;
//	debug();
	buildfail();
//	printf("%d %d\n",cnt,tot);
	puts(dfs(0)?"TAK":"NIE");
	return 0;
}

2.CF163E e-Government

本题我投了题解,就不再写一遍了。

完 ! 结 ! 撒 ! 花 !

posted @ 2021-06-29 16:46  WhaleAtCola  阅读(733)  评论(3编辑  收藏  举报