首先,看清楚了,这是AC自动机不是自动AC机
引用AC自动机模板题上的一句话:
ovo
在学习AC自动机之前,应该先掌握一下两个前置技能:
AC自动机,通俗地讲,就是在Trie上跑KMP。AC自动机利用Trie的性质和KMP的思想,可以实现字符串的多模匹配。KMP是单模匹配,而它与AC自动机最大的区别就在fail指针的求法,其余思想基本相同。
所谓多模匹配,即给出若干个模式串和一个文本串,需要查找这些模式串在文本串中出现的情况。
AC自动机的操作分为三步:
既然是要利用Trie,自然要先建立一棵Trie了。本文以she,he,say,her,shr五个字符串为例建立一棵Trie:
其中,root为根节点,绿色节点表示该节点为某个单词的结尾,也就是结尾标记。
AC自动机的建树方法与Trie完全相同,在这里就不再赘述。
建树代码:
void add(string s)
{
int p=0;
for(int i=0;i<s.size();i++)
{
if(!ac[p].son[s[i]-'a'])
ac[p].son[s[i]-'a']=++tot;
p=ac[p].son[s[i]-'a'];
}
ac[p].end++;
}
求fail指针是AC自动机最精髓的地方,也是最大的难点。不过,在掌握了KMP算法之后,理解起来也不难。
AC自动机中fail指针的作用与KMP中next数组的作用相同,都是在当前字符串失配时跳转到它指向的位置继续匹配。而AC自动机之所以能够进行多模匹配,就是因为fail指针。
在AC自动机中,fail指针用BFS来求。
步骤:
- 建立一个队列
- 将根的fail指针指向自己
- 将与根相连的节点的fail指针指向根,并将它们入队
- 取出队头h,遍历它的儿子。设当前遍历到的儿子节点为x,找到h节点的fail指针指向的节点,设其为k
- 若k有与x相同的儿子s,则将x的fail指针指向s;否则,找到k的fail指针,重复第5步,若一直都没有找到,则将x的fail指针指向根节点。将x入队,重复第4、5步,直到队列为空
仍然以she,he,say,her,shr五个字符串为例,如图:
- 如图中红线所示,将与root相连的h、s的fail指针指向root并将它们入队
- 如图中蓝线所示,取出h,找到h的儿子e,因为h的fail指针指向root且root的儿子没有e,因此e的fail指针指向root,并将e入队;取出s,找到s的儿子a,因为s的fail指针指向root且root的儿子没有a,因此a的fail指针指向root,并将a入队;找到s的儿子h,因为h的fail指针指向root且root的儿子有h,因此h的fail指针指向与root相连的h,并将h入队
- 如图中绿线所示,取出e,找到e的儿子r,因为e的fail指针指向root且root的儿子没有r,因此r的fail指针指向root,并将r入队;取出a,找到a的儿子y,因为a的fail指针指向root且root的儿子没有y,因此y的fail指针指向root,并将y入队;取出h,找到h的儿子e,因为h的fail指针指向与root相连的h且该节点的儿子有e,因此e的指针指向与root相连的h的儿子e,并将e入队;找到h的儿子r,因为h的fail指针指向与root相连的h且该节点的儿子没有r,因此找到与root相连的h的fail指针指向的root,而root的儿子也没有r,因此r的指针指向root,并将r入队
- 最后,取出r,y,e,r,发现它们均没有儿子节点。此时队列为空,停止遍历。
队列的状态是这样的:
h s
s e
e a h
a h r
h r y
r y e r
这样讲可能有点乱,请结合图和队列状态理解,不会难。
在实际实现过程中,若一直重复以上的第4、5步,时间复杂度难免会高。这里有一个巧妙的方法:当发现一个节点x没有某一个儿子s时,直接将s作为指针指向x的fail指针与s相同的这个儿子。这样实际上就是在模拟第4、5步反复查找的过程,这个指针会从上到下传递下来。因为当我们将根节点的编号设为0时,若一个节点没有儿子,就相当于这个儿子作为指针指向了根节点。这样可以更加方便地实现第4、5步。
求fail指针代码:
void build()
{
for(int i=0;i<26;i++)
if(ac[0].son[i])
{
ac[ac[0].son[i]].fail=0;
q.push(ac[0].son[i]);
}//将与根相连的节点的fail指针指向根节点并将它们入队
while(q.size())
{
int now=q.front();
q.pop();//取出队头
for(int i=0;i<26;i++)
if(ac[now].son[i])
{
ac[ac[now].son[i]].fail=ac[ac[now].fail].son[i];
q.push(ac[now].son[i]);
}
else
ac[now].son[i]=ac[ac[now].fail].son[i];
}//重复第4、5步
}
字符串匹配的思想与KMP基本相同,实现方式与Trie上查找字符串类似。将文本串从头到尾一位一位在Trie上查找,对于每一个节点,若没有被遍历过,沿着它的fail指针走,直到根节点或一个已遍历过的点。对于路径上的所有点,将答案加上它的结尾标记,即当前节点为几个字符串的结尾,然后将其结尾标记改为-1,以显示其已遍历过。
还是以这个图为例:
若文本串为yasherhs,则:
- 对于y,a,Trie中没有对应的路径
- 对于s,h,e,在Trie中可以沿着root-s-h-e这条路径走到第四层节点e,答案加1,沿着其fail路径向上可以走到第三层节点e,答案加1;
- 对于r,此时指针指向第四层节点e的儿子指向的节点,也就是其fail指针指向的第三层节点e,随后指向右下角节点r,答案加1;
- 对于h,s,因为已经遍历过了,因此不会再进行遍历
为什么走到一个已遍历过的点也要停止呢?因为若一个节点已被遍历,则沿着它的fail指针走直到根节点的这条路径上的所有节点一定都被遍历过了,若在走一遍则属于浪费时间,因此直接停止即可。
字符串匹配代码:
int get()
{
int p=0,ans=0;
for(int i=0;i<f.size();i++)
{
p=ac[p].son[f[i]-'a'];
for(int j=p;j && ac[j].end!=-1;j=ac[j].fail)
{
ans+=ac[j].end;
ac[j].end=-1;
}
}
return ans;
}
最后奉上完整代码:
#include<iostream>
#include<string>
#include<queue>
using namespace std;
const int N=1e6;
int tot=0,n;
string f;
queue<int> q;
struct T
{
int end=0,fail=0,son[26];分别表示结尾标记,fail指针和儿子节点
}ac[N];
void add(string s)
{
int p=0;
for(int i=0;i<s.size();i++)
{
if(!ac[p].son[s[i]-'a'])
ac[p].son[s[i]-'a']=++tot;
p=ac[p].son[s[i]-'a'];
}
ac[p].end++;
}//建树
void build()
{
for(int i=0;i<26;i++)
if(ac[0].son[i])
{
ac[ac[0].son[i]].fail=0;
q.push(ac[0].son[i]);
}
while(q.size())
{
int now=q.front();
q.pop();
for(int i=0;i<26;i++)
if(ac[now].son[i])
{
ac[ac[now].son[i]].fail=ac[ac[now].fail].son[i];
q.push(ac[now].son[i]);
}
else
ac[now].son[i]=ac[ac[now].fail].son[i];
}
}//求fail指针
int get()
{
int p=0,ans=0;
for(int i=0;i<f.size();i++)
{
p=ac[p].son[f[i]-'a'];
for(int j=p;j && ac[j].end!=-1;j=ac[j].fail)
{
ans+=ac[j].end;
ac[j].end=-1;
}
}
return ans;
}//字符串匹配
int main()
{
cin>>n;
for(int i=1;i<=n;i++)
{
string s;
cin>>s;
add(s);
}//输入模式串并插入Trie
ac[0].fail=0;//将根节点的fail指针指向自己,其实这步可以不要,因为默认就是0
build();//求fail指针
cin>>f;
cout<<get()<<endl;//输入文本串并匹配,直接输出答案
return 0;
}
习题:
声明:本文部分内容参考了一些大佬的博客
参考资料:
2019.5.2 于厦门外国语学校石狮分校