写在前面
鸣谢:
OiWiki
「笔记」AC 自动机---LuckyBlock
字符串四姐妹---老色批
AC自动机讲解超详细---某不知名大佬
Q:AC自动机?是能自己AC题目的算法吗?(兴奋)
A:不不不,那叫自动AC机,通过打开答案文件输出答案的一种小手段,在比赛中使用还会有禁赛三年的奖励,而AC自动机是一个字符串匹配算法
AC自动机,全称(Aho-Corasick automaton),是一种用来处理字符串多模式匹配的算法
本人将尽可能详细的解释AC自动机的算法流程(其实大部分抄的Oiwiki,这是一个帮助我们共同理解的过程,毕竟作者也是个萌新。开始接受的过程可能比较困难,但多回顾几遍还是有助于理解的
算法流程
什么是自动机?(粘个链接,感性理解就好,不要过于执着)
引例:
给定 (n) 个模式串 (s_i) 和一个文本串 (t),求有多少个不同的模式串在文本串里出现过。
两个模式串不同当且仅当他们编号不同。
概述:
结合Trie的结构和KMP的思想建立,建立一个AC自动机主要通过两个步骤:
-
1、建立Trie树;
-
2、对Trie树上的所有结点构造失配指针
Trie树的构建(第一步)
这个Trie树就是普通的Trie树,该怎么建怎么建
解释一下Trie树结点的含义:表示某个模式串的前缀
后文也将称作状态。一个结点表示一个状态,Trie树的边就是状态的转移
形式化的说,对于若干个模式串 (s_1,s_2,s_3···s_n),将它们构建一个Trie树后的所有状态的集合记为 (Q)
失配指针(第二步)
AC 自动机利用一个 fail 指针来辅助多模式串的匹配。
状态 (u) 的 fail 指针指向另一个状态 (v) ,其中 (v in Q) ,且 (v) 是 (u) 的最长后缀(即在若干个后缀状态中取最长的一个作为 fail 指针)。
注意和KMP的next指针的区别:
两者都是在失配的时候用于跳转的指针;
next指针求的是最长的border(最长的 相同的 前后缀),而fail指针指向所有模式串的前缀中匹配当前状态的最长后缀
因为 KMP 只对一个模式串做匹配,而 AC 自动机要对多个模式串做匹配。有可能 fail 指针指向的结点对应着另一个模式串,两者前缀不同。
AC 自动机在做匹配时,同一位上可匹配多个模式串。
构建失配指针
(可以参考KMP中构建next指针的思想(
考虑更新 (fail_u),(u) 的父节点是 (p) , (p) 通过字符 (c) 的边指向 (u) ,即 (tr[p,c] = u) 。假设深度小于 (u) 的所有结点的 (fail) 指针均已求得。
如果 (tr[fail_p,c]) 存在:则让 (fail_u) 指向 (tr[fail[p],c]) 。相当于在 (p) 和 (fail) 后面加一个字符
c
,分别对应 (u) 和 (fail_u) 。
如果 (tr[fail_p,c]) 不存在:那么我们继续找到 (tr[fail_{fail_p},c],c]) 。重复 (1) 的判断过程,一直跳 (fail_u) 指针指到根结点。
如果真的没有,就让 (fail_u) 指针指向根结点。
这样就完成了 (fail) 的构建,并得到一份比较暴力的构建方式,我们来看优化
字典树和字典图
先来看构建函数 build()
,该函数的目标有两个,一个是构建 fail 指针,一个是构建自动机。
void build(){
for(int i = 0; i < 26; ++i) if(tr[0][i]) q.push(tr[0][i]);
//如果存在这个边就入队
while(!q.empty()){
int u = q.front(); q.pop();
for(int i = 0; i < 26; ++i){
if(tr[u][i]) fail[tr[u][i]] = tr[fail[u]][i], q.push(tr[u][i]);
//按照上面所说的方式更新fail指针
else tr[u][i] = tr[fail[u]][i];//这是那个优化,后面会讲
}
}
}
原来的构建方法可以通过 (while) 循环寻找 (fail) 结点实现,循环太多次导致复杂度太高
上面提到的优化就是通过else
语句的代码修改了字典树的结构。
而它将不存在的字典树状态链连接到失配指针的对应状态。使得再次遍历这里的时候会继续向下跳转,起到一个通过继续开链来压缩路径的效果,这样就能节省很多时间。
这样AC 自动机修改字典树结构连出的边就会使字典树变为字典图
会不会影响原树?在原字典树中,每一个结点代表一个字符串 ,是某个模式串的前缀。而在修改字典树结构后,尽管增加了许多转移关系,但结点(状态)所代表的字符串是不变的。
多模式匹配
(这只是对于引例的query
函数,具体题目的函数写法可能不太相同)
int query(char *t){
int u = 0, res = 0;
for(int i = 1; t[i]; ++i){
u = tr[u][t[i] - 'a'];
for(int j = u; j && e[j] != -1; j = fail[j]){
res += e[j], e[j] = -1;
}
}
return res;
}
这里 (u) 作为字典树上当前匹配到的结点, (res) 即返回的答案。循环遍历匹配串, (u) 在字典树上跟踪当前字符。利用 (fail) 指针找出所有匹配的模式串,累加到答案中。然后清零。对 (cnt[j]) 取反的操作用来判断 (cnt[j]) 是否等于 (-1)。在上文中我们分析过,字典树的结构其实就是一个 (trans) 函数,而构建好这个函数后,在匹配字符串的过程中,我们会舍弃部分前缀达到最低限度的匹配。(fail) 指针则指向了更多的匹配状态。
例题
P3808 【模板】AC自动机(简单版)
P3796 【模板】AC自动机(加强版)
P5357 【模板】AC自动机(二次加强版)