【笔记】AC自动机(ACAM)
这篇文章不仅写了普通的原算法,还附了好几道好题和题解,如果大佬您已经学了 ACAM,不妨做做文后的题目,如果这些题您也都做过了,那就点个赞支持一下吧!
upd 8.29:把之前咕掉的三道题补上了。
前置知识
kmp 模式匹配、Trie。
没了。
介绍:这玩意是干什么的
想必你第一次看到 AC 自动机这个名字,心潮涌动。
其实这和做题 AC 啥关系没有,这个 AC 是Aho-Corasick,我也不知道啥意思。
你一定知道 kmp 是在一个文本串中匹配一个模式串。
AC 自动机(ACam/ACAM:Aho-Corasick automaton)可以让你在一个文本串中匹配一堆模式串,但文本串只要扫一遍,很是NB。
更通俗的解释:看了也没有帮助。
正经板子
裸T:
此处以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}\) 最长
思想与步骤
大致参考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;
}