AC自动机学习笔记
变量释义
int trie[200005][26]; //AC自动机的字典树
int fail[200005]; //失配指针
int ans[200005]; //每个串出现的次数
int pos[200005]; //每个串的结尾的序号
int ed[200005]; //该节点是否为结尾,以该点结尾的模式串的数量
trie数组:仍然是字典树中的那个节点,但是需要注意节点另外一个非常重要的性质,代表一个从根到该点的前缀子串。
fail数组:失配指针,指向的是当前点代表的前缀子串在所有模式串中的最长后缀,创建这个数组的原因也很明显,当这个模式串匹配失败的时候,我们总是想要转移到一个已经匹配到的字符最多的模式串上去。
显然fail指针指向的点的深度必然小于当前点的深度,因此对整棵字典树构造fail指针要使用bfs
核心流程
AC自动机分三个步骤:建字典树,构造fail指针,跑文本串。中间可能还会需要建fail树。
建字典树:略。
代码:
int trie_insert(char *s){ //建立字典树
int len=strlen(s),now=0;
for(int i=0;i<len;i++){
if(!trie[now][s[i]-'a']){
trie[now][s[i]-'a']=++cnt;
}
now=trie[now][s[i]-'a'];
}
ed[now]++;
return now;
}
构造fail指针:每次对某个字母连失配指针的时候,看父亲的失配指针是否有对应这个字母的转移,如果没有,则再往上找失配指针是否有对应这个字母的转移,直到找到或者到根节点。
但是有个问题:暴力跳fail指针可能会导致复杂度退化为
n
2
n^2
n2。
解决方式:构造fail指针的同时记录下个能跳到的fail指针。即判断当前是否有字符i的转移,有则直接对fail赋值为父亲fail的转移,没有则将字符i的转移记录为父亲fail的转移,(有点并查集的味道)。
可以发现,失配指针构成了一棵树,且树上点的父亲一定是这个点对应的最长后缀。这个点的所有祖先都是这个点对应的最长后缀,且随着深度的减少,后缀的长度也下降。
所以,有个神奇的思想,将所有的模式串翻转后构造字典树,就是原来的失配指针树???不对。(事实上,构造出这样的例子也极少)
代码:
void get_fail() { //获取fail标签
for (int i=0;i<26;i++)
if (trie[0][i]) q.push(trie[0][i]);
while (!q.empty()) {
int x=q.front(); q.pop();
for (int i=0;i<26;i++)
if (trie[x][i]) { //fail树的路径压缩
fail[trie[x][i]]=trie[fail[x]][i];
q.push(trie[x][i]);
}
else trie[x][i]=trie[fail[x]][i];
}
}
像上面这么写最大的好处就是在匹配的过程中完全用不到fail指针,减少了思维量和出错的概率。
跑文本串:就跟字典树一样跑就行了。。。因为之前的路径压缩的操作,不用再判断是否失配了。
代码:
void query(char *s){
int len=strlen(s),now=0;
for(int i=0;i<len;i++){
now=trie[now][s[i]-'a'];
}
}
时间复杂度: O ( n ∗ T ) O(n*T) O(n∗T), T T T为字符集的个数,一般为常数
典型例题
1.求共有多少个不同的模式串出现
因为一个点的fail树上的祖先全部为当前点对应的前缀子串的后缀,所以跑匹配的时候,跑到一个点,就要把这个点fail树上的所有祖先的出现次数加上ed的值,为了防止暴力跳fail指针导致的复杂度退化,又因为只需求是否出现,搜索到一个点后,就要把这个点打上标记,跳fail的时候遇到有标记的点就停止。
代码:
void query(char *s){
int len=strlen(s),now=0;
for(int i=0;i<len;i++){
now=trie[now][s[i]-'a'];
for(int j=now;j&&ed[j]!=-1;j=fail[j]){
ans[j]+=ed[j];
ed[j]=-1;
}
}
}
2.求模式串的出现次数
这时,打标记就没用了,考虑每次跳fail指针的时候都是从子孙跳到祖先,不妨统计每个祖先共被自己的子孙跳到过多少次,这时就需要建fail树了,统计子树的ans和乘上该点的ed值,即为祖先出现的次数,相应的,query函数中只需将ans[now]加ed[now]即可。
代码:
void query(char *s){ //查询模式串在文本串中出现的次数
int len=strlen(s),now=0;
for(int i=0;i<len;i++){
now=trie[now][s[i]-'a'];
ans[now]+=ed[now];
}
}
建树:
for(int i=1;i<=cnt;i++){ //跳fail指针的优化,建立fail树,每个子串出现的次数转化为子树中的ans和。
v[fail[i]].push_back(i);
}
值得一提的是,很多字符串相关的算法中的指针树都是非常实用的,都需要深刻地理解。
比如:kmp中的前缀函数next树,ac自动机中的fail树,后缀自动机中的后缀链接link树。