自动机全家桶
自动机真的好难!
自动机
一个确定有限状态自动机,即 DFA,可以理解成一张有向图,每一个结点都是一个状态。
自动机由五部分组成:
- 字符集
- 状态集合 ,相当于图上的顶点
- 起始状态
- 接受状态集合
- 转移函数 ,其中第一个参数和结果都为状态,第二个参数为字符集的一个字符,相当于 DFA 中的边,每条边上都有一个字符。
这不是重点,重点是下面介绍的几种自动机。
序列自动机
比较简单的一种自动机,主要用来识别一个串的子序列。
一个长度为 的字符串 的序列自动机包含 个状态,下图(来自知乎 @Pecco)为 的序列自动机。
我们可以维护一个 表示从 开始字符 第一次出现的位置,其中 。
对于一个文本串 ,我们可以从 递推,每一次显然 ,,然后再将 置为 即可。
以下是 的代码:
fro(i, n, 1) {
rep(j, 0, 25) ne[i][j] = ne[i + 1][j];
ne[i][s[i] - 'a'] = i;
}
时间和空间复杂度都是
会了这个后,你就可以完成很多的题目。
ARC081C
求不是一个字符串 的子序列的最短串,我们可以先对 建立序列自动机,然后从起始状态开始 ,对于自动机中的一个状态 ,枚举所有的 ,直到找到第一个没有出现过的,然后就找到了。
我们可以记录一个 表示第 状态是从哪里过来的,然后倒序输出即可。
P1819
本题相当于求 三个字符串的公共子序列数量。
首先还是先分别求出三个字符串的 备用,然后我们考虑 。
令 表示序列 从 开始, 从 开始, 从 开始的公共子序列数量。
转移时我们考虑枚举下一个公共字符 ,则可以转移到 ,即:
记忆化搜索即可。
在实现过程中要注意将 置为 ,否则会自己跳到自己,就死循环了,或者你在 转移的时候加一也行。
为了方便实现,我们考虑将 封装到结构体里,就不用写三遍了。
时间为状态数乘转移数,是 。
双倍经验题:P3856
P5826
可以发现,除了字符集大小非常大以外,都是非常模板的,如果暴力 的话时间空间都受不了。
但我们观察求 的过程,其实对于 每次只有 相比 发生了变化,所以我们其实使用了很多无用的空间和操作。我们考虑使用主席树的思想,每次单点修改即可。
代码其实还算好写,因为只用到了最模板的主席树,然后用主席树查询即可。
P4608
如果数据不需要高精度的话是一道非常眉清目秀的题目,对于 和 的情况分开来写两个 即可,思路类似 ,输出路径可以在记忆化搜索函数的开头输出答案序列即可。
为什么要演奏高精度!气死了喵!
坑点:空的序列也是要输出的,而且是英文大小写字母。
AC 自动机
建立
事情开始变得不对劲起来了
自动机其实就是 ,本质上就是利用 的思想同时对多个模式串进行匹配。如果不熟悉这两个建议先去复习一下。
在做 自动机之前,我们得先建出一个 然后如同 定义 数组一样,我们定义 表示所有模式串中最长的前缀中匹配当前 结点所对应状态的后缀。有点绕,你可以理解成 中的最长公共前后缀不一定在同一个序列上,可能是一个不同序列的前缀和当前 结点所对应状态的后缀相等。
比如当前 中已经有了 和 ,那么你可以理解为 ,只不过真是存的是对应 的状态 ,对应 的状态为 ,则 。
我们考虑如何建立所有的 指针。
按照 的思路,我们考虑从 的根节点开始,向下逐位地匹配。
对于一个结点 ,它的父节点为 ,。我们考虑用深度小于 的结点的 来推 的 。
如果 存在,就让 ,相当于在 和 后面都加上一个字符 。
如果 不存在,我们就继续跳 ,……直到根节点如果也不存在,就让 指向根节点。
OI-wiki 的动图:
我们发现一只跳 显然做了很多无用功,所以可以进行路径压缩。
我们还是思考对于一个结点 ,令 ,分两种情况考虑:
- 如果 存在,那么我们可以直接令 ,不考虑 是否存在。这利用到了数学归纳法的思想,我们假定前面的层数都已经计算好了,所以可以直接像上述一般赋值。换句话说,我们不用考虑到达 后怎么跳,因为这已经是算好的。
- 如果 不存在,则令 。因为你考虑朴素操作中如果 不存在会一直跳 直到找到或是到根节点,而我们现在是直接将 赋值,所以我们需要冒充有一直向上跳,从而便有了如此地设计。
认真理解一下这一长串文字,其实并不是非常难懂。
到此, 构建的部分就告一段落,下面给出插入和构建的模板代码。
顺便再提一嘴,这样建出来的 其实可以称作 图,因为 的缘故它已经不能称作树了。
void insert() {
int p = 0;
for (int i = 0; s[i]; i ++) {
int c = s[i] - 'a';
if (!tr[p][c]) tr[p][c] = ++ idx;
p = tr[p][c];
}
cnt[p] ++;
}
void build() {
queue<int> q;
rep(i, 0, 25)
if (tr[0][i]) q.push(tr[0][i]);
while (q.size()) {
int u = q.front(); q.pop();
rep(i, 0, 25) {
int p = tr[u][i];
if (!p) tr[u][i] = tr[ne[u]][i];
else {
ne[p] = tr[ne[u]][i];
q.push(p);
}
}
}
}
接下来我们考虑匹配,我们将会以一道模板题为例。
P3808(简单的匹配)
我们需要查询有多少个不同的模式串在文本串中出现过。
首先我们按照上述过程对所有模式串建立 自动机,然后考虑匹配怎么做。
我们可以用 表示当前匹配到的 自动机状态,在遍历文本串时顺着文本串的字符 走 ,然后不断地向上跳 将走到的所有节点加上贡献,然后把结点的 清空,避免重复计算。
文字叙述比较抽象,可以结合图片理解(还是 OI-wiki):
完整的代码可以去[这里](记录详情 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn))
匹配的代码:
// 由于我们将 cnt[p] 清 0 的缘故,复杂度是对的
int res = 0;
for (int i = 0, u = 0; s[i]; i ++) {
int c = s[i] - 'a';
u = tr[u][c];
int p = u;
while (p && cnt[p]) {
res += cnt[p];
cnt[p] = 0;
p = ne[p];
}
}
P5357(匹配优化)
朴素做法十分简单,在 插入字符串时记录一下 表示 作为结尾对应的字符串序号,然后在匹配向上跳的过程中,不断将 即可。
但这样子由于会一直跳 ,并且不像上一题有清空的优化,所以会超时。
我们考虑 指针的一个性质:如果 自动机中只保留 边,那么剩余的图一定是一棵树。这是显然的。所以上文的匹配就可以转化成 树上的链求和问题,就可以进行优化。
我们按照 树做拓扑排序。因为如果一个结点 有贡献,那么它往上跳 跳到的所有点都有贡献,所以我们可以不用暴力跳,在查询的时候只为找到结点的 进行计算,然后按照拓扑序计算即可。
代码实现时我们要在 中加入入度统计。
注意模式串中可能有重复的,所以要特殊处理一下,可以看代码理解,这里就不细说了。
代码马蜂应该还是比较不错的?
const int N = 200010, M = 2000010;
int n, idx[N], mp[N], res[N];
char s[M];
namespace AC {
int tr[N][26], ne[N], pidx, degs[N], tot, ans[N];
void insert(char s[N], int id) {
int p = 0;
for (int i = 0; s[i]; i ++) {
int c = s[i] - 'a';
if (!tr[p][c]) tr[p][c] = ++ tot;
p = tr[p][c];
}
if (!mp[p]) mp[p] = ++ pidx;
idx[id] = mp[p];
}
void build() {
queue<int> q;
rep(i, 0, 25)
if (tr[0][i]) q.push(tr[0][i]);
while (q.size()) {
int u = q.front(); q.pop();
rep(i, 0, 25) {
int p = tr[u][i];
if (!p) tr[u][i] = tr[ne[u]][i];
else {
ne[p] = tr[ne[u]][i];
degs[tr[ne[u]][i]] ++;
q.push(p);
}
}
}
}
void query(char s[N]) {
int p = 0;
for (int i = 0; s[i]; i ++) {
p = tr[p][s[i] - 'a'];
ans[p] ++;
}
}
void topu() {
queue<int> q;
rep(i, 1, tot)
if (!degs[i]) q.push(i);
while(q.size()) {
int u = q.front(); q.pop();
res[mp[u]] = ans[u];
ans[ne[u]] += ans[u];
degs[ne[u]] --;
if (!degs[ne[u]]) q.push(ne[u]);
}
}
};
int main() {
n = read();
rep(i, 1, n) {
scanf("%s", s);
AC::insert(s, i);
}
AC::build();
scanf("%s", s);
AC::query(s); AC::topu();
rep(i, 1, n) printf("%d\n", res[idx[i]]);
return 0;
}
P2414
一道非常有趣的 自动机题。
首先,根据 的定义,对于 自动机中的一个 的 指向 ,则 表示的字符串一定出现在 表示的字符串中。
所以原题中的询问可以转换为找所有属于 的结点中 直接或间接指向 的结束位置的数量。
所以可以进一步转化成在 树中,以 结束点为根的子树中,属于 的点的数量。我们只要把 的点全部标记为 ,然后每次对 的末节点求一遍和即可。
我们考虑将 树跑一遍 得出 序,这样这个问题就变成了序列问题。由于一个点的子树在 序上一定是连续的,所以可以直接使用树状数组求和。
具体来说我们将询问都离线下来,然后从左到右扫描给出的字符串,分三种情况:
- 若遇到 ,标记减一然后回退。
- 若遇到 ,则可以直接解决 为当前点的询问
- 否则走一步,并将走到的结点加一
要维护的细节还是很多的,包括但不限于字符串 的映射和上一步的维护。
P6257
考虑题目给出的名字都是前面加一个字母,这非常不友好,所以我们考虑每次在后面加一个字母,然后将所有的询问串都翻转。这样题目就转化成了查找有多少个人名字后缀包含某个询问串。
这就比较好做了,我们可以对于所有翻转的查询串建立 自动机,然后对于所有的名字建 。显然根据题目给出的名字结构是非常好建 的。
接着我们考虑一个名字在 自动机中出现的后缀肯定是一条 链,所以我们不妨建出 树。直接跳显然不对,但我们只要在最长的后缀的位置标记,最后用拓扑排序或者 计算答案即可。
回文自动机
在学习回文自动机()之前最好先学 喵。
回文自动机是一个高效存储所有回文子串的自动机,它不同于 中间加符号让偶串变奇串,而是直接将偶串和奇串分开考虑。
具体来说,我们维护一个奇根和偶根,其中奇根编号为 ,偶根编号为 ,奇根对应回文串长度为 ,偶根对应回文串长度为 。不难发现,奇根和偶根便是 的初始状态。
定义 上的一个点到根的路径上的字符串为回文串的一半,即 的读法是从一个点读到根再倒着读回来。而连接奇根的边只读一次,举个例子:
上图中 结点就代表 , 结点代表 。
同样地 也有自己的 指针,代表一个状态除自己以外的最长回文后缀。同时对于每个结点我们还要记录 表示 所对应回文串的实际长度。
如何构造 ?其实和 自动机十分相似。
我们考虑已经计算了一个串的前 位的信息,令 的最长回文后缀对应的状态 ,考虑如何计算第 位的信息。
显然我们可以考虑能否在 的左右两侧各添加一个 看看能否匹配。即判断 是否等于 ,为什么是这样的坐标可以自己算算。
如果当前 无法满足,就一直跳 即可解决。最后对于第 位来说,则第 位对应的状态 就有 。
P5496
先放模板代码
const int N = 500010;
int n;
int tr[N][26], fail[N], len[N], idx, num[N];
char s[N];
int getfail(int x, int i) {
while (i - len[x] - 1 < 0 || s[i - len[x] - 1] != s[i]) x = fail[x];
return x;
}
int main() {
scanf("%s", s); n = strlen(s);
int last;
fail[0] = 1, len[1] = -1, idx = 1;
for (int i = 0, u = 0; i < n; i ++) {
if (i >= 1) s[i] = (s[i] - 97 + last) % 26 + 97;
int c = s[i] - 'a', p = getfail(u, i);
if (!tr[p][c]) {
fail[++ idx] = tr[getfail(fail[p], i)][c];
tr[p][c] = idx;
len[idx] = len[p] + 2;
num[idx] = num[fail[idx]] + 1;
}
u = tr[p][c];
last = num[u];
printf("%d ", last);
}
return 0;
}
解释一下, 就是跳 的过程。我们对于一个串 循环遍历它的每一位,然后将这一位插入到 中。 就是用来处理题目询问的,我们可以发现一个状态是比它的最长回文后缀多一个回文串的。
然后便是一些重要的内容:
- 偶根要指向奇根,原因是如果类似于 的情况, 怎么都匹配不上,就会一直走到偶根。而由于 如果只有自己的话就是一个回文串,是一定可以挂到奇根上的,所以需要从偶根去到奇根。
- 求新点的 一定要在建立新点前,否则会出现神秘情况卡死。同时求新点的 时也不能直接使用 ,否则自己会被当成自己的最长回文后缀,就卡死了。
可以发现用这种方式挂出来的自动机只有 个结点,时间复杂度总共是 的。
P3649
简单应用,我们只要在插点的时候对于每个 让 。然后由于回文自动机插入的时候本身就是按照拓扑序插入的,所以我们只要再插完点后再循环一遍所有的点,让 cnt[fail[i]] += cnt[i]
即可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步