AC 自动机

1 引入

对于传统 KMP,可以解决单模式串匹配的问题。

但是对于下面的问题,好像 KMP 就显得有些弱了:

给定 n 个模式串 si 和一个文本串 t,求有多少个不同的模式串在文本串里出现过。

那么对于这个问题,我们就要使用 AC 自动机求解。

2 实现

以上面的问题为例讲解 AC 自动机。

2.1 Trie 树

先将所有 n 个模式串建一颗 Trie 树。

举例:

假如我们匹配字符串,匹配到了 4 号点之后,还需要从根开始匹配吗?

那么这样的效率太过低下。我们发现,匹配上 4 的时候肯定也匹配上了 7 号点,因此可以直接跳到 7 号。

这和 KMP 的 nxt 太像了,我们叫它失配指针 fail

2.2 Fail 指针

2.2.1 Fail 指针的意义

对于上面的理解,faili 指的是 i 失配时能调到的位置。

他的实质是:当前字符串最长的能在 Trie 树上找到的后缀的位置。

因此求出 fail 指针就是接下来的问题。

2.2.2 求解 Fail 指针

2.2.2.1 理论

首先我们有 fail1=0

然后设点 i 的父亲是 fafa 指向 failfa。那么如果 failfa 有和 i 的字符相同的儿子 j,则faili=j

其实很好理解,两个字符串同时加上一个字符,一定还是后缀。

由此我们发现求 faili 要先求出 failfa​,我们考虑使用 Bfs 的方式来求解 Fail 指针。

2.2.2.2 代码实现

注意一些细节:

  • 我们在刚开始的时候将 0 号点的儿子全部指向 1
  • 如果有一个节点 p 不存在某个儿子 i,那么就将这个 i 节点设为 failp 的值与 i 相同的儿子。这样可以保证任意节点的任意儿子都存在,并且满足 fail 的定义。

可以画图理解第二条,这里相当于通过修改 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 指针后,查询就很简单了。

如果一个字符串匹配成功,那么他的 fail 一定成功,failfail 也会成功……;

那么为了避免重复,我们每次经过一个点就打标记为 1

我们在 Trie 中再维护一个 flag 即可。

代码:

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)

由于要求出现次数最多的字符串,我们将 flag 设为字符串的编号。

同时由于要重复计算,因此不能打标记为 1

最后开一个 ans 数组,对于有标记的就 ans++

代码:

#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 拓扑排序建图优化

对于刚刚的代码,复杂度是 O(nm) 级别的,因为我们在查询的时候是反复暴力跳 fail 来求的。同时又由于无法打标记,所以复杂度很高。

我们想让每个点只经过一次,有办法吗?

我们发现,每一个点会对他的所有 fail 上的父亲做出贡献。因此我们在遍历的时候只将这个点的 ans 加上一。在最后统计的时候,我们将 ansfaili 加上 ansi 即可求出正确答案。

那么如何更新呢?我们将所有的 fail 指针提取出来(程序里并不用真的提取),会形成一个 DAG,那么我们直接拓扑排序递推一下就可以了。

代码:

#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 子树求和

与拓扑排序优化思路类似,预先将子树求和,询问时累加即可。

此处不在赘述(主要因为懒得写了)

posted @   UKE_Automation  阅读(43)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 一文读懂知识蒸馏
· 终于写完轮子一部分:tcp代理 了,记录一下
点击右上角即可分享
微信分享提示