AC 自动机学习笔记
1.KMP 自动机
1.1 内容
KMP 自动机本质上就是单串的 AC 自动机。
我们定义转移函数为:
其实也就是模拟了 KMP 的整个过程。
1.2 应用
自动机上跑 dp 是最常见的应用,一般会有一维是自动机的状态。
对于一些问题还可以用矩阵优化。
CF808G Anthem of Berland
设 \(dp(i,j)\) 表示确定了前 \(i\) 个字符,当前在 \(j\) 状态,最多匹配了几次。转移就是简单的线性 dp。
P3193 [HNOI2008] GT考试
设 \(dp(i,j)\) 表示确定了前 \(i\) 个字符,当前在 \(j\) 状态的方案数。转移用矩阵快速幂优化。
P3082 [USACO13MAR] Necklace G
设 \(dp(i,j)\) 表示前 \(i\) 个确定,在 \(j\) 状态,最多保留几个。
2. AC 自动机
2.1 内容
假设现在我们是一个多串匹配的问题,我们该如何建立自动机。
首先,我们需要将所有串建成一颗 Trie,然后我们定义转移函数:
其中的 fail 称作失陪指针,和单串中的 \(\pi\) 是类似的,指向最长的真后缀。
但是 AC 自动机其实有两个和重要的东西:转移表和 fail 树,转移表就是上面的东西,而 fail 树指的是每个点向 fail 连边形成的树。这个在解决问题时帮助很大。
我们考虑 5357 【模板】AC 自动机 的问题:
给定 \(S\) 和 \(t_1 \sim t_n\),求每个 \(t_i\) 在 \(S\) 中出现次数。
我们对 \(t\) 建出 AC 自动机,然后我们跑一边 \(S\),将其经过的所有点都打上标记。
我们观察 fail 树会发现每个串出现当且仅当经过的是子树内的点。于是我们求子树和就能得出问题的答案。
建立 AC 自动机的过程可以用 bfs 来建,同时由于我们遍历的顺序是字典序,所以我们可以把队列保留下来,求子树和时倒序求即可。
给出代码:
#include <iostream>
#include <vector>
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 2e5 + 5;
const int S = 26;
const int M = 2e6 + 5;
int n;
int ch[N][S] = {{0}}, fail[N] = {0}, cnt;
int ed[N] = {0}; // ed[i]: 第 i 个模式串的节点
int f[N] = {0}; // f[i]: 节点 i 的经过次数 ==> 子树 i 的经过次数
int q[N] = {0}, fr, ba;
char s[M];
void insert(int x) {//第 x 个模式串
int now = 0;
for (int i = 0; s[i]; i++) {
if (!ch[now][s[i] - 'a'])
ch[now][s[i] - 'a'] = ++cnt;
now = ch[now][s[i] - 'a'];
}
ed[x] = now;
}
void getfail() {
fr = 1, ba = 0;
for (int i = 0; i < S; i++)
if (ch[0][i])
q[++ba] = ch[0][i];
while (fr <= ba) {
int x = q[fr++];
for (int i = 0; i < S; i++)
if (!ch[x][i])
ch[x][i] = ch[fail[x]][i];
else {
fail[ch[x][i]] = ch[fail[x]][i];
q[++ba] = ch[x][i];
}
}
}
void run() {
int now = 0;
for (int i = 0; s[i]; i++) {
now = ch[now][s[i] - 'a'];
++f[now];
}
}
int main() {
cnt = 0;
scanf("%d", &n);
for (int i = 1; i <= n; i++) {
scanf("%s", s);
insert(i);
}
getfail();
scanf("%s", s);
run();
for (int i = cnt; i >= 1; i--)
f[fail[q[i]]] += f[q[i]];
for (int i = 1; i <= n; i++)
printf("%d\n", f[ed[i]]);
return 0;
}
2.1 应用
AC 自动机可以配合 dp,数据结构等解决复杂问题。
P6257 [ICPC2019 WF] First of Her Name
首先自然是把名字反过来,然后就可以建出 AC 自动机,现在我们把查询也反过来,变成查询拥有给定后缀的字符串个数。
由于是后缀,所以我们考虑把询问串也丢到 AC 自动机中,这样所有以这个串为后缀的字符串都在其 fail 树的子树中!求子树和即可。
CF856B Similar Words
转化一下题意:两个串不能同时当选当且仅当相等或者 \(A\) 去掉第一个字符是 \(B\)。
我们发现这个限制意味着 \(B\) 是 \(A\) 的 fail 指向的点,建出 fail 树后转化成一些边的两端不能同时选,树上最大独立集!
直接 \(dp\) 即可。
P2444 [POI2000] 病毒
经典题目。
我们考虑建出 AC 自动机,显然如果是无限长那么一定存在循环节,这意味着我们能在 AC 自动机上找到一个环并且没有经过任何不该去的节点。
我们发现,如果一个点在 Trie 上的祖先有单词,或者 fail 树上的祖先有单词就不能去,否掉这些点,然后拓扑排序判环即可。
CF696D Legen...
在 AC 自动机上跑 dp,矩阵优化。
P5231 [JSOI2012] 玄武密码
求 fail 子树和,然后直接统计即可。
P3121 [USACO15FEB] Censoring G
找到就删,维护一个栈记录所有当问过的状态方便一步跳回。
P3041 [USACO12JAN] Video Game G
自动机上跑 dp,\(dp(i,j)\) 前 \(i\) 个在 \(j\) 的最多得分。其实数据范围可以开大然后矩阵优化。
P4052 [JSOI2007] 文本生成器
也是 dp,需要多记一维表示是否已经到达过终止状态。
P2414 [NOI2011] 阿狸的打字机
超级无敌经典题,而且其实出的很好。
首先我们对于所有串建出 AC 自动机。我们观察如果 \(x\) 在 \(y\) 中出现意味着什么。
意味着从 Trie 上 \(y\) 路径上的点的 fail 树上的祖先有 \(x\),所以我们可以转化成一条从根出发的路径上有多少在 dfn 序的某个区间内。
这个问题可以离线然后用 BIT 和一遍 dfs 搞定。
CF163E e-Government
我们考虑先建出 AC 自动机,然后对于每次修改直接修改一个点的子树的所有权值,然后询问就是跑一遍,每次询问单点。
用 BIT 和 dfs 序维护即可。
CF1163D Mysterious Code
AC 自动机上跑 dp。
CF1400F x-prime Substrings
发现 \(x\) 很小,并且限制看上去不太可转化,而且任意的方案数是 \(\binom{20}{10}\) 并不大,所以不妨考虑直接搜索出所有的 x-prime 字符串。
最后的结果就是最大不超过 \(5000\) 个,所以我们就将问题转化成了不含一些子串的计数问题,直接 AC 自动机上跑 dp 即可。
CF1015F Bracket Substring
单串显然是建立 KMP 自动机,然后设 \(dp(i,j,k,0/1)\) 表示确定了前 \(i\) 位,在状态 \(j\),当前的前缀和是 \(k\),是否包含了字串的方案数,直接 dp 即可,时间复杂度 \(O(n^3)\)。
CF1252D Find String in a Grid
总算没那么套路了
我们考虑 AC 自动机能够解决的问题:每次检查自动机中的字符串在一个字符串中出现了多少次。
所以我们考虑对于查询串建出 AC 自动机,然后每次找到所有的极大 L 型:从第一列出发,到达某个点后向下走到最后一行。
我们对于所有的极大 L 型看成字符串跑 AC 自动机,然后在 fail 树求和就可以得出题目的询问。
但是这样对于不拐弯的会算重复!所以我们需要再次计算所有不拐弯的个数并减去。
这里注意对于向下走的我们需要再建一个反串的 AC 自动机,否则容斥系数不好算。
总时间复杂度是 \(O(n^3)\)。
CF710F String Set Queries
和 e-Government 很想,但是字符串集合一开始不给定且强制在线,这意味着我们不可能对所有出现的字符串一次性建完 AC 自动机,必须动态增删。
由于 AC 自动机不支持修改,意味着每次都要重构。
首先我们发现,我们可以对于所有加入过和删除过的分开维护,每次查询用加入过的减去删除过的即可,所以我们现在只用考虑加入的。
第一种思路是根号分治,维护 \(sqrt n\) 个 AC 自动机,这样修改和查询时间复杂度都是 \(O(n\sqrt n)\),是一种思路。
但是其实我们也可以考虑二进制分组,我们假设当前有 \(k\) 个,则所有当前 \(k\) 的二进制表示下为 \(1\) 的都是满的,这样新增一个就相当于不断进位,所以每个字符串至多贡献 \(\log n\) 次,时间复杂度比根号分治好。
CF963D Frequency of String
经典结论题。
我们有一个经典结论:如果一个若干正数和一定,则其中不同的出现次数只有 \(O(\sqrt n)\) 个,证明显然。
我们发现,查询串的总长一定,且互不相同,考虑每种长度相同的总共只会出现 \(n\) 次,而只有 \(\sqrt n\) 个不同的长度,所以所有出现位置只有 \(O(n\sqrt n)\) 个!
于是我们直接用 AC 自动机暴力求出每个串的所有出现位置然后双指针处理询问即可,时间复杂度 \(O(n\sqrt n)\)。
CF86C Genetic engineering
设 \(dp(i,j,k)\) 表示前 \(i\) 位,在状态 \(j\),当前有 \(k\) 位没覆盖到。然后对于每个状态处理出其祖先的最长长度,转移时判断一下即可。
P5840 [COCI2015] Divljak
考虑建出 \(S_1, \dots, S_n\) 的 AC 自动机,然后我们每次加入一个串就直接计算贡献。
为了防止重复有两种方法,一种可以每次记一下当前链的最浅的没被覆盖到的,一种是利用虚树 dfn 排序然后两两路径差分即可。
时间复杂度 \(O(n \log n)\)。