AC自动机
感谢博主:https://blog.csdn.net/bestsort/article/details/82947639
感谢博主 : https://fanfansann.blog.csdn.net/article/details/106160470
简介
要学AC自动机需要自备两个前置技能:KMP和trie树(其实个人感觉不会kmp也行,失配指针的概念并不难)
其中,KMP是用于一对一的字符串匹配,而trie虽然能用于多模式匹配,但是每次匹配失败都需要进行回溯,如果模式串很长的话会很浪费时间,所以AC自动机应运而生,如同Manacher一样,AC自动机利用某些操作阻止了模式串匹配阶段的回溯,将时间复杂度优化到了O ( n ) O(n)为文本串长度
建立AC自动机
建立一个AC自动机通常需要两个步骤:
- 基础的TRIE结构:将所有的模式串构成一棵Trie。
- KMP的思想:对Trie树上所有的结点构造失配指针。
然后就可以利用它进行多模式匹配了。
构造失配( fail )指针
在讲构造以前,先来对比一下这里的 fail 指针与KMP中的next指针:
共同点-两者同样是在失配的时候用于跳转的指针。
不同点-KMP要求的是最长相同真前后缀,而AC自动机只需要相同后缀即可。
因为KMP只对一个模式串做匹配,而AC自动机要对多个模式串做匹配。
有可能 fail 指针指向的结点对应着另一个模式串,两者前缀不同。
也就是说,AC自动机在对匹配串做逐位匹配时,同一位上可能匹配多个模式串。
因此 fail指针会在字典树上的结点来回穿梭,而不像KMP在线性结构上跳转。
下面介绍构建 fail指针的基础思想:(强调!基础思想!基础!)
构建 fail指针,可以参考KMP中构造next数组的思想。
我们利用部分已经求出 fail 指针的结点推导出当前结点的 fail 指针。具体我们用BFS实现:
考虑字典树中当前的节点u,u的父节点是p,p通过字符c的边指向u。
假设深度小于u的所有节点的 fail指针都已求得。那么p的 fail 指针显然也已求得。
我们跳转到p的 fail 指针指向的结点 fail[p] ;
如果结点 fail[p] 通过字母 c 连接到的子结点 w 存在:
则让u的fail指针指向这个结点 w( fail[u]=w )。
相当于在 p 和 fail[p] 后面加一个字符 c ,就构成了 fail[u] 。
如果 fail[p] 通过字母 c 连接到的子结点 w 不存在:
那么我们继续找到 fail[fail[p]] 指针指向的结点,重复上述判断过程,一直跳 fail 指针直到根节点。
如果真的没有,就令 fail[u]= 根节点。
如此即完成了 fail 指针的构建。
图解构造
- 黄色结点表示当前的结点u,绿色结点表示已经BFS遍历完毕的结点,红/橙色的边表示 fail指针。
- 2号节点的 fail 指针画错了, fail[2]=0
- 我们重点分析结点6的 fail指针构建:
- 找到6的父节点5,5的 fail指针指向10,然而10结点没有字母’s’连出的边;
- 所以跳到10的 fail 指针指向的结点0,发现0结点有字母’s’连出的边,指向7结点;
- 所以 fail[6]=7
另外,在构建 fail指针的同时,我们也对TRIE中模式串的结尾构建 fail指针。这样在匹配到结尾后能自动跳转到下一个匹配项。具体见代码实现。
下面开始用图学习ac自动机吧(个人比较喜欢放图,能用一张图解决的绝不叨叨)
首先给定模式串"ash",“shex”,“bcd”,“sha”,然后我们根据模式串建立如下trie树:
然后我们再了解下一步:
ac自动机,就是在tire树的基础上,增加一个fail指针,如果当前点匹配失败,则将指针转移到fail指针指向的地方,这样就不用回溯,而可以路匹配下去了.(当前模式串后缀和fail指针指向的模式串部分前缀相同,如abce和bcd,我们找到c发现下一个要找的不是e,就跳到bcd中的c处,看看此处的下一个字符(d)是不是应该找的那一个)
一般,fail指针的构建都是用bfs实现的
首先每个模式串的首字母肯定是指向根节点的(一个字母你瞎指什么指,指了也是头字母有什么用嘛)
现在第一层bfs遍历完了,开始第二层
(根节点为第0层)第二层a的子节点为s,但是我们还是要从a-z遍历,如果不存在这个子节点我们就让他指向根节点(如下图红色的a)
当我们遍历到s的时候,由于存在s这个节点,我们就让他的fail指针指向他父亲节点(a)的fail指针指向的那个节点(根)的具有相同字母的子节点(第一层的s),也就是这样
按照相同规律构建第二层后,到了第三层的h点,还是按照上面的规则,我们找到h的父亲节点(s)fail指针指向的那个位置(第一层的s)然后指向它所指向的相同字母根->s->h的这个链的h节点,如下图
完全构造好后的树
然后匹配就很简单了,这里以ashe为例
我们先用ash匹配,到h了发现:诶这里ash是一个完整的模式串,好的ans++,然后找下一个e,可是ash后面没字母了啊,我们就跳到hfail指针指向的那个h继续找,还是没有?再跳,结果当前的h指向的是根节点,又从根节点找,然而还是没有找到e,程序END
过程如下图
模板
#include <queue>
#include <cstdlib>
#include <cmath>
#include <cstdio>
#include <string>
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
typedef long long ll;
const int maxn = 2*1e6+9;
int trie[maxn][26]; //字典树
int cntword[maxn]; //记录该单词出现次数
int fail[maxn]; //失败时的回溯指针
int cnt = 0;
void insertWords(string s){
int root = 0;
for(int i=0;i<s.size();i++){
int next = s[i] - 'a';
if(!trie[root][next])
trie[root][next] = ++cnt;
root = trie[root][next];
}
cntword[root]++; //当前节点单词数+1
}
void getFail(){
queue <int>q;
for(int i=0;i<26;i++){ //将第二层所有出现了的字母扔进队列
if(trie[0][i]){
fail[trie[0][i]] = 0;
q.push(trie[0][i]);
}
}
//fail[now] ->当前节点now的失败指针指向的地方
tire[now][i] -> 下一个字母为i+'a'的节点的下标为tire[now][i]
while(!q.empty()){
int now = q.front();
q.pop();
for(int i=0;i<26;i++){ //查询26个字母
if(trie[now][i]){
//如果有这个子节点为字母i+'a',则
//让这个节点的失败指针指向(((他父亲节点)的失败指针所指向的那个节点)的下一个节点)
//有点绕,为了方便理解特意加了括号
fail[trie[now][i]] = trie[fail[now]][i];
q.push(trie[now][i]);
}
else//否则就让当前节点的这个子节点
//指向当前节点fail指针的这个子节点
trie[now][i] = trie[fail[now]][i];
}
}
}
int query(string s){
int now = 0,ans = 0;
for(int i=0;i<s.size();i++){ //遍历文本串
now = trie[now][s[i]-'a']; //从s[i]点开始寻找
for(int j=now;j && cntword[j]!=-1;j=fail[j]){
//一直向下寻找,直到匹配失败(失败指针指向根或者当前节点已找过).
ans += cntword[j];
cntword[j] = -1; //将遍历国后的节点标记,防止重复计算
}
}
return ans;
}
int main() {
int n;
string s;
cin >> n;
for(int i=0;i<n;i++){
cin >> s ;
insertWords(s);
}
fail[0] = 0;
getFail();
cin >> s ;
cout << query(s) << endl;
return 0;
}