浅谈 AC 自动机
本文章同步发表在洛谷博客。
前置知识
AC 自动机的前置知识有两个,一个是字典树(又称 Trie 树),还有一个则是 KMP 算法。
请先确保你会这两种算法再来学习 AC 自动机。
如果不会,你可以考虑先看一下我的两篇关于它们的笔记:
问题引入
我们都知道 KMP 算法解决的是字符串匹配的问题,但是通常都只找一个 \(t\) 在 \(s\) 中出现的次数情况。如果给了你多个 \(t_i\),要你分别在 \(s\) 中找,使用 AC 自动机,会发生什么呢?显而易见,我们需要对多个 \(t_i\) 分别都求一次 border,也就是那个什么 \(nxt\) 数组——那多麻烦啊!不超时就怪了!因此,我们只要上些更厉害的法子,比如说这里讲到的 AC 自动机。
首先说一下 AC 自动机大体的这个原理啊。考虑按照 KMP 的思路对这一堆的 \(t\) 串建失配图,你会发现失配图就是一个 Trie 树,不过每个点多了一条失配边。
AC 自动机详细解法
接下来我将解析 AC 自动机具体的解法。
在这里,我将 AC 自动机的解法过程分成了三个步骤,先建树,接着求失配指针,最后执行查询。
我将按照这三个步骤的顺序依次进行讲解。
第一步:建树
当然就是建那个 Trie 树啦!也就是所谓的失配图。因为它的核心骨干就是一棵对于所有 \(t_i\) 的 Trie 树,因此你只要在读入每个 \(t_i\) 的时候分别扔到 Trie 树里去就好了。
哦对,刚才忘了说,这个 AC 自动机的 Trie 树的每个节点里边具体要存些什么信息呢?跟普通的一样?不不不,这里可不止存些什么儿子节点的信息,还需要记录失配指针 \(fail\) 以及这个节点的贡献情况 \(val\)。
struct Trie{int fail,son[30],val;}t[N];//存储 Trie 树信息
void BuildTrie(string str){
int now=0;//令根为 0 号节点
for(char c:str){
if(!t[now].son[c-'a'])
t[now].son[c-'a']=(++Cnt);
//Cnt 记录了当前总共的非根节点个数
now=t[now].son[c-'a'];
}t[now].val++;return;
}//建树的函数
n=read();
for(int i=1;i<=n;i++){
string str;cin>>str;
BuildTrie(str);//读取字符串并建树
}
第二步:求失配指针
第二步就是求失配指针啦。这可是至关重要的一步呐!
首先说说失配指针的定义是什么。求解 KMP 的时候,如果匹配错误了,是不是就会进行挪动?这里的挪动是不是就和 border 有关?这个失配指针呢,就是你在 Trie 树上匹配时候,如果在这个节点位置匹配错误,那么接下来应该挪动到哪个位置去。这就是失配指针。
刚那是抽象意义理解。在 Trie 树上,一个点的失配指针,形象点怎么说?噢,很简单的,比如说 \(x\) 的失配指针是 \(y\),则应该满足从根到 \(y\) 的字符串和 \(x\) 往上同样长度的字符串一模一样。不存在就直接是根啦,怎么说都是一样的,因为是空的哇。
我们可以使用 BFS 来完成失配指针的构造。可以发现,一个节点的失配指针大致是这么求的:沿着其父节点的失配指针一直往上跳,直到找到拥有当前这个字母的子节点的节点的那个子节点,这就是它的失配指针。如果跳到顶了还没找到就直接是根了,对不?
好难的样子啊,不理解怎么办呢?其实没有这么复杂。
如果你多画图多推敲,你会发现一个更加,呃,惊人一些的规律:如果存在这种字母的子节点,那么这个子节点的失配指针是指向当前节点的失配指针所指向的节点的这种字母的子节点;反之不存在这种字母的子节点,我们变化一下,让当前这个子节点转化成当前节点的失配指针所指向的节点的这种字母的子节点。哎对对对,这两个是同一个东西,都是这个什么当前节点的失配指针所指向的节点的这种字母的子节点,只不过当存在这个字母的时候咱是给 \(fail\) 值,不存在的时候咱是去换儿子!这个方法很精妙啊,口头叙述特别麻烦,我建议的是自己多举一些例子,画画图,自然就一下子懂了。特别厉害,尤其是这个子节点转化。
于是这就比上面的求起来方便多了,虽然说起来更绕口了呃呃。
过程就是一个 BFS,不过我最初先把第一层的求出来了,因为那都是指向根的。然后把它们扔进队列当起点,从它们出发开始跑就完了!
void CountFail(){//求失配指针
queue<int> q;//新建队列跑 BFS
while(!q.empty())q.pop();//清空队列
t[0].fail=0;//根没有失配指针
for(int i=0;i<26;i++)//第二层的提前处理一下
if(t[0].son[i])//这个字符是有儿子的,不是空节点
t[t[0].son[i]].fail=0,//首先让失配指针指向根节点
q.push(t[0].son[i]);//然后把其塞进队列里边
while(!q.empty()){//开始遍历队列搞 BFS
int u=q.front();q.pop();//取队头
for(int i=0;i<26;i++){//枚举每种字符的子节点
if(t[u].son[i])//存在这个儿子,不是空节点
t[t[u].son[i]].fail=t[t[u].fail].son[i],
//fail 值即为当前节点的 fail 的对应字符儿子
q.push(t[u].son[i]);//也给搞到队列里头去
else //否则不存在这个儿子,里边是空的
t[u].son[i]=t[t[u].fail].son[i];
//那么这个儿子更新为当前节点的 fail 的对应字符儿子
}
}return;//于是所有的 fail 就都求完啦
}
第三步:执行查询
就是去查答案啦!
这个很简单的,我们只需要每次按部就班跳一层,然后开个临时变量,沿着全部 \(fail\) 的路线走上一通,就完事儿啦!注意做点标记,不然重复经过一个地方重复多算就不好了!
本来是可以,其实是可以,写一个大 while 然后疯狂判断搞定的,不过我觉得有点麻烦,于是我就写成了 for 循环的模式,每次先跳正常的,再跳失配指针。
int QueryACAM(string str){
int now=0,res=0;
//从根节点开始,并实时记录答案
for(char c:str){//遍历字符串
now=t[now].son[c-'a'];//往下跳一层
int tmp=now;//临时变量
while(tmp&&t[tmp].val!=-1)//非空且非重复
res+=t[tmp].val,//加上这个节点对应的答案
t[tmp].val=-1,//设置成 -1 防止重复计算
tmp=t[tmp].fail;//沿着 fail 的路线继续跑
}return res;//算出的答案返回回去
}
P3808 AC 自动机(简单版)
模板题……就是上面的代码结合起来。
放个完整的供参考吧。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
using namespace std;
const int N = 1e6+5;
struct Trie{int fail,son[30],val;}t[N];
int n,Cnt;string s;
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
void BuildTrie(string str){//建树
int now=0;//从根开始跑
for(char c:str){
if(!t[now].son[c-'a'])
t[now].son[c-'a']=(++Cnt);//没有就新建
now=t[now].son[c-'a'];//往下跳
}t[now].val++;return;//权值增加
}
void CountFail(){//求失配指针
queue<int> q;//新建队列跑 BFS
while(!q.empty())q.pop();//清空队列
t[0].fail=0;//根没有失配指针
for(int i=0;i<26;i++)//第二层的提前处理一下
if(t[0].son[i])//这个字符是有儿子的,不是空节点
t[t[0].son[i]].fail=0,//首先让失配指针指向根节点
q.push(t[0].son[i]);//然后把其塞进队列里边
while(!q.empty()){//开始遍历队列搞 BFS
int u=q.front();q.pop();//取队头
for(int i=0;i<26;i++){//枚举每种字符的子节点
if(t[u].son[i])//存在这个儿子,不是空节点
t[t[u].son[i]].fail=t[t[u].fail].son[i],
//fail 值即为当前节点的 fail 的对应字符儿子
q.push(t[u].son[i]);//也给搞到队列里头去
else //否则不存在这个儿子,里边是空的
t[u].son[i]=t[t[u].fail].son[i];
//那么这个儿子更新为当前节点的 fail 的对应字符儿子
}
}return;//于是所有的 fail 就都求完啦
}
int QueryACAM(string str){
int now=0,res=0;
//从根节点开始,并实时记录答案
for(char c:str){//遍历字符串
now=t[now].son[c-'a'];//往下跳一层
int tmp=now;//临时变量
while(tmp&&t[tmp].val!=-1)//非空且非重复
res+=t[tmp].val,//加上这个节点对应的答案
t[tmp].val=-1,//设置成 -1 防止重复计算
tmp=t[tmp].fail;//沿着 fail 的路线继续跑
}return res;//算出的答案返回回去
}
int main(){
n=read();
for(int i=1;i<=n;i++){
string str;cin>>str;
BuildTrie(str);//读取字符串并建树
}CountFail();//求失配指针
cin>>s;//读入那个要去匹配的串
cout<<QueryACAM(s)<<"\n";//输出查询结果
return 0;
}
P3796 AC 自动机(简单版 II)
稍微修改一下 \(val\) 的数值以及后面查询那里存储的答案就可以过啦,最后要记得求 \(\max\) 并输出方案。
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
using namespace std;
const int N = 1e6+5;
struct Trie{int fail,son[30],ed;}t[N];
int n,Cnt,Ans[N],Mx;string s[N];
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
void Clean(int x){//清空函数
for(int i=0;i<26;i++)t[x].son[i]=0;
t[x].fail=0,t[x].ed=0;return;
}
void BuildTrie(string str,int id){//建树
int now=0;//从根开始跑
for(char c:str){
if(!t[now].son[c-'a'])
t[now].son[c-'a']=(++Cnt),Clean(Cnt);
now=t[now].son[c-'a'];//往下跳
}t[now].ed=id;return;//权值增加
}
void CountFail(){//求失配指针
queue<int> q;//新建队列跑 BFS
while(!q.empty())q.pop();//清空队列
t[0].fail=0;//根没有失配指针
for(int i=0;i<26;i++)//第二层的提前处理一下
if(t[0].son[i])//这个字符是有儿子的,不是空节点
t[t[0].son[i]].fail=0,//首先让失配指针指向根节点
q.push(t[0].son[i]);//然后把其塞进队列里边
while(!q.empty()){//开始遍历队列搞 BFS
int u=q.front();q.pop();//取队头
for(int i=0;i<26;i++){//枚举每种字符的子节点
if(t[u].son[i])//存在这个儿子,不是空节点
t[t[u].son[i]].fail=t[t[u].fail].son[i],
//fail 值即为当前节点的 fail 的对应字符儿子
q.push(t[u].son[i]);//也给搞到队列里头去
else //否则不存在这个儿子,里边是空的
t[u].son[i]=t[t[u].fail].son[i];
//那么这个儿子更新为当前节点的 fail 的对应字符儿子
}
}return;//于是所有的 fail 就都求完啦
}
void SolveACAM(string str){
int now=0;//从根节点开始跑
for(char c:str){//遍历字符串
now=t[now].son[c-'a'];//往下跳一层
int tmp=now;//临时变量
while(tmp&&t[tmp].ed!=-1)//非空且非重复
Ans[t[tmp].ed]++,//累加对应串的答案
tmp=t[tmp].fail;//沿着 fail 的路线继续跑
}return;//全部搞定
}
int main(){
while(1){
n=read();//读入
if(!n)break;//结束了,跳出
for(int i=0;i<=Cnt;i++)Clean(i);//多测清空
Cnt=0;//多测清空
for(int i=1;i<=n;i++){
cin>>s[i];//读入
Ans[i]=0;//初始化
BuildTrie(s[i],i);
}CountFail();//求失配指针
cin>>s[0];SolveACAM(s[0]);//对应去处理
Mx=0;//用于等下求最大值
for(int i=1;i<=n;i++)
Mx=max(Mx,Ans[i]);//求出最大值
cout<<Mx<<"\n";//输出
for(int i=1;i<=n;i++)
if(Ans[i]==Mx)//如果个数正好
cout<<s[i]<<"\n";//输出字符串
}
return 0;
}

浙公网安备 33010602011771号