AC 自动机
学习 AC 自动机的第一要义:记住它不能帮你自动 AC !!!
- AC 自动机(以下简称 ACAM ),是一种多模式串匹配算法,它是由贝尔实验室的两位研究人员 Alfred V. Aho 和 Margaret J.Corasick 于1975年发明。
提到模式串匹配算法,你也许会想到大名鼎鼎的 KMP 算法。没错,它是最常用的单模式串匹配算法,但是如果要在一段文本串中一次性匹配 \(m\) 个模式串,KMP 就只能哈哈的跑 \(m\) 次,这个效率是我们无法接受的。为了解决“多模式串快速匹配”问题,人们发明了 ACAM。
Part 1 AC 自动机原理
在了解 ACAM 之前,你最好要了解以下前置知识:
- KMP 模式串匹配(其实个人觉得不了解 KMP 也能学会 ACAM )
- Trie (熟练掌握)
学习 ACAM 怎么能没有图呢,让我们从图入手,构建一个 ACAM !
假设你现在有 3 只模式串:bbaa,aa,abaa 。
第一步:构建 Trie 树
这一步很简单,就是把所有的模式串插入到一棵 Trie 里。构建出的 Trie 应该是这样的:( Linux 画图还是挺费劲的,大家凑合凑合)
插入的同时,可以在每个模式串的结尾打一个标记(红圈),表示这个模式串到结尾了。
Code
struct Trie{
int fail,num;
int ch[28];
}tr[maxn];
void Build(){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';
if(tr[u].ch[s]==0) tr[u].ch[s]=++cnt;//没有就新建一个结点
u=tr[u].ch[s];
}
tr[u].num++;//标记结尾结点
}
第二步:构建 fail 指针
这是 ACAM 的核心操作。
有些博客中说 fail 指针告诉我们如果当前结点失配了,该从哪里继续匹配,这固然是对的,但是不全面。
fail 指针的定义应该是:指向 可以与 以当前结点为结尾的 字符串的 某一个后缀 匹配的 最长的前缀的 末尾结点的 位置。
我知道这有点套娃,我们用图来理解会好一点,还是上面那个图:
(为了方便区分,这里给结点编号 1 到 9 。我们用 xy 表示一个结点,x 表示这个结点的字符,y 表示这个结点的编号)
譬如这样,a3 是串 bba 的一个后缀,我们发现 a5 是某个串的一个前缀,而且和 bba 的后缀相同,而且最长。于是把 a3 的 fail 指向 a5 。
再来看 a4 ,显然 a4 的 fail 指针好像也可以指向 a5 。但是以 a4 为结尾的串 bbaa 有一个后缀是 aa ,而 a5,a6 正好构成了一个与之匹配的更长的前缀,于是 a4 的 fail 指针应该指向 a6 。
这样看起来比较无厘头,因为我们没有按照顺序求解 fail 指针,实际上,它是通过一发由根出发的 bfs 求出来的。
首先,规定根结点的儿子们的 fail 为根(因为它除了自己不可能有与它匹配的前缀),把根结点的儿子们入队。
每次取出队头 \(u\) ,扫描队头结点的所有非空的儿子 \(v\) ,\(v\) 的 fail 指针是 \(u\) 的 fail 指针指向结点的 \(v\) 儿子。
-
Q:为什么?
A:显然,已经入队的结点的 fail 指针一定已经求好了,也就是我们找到了一个匹配的最长的前缀。现在 \(u\) 后面又来了一个字符 \(v\) ,那我们看看刚才匹配上的那个前缀存不存在一个结点 \(v'\) 与新来的 \(v\) 相同,如果有,我们就可以继续匹配这个前缀,就像例子中 3a,4a 的匹配那样。
-
Q:如果父节点 \(u\) 的 fail 指针没有与新来的 \(v\) 匹配的字符呢?
A:这就需要我们保证它有一个这样的结点 \(v\) ,在下面会提到如何操作。
现在 \(u\) 还有一些空的儿子,我们在下一次求解 fail 的时候如果连向了这些空儿子,就相当于直接连向了根,不能保证“最长前缀”的性质,这样不好。所以我们需要保证 \(u\) 的每个儿子都非空。但是我们显然不可以无中生有给他造出来一个儿子,此时应该向别的结点“借”儿子。
假设现在有一个结点 \(w\) 要把它的 fail 边连向 \(u\) 的某个空儿子,那么分析一波性质:
-
根据定义,以 \(u\) 为结尾的前缀一定匹配以 \(w\) 的父亲为结尾的某个后缀。
-
考虑 \(u\) 的 fail 指针指向的结点 \(x\) ,以 \(x\) 结尾的前缀一定匹配以 \(u\) 为结尾的某个后缀。
那么可以推出:以 \(x\) 为结尾的前缀匹配以 \(w\) 的父亲为结尾的某个后缀。
大呼卧槽?没关系,看看下面的图,这有点像 KMP 跳 next 数组。
假设以 \(u\) 为结尾的前缀,匹配了以 \(w\) 的父亲(记作 \(w'\) )为结尾的某个后缀(红色部分为匹配上的字符),而以 \(x\) 为结尾的前缀又匹配了以 \(u\) 为结尾的某个后缀(蓝色部分为匹配上的字符),显然三段字符的蓝色部分都应该相等,也就是以 \(x\) 为结尾的前缀匹配以 \(w\) 的父亲为结尾的某个后缀。
总的来说,意思就是如果 \(w\) 要连接 fail 边的 \(v\) 在 \(u\) 的儿子中没有,应该到 \(x\) 中找 \(v\) 并连接 fail 边,因为以 \(x\) 为结尾的前缀匹配以 \(w'\) 为结尾的后缀。
但是写代码的时候,我们不可能每次都往上跳啊跳,直到找到一个有 \(v\) 儿子的结点吧,这样不仅麻烦,时间上也花费不起。所以为了方便,我们直接把 \(x\) 的 \(v\) 儿子 “借给” \(u\) ,也就是把 \(u\) 的 \(v\) 儿子连向 \(x\) 的 \(v\) 儿子。
-
Q:那如果 \(x\) 也没有 \(v\) 儿子呢?
A:
你怎么这么多屁话?我们处理 fail 基于 bfs,也就是说,在求 \(u\) 之前,一定已经求解完了 \(x\) 的 fail 指针。此时 \(x\) 的儿子该借的都借完了,不可能有空的。不过特别的,如果真的找不到结点借的话,就说明满足条件的结点不存在,我们也就不用考虑满足“最长”的性质了,直接把这个儿子连向根即可。
现在我们按照上面的方法,把上面图中的 fail 指针画出来吧:
Code
void make_fail(){
std::queue<int>q;
for(int i=0;i<26;++i){
if(tr[0].ch[i]!=0){
tr[tr[0].ch[i]].fail=0;
q.push(tr[0].ch[i]);
}
}//显然根节点的所有儿子的fail指针应该是0,把这些儿子入队
while(q.size()){
int u=q.front();//取出队头
q.pop();
for(int i=0;i<26;++i){
int v=tr[u].ch[i];//枚举它的每个儿子
if(v){//如果这个儿子非空
tr[v].fail=tr[tr[u].fail].ch[i];
//这个儿子的fail指针应该指向它父亲的fail指针指向的节点的这个儿子
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
//去fail指针指向的结点借儿子
}
}
}
第三步:匹配文本串
Mission:给你一个文本串,求出模式串中有几个出现在这个文本串中。
假设文本串是 bbaabaa 。
先拿出来刚才构建好的 Trie 图(由于我们有“借儿子”这种操作,它现在已经不是一棵树,而是一个 DAG 了话说自动机是不是都相当于构建了一个 DAG,雾)。
从根结点出发,开始匹配,每次在 Trie 图尝试往匹配文本串的方向跳,假设跳到了 \(u\) 。
从 \(u\) 出发,不停的跳 fail 边,直到跳到根,统计这期间跳到的模式串有哪些。
哈?为什么要跳 fail 边?因为以 fail 指针指向结点 \(w\) 为结尾的前缀是可以匹配以 \(u\) 为结尾的某个后缀的,而以 \(u\) 为结尾的后缀已经在文本串中出现了,也就相当于以 \(w\) 为结尾的前缀也在文本串中出现了,而这里面可能包含了一些模式串,如果不跳就会漏掉哦!
我们先跳前 4 个字符吧,蓝色是我们跳文本串的路径,而在跳到第四个字符的时候,我们沿着 fail 边跳的时候(红色),找到了一个模式串 aa 。
接着匹配,发现 4a 好像没有儿子了,那怎么办,回根重新找?显然不对,如果回到根的话,你会发现 abaa 没被计入答案。
我们希望可以继续找到一个 b 字符以继续匹配。记得我们之前“借”的儿子吗?这时候他们又派上用场了,其实这里 4a 是有字符为 b 的儿子的,只是我们没画出来。在上面“借”儿子的过程中,6a 从 5a 借到了 7b 这个儿子,然后 4a 找到 6a 借,也借到了 7b 这个儿子,所以我们下一次应该去 7b 继续匹配。
由于上面我们已经把“借儿子”的过程处理好了,所以这里我们直接访问 4a 的字符为 b 的儿子就可以直接走到 7b 继续匹配了(没错就是啥也不干,躺平就完了)。
一直跳到文本串的结尾,匹配上了 9a ,于是 3 个模式串都出现在了文本串中,答案为 3 ,大功告成!如果细心的话你会发现,在 9a 跳 fail 边的时候又找到了一次模式串 aa 哦!(即为图中橙色边)
既然我们只想知道文本串是不是出现过,就可以在跳 fail 边的时候打个标记,表示这个结点已经访问过了,这时跳出循环,节约了时间。
这种做法是基于 fail 边构成了一棵树的性质,我们称之为 fail 树。
-
Q:为什么 fail 是一棵树?
A:每个结点有且仅有一条 fail 指针,又因为 Trie 树是联通的,所以这个东西满足“联通”与“有 n-1 条边”这两个性质,所以是树。
如果我们访问了树上的一条到根的链,那么我们就标记了这条链。下一次从别的结点开始跳的时候,可能会跳到这条链上,此时再要跳到根,实际上与之前跳过的路径是一样的。如果这一段重合路径中出现了模式串,那么在之前跳的时候一定已经被统计上了,所以这个做法是不会漏统计的。
Code
int AC_automaton(){
int len=std::strlen(ch);
int u=0,ans=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';//取出文本串的这一位
u=tr[u].ch[s];//u跳到当前节点的这个儿子
int v=u;
while(v && tr[v].num!=-1){
//不停跳fail指针直到跳到根,统计这一位匹配多少模式串
ans+=tr[v].num;
tr[v].num=-1;//标记已经访问过了
v=tr[v].fail;
}
}
return ans;//返回找到的模式串数量
}
时空复杂度
时间复杂度 \(O(N+\sum |s_i|)\) 其中 \(N\) 为文本串长度,\(\sum s_i\) 为模式串长度之和。
证明显然是我们扫描了文本串,在 fail 树上每个结点只会访问一次,实际上因为两个模式串存在公共前缀的原因,这个复杂度不可能跑满。
空间复杂度 \(O(N+\sum |s_i|)\) 这是记录下文本串和构建 Trie 的复杂度。(或许你可以一边读文本串一边跑 ACAM ,这样就是 \(O(\sum |s_i|)\) 的。。。
Part 2 总代码
其实上面给出的代码已经挺全面了,为了方便一些键盘上只有 Ctrl C V 的小伙伴,这里贴出总代码吧。
下面给出的参考代码以 洛谷P3808【模版】AC 自动机 为实现目标。
#include<bits/stdtr1c++.h>
namespace zhang_tao{
#define inf 0x7f7f7f7f
template <typename _T>
inline _T const& read(_T &x){
x=0;int fh=1;
char ch=getchar();
while(!isdigit(ch)){
if(ch=='-')
fh=-1;
ch=getchar();
}
while(isdigit(ch)){
x=x*10+ch-'0';
ch=getchar();
}
return x*=fh;
}
void write(int x){
if(x<0) putchar('-'),x=-x;
if(x>9) write(x/10);
putchar(x%10+48);
}
inline int _gcd(const int x,const int y){
return y?_gcd(y,x%y):x;
}
inline void _swap(int &x,int &y){
x^=y^=x^=y;
}
#undef inf
} // namespace zhang_tao
using namespace zhang_tao;
const int maxn=1000005;
int n,cnt;
char ch[maxn];
struct Trie{
int fail,num;
int ch[28];
}tr[maxn];
void Build(){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';
if(tr[u].ch[s]==0) tr[u].ch[s]=++cnt;
u=tr[u].ch[s];
}
tr[u].num++;
}
void make_fail(){
std::queue<int>q;
for(int i=0;i<26;++i){
if(tr[0].ch[i]!=0){
tr[tr[0].ch[i]].fail=0;
q.push(tr[0].ch[i]);
}
}//显然根节点的所有儿子的fail指针应该是0,把这些儿子入队
while(q.size()){
int u=q.front();//取出队头
q.pop();
for(int i=0;i<26;++i){
int v=tr[u].ch[i];//枚举它的每个儿子
if(v){//如果这个儿子不是0
tr[v].fail=tr[tr[u].fail].ch[i];
//这个儿子的fail指针应该指向它父亲的fail指针指向的节点的这个儿子
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
//这个节点直接失配了,去u的fail指针继续尝试匹配
}
}
}
int AC_automaton(){
int len=std::strlen(ch);
int u=0,ans=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';//取出文本串的这一位
u=tr[u].ch[s];//u跳到当前节点的这个儿子
int v=u;
while(v && tr[v].num!=-1){
//不停跳fail指针直到跳到0,统计这一位匹配多少模式串
ans+=tr[v].num;
tr[v].num=-1;
v=tr[v].fail;
}
}
return ans;
}
signed main(){
read(n);
for(int i=1;i<=n;++i){
scanf("%s",ch);
Build();
}
tr[0].fail=0;
make_fail();
scanf("%s",ch);
write(AC_automaton()),putchar('\n');
return 0;
}
结语
关于 ACAM 的基础知识的分享就到这里了,如果您觉得不明白的话,可以参考上面的图文,进行手动画图推导,亲测这样十分有助于理解。
如果您觉得我讲的不错的话,可以点个赞吗?/kel
如果您有疑问的话,欢迎评论或者加笔者邮箱(在侧边栏里)。
PS:如果你只会 ACAM 模版的话,那你也就只能做 ACAM 模版题了。
关于 ACAM 的一些做题技巧,我会再写一篇博客,分享给大家。