AC 自动机
前言
\(\text{AC}\) 自动机(\(\text{Aho-Corasick Automaton}\))在一定程度上可以看作 \(\text{KMP}\) 的升级版,\(\text{KMP}\) 是单模匹配算法,在一个文本串中查找一个模式串;\(\text{AC}\) 自动机是多模匹配算法,在一个文本串中同时查找多个不同的模式串。
多模匹配问题:给定一个长度为 \(n\) 的文本 \(S\),以及 \(k\) 个平均长度为 \(m\) 的模式串 \(P_1\),\(P_2\),···,\(P_k\),要求搜索这些模式串出现的位置。
在解析 \(\text{AC}\) 自动机之前,先思考如何用暴力法解决多模匹配问题。
用暴力法求解多模匹配问题,就是对每个 \(P_1\),\(P_2\),···,\(P_k\) 分别在 \(S\) 上做一次匹配。可以用 \(\text{KMP}\) 对每个 \(P\) 分别做一次,总复杂度为 \(O((n+m)k)\)。
这种方法的效率很低,问题出在需要按部就班地一 \(S\) 的每个字符为开头检查是否匹配每个 \(P\),指向 \(S\) 的 \(i\) 指针发生了多次回溯。
在文本串 \(S=\)abcde 中匹配了 \(3\) 个模式串 \(P_1=\) abcd,\(P_2=\)b,\(P_3=\)cd。若一个个地匹配,第 \(1\) 次匹配了 \(S_0\)~\(S_3\) 的 abcd,\(i\) 移动到了 \(3\) 位置;第 \(2\) 次从 \(S_1\) 开始匹配,\(i\) 需要回溯到 \(1\) 位置,匹配了 \(P_2\) 等等。
暴力法是低效的,对于这种复杂的多模匹配问题,需要用复杂的算法 \(\text{AC}\) 自动机来解决。
正文
用一句话概括 \(\text{AC}\) 自动机的思想:
有没有优化的方法,是 \(i\) 不用回溯?若能在 \(i\) 移动过程中同时匹配多个 \(P\),就能避免回溯 \(i\)。例如 \(i=1\) 时,同时匹配了 \(P_1\) 和 \(P_2\)。
考虑一下两个问题:
- 如何同时处理多个串?字典树能高效的处理多个字符串,可以应用在这里。先把 \(k\) 个 \(P\) 建成一个普通的字典树,然后把 \(S\) 看作很多单词的组合体,在字典树上找这些单词。
在字典树上匹配多个 \(P\) 的最简单方法是:从 \(S_1\) 开始,到字典树上找以 \(S_0\) 开头的模式串,无论是否找到以 \(S_1\) 开头的模式串,下一步再从 \(S_2\) 开始,找以 \(S_2\) 开头的模式串;下一步 \(S_3\) 开始,一直到 \(S_n\)。共找 \(n\) 次,每次平均匹配 \(m\) 个字符,总复杂度为 \(O(nm)\) ,和简单地在 \(S\) 上做 \(k\) 次 \(\text{KMP}\) 差不多。 - 如何避免回溯 \(S\) 的 \(i\) 指针?回忆 \(\text{KMP}\) 加快匹配的思路,\(\text{KMP}\) 的根本思想是利用 \(\text{Next}\) 数组避免回溯 \(S\) 的 \(i\) 指针。那么在字典树上,是否能有一个类似 \(\text{Next}\) 数组的数据,避免回溯 \(i\) 指针?
以上两点就是 \(\text{AC}\) 自动机算法的思路。
| 单模匹配 | 暴力法:\(S\) 的 \(i\) 指针需要回溯 | \(\text{KMP}\):用基于模式串的 \(\text{Next}\) 数组避免 \(i\) 回溯 |
|---|---|---|
| 多模匹配 | 暴力法:\(S\) 的 \(i\) 指针需要回溯 | \(\text{AC}\) 自动机:用基于字典树的 \(\text{Fail}\) 指针避免 \(i\) 回溯 |
\(\text{AC}\) 自动机的构造
首先把模式串 abcd b cd 建成一棵字典树。
模拟 \(S=\)abcde 在字典树上的匹配过程:
-
从字符
a出发,匹配到 \(2\) 号点b时,查询到它的 \(\text{Fail}\) 指针指向 \(5\) 号点的b,且 \(5\) 号点是一个终点,则找到了一个匹配 \(P_2=\)b是ab的一个后缀。 -
继续匹配到 \(3\) 号点
c,它的 \(\text{Fail}\) 指针指向 \(6\) 号点c,但 \(6\) 号点不是一个终点。c也是abc的一个后缀。 -
匹配到 \(4\) 号点
d,\(4\) 号点自己是终点,找到了一个匹配 \(P_1=\)abcd。另外, \(4\) 号点的 \(\text{Fail}\) 指针指向 \(7\) 号点,且 \(7\) 号点是一个终点,找到了一个匹配 \(P_3=\)cd。cd是abcd的一个后缀。
以上匹配过程,只做了 \(5\) 次比较操作。
事实上,每一个节点都有一个 \(\text{Fail}\) 指针,若上层没有同字符是,\(\text{Fail}\) 指向根节点。
若上层的同字符节点不在后缀中,则无法完成同时匹配多个模式串的功能。
\(\text{Fail}\) 指针的计算
如何编码求得 \(\text{Fail}\) 指针?每个节点都有 \(\text{Fail}\) 指针,而指针指向上层的某个节点,这显然是一个 \(\text{BFS}\) 过程:求得一层所有节点的 \(\text{Fail}\) 指针后,在继续求下一层所有节点的 \(\text{Fail}\) 指针。
一个节点 \(x\) 的 \(\text{Fail}\) 指针指向的节点是父节点的 \(\text{Fail}\) 指针所指向的节点的与 \(x\) 节点同字符的子节点。通过这样的赋值,\(x\) 得到了这个同字符节点的后缀关系。
然而有时候父节点的 \(\text{Fail}\) 指针所指向的节点并没有一个与 \(x\) 节点同字符的子节点。这里用到一个关键技巧:在处理父节点的 \(\text{Fail}\) 指针指向的节点时,虚拟一个子节点直接等同于与该点同层的与 \(x\) 节点的同字符点,此时,\(x\) 点的父节点的 \(\text{Fail}\) 指针指向的节点就有了一个与 \(x\) 同字符的子节点,而这个子节点就是与该子节点的父节点同层的与 \(x\) 节点的同字符点。最终的结果是 \(x\) 节点的 \(\text{Fail}\) 指针指向与 \(x\) 点的父节点 \(Fail\) 指针指向的节点同层的与 \(x\) 同字符的节点,实现了跨层计算。
\(\text{AC}\) 自动机的复杂度
\(k\) 个模式串,平均长度为 \(m\);文本串长度为 \(n\)。建立 \(Trie\) 树复杂度为 \(O(km)\);求 \(km\) 个节点的 \(\text{Fail}\) 指针,复杂度为 \(O(km)\);模式匹配复杂度为 \(O(nm)\),乘以 \(m\) 的原因是 \(S\) 的每个字符找 \(Fail\) 可能需要检查 \(m\) 个模式串,如 \(4\) 个模式串 abcd bcd cd d 匹配到 d 时,\(\text{Fail}\) 指针需要操作 \(4\) 次。总时间复杂度为 \(O(km+km+nm)=O(km+nm)\),看起来似乎仍然很高。不过模式匹配的效率与模式串的特征有关,一般情况下 \(O(nm)\) 接近于 \(O(n)\)。只有在恶劣的情况下,即 \(k\) 个模式串是上述例子这种奇怪的后缀包含关系,此时复杂度才退化到了 \(O(nm)\)。
实现
\(\text{AC}\) 自动机的代码并不长,请对照前面的解释仔细理解:
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
typedef pair<int,int> pii;
const int N=1e6+10;
const int M=1e3+10;
const int inf=1e17;
const int mod=1e9+7;
struct node{
int son[26];
int end;
int fail;
};
int m;
node t[N];
queue<int> q;
void insert(string s){
int now=0;
for(int i=0;i<s.size();i++){
int ch=s[i]-'a';
if(t[now].son[ch]==0){
t[now].son[ch]=++m;
}
now=t[now].son[ch];
}
t[now].end++;
return ;
}
void get_fail(){
for(int i=0;i<26;i++){
if(t[0].son[i]){
q.push(t[0].son[i]);
}
}
while(!q.empty()){
int now=q.front();
q.pop();
for(int i=0;i<26;i++){
if(t[now].son[i]){
t[t[now].son[i]].fail=t[t[now].fail].son[i];
q.push(t[now].son[i]);
}else{
t[now].son[i]=t[t[now].fail].son[i];
}
}
}
return ;
}
int query(string s){
int ans=0;
int now=0;
for(int i=0;i<s.size();i++){
int ch=s[i]-'a';
now=t[now].son[ch];
int tmp=now;
while(tmp&&t[tmp].end!=-1){
ans+=t[tmp].end;
t[tmp].end=-1;
tmp=t[tmp].fail;
}
}
return ans;
}
int n;
string s;
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
m=1;
for(int i=1;i<=n;i++){
string x;
cin>>x;
insert(x);
}
get_fail();
cin>>s;
cout<<query(s)<<endl;
return 0;
}
注释版:
#include<bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
typedef pair<int,int> pii;
const int N=1e6+10;
const int M=1e3+10;
const int inf=1e17;
const int mod=1e9+7;
struct node{
int son[26]; //26个字母
int end; //字符串结尾标记
int fail; //失配指针
};
int m; //新分配的存储空间
node t[N]; //Trie[],字典树
queue<int> q;
void insert(string s){ //在字典树上插入单词 s
int now=0; //字典树上当前匹配到的节点,从 root=0 开始
for(int i=0;i<s.size();i++){ //逐一在字典树上查找 s[] 的每个字符
int ch=s[i]-'a';
if(t[now].son[ch]==0){ //如果这个字符还没有存过
t[now].son[ch]=++m; //把 m 位置分配给这个字符
}
now=t[now].son[ch]; //沿着字典树往下走
}
t[now].end++; //end>0,是字符串的结尾,同时表示个数
return ;
}
void get_fail(){ //用 BFS 构建每个节点的 Fail 指针
for(int i=0;i<26;i++){ //把第 1 层入队,即 root 的子节点
if(t[0].son[i]){ //这个位置有字符
q.push(t[0].son[i]);
}
}
while(!q.empty()){
int now=q.front(); //队首的 Fail 指针以求得,下面求他孩子的 Fail 指针
q.pop();
for(int i=0;i<26;i++){ //遍历 now 的所有孩子
if(t[now].son[i]){ //若这个位置有字符
t[t[now].son[i]].fail=t[t[now].fail].son[i];
//这个孩子的 Fail=“父节点的 Fail 指针所指向的节点的与 x 同字符的子节点”
q.push(t[now].son[i]); //这个孩子入队,后面在处理它的孩子
}else{ //若这个位置无字符
t[now].son[i]=t[t[now].fail].son[i];
//虚拟节点,由于底层的 Fail 指针计算
}
}
}
return ;
}
int query(string s){ //在文本串 s 中找有多少个 模式串
int ans=0;
int now=0; //从 root=0 开始找
for(int i=0;i<s.size();i++){ //对文本串进行遍历
int ch=s[i]-'a';
now=t[now].son[ch];
int tmp=now;
while(tmp&&t[tmp].end!=-1){ //利用 Fail 指针找出所有匹配的模式串
ans+=t[tmp].end; //累加到答案中,若不是结尾,end=0
t[tmp].end=-1; //以这个字符为结尾的模式串已经统计,后面不再统计
tmp=t[tmp].fail; //Fail 指针跳转
}
}
return ans;
}
int n;
string s;
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
cin>>n;
m=1; //把 m=0 留给 root
for(int i=1;i<=n;i++){
string x; //输入模式串
cin>>x;
insert(x); //插入到字典树中
}
get_fail(); //计算字典树上每个结点的 Fail 指针
cin>>s;
cout<<query(s)<<endl;
return 0;
}

浙公网安备 33010602011771号