浅谈 ACAM
Wyh_dailyAC
·
·
算法·理论
版权声明
本文遵循 CC BY-NC-ND 4.0 协议,作者:Wyh_dailyAC,转载请获得作者授权。
本文首次编写时间:2026-01-22 20:05,本文最后一次更改时间:2026-01-30 15:43。
前言
博客链接。
本文是笔者《“浅谈算法”文章集》的第一个讲解类文章。
本文无图,主要讲 ACAM 的思想,故本文适合对 AC 自动机的结构有大致了解,但不了解其具体思想的人。
在本文中,如无特殊标注,字符串的下标从 1 开始。
upd (2026/1/30):修复了一处笔误;增加了博客链接。
AC 自动机的来历
引用百度百科上的一句话:
AC 自动机(外文名:Aho-Corasick automaton)是 1975 年由贝尔实验室提出的多模式匹配算法,基于字典树(Trie)结构结合 KMP 算法失败指针思想构建。
这里有几个关键词:Trie,KMP 失败指针思想。
下面主要介绍“KMP 失败指针思想”相关内容,对 Trie 的讲解有所缺失。不太了解 Trie 的可以看:OI Wiki - Trie。
AC 自动机的前置知识
KMP 的前缀函数 & 失配指针思想
先定义前缀函数为一个长 n 的数组 \pi[i],简单来说,\pi[i] 就是子串 s[1\dots i] 最长的相等的真前缀与真后缀的长度。
下面直接讲解最优化的前缀函数求法,这里就运用了失配指针思想。
设若已经处理出了 \pi[1\dots i-1],考虑如何求 \pi[i]。
注意到 \pi[i-1] 到 \pi[i] 的增量最大为 1,最小为 -\pi[i-1],这里我们先考虑增加 1 的情况。
不妨举个例子:\texttt{abcab}。
可以看出,\texttt{abcab} 的 \pi[1\dots4] 分别为 0,0,0,1,如果想要让 \pi[5]=\pi[4]+1,那么必须有 s[1+1]=s[4+1] 才行(这里把下标写作 1+1,4+1,是为了体现出该点是原 \pi[4] 的边界点的延伸)。这个条件显然是充要的,且在 s 中确实成立,所以这是一个正确的判定策略,即当 s[\pi[i-1]+1]=s[i-1+1] 时,有 \pi[i]=\pi[i-1]+1。
接下来讨论 s[\pi[i-1]+1]\ne s[i-1+1] 的情况,这就叫失配,即失去(进一步的)匹配。
失去进一步的匹配,就要尝试更劣的情况,“退而求其次”。
具体是怎么样“退而求其次”呢?
考虑我们是怎么在 \pi[i-1]\rightarrow \pi[i] 这个过程中失配的,显然是因为在尝试进一步的匹配时,对 \pi[i-1] 所对应的最长 border 进行了加入 s[i] 的操作,导致新 border 不满足其自身性质,那我们如果要退而求其次,就应该尝试去寻找 s[1\dots i-1] 中次长的 border,并判断其加入 s[i] 后是否满足 border 性质,如此往复,直到新加入后确实满足 border 性质——我们总是想贪心地令 \pi[i] 最大,这显然要一个一个、从长到短的去试,又因为新加入字符所能带来的增量最大也就 1,而不会出现小 border 在增量后超越大 border 的情况,所以这个贪心思想是正确的。
现在,问题转化为了串 s[1\dots i-1] 在当前 border 上失配时,该如何寻找次长 border——这就是失配指针思想:在失去匹配时退而求其次,考虑次优情况(这个思想在 AC 自动机上会有更深刻的体现)。
先下一个结论:对于 s[1\dots i] 的最长 border,其次长 border 的长度为 \pi[\pi[i]],即 s[1\dots \pi[i]] 的最长 border 长度。下证此结论。
证明:
先证明 \pi[\pi[i]] 作为 s[1\dots i] 的 border 长度的合法性。由定义知 s[1\dots\pi[i]]=s[i-\pi[i]+1\dots i],s[i-\pi[i]+1\dots i-\pi[i]+\pi[\pi[i]]=s[i-\pi[\pi[i]]+1\dots i],可得 s[1\dots\pi[\pi[i]]]=s[i-\pi[i]+1\dots i-\pi[i]+\pi[\pi[i]],等量代换即可得 s[1\dots\pi[\pi[i]]]=s[i-\pi[\pi[i]]+1\dots i],所以“\pi[\pi[i]] 可以作为 s[1\dots i] 的 border 的长度”。
设若 s[1\dots i] 的次长 border 的长度不为 \pi[\pi[i]],而是比 \pi[\pi[i]] 更优的 tlen(注意 \pi[\pi[i]]<tlen<\pi[i])。根据 border 定义,有 s[1\dots tlen]=s[i-tlen+1\dots i],又因为 s[1\dots\pi[i]]=s[i-\pi[i]+1\dots i] 且 \pi[\pi[i]]<tlen<\pi[i],所以有 s[1\dots tlen]=s[i-\pi[i]+1\dots i-\pi[i]+1+tlen-1],即 s[1\dots tlen]=s[i-\pi[i]+1\dots i-\pi[i]+tlen],等量代换得到 s[i-\pi[i]+1\dots i-\pi[i]+tlen]=s[i-tlen+1\dots i],则 s[i-\pi[i]+1\dots i] 即 s[1\dots \pi[i]] 的最长 border 的长度 \pi[\pi[i]]\ge tlen,这显然与 \pi[\pi[i]]<tlen 的假设条件矛盾,故假设不成立。假设不成立,则证得 s[1\dots i] 的次长 border 的长度绝对不会比 \pi[\pi[i]] 更优,结合前面“\pi[\pi[i]] 可以作为 s[1\dots i] 的 border 的长度”的结论,综上证得“对于 s[1\dots i] 的最长 border,其次长 border 的长度为 \pi[\pi[i]]”。原结论证毕。
将上面结论推广,我们就有了求当前 border 的次长 border 长度的算法,于是有下面的“求 \pi[i]”代码。
for (int i = 2; i <= lent + 1 + lens; ++i) {
int j = pi[i - 1];
while (j > 0 && cur[i] != cur[j + 1]) j = pi[j];
if (cur[i] == cur[j + 1]) pi[i] = j + 1;
}
上面代码求的是一个长为 lens+lent+1 的字符串的 \pi 数组。
KMP 其实就是构造“模式串 + 特殊字符 + 文本串”的新串,然后求 \pi 数组的算法,其思想就是求 \pi 时“退而求其次”的失配指针思想。
AC 自动机相关的定义
定义 ch 数组表示 Trie 的出边数组,定义一个 Trie 上节点的 fail 指针指向 Trie 上代表其最长真后缀的节点(如 Trie 上无该点真后缀,则指向源点)(注意 fail 指针的构建由浅到深)。
为什么 ACAM 要在 Trie 上建立
这其实挺显然的吧,因为 Trie 可以表示字符串的所有子串,因为 Trie 很省节点,因为 Trie 可以根据节点所在层和入边字符来保证节点的性质独立性,因为 Trie 有很多优良的性质与适用场景……
上面所例举的,无不是 ACAM 在 Trie 上建立的理由。
AC 自动机的建立
归根结底,ACAM 就是 KMP 的一个多模版的拓展,所以其所有的内容都是可以类比 KMP 的内容的,要坚信、理解这一点,才能通过 KMP 的思想,来完全理解 ACAM 的思想。
理解 fail 指针的算法意义
这一部分非常重要,读者要与 KMP 的思想结合着思考,才能更深刻地理解 fail 指针的意义。
考虑下面一个问题(AC 自动机模板):
给你一个文本串 S 和 n 个模式串 T_{1 \sim n},请你分别求出每个模式串 T_i 在 S 中出现的次数。
我们先不管 fail 的构造,也不考虑暴力跳 fail 链 / 其他算法的时间复杂度,我们就考虑最朴素的方法怎么做。
考虑最朴素的方法,就是枚举每一个子串,然后查这个子串是否为一个模式串。
这个方法怎么放到 fail 树上实现呢?
显然,子串其实就是对一个串的前缀再扣掉一部分的前缀,也就是一个前缀串的后缀。
前缀串就是 Trie 上节点啊!而 fail 指针指向的是一个 Trie 上节点的真后缀啊!那跳 fail 就是枚举前缀的后缀(即子串)啊!
于是我们有了一个最朴素的方法:先把模式串插入 Trie,且标记结束节点,然后把文本串放到 Trie 上查询,对于查询到的节点暴力地跳 fail 链(枚举其子串),并查询当前节点的标记情况(如有标记则答案增加,否则不变)。
但这就显着这个 fail 树建立带来的优化没多少啊,况且 fail 优化的意义与 KMP 的思想呢?
很显然,我们要尝试将模式串结束节点的标记同步到其他的 fail 树节点上(注意 Trie 树和 fail 树的节点其实是共用的,以下可能会混用)。
我们可以采用树上差分那种链上前缀和思想,把标记产生的贡献打到 fail 树的节点上。
接下来我们要决定转移标记的顺序——注意到,fail 树由深向浅指向,所以其绝对是一个 DAG。
DAG?很明显可以拓扑排序——这样就保证了标记同步的顺序。
好了,设若我们已经完成了拓扑优化,那显然我们还要把暴力跳 fail 的时间复杂度优化掉。
其应用思想是:当匹配失败时,不盲目回退到起点,而是**跳转到已匹配部分的最长可复用后缀继续匹配。**
这就是和 KMP 近似的**失配指针(数组)思想。**
在未作任何处理就硬是跳 $fail$ 时,我们会遇到很多没有任何用处的节点,这些节点上我们还是失配的,这不符合“跳转到已匹配部分的最长可复用后缀继续匹配”的目的。
所以如果能把这段路径**压缩**掉,那我们就成功把查询的时间复杂度优化到线性了。
于是接下来,我们讲 $fail$ 指针与 $fail$ 树的构建。
### $fail$ 指针的构建
先看一下“P5357 【模板】AC 自动机”的代码吧(如下)。
```cpp line-numbers
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 200000 + 5;
const int SIG = 26;
int nxt[N][SIG], fail[N], cnt[N];
int node_cnt;
vector<int> topo;
inline int id(char c) {
return c - 'a';
}
void init() {
for (int i = 0; i <= node_cnt; ++i) {
memset(nxt[i], 0, sizeof nxt[i]);
fail[i] = cnt[i] = 0;
}
node_cnt = 0;
topo.clear();
}
int insert(string &s) {
int p = 0;
for (char c : s) {
int d = id(c);
if (!nxt[p][d]) nxt[p][d] = ++node_cnt;
p = nxt[p][d];
}
return p;
}
void build_fail() {
queue<int> q;
for (int c = 0; c < SIG; ++c) {
if (nxt[0][c]) q.push(nxt[0][c]);
}
while (!q.empty()) {
int u = q.front(); q.pop();
topo.push_back(u);
for (int c = 0; c < SIG; ++c) {
int v = nxt[u][c];
if (v) {
fail[v] = nxt[fail[u]][c];
q.push(v);
} else {
nxt[u][c] = nxt[fail[u]][c];
}
}
}
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int n; cin >> n;
vector<string> t(n);
for (int i = 0; i < n; ++i) cin >> t[i];
string s; cin >> s;
init();
vector<int> pos(n);
for (int i = 0; i < n; ++i) {
pos[i] = insert(t[i]);
}
build_fail();
int p = 0;
for (auto c : s) {
p = nxt[p][id(c)];
cnt[p]++;
}
for (int i = topo.size() - 1; i >= 0; --i) {
int u = topo[i];
cnt[fail[u]] += cnt[u];
}
for (int i = 0; i < n; ++i) {
cout << cnt[pos[i]] << "\n";
}
}
```
单看 `build_fail` 函数:
```cpp line-numbers
void build_fail() {
queue<int> q;
for (int c = 0; c < SIG; ++c) {
if (nxt[0][c]) q.push(nxt[0][c]);
}
while (!q.empty()) {
int u = q.front(); q.pop();
topo.push_back(u);
for (int c = 0; c < SIG; ++c) {
int v = nxt[u][c];
if (v) {
fail[v] = nxt[fail[u]][c];
q.push(v);
} else {
nxt[u][c] = nxt[fail[u]][c];
}
}
}
}
```
前面看似都很正常,就是普通的入队、出队。
但着重看下面单拿出来这段:
```cpp line-numbers
int v = nxt[u][c];
if (v) {
fail[v] = nxt[fail[u]][c];
q.push(v);
} else {
nxt[u][c] = nxt[fail[u]][c];
}
```
**这里,就是 $fail$ 指针的构建核心,其与 KMP 中 $\pi$ 的构建十分相似。**
在此重新阐发一下在 ACAM 上的与 KMP 不太相同的**失配条件:旧串节点没有可能转移到新串节点的出边。**
如果这个失配条件一直不满足,那是因为一直有和文本串匹配的模式串。
考虑 KMP 是如何解决失配问题的:如果在前后缀各拓展一位新字符后,前后缀不满足 $border$ 的性质,那么寻求次优值,并再次进行拓展与匹配,直到成功。
看看上面这段代码是怎么处理失配的,这里先看 $v$ 节点在 Trie 上实际存在的情况。
理论上,在没有路径压缩的情况下,如果匹配时在 $v$ 点失配了,会直接跳到 $v$ 在 Trie 上的最大真后缀,并**延伸出来一个 $c$,再次查询节点的存在性,即失配状态**。这部分其实很好理解,**就是 KMP 的失配指针思想的改造版。**
但实际上,`if` 部分和 `else` 部分结合,就已经有**路径压缩**的能力了!
如果你将 $fail$ 看作从非法匹配到最优合法匹配的路径,那么在上面这段代码 `if` 处理的过程中,$fail_v$ 有两种可能:一是其跳到了一个实际存在的节点,此时继续匹配即可;二是跳到了一个实际不存在的点,**但是,`else` 中已经对点不存在的情况做了“直接跳到失配态”的处理**,且这种处理可以看作一种逐层处理的、类似递归的算法(可以类比**路径压缩的并查集**),所以第二种情况依旧到了一个最优合法匹配点,所以 $fail$ 路径压缩后的性质依旧保留,所以我们确实实现了 $fail$ 指针的路径压缩。
重申一遍:由于失配指针是由浅入深、逐层建立,而路径压缩的指向同 $fail$ 的指向,是**从深到浅**的指向,所以在处理当前层时,所需的上层状态绝对已被处理,就不会出现指向状态未被处理的情况了。
至此,ACAM 中最重要的 $fail$ 指针构造就讲完了。至于 $fail$ 树,直接把指向关系作为边提取出来,进行建图即可。
## AC 自动机的时间复杂度
显然,ACAM 的时间复杂度与 Trie 的节点数相关。定义 $\sum{|s_i|}$ 表示模式串长度和,$|S|$ 表示文本串长度,$|\sum|$ 表示字符集大小,则在不构造 Trie 图时,跑 ACAM 的时间复杂度为 $O(\sum{|s_i|+|S|})$;构建 Trie 图后跑,时间复杂度为 $O(\sum{|s_i|+n|\sum|+|S|})$,挺好理解的。
## 实践应用 - 例题选讲
### AC 自动机到图论
#### [P2444 [POI 2000] 病毒](https://www.luogu.com.cn/problem/P2444)
题意概括:给出多个 0 / 1 模式串,求构造一个无限长且无法匹配的 0 / 1 文本串的可行性。
显然这是个多模匹配问题,考虑把问题转化到 ACAM 上解决。
首先,我们对代表每个模式串结尾的节点标记成危险节点。
发现正着做没什么思路,于是正难则反,考虑当有一个符合要求的无限长文本串时,将该文本串在 Trie 上跑会怎么样——显然,此时每一时刻的状态节点会组成一个环,且这些状态节点中没有危险节点 / 能从危险节点拓展到的节点。
所以本题转化为了一个 Trie 图上的图论问题,DFS 即可。
### AC 自动机优化 DP
注:ACAM 优化 DP,通常会把 ACAM 上的状态节点融入 DP 的状态中。
#### [P2292 [HNOI2004] L 语言](https://www.luogu.com.cn/problem/P2292)
题意概括:给定 $n$ 个模式串 $s$ 和 $m$ 个主串 $t$,对于每一个 $t$,请求出其最长的前缀,满足该前缀是由若干模式串(可以多次使用)首尾拼接而成的。**注意 $|s|\le10$。**
考虑最朴素的做法,就是暴力跳 $fail$,即枚举当前位代表前缀的真后缀,然后暴力判断转移。
注意到我上面给出了一个重点标注的条件:$|s|\le10$——这启示着我们进行状态压缩 DP。
具体状压的状态是啥?上面朴素 DP 转移时枚举了最后一段模式串,其长度显然不超过 $10$,且转移只与这 $10$ 长度的串有关,所以我们压缩**当前点前面 $10$ 位的 DP 值。**
然后优化跳 $fail$,即要把自动机上某节点能通过跳 $fail$ 跳到的所有**终止**节点代表的串的长度压缩下来,显然这里可以采用与“P5357 【模板】AC 自动机”近似的预处理优化,只不过这回是从源点,反着 $fail$ 指针的方向进行更新,且对模式串终止节点的初始化值为 $2^{|s|}$。经此优化后,判断 DP 值只需要把节点预处理值与状压的状态进行与运算即可。
到此,状压 DP 与 AC 自动机就结合了起来,且其时间复杂度为“构造 AC 自动机”“预处理节点值”“遍历串”这三个操作时间复杂度之和。
~~其实本题可以通过优化枚举通过,这里不讲这种未验证方法。~~
### 推荐题单
[P2292 [HNOI2004] L 语言](https://www.luogu.com.cn/problem/P2292)
[P3966 [TJOI2013] 单词](https://www.luogu.com.cn/problem/P3966)
[P3041 [USACO12JAN] Video Game G](https://www.luogu.com.cn/problem/P3041)
[P2444 [POI 2000] 病毒](https://www.luogu.com.cn/problem/P2444)
[P2414 [NOI2011] 阿狸的打字机](https://www.luogu.com.cn/problem/P2414)
[P3311 [SDOI2014] 数数](https://www.luogu.com.cn/problem/P3311)