AC 自动机
1 引入
对于传统 KMP,可以解决单模式串匹配的问题。
但是对于下面的问题,好像 KMP 就显得有些弱了:
给定
个模式串 和一个文本串 ,求有多少个不同的模式串在文本串里出现过。
那么对于这个问题,我们就要使用 AC 自动机求解。
2 实现
以上面的问题为例讲解 AC 自动机。
2.1 Trie 树
先将所有
举例:
假如我们匹配字符串,匹配到了
那么这样的效率太过低下。我们发现,匹配上
这和 KMP 的 nxt 太像了,我们叫它失配指针
2.2 Fail 指针
2.2.1 Fail 指针的意义
对于上面的理解,
他的实质是:当前字符串最长的能在 Trie 树上找到的后缀的位置。
因此求出 fail 指针就是接下来的问题。
2.2.2 求解 Fail 指针
2.2.2.1 理论
首先我们有
然后设点
其实很好理解,两个字符串同时加上一个字符,一定还是后缀。
由此我们发现求
2.2.2.2 代码实现
注意一些细节:
- 我们在刚开始的时候将
号点的儿子全部指向 。 - 如果有一个节点
不存在某个儿子 ,那么就将这个 节点设为 的值与 相同的儿子。这样可以保证任意节点的任意儿子都存在,并且满足 的定义。
可以画图理解第二条,这里相当于通过修改 Trie 的结构来完成求解。
代码:
void build() {
for(int i = 0; i <= 25; i++) {
tr[0].son[i] = 1;
}
q.push(1);
tr[1].fail = 0;
while(!q.empty()) {
int u = q.front();
q.pop();
for(int i = 0; i <= 25; i++) {
int v = tr[u].son[i];
int fa = tr[u].fail;
if(!v) {
tr[u].son[i] = tr[fa].son[i];
continue;
}
tr[v].fail = tr[fa].son[i];
q.push(v);
}
}
}
2.3 查询
求出 Fail 指针后,查询就很简单了。
如果一个字符串匹配成功,那么他的
那么为了避免重复,我们每次经过一个点就打标记为
我们在 Trie 中再维护一个
代码:
int query(string s) {
int u = 1, ans = 0;
for(int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
int k = tr[u].son[c];
while(k > 1 && tr[k].flag != -1) {
ans += tr[k].flag;
tr[k].flag = -1;
k = tr[k].fail;
}
u = tr[u].son[c];
}
return ans;
}
2.4 完整代码
P3808 AC 自动机(简单版) 的完整代码如下:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int Maxn = 1e6 + 5;
int n;
string s[Maxn], t;
struct Trie {
int son[27], fail, flag;
}tr[Maxn];
int cnt = 1;
void insert(string s) {
int u = 1;
for(int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
if(!tr[u].son[c]) {
tr[u].son[c] = ++cnt;
}
u = tr[u].son[c];
}
tr[u].flag++;
}
queue <int> q;
void build() {
for(int i = 0; i <= 25; i++) {
tr[0].son[i] = 1;
}
q.push(1);
tr[1].fail = 0;
while(!q.empty()) {
int u = q.front();
q.pop();
for(int i = 0; i <= 25; i++) {
int v = tr[u].son[i];
int fa = tr[u].fail;
if(!v) {
tr[u].son[i] = tr[fa].son[i];
continue;
}
tr[v].fail = tr[fa].son[i];
q.push(v);
}
}
}
int query(string s) {
int u = 1, ans = 0;
for(int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
int k = tr[u].son[c];
while(k > 1 && tr[k].flag != -1) {
ans += tr[k].flag;
tr[k].flag = -1;
k = tr[k].fail;
}
u = tr[u].son[c];
}
return ans;
}
int T;
int main() {
ios::sync_with_stdio(0);
cin >> T;
while(T--) {
memset(tr, 0, sizeof tr);
cin >> n;
for(int i = 1; i <= n; i++) {
cin >> s[i];
insert(s[i]);
}
cin >> t;
build();
cout << query(t) << '\n';
}
return 0;
}
3 一些应用及优化
3.1 应用
我们来看 P3796 AC 自动机(简单版 II)。
由于要求出现次数最多的字符串,我们将
同时由于要重复计算,因此不能打标记为
最后开一个
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int Maxn = 1e6 + 5;
int n;
string s[200], t;
struct Trie {
int son[27], flag, fail;
}tr[Maxn];
int cnt = 1;
void insert(string s, int id) {
int u = 1;
for(int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
if(!tr[u].son[c]) {
tr[u].son[c] = ++cnt;
}
u = tr[u].son[c];
}
tr[u].flag = id;
}
queue <int> q;
void build() {
for(int i = 0; i <= 25; i++) {
tr[0].son[i] = 1;
}
q.push(1);
tr[1].fail = 0;
while(!q.empty()) {
int u = q.front();
q.pop();
for(int i = 0; i <= 25; i++) {
int v = tr[u].son[i];
int fail = tr[u].fail;
if(!v) {
tr[u].son[i] = tr[fail].son[i];
continue;
}
tr[v].fail = tr[fail].son[i];
q.push(v);
}
}
}
int ans[205];
void query(string s) {
int u = 1;
for(int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
int k = tr[u].son[c];
while(k > 1) {
if(tr[k].flag) {
ans[tr[k].flag]++;
}
k = tr[k].fail;
}
u = tr[u].son[c];
}
}
int num = 0, p = 0;
string tmp[205];
int main() {
ios::sync_with_stdio(0);
while(1) {
cin >> n;
if(n == 0) break;
memset(tr, 0, sizeof tr);
memset(ans, 0, sizeof ans);
cnt = 1;
num = p = 0;
for(int i = 1; i <= n; i++) {
cin >> s[i];
insert(s[i], i);
}
cin >> t;
build();
query(t);
for(int i = 1; i <= n; i++) {
if(ans[i] > num) {
num = ans[i];
p = 1;
tmp[p] = s[i];
}
else if(ans[i] == num) {
tmp[++p] = s[i];
}
}
cout << num << '\n';
for(int i = 1; i <= p; i++) {
cout << tmp[i] <<'\n';
}
}
return 0;
}
3.2 优化
我们来看 P5357 【模板】AC 自动机。
我们发现这道题好像和上一道题一样,然而交上去发现,TLE 76pts。
那么我们来看下面的优化。
3.2.1 拓扑排序建图优化
对于刚刚的代码,复杂度是
我们想让每个点只经过一次,有办法吗?
我们发现,每一个点会对他的所有
那么如何更新呢?我们将所有的
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int Maxn = 2e5 + 5;
const int Maxm = 1e6 + 5;
int n;
string s[Maxn], t;
struct Trie {
int son[27], flag, fail, ans;
}tr[Maxm];
int cnt = 1;
int num[Maxn];
void insert(string s, int id) {
int u = 1;
for(int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
if(!tr[u].son[c]) {
tr[u].son[c] = ++cnt;
}
u = tr[u].son[c];
}
if(!tr[u].flag) tr[u].flag = id;
num[id] = tr[u].flag;
}
queue <int> q;
int in[Maxn];
void build() {
for(int i = 0; i <= 25; i++) {
tr[0].son[i] = 1;
}
q.push(1);
tr[1].fail = 0;
while(!q.empty()) {
int u = q.front();
q.pop();
for(int i = 0; i <= 25; i++) {
int v = tr[u].son[i];
int fail = tr[u].fail;
if(!v) {
tr[u].son[i] = tr[fail].son[i];
continue;
}
tr[v].fail = tr[fail].son[i];
in[tr[v].fail]++;
q.push(v);
}
}
}
int ans[Maxn];
void query(string s) {
int u = 1;
for(int i = 0; i < s.size(); i++) {
int c = s[i] - 'a';
u = tr[u].son[c];
tr[u].ans++;
}
}
void toposort() {
for(int i = 1; i <= cnt; i++) {
if(!in[i]) q.push(i);
}
while(!q.empty()) {
int u = q.front();
q.pop();
if(tr[u].flag) ans[tr[u].flag] = tr[u].ans;
int v = tr[u].fail;
in[v]--;
tr[v].ans += tr[u].ans;
if(!in[v]) q.push(v);
}
}
int main() {
ios::sync_with_stdio(0);
cin >> n;
for(int i = 1; i <= n; i++) {
cin >> s[i];
insert(s[i], i);
}
cin >> t;
build();
query(t);
toposort();
for(int i = 1; i <= n; i++) {
cout << ans[num[i]] << '\n';
}
return 0;
}
3.2.2 子树求和
与拓扑排序优化思路类似,预先将子树求和,询问时累加即可。
此处不在赘述(主要因为懒得写了)。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下