最近老师呢,让我们做了一套题,然后里面有一道可持久化trie树,蒟蒻不会。蒟蒻呢,于是想去学。但是可持久化还是太早了,于是从ac自动机开始!
AC自动机嘛……有点类似于trietree上的KMP。
前置知识,字典树 ,kmp(其实知道kmp思想就好啦)
其实呢,蒟蒻tsz原本是不会ac自动机的,但是看着别人的博客去学了。最后学会了,于是想写一篇博客。
首先本人在洛谷上做了两道题,坐标: 简单版 , 加强版。然后开始讲课!
我们看过题后发现,如果每个模式串都去和文本串匹配一遍的话,哪怕用kmp,或者只用字典树的话,肯定会TLE。
照着正常的字典树打,我们可以考虑加上一个叫做“fail”的指针,指向的是上一个可以匹配的地方。
比如说这里有一个字典树:
root为根节点,接下来每一个节点都存一个字符,这就是正常的字典树。
然后我们在看一看加了fail指针的字典树:
PS:没有加箭头,fail指针的方向是向上的,自行脑补。
红色的是fail指针,现在能不能找到一些些规律?
最简单,最显而易见的是:每一个节点的fail指针都是指着和当前节点代表字符相同的节点(或者是root)。
其实,还有一个规律:每一个节点的fail指针,指的是它父亲的fail指针的对应子节点。
为什么是这样?为什么要设fail指针?别急我们慢慢来看~
首先我们看到这个fail指针:
对于这个abc→bc的fail指针我们可以发现它是从ab→b推导过来的。然后可以发现fail指针所指的字符串是当前字符串的后缀。
(abc指的是路径root→a→b→c的最后一个节点,bc指的是路径root→b→c的最后一个节点,下文同)
当我们做完trie树和fail指针之后,接下来肯定是查询,这时候我们只会用到我们的文本串。
假设对于上图字典树,我们有一个文本串‘abcd’,那么首先它肯定会访问root,接下来走到a,由于a的fail指针指向的是root,没有意义,所以此次被查询的只有a。
于是往下走到ab。ab有一个fail指针,指向的是b。b指向的是root,没有意义。于是此次查询的是ab和b两个节点。
然后继续往下走,走到abc,abc有一个fail指针,指向的是bc。bc也有个指针,指向的是c。然而c指向的是root,没有意义。于是本次操作查询的是abc,bc,c,这三个节点。
之后问题就来了:那么按道理,我之后应该走的是abcd这一个节点啊,但是我没有这个节点,怎么办?
这里有一个非常奇妙的方法:当不存在这个子节点的话,当前节点就会把它fail指针的对应子节点当做自己的子节点。
abc没有自己的'd'子节点,于是它就把bc的'd'子节点当做自己的子节点,但是bc原本也没有子节点,它的‘d’子节点本来就是c的‘d’子节点cd。于是abc的‘d’子节点就是cd了!
所以最后一步文本串往下走,也就是走到cd节点,重复之前的操作,查询到了cd和d节点。
然后我们就知道最后的答案啦~
那怎么做fail指针呢?有种东西叫bfs,具体看代码:
void trie_fail()
{
queue<int>q;
for(int i=0;i<26;i++)
{
if(ac[0].nxt[i]!=0)
{
ac[ac[0].nxt[i]].fail=0;
q.push(ac[0].nxt[i]);
}
}//root的直接子树先预处理
while(!q.empty())
{
int x=q.front();
q.pop();
for(int i=0;i<26;i++)
{
if(ac[x].nxt[i]!=0)//如果存在这个子节点
{
ac[ac[x].nxt[i]].fail=ac[ac[x].fail].nxt[i];
//子节点的fail等于当前节点fail的对应子节点
q.push(ac[x].nxt[i]);//压进队列
}
else ac[x].nxt[i]=ac[ac[x].fail].nxt[i];
//如果不存在这个子节点,那么子节点就等于当前节点fail的对应子节点
}
}
}
顺便贴一张luogu【p3796】的代码:
#include<bits/stdc++.h>
#define maxn 100001
using namespace std;
struct trie
{
int nxt[26],fail,num;
}ac[maxn];
int n,cnt,sum,ans[maxn];
char s[1000001],ch[210][110];
void clan()
{
for(int i=0;i<=cnt;i++)
{
memset(ac[i].nxt,0,sizeof(ac[i].nxt));
ac[i].num=ac[i].fail=0;
}
cnt=0;sum=0;
for(int i=1;i<=n;i++)ans[i]=0;
}
void trie_ins(int i)
{
int root=0,len=strlen(ch[i]+1);
for(int j=1;j<=len;j++)
{
if(!ac[root].nxt[ch[i][j]-'a'])ac[root].nxt[ch[i][j]-'a']=++cnt;
root=ac[root].nxt[ch[i][j]-'a'];
}
ac[root].num=i;//题目保证了每一个模式串不一样,直接记录一下编号就好
}
void trie_fail()
{
queue<int>q;
for(int i=0;i<26;i++)
{
if(ac[0].nxt[i]!=0)
{
ac[ac[0].nxt[i]].fail=0;
q.push(ac[0].nxt[i]);
}
}
while(!q.empty())
{
int x=q.front();
q.pop();
for(int i=0;i<26;i++)
{
if(ac[x].nxt[i]!=0)
{
ac[ac[x].nxt[i]].fail=ac[ac[x].fail].nxt[i];
q.push(ac[x].nxt[i]);
}
else ac[x].nxt[i]=ac[ac[x].fail].nxt[i];
}
}
}
void quest()
{
int root=0,len=strlen(s+1);
for(int i=1;i<=len;i++)
{
int v=s[i]-'a';
int k=ac[root].nxt[v];
while(k>=1)//k=0的话就访问到root了
{
if(ac[k].num!=0)ans[ac[k].num]++;
k=ac[k].fail;
}
root=ac[root].nxt[v];
}
}
int main()
{
while(1)
{
scanf("%d",&n);
if(n==0)return 0;
clan();//初始化,函数名随便取的
for(int i=1;i<=n;i++)
{
scanf("%s",ch[i]+1);
trie_ins(i);//插入
}
trie_fail();//建立fail指针
scanf("%s",s+1);
quest();//查询
int maxx=0;
for(int i=1;i<=n;i++)maxx=max(maxx,ans[i]);
printf("%d\n",maxx);
for(int i=1;i<=n;i++)
{
if(maxx==ans[i])
{
int len=strlen(ch[i]+1);
for(int j=1;j<=len;j++)printf("%c",ch[i][j]);
printf("\n");
}
}
}
}
最后来一个小插曲/番外/小故事:
tsz前天AC了ac自动机加强版并从此学会了ac自动机。但是之后有碰到了ac自动机的简单版,然后没有过样例。最后用样例又hack了加强版的模板框架,发现自己是while(k>=1)打成了while(k>1),大呼:“数据好水”。
最后希望大家学完新算法要多刷同类型题,不仅是巩固知识,也可以检验一下模板的准确性。