“浅谈”回文自动机 (PAM)
会定期进行 Update。
Update on 2025/4/10 ~ 2025/4/11 初稿。
Update on 2025/4/15 增加例题:P4762 [CERC2014] Virus synthesis
正文
回文自动机(palindrome automaton),一种用来高效储存查询回文子串的自动机,写起来会比 manacher 好写,并且会更简单易懂(包括例题)。
前置知识
了解什么是自动机即可。 大神们可以跳过这一段
自动机是一个对信号序列进行判定的数学模型。
这句话涉及到的名词比较多,逐一解释一下。「信号序列」是指一连串有顺序的信号,例如字符串从前到后的每一个字符、数组从 1 到 n 的每一个数、数从高到低的每一位等。「判定」是指针对某一个命题给出或真或假的回答。有时我们需要对一个信号序列进行判定。一个简单的例子就是判定一个二进制数是奇数还是偶数,较复杂的例子例如判定一个字符串是否回文,判定一个字符串是不是某个特定字符串的子序列等等。
自动机的工作原理和地图很类似。假设你在你家,然后你从你家到学校,按顺序经过了很多路口。每个路口都有岔路,而你在所有这些路口的选择就构成了一个序列。
例如,你的选择序列是「家门 -> 右拐 -> 萍水西街 -> 尚园街 -> 古墩路 -> 地铁站 -> 下宁桥」,那你按顺序经过的路口可能是「家 -> 家门口 -> 萍水西街竞舟北路口 -> 萍水西街尚圆街路口 -> 尚园街古墩路口 -> 古墩路中 -> 三坝地铁站 -> 下宁桥地铁站」。可以发现,上学的选择序列不止这一个。同样要去地铁站,你还可以从竞舟北路绕道,或者横穿文鼎苑抄近路。
而我们如果找到一个选择序列,就可以在地图上比划出这个选择序列能不能去学校。比如,如果一个选择序列是「家门 -> 右拐 -> 萍水西街 -> 尚园街 -> 古墩路 -> 地铁站 -> 钱江路 -> 四号线站台 -> 联庄」,那么它就不会带你去同一个学校,但是仍旧可能是一个可被接受的序列,因为目标地点可能不止一个。
也就是说,我们通过这个地图和一组目的地,将信号序列分成了三类,一类是无法识别的信号序列(例如「家门 -> ???」),一类是能去学校的信号序列,另一类是不能的信号序列。我们将所有合法的信号序列分成了两类,完成了一个判定问题。
既然自动机是一个数学模型,那么显然不可能是一张地图。对地图进行抽象之后,可以简化为一个有向图。因此,自动机的结构就是一张有向图。
-- 而自动机的工作方式和流程图类似,不同的是:自动机的每一个结点都是一个判定结点;自动机的结点只是一个单纯的状态而非任务;自动机的边可以接受多种字符(不局限于 T 或 F)。
——————————————————————————————————————————— By OI-wiki
定义
状态
我们用 PAM 上的一个节点来表示一个回文子串,作为 PAM 的一个状态。但回文串分奇偶两种,像 manacher 一样在每两个字符之间加分隔符是很麻烦的。因此,我们把 PAM 的状态分成两个部分,一部分存奇回文串,另一部分存偶回文串。
同理,我们把根也分为奇根和偶根。它们不表示任何字符串,只作为初始状态而存在。
因为存的是回文串,我们其实只需要对一个串记录其中一半位置的字符是什么,所以定义 PAM 上的一个点到根的路径上的字符串表示它所代表的回文串的其中一半,这一点上 PAM 与以前学过的自动机状态的定义都不同。
- 换句话说,对于一个点它实际表示的回文串,在 PAM 上的读法是从它开始沿着 PAM 读到根,再原路读回该点形成的字符串。这里注意如果是奇回文串,与根相连的那个字符边只读一次。
令奇根为 1,偶根为 0 。那么对于 s = abaabc,建出的 PAM 如下图(省略 Fail 指针):

每个点表示的回文串根据上文所述即可读出,例如:
- 点 3,表示 "b"。
- 点 5,表示 "a"。
- 点 4,表示 "aba"。
- 点 6,表示 "baab"。
Fail 指针
回文自动机关键点,类似于 AC 自动机的失配树。
\(fail(x)\) 表示 \(x\) 的最长回文后缀,由于 \(x\) 是回文串,所以 \(fail (x)\) 也是最长回文前缀,这里的 \(fail\) 显然存储在 PAM 存储回文串 \(x\) 的位置上。
对于初始状态,可以将偶根的 \(fail\) 指针指向奇根,因为奇根永远不会失配,所有长度为 \(1\) 的串都是回文串。
再引入两个定义:
-
\(len(x)\) 表示 \(x\) 这个节点表示的回文串的原串的长度,用于在 PAM 构造时判断它在原串中的位置。
-
\(fa(x)\) 表示 \(x\) 这个节点在 PAM 上的父亲,注意并非 \(fail\);
所以 \(len(x) = len(fa(x)) + 2\),再次提醒 \(len (x)\) 不一定等于 \(len(fail(x)) + 2\)。
同时为了让奇回文串也满足条件,令 \(len(1) = -1\)。
继续以 abaabc 为例,加上 \(fail\) 指针后是:

PAM 的构造
PAM 的构造方式是在线的。
即每次添加一个字符 \(c\) 时,在原来的 PAM 基础上添加与新增字符相关的状态和转移。
假设对于一个长度为 \(n\) 的字符串 \(s\),我们已经构造完了前 \(n-1\) 个字符,现在要加入第 \(n\) 个字符。设这个字符为 \(c\),前 \(n-1\) 个字符最长回文后缀对应的状态是 \(now\)。
要新增的状态就是以第 \(n\) 个字符结尾,且在以前没有出现过的回文串。考虑到一个回文串前后各去掉一位还是回文串,新增的回文串前后去掉一位,一定是某个以 \(n-1\) 为结尾的回文串。
我们要找的就是以 \(n-1\) 为结尾的回文串中,前一位恰好是 \(c\) 的最长的串。发现上一次构造到的节点 \(now\) 就表示以 \(n-1\) 为结尾的最长回文串,因此我们不断令 \(now\gets \text{Fail}(now)\),直至满足条件。设使条件满足的状态为 \(pos\)。那么以 \(n\) 为结尾的最长回文后缀就是在 \(x\) 前后各加一个 \(c\)。根据 PAM 的定义,这个状态就是 \(pos\) 通过字符 \(c\) 的边连向的儿子。
如果 \(pos\) 没有对应的儿子,代表这个回文串是新增的,我们添加一个新的状态。否则既然已经存在就不用管了。
那么对于这个新的状态,可以证明只有最长的这个回文后缀是新增的。考虑一个比它短的回文后缀,可以在最长的里面对称到一个不包括 \(n\) 的字符串,这一定是以前出现过的状态。

(如果黑的是最长回文后缀,蓝的是次长回文后缀,那显然红的和蓝的相同,也是回文串,在前面出现过。)
那么我们令新点为 \(new\),容易得出 \(fa(new)=pos\),\(\text{len}(new)=\text{len}(pos)+2\)。
只剩下它的 Fail 还没有求了。
发现求 Fail 指针和求最长以 \(n\) 结尾的回文后缀的本质是一样的。这次我们从 \(\text{Fail}(pos)\) 开始跳,找到第一个能在前后各加一个 \(c\) 的回文串即可。
如果到最后都没匹配到,令 \(\text{Fail}(new)=0\) 就好了。
你可能会奇怪为什么不指向奇根呢,这两个都是啥也没有。考虑这样的情况:原来字符串只有一个字符 \(\texttt{a}\),现在变成了 \(\texttt{aa}\)。那么这个新回文串是从偶根同时也是之前的 Fail 转移过来的。如果一开始把 \(a\) 的 Fail 指向奇根就会转移不到这种情况。
大功告成。
P5496 【模板】回文自动机(PAM)
solution
根据 \(fail\) 的定义,发现对于 PAM 上的一个状态,以它结尾的回文后缀个数就是它在 \(fail\) 树上的深度。
那么我们只需要在构建过程中记录每个状态在 \(fail\) 树上的深度即可。
设函数 getfail (x, i) 表示从状态 x 开始查找首个前一位与 a[i] 相同的回文后缀状态。
inline int getfail (int x, int i) {
while (i - len[x] - 1 < 0 || s[i - len[x] - 1] != s[i]) x = fail[x];
return x;
}
完整代码
#include <bits/stdc++.h>
using namespace std;
const int N = 5e5 + 5;
char a[N];
int len[N], fail[N], ch[N][27], cur, pos, tot = 1, dep[N];
inline int getfail (int x, int i) {
while (i - len[x] - 1 < 0 || a[i - len[x] - 1] != a[i]) x = fail[x];
return x;
}
signed main () {
ios::sync_with_stdio (0);
cin.tie (0), cout.tie (0);
cin >> (a + 1);
int n = strlen (a + 1), ans = 0;
fail[0] = 1, len[1] = -1;
for (int i = 1; i <= n; ++ i) {
if (i >= 2) a[i] = (a[i] + ans - 97) % 26 + 97;
pos = getfail (cur, i);
if (!ch[pos][a[i] - 'a']) {
fail[++ tot] = ch[getfail (fail[pos], i)][a[i] - 'a'];
ch[pos][a[i] - 'a'] = tot;
len[tot] = len[pos] + 2;
dep[tot] = dep[fail[tot]] + 1;
}
cur = ch[pos][a[i] - 'a'];
cout << (ans = dep[cur]) << " ";
}
return 0;
}
P4762 [CERC2014] Virus synthesis
题目大意
给定一个由 A、G、T、C 组成的字符串 \(S\),你有现在有一个空串,请进行如下两种操作得到 \(S\)。
- 添加一个字符串到当前字符串的开头或结尾
- 复制当前字符串,设当前字符串为 \(T\),复制的串为 \(T_1\),翻转 \(T_1\),然后可以选择得到 \(T_1+T\) 或 \(T+T_1\) 其中一个。
求最少的操作次数。
本文来自博客园,作者:FChang,转载请注明原文链接:https://www.cnblogs.com/FChang/p/18819683

浙公网安备 33010602011771号