AC 自动机学习笔记

1.KMP 自动机

1.1 内容

KMP 自动机本质上就是单串的 AC 自动机。

我们定义转移函数为:

\[\delta(i, c) = \begin{cases} \delta(\pi_i, c)&s_{i+1} \not= c\\ i + 1&s_{i+1} = c \end{cases} \]

其实也就是模拟了 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,然后我们定义转移函数:

\[\delta(i, c) = \begin{cases} child[i][c]&child[i][c] \text{ exist}\\ \delta(fail_i, c)&\text{Otherwise.} \end{cases} \]

其中的 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)\)

提交记录

posted @ 2024-08-09 21:50  rlc202204  阅读(14)  评论(0编辑  收藏  举报