浅谈AC自动机
说在前面
当我在对着 \(OIwiki\) 学习AC自动机时,我是边骂边学的,毕竟一开始那个定义与后文的代码不匹配时,我硬着头皮肝了几个小时,还以为我理解不够深入......结果发现这是优化后的一个新方法,后面才交代......所以说,这篇博客也用于解释 \(WiKi\) 上的一些解释地不是特别好的问题。
什么是AC自动机
AC自动机不是让你自动AC的......那种恐怕是自动AC机。AC自动机是一种处理字符串匹配问题的一种数据结构,采用字典树的形式,运用 KMP 的思想来匹配字符串。
AC自动机与字典树的区别
AC自动机也是基于字典树之上的,不过AC自动机会多出一个 \(fail边\) 的概念,其意义与 KMP 的 \(next\) 失配数组相似,都是用于失配时跳边。该 \(fail边\) 建立在字典树节点与节点之间,由此会多出一些前向边,横叉边的概念,在此不用理会。
详谈 \(fail边\)
当你建立出一个字典树时,就相当于建立出了一个大的各个模式串的前缀的集合题,\(fail边\)的跳跃思想和KMP的 \(next\) 数组类似,但是KMP的 \(next\) 数组求的是当前前缀所匹配的最大后缀的位置,而 \(fail边\)不同,它保存的是所有模式串前缀下的最大公共后缀的所在节点的位置。而AC自动机的过程其实就是构建 \(fail边\) ,至于查询?那是后面的事了。
构造AC自动机
一个小小的性质
首先,你应当明白一个性质,如果一个节点指向的 \(fail边\) 存在,那么它的儿子节点的 fail 边一定是这个节点通过 \(fail边\) 所对的节点 x 的儿子 nex[x][c] (假设这个点存在),仔细思考便会发现这个性质十分正确。那么,我们考虑依据这个性质建立 \(fail边\) 集合。
构造 \(fail边\)
我们用一个队列来存储那些已经求好 \(fail边\) 的节点,每次我们弹出一个节点,然后构造这个节点的所有儿子的 \(fail边\)。
具体代码如下:
queue<int> q;
int pos=0;
for(int i=0;i<26;i++)
if(nex[pos][i])
q.push(nex[pos][i]);//将根节点的几个子节点接入队列,他们的fail边就是根节点
一开始初始化。
while(!q.empty()) {
pos=q.front();
q.pop();
for(int i=0;i<26;i++) {
if(nex[pos][i]) //对于失配指针的建立,是当且仅当建立在有这么一个节点上的
fail[nex[pos][i]] = nex[fail[pos]][i],q.push(nex[pos][i]);
//对于当前失配时的字典树节点,取出一个区间的后缀,然后连接到那么一个节点使那个节点前面的节点的前缀和这个区间的后缀相同且最长,其fail指针已定,故加入队列
else
nex[pos][i] = nex[fail[pos]][i];
//反之则是重构字典树,让这个莫须有的点直接连接到另一个节点(或有或无)
//属于一种比较不错的优化,查询时节约了部分时间
}
}
然后对于每个儿子节点,如果其存在,那么就是按照上面的思想建立 \(fail边\) (不理会该 \(fail边\) 所对的节点是否存在,看到后面就明白了。),如果其不存在,那么我们就直接将这个不存在节点指向另一个节点,这个节点就是其父亲节点所对的点的儿子,不管其存在是否,重点在于这个方法能在查询时优化一下下。
这相当于我们重构了一遍字典树,把无用的节点直接指向有用的节点,同时也很好地对有用的节点构造了相应的 \(fail边\) 。
查询操作
提前声明
接下来放出的代码是用于查询一个文本串内有多少个子串在模式串内出现过,这也是大部分题的问法(也有可能现在变式出得多一点)。
查询
对于一个文本串,我们要求其有多少个子串在模式串内出现过,说白了对于当前位置的字符,我们遍历其所有 \(fail边\) 所指的节点,然后统计这个节点是否是一个单词的结尾便可。其正确性是不言而喻的,我们遍历到的所有节点的所在链的部分后缀肯定与文本串当前位置的前缀相同(不明白请回头再来。)
所以,我们可以通过跳 \(fail边\) 的方法来快速匹配每一个在字典树上的链,我们能到达的链当且仅当是文本串上的字串。
code:
int search(char s[],int len) {//此函数为计算输入的字符串里面有多少个字串在字典树上出现
int pos=0,res=0;
for(int i=0;i<len;i++) {
int c=s[i]-'a';
pos=nex[pos][c];//节点的转移
for(int j=pos;j/*不能回到起始节点,反则会卡死*/&&excist[j]!=-1/*判断遍历情况*/;j=fail[j]) {
res+=excist[j];
excist[j]=-1;
}
}
return res;
}
[luoguP3808模板][https://www.luogu.com.cn/problem/P3808]
完整代码
#include<iostream>
#include<cstdio>
#include<queue>
#include<cstring>
#define Maxn 5200000
using namespace std;
int n,ans;
char s[Maxn];
struct Trie{
int nex[Maxn][26],cnt;
int excist[Maxn];
int fail[Maxn];
void inserd(char *s,int len) {
int pos=0;
for(int i=0;i<len;i++) {
int c=s[i]-'a';
if(!nex[pos][c]) nex[pos][c]=++cnt;
pos=nex[pos][c];
}
excist[pos]++;
}
void build() {
queue<int> q;
int pos=0;
for(int i=0;i<26;i++)
if(nex[pos][i])
q.push(nex[pos][i]);//将根节点的几个子节点接入队列,他们的fail边就是根节点
while(!q.empty()) {
pos=q.front();
q.pop();
for(int i=0;i<26;i++) {
if(nex[pos][i]) //对于失配指针的建立,是当且仅当建立在有这么一个节点上的
fail[nex[pos][i]] = nex[fail[pos]][i],q.push(nex[pos][i]);
//对于当前失配时的字典树节点,取出一个区间的后缀,然后连接到那么一个节点使那个节点前面的节点的前缀和这个区间的后缀相同且最长,其fail指针已定,故加入队列
else
nex[pos][i] = nex[fail[pos]][i];
//反之则是重构字典树,让这个莫须有的点直接连接到另一个节点(或有或无)
//属于一种比较不错的优化,查询时节约了部分时间
}
}
}
int search(char s[],int len) {//此函数为计算输入的字符串里面有多少个字串在字典树上出现
int pos=0,res=0;
for(int i=0;i<len;i++) {
int c=s[i]-'a';
pos=nex[pos][c];//节点的转移
for(int j=pos;j/*不能回到起始节点,反则会卡死*/&&excist[j]!=-1/*判断遍历情况*/;j=fail[j]) {
res+=excist[j];
excist[j]=-1;
}
}
return res;
}
}trie;
int main(){
scanf("%d",&n);
for(int i=1;i<=n;i++){
scanf("%s",s);
int len=strlen(s);
trie.inserd(s,len);
}
trie.build();
scanf("%s",s);
int len=strlen(s);
ans=trie.search(s,len);
printf("%d",ans);
return 0;
}