AC自动机

创建时间:2025-07-24


AC自动机的概念

AC自动机(Aho-Corasick automaton)是一种可以实现多模式串匹配的算法,和 KMP 有点像,但 KMP 是能实现单一模式串匹配。

对于模式串 \(s_i\) 和文本串 \(t\)\(s_i\)\(t\) 中出现的充要条件显然是 \(s_i\)\(t\) 的某个前缀的后缀,例如 \(s_i=\textup{aab},t=\textup{cbaabdd}\)\(s\)\(t\) 中出现且 \(s_i\)\(t\) 的前缀 \(\textup{cbaab}\) 的后缀 \(\textup{aab}\)。在 \(\{s\}\) 中找 \(t\) 的前缀很简单,只需要建一个字典树,将每一个 \(s_i\) 插入字典树,然后从根节点到 \(t\) 向下走就行了。

不妨为每个节点设 \(fail\) 指针指向字典树中以该节点结尾的字符串的后缀的结束节点。那么显然以 \(fail_i\) 为结尾的字符串是以 \(i\) 结尾的字符串的后缀,\(fail_{fail_i}\) 同样是,\(fail_{fail{fail...i}}\) 也是,故只需要顺着 \(fail\) 指针一直走就能找到所有后缀。为了不遗漏,我们记 \(fail_i\)字典树中存在的以 \(i\) 节点为结尾的字符串的最长后缀的结束节点编号,若没有则 \(fail_i=0\)

现在的问题是如何求出 \(fail_i\)。容易发现,\(fail_i\) 在字典树中的深度一定小于 \(i\) 的深度,且 \(i\)\(fail\) 是可以从 \(i\) 的父节点的 \(fail\) 转移的。故可以通过 BFS 得到 \(fail_i\)。当 \(trie[cur][i]\) 存在时,\(trie[cur][i]\)\(fail\) 一定是 \(cur\) 代表的字符串的某个后缀的结束节点 \(j\) 的孩子 \(trie[j][i]\)。显然,当 \(trie[fail_{cur}][i]\) 存在时,有 \(trie[cur][i]\)\(fail\) 就是 \(trie[fail_{cur}][i]\);当 \(trie[fail_{cur}][i]\) 不存在时,可以在 BFS 到 \(fail_{cur}\) 时提前将 \(trie[fail_{cur}][i]\) 设为 \(trie[fail_{fail_i}][i]\),如果 \(trie[fail_{fail_i}][i]\) 也不存在则继续嵌套。故有:
\(trie[cur][i] \neq 0\)

\[fail_{trie[cur][i]}=trie[fail_{cur}][i] \]

\(tire[cur][i] = 0\)

\[trie[cur][i]=trie[fail_{cur}][i] \]

由此写出求 \(fail\) 指针的函数 build_fail() :


void build_fail() {
	queue<int> q;
	for (int i = 0; i < 26; i++)
		if (trie[0][i])
			q.push(trie[0][i]);
	while (!q.empty()) {
		int cur = q.front();
		q.pop();
		for (int i = 0; i < 26; i++)
			if (trie[cur][i]) {
				fail[trie[cur][i]] = trie[fail[cur]][i];
				q.push(trie[cur][i]);
			} else
				trie[cur][i] = trie[fail[cur]][i];
	}
}

有了 \(fail\) 指针,就可以按照之前的逻辑先枚举前缀,再通过 \(fail\) 指针找后缀在文本串 \(t\) 中寻找模式串 \(s_i\)。以P3808 AC 自动机(简单版)
,求在文本串中出现过的模式串的个数总和实现 AC自动机:

#include <bits/stdc++.h>

using namespace std;

const int MAX_N = 1e6 + 50;

int n, trie[MAX_N][26], tot;
int cnt[MAX_N], fail[MAX_N];
string s[MAX_N], t;

void insert(string s) {
	int cur = 0;
	for (char ch : s) {
		int i = ch - 'a';
		if (!trie[cur][i])
			trie[cur][i] = ++tot;
		cur = trie[cur][i];
	}
	cnt[cur]++;
} 

void build_fail() {
	queue<int> q;
	for (int i = 0; i < 26; i++)
		if (trie[0][i])
			q.push(trie[0][i]);
	while (!q.empty()) {
		int cur = q.front();
		q.pop();
		for (int i = 0; i < 26; i++)
			if (trie[cur][i]) {
				fail[trie[cur][i]] = trie[fail[cur]][i];
				q.push(trie[cur][i]);
			} else
				trie[cur][i] = trie[fail[cur]][i];
	}
}

int query(string t) {
	int cur = 0, ans = 0;
	for (char ch : t) {
		int i = ch - 'a';
		cur = trie[cur][i];
		for (int j = cur; j && ~cnt[j]; j = fail[j])
			ans += cnt[j], cnt[j] = -1;
	}
	return ans;
}

int main() {
	cin >> n;
	for (int i = 1; i <= n; i++) {
		cin >> s[i];
		insert(s[i]);
	}
	build_fail(); 
	cin >> t;
	cout << query(t) << '\n';
	return 0;
}

由于这道题只需要确定每个模式串是否在文本串中出现,所以可以通过剪枝优化使时间复杂度达到 \(O(\sum|s|+|t|)\)。然而一般题目不可以使用剪枝,这么在字典树上跳来跳去的做法复杂度很大,容易超时,此时一般使用下面提到的 \(fail\) 树解决问题。

AC自动机的应用

AC自动机最大的特点就在于它的 \(fail\) 指针,注意到每节点有且仅有一个 \(fail\) 指针,所有 \(fail\) 最终都指向 \(0\),正如树中每个节点有且仅有一个父节点,所有父节点最终汇集在根节点。故可以\(fail_i\) 为父亲,\(i\) 为儿子建出一颗以 \(0\) 为根的 \(fail\)。树形结构是很容易维护、操作的结构,为各种问题提供了很大的便利,\(fail\) 树也是 AC自动机最核心的应用。

P5357 【模板】AC 自动机为例,题目要求每个模式串在文本串中的出现次数。按照上面的每次暴力枚举后缀显然会 TLE。

显然的是,一旦某个节点 \(i\) 的出现次数 \(cnt\) 被更新了,\(fail_i\)\(cnt\) 也一定会被更新。在 \(fail\) 树中,相当于一旦某个子节点被更新,父节点也一定会被更新。故在匹配后缀是并不需要将每个后缀都更新,只需要先更新第一个,后面的可以在最后通过树形 DP 递推得到并还原。

代码:

#include <bits/stdc++.h>

using namespace std;

const int MAX_N = 2e5 + 50;

int n, trie[MAX_N][26], tot;
int cnt[MAX_N], fail[MAX_N];
int mp[MAX_N];
vector<int> e[MAX_N];
string s[MAX_N], t;

int insert(string s) {
	int cur = 0;
	for (char ch : s) {
		int i = ch - 'a';
		if (!trie[cur][i])
			trie[cur][i] = ++tot;
		cur = trie[cur][i];
	}
	return cur;
} 

void build_fail() {
	queue<int> q;
	for (int i = 0; i < 26; i++)
		if (trie[0][i])
			q.push(trie[0][i]);
	while (!q.empty()) {
		int cur = q.front();
		q.pop();
		for (int i = 0; i < 26; i++)
			if (trie[cur][i]) {
				fail[trie[cur][i]] = trie[fail[cur]][i];
				q.push(trie[cur][i]);
			} else
				trie[cur][i] = trie[fail[cur]][i];
	}
}

void work(string t) {
	int cur = 0;
	for (char ch : t) {
		cur = trie[cur][ch - 'a'];
		cnt[cur]++;
	}
}

void dfs(int u) {
	for (int v : e[u]) {
		dfs(v);
		cnt[u] += cnt[v];
	}
}

int main() {
	cin >> n;
	for (int i = 1; i <= n; i++) {
		cin >> s[i];
		mp[i] = insert(s[i]);
	}
	build_fail(); 
	for (int i = 1; i <= tot; i++)
		e[fail[i]].push_back(i);
	cin >> t;
	work(t);
	dfs(0);
	for (int i = 1; i <= n; i++)
		cout << cnt[mp[i]] << '\n';
	return 0;
}

除了树形 DP,\(fail\) 树也常常与树上差分、树上倍增等树形结构处理技巧,或者 \(dfs\) 序/树链剖分+树状数组/线段树等数据结构结合。下面是 AC自动机的一些其他应用:

P3796 AC 自动机(简单版 II)
P3041 [USACO12JAN] Video Game G
CF547E Mike and Friends
CF163E e-Government
P5840 [COCI 2015] Divljak

posted @ 2026-06-02 16:13  xubaichuan  阅读(11)  评论(0)    收藏  举报