浅谈AC自动机(附Trie&KMP)
吹雪吹雪吹
2018-07-20 18:29:46
## Aho-Corasick Automaton · AC自动机
AC自动机是一个高效的字符串多模匹配算法,它的核心想法是把KMP的失配指针做到Trie上,从而实现对于所有模板字符串而言的单次文本串扫描出结果。由此可见,若要整下AC自动机,必须先掌握Trie和KMP。
**注意!**请务必仔细阅读并~~深入~~理解KMP,否则看AC机会有点困难
另外,AC自动机真的不是自动帮你AC的机子 ~~其实你可以找AK自动机~~。
下文涉及的Trie以及KMP主要为AC自动机做铺垫,详细算法介绍请见相应文章。~~KMP好像比较详细的呢~~
---
## Part.1 - 字典树(Trie)
其实字典树、前缀树什么的是同一个东西。
![这里写图片描述](https://www.z4a.net/images/2018/07/21/20180326195216730.png)
不难看出,树上每个节点都对应了一个单词。Trie就是这样一颗多叉树(26叉较常用,图中省略了空子树),它的边用一个大小为26的数组记录,这样就可以实现O(1)向下查找。例如单词“tea”,它对应树中路径root (第零层) > 1-1(第一层左往右第一个) > 2-2 > 3-1 。
插入:从根开始,顺着边往下走(O(1)递进,实现见代码),当走到词的末位时标记当前节点
查询:从根开始,顺着边往下走(中途走不通视为查询失败),当走到词的末位时检查当前节点,确认是否被标记。
实现方法:~~市面上~~ 有两种:数组模拟和充斥着指针的动态实现,建议初学者使用静态数组,dalao们请随意
代码~~一言不合就封装~~:
```cpp
class Trie
{
private:
class Nodes
{
public:
Nodes* nxt[26];
bool end;
Nodes()
{
for(int i=0;i<26;++i)
nxt[i]=NULL;
end=false;
}
void init()
{
for(int i=0;i<26;++i)
nxt[i]=NULL;
end=false;
}
};
Nodes root,*now;
public:
Trie()
{
this->root.init();
this->now=NULL;
}
void init()
{
this->root.init();
this->now=NULL;
}
int insert(const char *s)
{
if(!s)
return 0;
this->now=&this->root;
for(int i=0;s[i];++i)
{
char ch=(s[i]>='a'&&s[i]<='z'?s[i]-'a':s[i]-'A');//假定大小写通用
//一般情况下这样写: char ch=s[i]-'a';
if(!this->now->nxt[ch])
{
if((this->now->nxt[ch]=new Nodes)==NULL)
{
return -1;//申请内存失败,返回错误信息
}
}
this->now=this->now->nxt[ch];
}
this->now->end=true;//标记单词末尾
return 1;
}
bool find(const char *s)
{
this->now=&this->root;
for(int i=0;s[i];++i)
{
char ch=(s[i]>='a'&&s[i]<='z'?s[i]-'a':s[i]-'A');
if(!this->now->nxt[ch])
{
return false;
}
this->now=this->now->nxt[ch];
}
return this->now->end;
}/*
~Trie()
{
}*/
};
```
---
## Part.2 - ~~看毛片~~(KMP)
看毛片算法至关重要,请务必仔细阅读并理解相应的内容。
#### ·浅谈一发
暴力求解时,有i指针在文章里,j指针在单词里,我们发现i和j总是在瞎jb乱跳。那么,能不能使其中一个递增,然后利用已有信息求出当前j的最大值呢?
假定我们有办法利用已有信息在log以内求出max(j),那么匹配就很顺利了:当前位置匹配则长度+1,否则求解max(j),后移j指针,直到j==0或者下一位匹配。
回到求解max(j):
我们已有一段匹配的子串,长度为len,那么max(j)一定小于len(废话)。再进一步,设max(j)=k,那么s[1..k]=s[(j-k+1)..j],否则k不合法。
观察:```s[1..k]=s[(j-k+1)..j]```,这不就是最长公共前后缀吗!
#### 部分匹配值
· 我也不知道是不是这么叫,反正实际上是最长公共前后缀。
· 最长公共前后缀: 找到max(len),使得字符串长度为len的前缀和后缀完全匹配。第三个栗子: ABCAB的最长公共前后缀为AB,len=2
~~算了还是说一下前缀和后缀吧:~~
```cpp
ABCBCD
len 前缀 后缀
0 (空串) (空串)
1 A D
2 AB CD
3 ABC BCD
4 ABCB CBCD
5 ABCBC BCBCD
......
6 ABCBCD ABCBCD
```
~~(如果您还是不会,自行Ctrl+w并百度)~~
#### 部分匹配值的获取(get_next函数)
· 惯例:如果你还不知道部分匹配值,请按Ctrl+w
next数组的意义:nxt[i]表示搜索词从开头(第零位)到第i位的部分匹配值
```cpp
void mknxt(int nxt[],int lw,char w[])
{
int k=0,i=1;//k是上一次的匹配值,i务必从1开始
for(k=0,i=1;i<lw;++i)
{
while(k>0&&w[i]!=w[k])//k>0不解释,w[i]!=w[k]即当前匹配不成功
k=nxt[k-1];//我也不想解释,可就是难解释啊
if(w[i]==w[k])//当前位置匹配成功
k++;
nxt[i]=k;
}
}
```
当前位置匹配成功的操作很好理解,k+1即可。
关键在于不相等的情况:k=nxt[k-1]是什么鬼!
![](https://www.z4a.net/images/2018/07/21/20180506153217645.png)
w[k]与w[i]不等的处理方法:显然长度为k的前后缀是不能用了,所以应当去考虑短一点的前后缀。展开图中大条子,出现上下两个小条子,小条子的结构也是绿黄绿,而且(1-1)=(1-2),(2-1)=(2-2)。我们知道(大条子绿色前缀)=(大条子绿色后缀),所以(1-1)=(1-2)=(2-1)=(2-2)。然后执行k=nxt[k-1],原图变为:
![这里写图片描述](https://www.z4a.net/images/2018/07/21/20180506154939290.png)
现在,大条子中的绿色部分已经缩短,k的值改变为原来的nxt[k-1]。再次检查w[k]和w[i]。
#### 查找
```cpp
void KMP()
{
for(int i=0,k=0;i<ls;++i)
{
while(k>0&&w[k]!=s[i])
k=nxt[k-1];
if(w[k]==s[i])
k++;
if(k==lw)
printf("Pattern occurs with shift:%d\n",(i-lw+1));
}
}
```
如果你看懂了next数组的构造,这里也就不需要解释了
---
## Part.3 - AC自动机
当需要找的东西太多时,KMP就显得很磨叽(需要对每一个模板串构造一次nxt数组并在所有文本串中找一次),因此引入AC自动机。
#### #关于AC自动机
~~当然是自动帮你AC题目的机器啦~~
一种多模式串匹配算法,该算法在1975年产生于贝尔实验室,是著名的多模式匹配算法之一。
有限状态自动机中最简单的一种。
![这里写图片描述](https://www.z4a.net/images/2018/07/21/20180331133525590.jpg)
#### ·浅谈AC自动机(怎么现在才讲啊!)
某dalao:AC自动机就是爬到树上看毛片。
dalao说的很好!~~但是看毛片是违法的~~ AC自动机实际上是把KMP与Trie结合,将KMP中的next转变为fail指针挂到Trie上。
显然,在KMP匹配的过程中,i指针**递增**,j指针(已匹配的末位)总是在"乱跳"。AC自动机的匹配过程如KMP类似,i指针递增,j指针在图上跳。
我们的目的是最大化当前匹配部分,也就是最大化j的值(在Trie上是节点深度)。
来回顾KMP:j+1与i不匹配时,借助next数组前推j指针,使得j'最大。
KMP是仅有一个单词的情况,那么当单词有多个呢?
显然,我们可以在别的单词里去找next。这时next已经被做成指针了,也就是fail(也叫失配边)
假设我们已经求得fail指针,那么接下来要做的是匹配。
与KMP类似,若新字符匹配成功则j指针下移一层,否则j不断沿着失配边走下去,直到j的深度为0(root),或者下一位成功匹配。
由于单词有多个,可能存在s为t的后缀的情况,因此追加一个后缀链接方便查找。
再举个例子:
现有单词:{"SAY","SHE","SHR","HER"}
首先构造出Trie如图:
![](https://www.z4a.net/images/2018/08/03/11.md.png)
接下来从第一层开始构造失配边(红色边):
由于到这一层的串再Trie中均没有合法的后缀,故把这一层所有点的失配边指回Root(可以理解为空串是其合法后缀)
![](https://www.z4a.net/images/2018/08/03/12.md.png)
---
第二层,出现了"H"是当前"SH"的后缀,将第二层'H'点的失配边指向第一层的'H',其余节点的fail指回Root
![](https://www.z4a.net/images/2018/08/03/13.md.png)
---
第三层,出现了"HE"是"SHE"的后缀,将第三层'E'的fail指向第二层'E'。
这时,虽然有"R"是"SHR"的后缀,但是"R"并不是一个从Root开始的合法串,故仍将'R'的失配边指向Root
![](https://www.z4a.net/images/2018/08/03/14.md.png)
接下来开始实现:
#### ·实现
#### #构造Trie
将模板串全部读入并构造Trie,这里需要在之前的Trie基础上新增last和fail指针,前者叫做后缀链接(后头再解释),后者就是失配边。
#### #~~画图~~构造失配边与后缀链接
失配边类似于KMP中的next,实际上指向了一个串的后缀(只需要满足后缀的关系就行),有了这玩意儿可以极大的方便查找;
后缀链接指向串的后缀(废话)且**该后缀是一个完整的单词**。后缀链接的作用很显然,如果有{"fubuki","ki"},某时刻已经找到了"fubuki",那么顺着后缀链接就能找到"ki",这样一来,后缀链接就可以进行递归累计。
```cpp
void ACA()//BFS构造失配边
{
queue<Trie*>que;
for(int i=0;i<26;++i)
{
now=root.nxt[i];
if(now)
{
que.push(now);
now->f=&root;
}
}//初始化队列
while(!que.empty())
{//逐层构造
Trie *hed=que.front();
que.pop();
for(int i=0;i<26;++i)
{//构造对于now->nxt[i]的fail与last
Trie *j=hed->f;
now=hed->nxt[i];
if(!now)
{
if(j)
hed->nxt[i]=j->nxt[i];//这句过会儿解释,请暂忽略
continue;
}
que.push(now);
while(j&&!j->nxt[i])//从hed->f的基础上获取hed->nxt[i]->f,找一个hed的后缀,且这个后缀的后一个字符为i
j=j->f;
/* <???>"*********"'字符' hed->nxt[i]对应的串(双引号内为已经确定fail的部分)和当前字符
"*********"'未知字符' hed->f对应的串及其下一个字符(未知)
显然,两端中双引号内字符串完全匹配,只需考虑二者的下一个字符是否相等
*/
if(j)//数组版无需判断,这里是指针防越界(空指针)
{
now->f=j->nxt[i];
now->lst=now->f->flg?now->f:now->f->lst;//如果失配边指向一个单词就直接记录lst,否则看看失配边指向的lst
}
if(!now->f)//空串是任意串的后缀,无法构造fail的就指回root Ps.数组版还是不需要。。。
now->f=&root;
}
}
}
```
#### #匹配(查询)
与KMP的Find相差无几,只不过由推动下标变为推动节点指针
```cpp
void find()
{
now=&root;
for(int i=0;s[i];++i)
{
int j=s[i]-'a';
while(now&&!now->nxt[j])//如果当前节点没有儿子i就顺着失配边走直到新节点有儿子i或走回root
now=now->f;
if(now)//再次防越界(空指针)
{
now=now->nxt[j];//相当于KMP里的指针后移
if(now->flg)//当前节点记录了单词
count(now);//递归累加出现次数
else if(now->lst)//当前串的后缀可能是个单词
count(now->lst);
}
else//now为空意味着走回root
now=&root;
}
return;
}
/*构造失配边时有一句:hed->nxt[i]=j->nxt[i];
实际上是加上一条不存在的边,把nxt[i]变成了另一个fail指针
如果构造时写了这句话,匹配的时候就无需写while(now&&!now->nxt[j])了*/
void count(Trie *now)
{
if(!now)
return;
cnt[now->flg]++;
count(now->lst);//当前串的后缀可能又是一个单词
}
```
---
#### #最后贴上完整代码,写的很粗鲁大家见谅
```cpp
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
int n,m,cnt[1005];
char s[1005];//不要在意大小
class Trie
{
public:
Trie *nxt[26],*f,*lst;
int flg;
Trie()
{
for(int i=0;i<26;++i)
nxt[i]=NULL;
f=lst=NULL;
flg=0;
}
}root,*now;//Trie
void count(Trie *now)
{
if(!now)//
return;
cnt[now->flg]++;
count(now->lst);
}//累计答案用的,不同题目会要求不同答案
void find()
{
now=&root;
for(int i=0;s[i];++i)
{
int j=s[i]-'a';
while(now&&!now->nxt[j])
now=now->f;
if(now)
{
now=now->nxt[j];
if(now->flg)
count(now);
else if(now->lst)
count(now->lst);
}
else
now=&root;
}
}
void ACA()//BFS构造失配边
{
queue<Trie*>que;
for(int i=0;i<26;++i)
{
now=root.nxt[i];
if(now)
{
que.push(now);
now->f=&root;
}
}
while(!que.empty())
{
Trie *hed=que.front();
que.pop();
for(int i=0;i<26;++i)
{
Trie *j=hed->f;
now=hed->nxt[i];
if(!now)
{
if(j)
hed->nxt[i]=j->nxt[i];
continue;
}
que.push(now);
while(j&&!j->nxt[i])
j=j->f;
if(j)
{
now->f=j->nxt[i];
now->lst=now->f->flg?now->f:now->f->lst;
}
if(!now->f)
now->f=&root;
}
}
}
int main()
{
freopen("test.in","r",stdin);
freopen("test.out","w",stdout);
scanf("%d%d",&n,&m);
for(int i=1;i<=n;++i)
{
now=&root;
scanf("%s",s);
for(int j=0;s[j];++j)
{
if(!now->nxt[s[j]-'a'])
now->nxt[s[j]-'a']=new Trie;
now=now->nxt[s[j]-'a'];
}
now->flg=i;
}
ACA();
for(int i=1;i<=m;++i)
{
scanf("%s",s);
find();
}
for(int i=1;i<=n;++i)
printf("%d ",cnt[i]);
return 0;
}
```
---
#### ·偷懒的我
为啥AC自动机写的比KMP短? ——你都理解KMP了,上树不就行了吗!
---
#### ·写在后边
如果哪一位dalao会 AK自动机 请尽快联系本蒟蒻,必有重谢!
本文多有不妥之处,欢迎批评指正!
~~本文不会有人转的。。。~~转载请注明出处。
参考:
刘汝佳《算法竞赛入门经典(训练指南)》
徐玄長(好吧我自己)[《KMP·看毛片》](https://blog.csdn.net/zj_0413_2017/article/details/80214703)