AC自动机
AC自动机的作用:给定一段包含 m m m个字符的文章,再给定 n n n个单词,找出有多少个单词在文章里面出现过。
AC自动机实际上是先将KMP算法和Trie字典树结合,用多个模式串构建一棵字典树,然后在这棵字典树上构建失配指针,失配指针相当于KMP算法中的next[]
数组(匹配失败时的回退位置),最后将主串在Trie树上进行模式匹配。
AC自动机算法分为3步:
- 构建一棵字典树
- 构建AC自动机
- 进行模式匹配
这里主要讲解如何构建AC自动机和如何进行模式匹配。
构建AC自动机
在构建AC自动机时,其实最主要的就是在求出字典树中每个节点的失配指针。什么是失配指针呢?失配指针就是说当你在当前节点失配时,你可以跳回到这颗树中的某个位置,而如何找到这个位置就是由失配指针来告诉的。
如下图所示:
AC自动机的失配指针所指向的节点代表的字符串是当前节点所代表的字符串的最长后缀。举个栗子,节点5所代表的字符串是she,这个字符串的后缀有{e},{he},我们去字典树中寻找看看是否存在字符串{he},发现节点2所代表的字符串就是{he},因此找到了当前节点5所代表的字符串she的最长后缀{he},那么就从节点5指向节点2,这就是失配指针。特别地,对于根节点来说,它的失配指针就是自己,根节点所代表的字符是空字符""。对于根节点的下一层节点,由于这一层节点所代表的字符串只有一个字符,而一个字符是不存在后缀的,也可以理解为它的最长后缀是空字符,因此这一层节点的失配指针都指向根节点,如图中的节点1和节点3。
那么,我们是如何知道当前节点的失配指针应该指向哪个位置的呢?
结论:这个位置就是 当前节点的父节点的失配指针所指向位置的孩子节点
如何理解呢?举个栗子,比如当前节点为4,它所代表的字符串是sh,那么它的最长后缀是{h}。4号节点的父节点是3号节点,如何找到4号节点的失配指针所指向的位置呢?我们来看其父节点3号节点的失配指针所指向的位置是根节点,然后我们看根节点的孩子节点中是否存在{h}呢?我们发现确实是存在的,它就是1号节点。也就是说当前4号节点的父节点3的失配指针所指向位置是根节点,根节点的孩子节点中存在字符为{h}的孩子节点1。于是当前4号节点的失配指针所指向的位置就是1号节点。
再来举个栗子,比如当前节点是5,它所代表的字符串是{she},那么它的后缀是{he},{e}。5号节点的父节点是4号节点,我们来看4号节点的失配指针所指向的位置是节点1,而节点1中存在字符e的这个孩子节点2。于是当前5号节点的失配指针所指向的位置就是2号节点。
最后举个栗子,比如当前节点是6,它所代表的字符串是{hi},那么它的后缀是{i}。6号节点的父节点是1号节点,我们来看1号节点的失配指针所指向的位置是根节点,但是我们发现根节点中并不存在字符为i的这个孩子节点,从图中我们发现节点6的失配指针所指向的位置是根节点。这似乎与我们的结论矛盾了。
但是其实不然,我们当发现当前节点的父节点的失配指针所指向位置不存在这个孩子节点时,我们其实是会把它给虚构出来的。也就是说,我们最终都会把这个节点给弄出来。下面会有解释。
构建AC自动机实际上是添加失配指针的过程。由于失配指针都是向上走的,所以从根节点开始进行广度优先遍历就可以得到了。
下面先来看一下构建AC自动机的代码:
void build()
{
int hh=0,tt=-1;
for(int i=0;i<26;i++)
{
if(tr[0][i])
q[++tt]=tr[0][i];
}
while(hh<=tt)
{
int t=q[hh++];
for(int i=0;i<26;i++)
{
int p=tr[t][i];
if(p)
{
ne[p]=tr[ne[t]][i];
q[++tt]=p;
}
else
tr[t][i]=tr[ne[t]][i];
}
}
}
tr[][]
是构建好的字典树ne[]
是失配指针
由于根节点的失配指针指向自己,含义就是根节点代表空字符。那么我们就可以从根节点孩子节点这一层开始进行BFS,因此如果根节点0存在i这个孩子,那么我们就把这个孩子节点tr[0][i]
加入到队列q中。然后接下来就是开始进行BFS的过程了
如何理解里面的那一段for循环代码呢?
t节点是p节点的父节点,p节点是t节点的子节点。如果节点t存在孩子节点i的话,也就是如果p
为true,那么就处理好节点p的失配指针应该指向的位置,并把这个节点p加入到队列q中,这样才能接着下一层;反之,如果节点t不存在孩子节点i的话,也就是如果p
为false,那么我们也需要处理好节点p的失配指针应该指向的位置,由于并不存在节点p,因此它没有机会被加入到队列q中。
如下图解释:
比如四个字符串:abcd,abd,bcd,cd,建立如下图Trie树:
解释这个代码:
if(p)
{
ne[p]=tr[ne[t]][i];
q[++tt]=p;
}
比如设t是节点b,设p是t的孩子节点,从图中可以看出节点b是存在孩子节点c的,因此 p p p为true,那么我们就更新p的失配指针应该指向的位置。此时p节点所代表的字符串是bc,它的后缀是{c},根据上面的结论,容易找到p节点的失配指针所指向的位置应该是右侧的root下面的孩子节点c。如下图:
接下来再解释这段代码:
else
tr[t][i]=tr[ne[t]][i];
比如我们尝试匹配abcde,从图中可以知道abcd都已经成功匹配了。设t为节点d,设p为t的孩子节点。但是从图中我们可以知道t并不存在孩子节点p,也就是我们发现d并没有e这个节点,那么我们就跳到d的fail指针,也就是bcd上的d,还是没有e,那么继续,到cd上的d,还是没有,只能到根了,还是没有。。。那么。。。就没有了。我们处理的时候,就可以把abcd上的d的不存在的儿子,指向d的fail指针的这个儿子。如下图所示:
此刻t为节点d,
p
=
t
r
[
t
]
[
i
]
=
e
p=tr[t][i]=e
p=tr[t][i]=e,但是节点e并不存在,我们的做法就是把这个不存在的节点e,它的失配指针=节点d它的失配指针所指向的节点。如上图所示,节点d的失配指针所指向的节点最终是根节点root,然后我们发现节点e并不存在,但是我们把它虚构出来了,并且它的失配指针指向了根节点root。即不存在的节点e成为了节点d的失配指针所指向的最终节点的子节点。如上图最右边根节点root虚构出了一个节点e。对应到代码中,tr[ne[t]][i]
表示的就是根节点root,tr[t][i]
表示就是不存在的节点e,这样赋值的含义就是节点e的失配指针指向的是root。
而这也刚好解释我们上面结论中说到的,当发现当前节点的父节点的失配指针所指向位置不存在这个孩子节点时,我们其实是会把它给虚构出来的也就是说,我们最终都会把这个节点给弄出来。
以上就是构建AC自动机的过程
模式匹配
模式匹配是指从树根开始处理模式串的每个字符,沿着当前字符的fail指针,一直遍历到cnt[p]
为止,在遍历过程中累加这些节点的cnt[p]
,累加后就将该节点标记为
c
n
t
[
p
]
=
−
1
cnt[p]=-1
cnt[p]=−1,避免重复统计。
c
n
t
[
p
]
cnt[p]
cnt[p]大于或等于1的节点都是可以匹配的节点。
int query()
{
//res记录的是这n个单词有多少个出现在这篇文章中
int res = 0;
for (int i = 0, j = 0; str[i]; i ++ )
{
int t = str[i] - 'a';
//这里是获得根节点下面一层的孩子节点
//由于根节点是空字符 所以我们从它的孩子节点开始进行模式匹配
j = tr[j][t];
int p = j;
//如果p=0,则说明fail指针退无可退,退到了根节点,而根节点代表空字符
//则说明该字符串的最长后缀是空字符,也就是该字符串没有后缀 那么就可以退出这个模式匹配了,再重新开始新一轮模式匹配
//并且如果该节点已经被标记为-1,则说明已经遍历过了,那么也应该退出了,不然会重复计算,重复走这些节点,最终会形成死循环
//因此每走过一个节点就将cnt[p]标记为-1 当碰到cnt[p]=-1则说明已经遍历过了,结束这次模式匹配.再重新开始新一轮模式匹配
while (p&&cnt[p]!=-1)
{
res += cnt[p];
cnt[p] = -1;
p = ne[p]; //沿着fail指针走
}
}
}
拿开头的那张图为栗子:
在字符串{shers}中包含了几个单词?首先从字典树的根开始,匹配了第一个字符s,然后匹配了第二个字符h,接着匹配第三个字符c,匹配成功单词{she}。5号节点的fail指针指向2号节点,那么又匹配了单词{he}。继续匹配第四个字符r,5号节点的r子节点指向其fail指针的r子节点,因此访问8号节点,继续匹配第5个字符s,匹配成功单词{hers}。这篇文章匹配完毕,res=3,包含3个单词。