回文自动机 PAM
1 概述
回文自动机(PAM),是一种用于维护回文子串的自动机。与其他自动机类似的,它由转移边以及 fail 指针构成,上面的每一个节点都代表一个回文子串。
回文自动机的结构与 AC 自动机比较相似,下面详细讲解。
2 结构
首先我们的 PAM 要存储所有的本质不同回文子串,那么考虑到回文子串的长度有奇偶之分,所以在 PAM 中我们要建立两个根:奇根和偶根,对应的两棵树上回文子串的长度分别为奇数和偶数。
接下来是转移边的设置,我们知道所有本质不同回文子串都可以看作是在原先的回文子串左右加上两个相同字符构成,所以我们将转移边上的字符就设为这个新加的字符。也就是说,如果 \(u\) 节点表示回文串 S,而 \((u,v)\) 边上字符为 c,则 \(v\) 上回文串为 cSc。如下图所示:

然后是 fail 指针,PAM 中存储了一个位置为结尾的最长回文串,如果想知道以这个结尾所有的回文子串,就需要记录每个回文串的最长回文后缀。那么这就是 PAM 的 fail 指针:该节点表示的回文串的最长回文后缀所对应的点。同时规定偶根的 fail 指向奇根。
最后,我们在每个节点上定义这个节点代表的回文串长度 \(len(x)\)。若当前节点为 \(x\),父亲为 \(fa\),则显然有 \(len(x)=len(fa)+2\)。为了让奇数长度回文串也满足这个要求,我们规定奇根的 \(len\) 为 \(-1\)。
下面我们就可以开始考虑构建 PAM 了。
3 构建 PAM
3.1 思想
我们使用增量的方法构建 PAM,即一个一个字符加入。
考虑我们已经构建好了前 \(i-1\) 位字符的 PAM,现在要插入第 \(i\) 位的字符。那么我们要找到一个以上一位结尾的回文子串 \(s[l,i-1]\),满足 \(s[l-1]=s[i]\)。也就是说现在我们要找出以上一位结尾的所有回文子串病判断,显然这与我们 fail 数组的定义是一致的。
于是我们不断跳 fail 指针,直到找到第一个节点 \(x\),满足 \(s[i]=s[i-len(x)-1]\),那么在节点 \(x\) 的回文串两边加上当前字符就是以 \(i\) 结尾最长回文子串。所以我们在 \(x\) 节点连一条 \(s[i]\) 的儿子即可。
现在考虑怎样求出新节点的 fail 指针,思路与上面是类似的,相当于我们要找到满足 \(s[i]=s[i-len(x)-1]\) 的第二个节点 \(x\)。那我们从上面求出的第一个节点 \(x\) 出发,按照同样的方法跳 fail 指针,这样找到的第一个节点 \(x'\) 就是新节点的 fail 指针。
注意在找父亲和找 fail 时,没有匹配上后连的根不一样。当找父亲没有找到时应连奇根,找 fail 没有找到应该找偶根。
首先显然找父亲没有找到说明以当前位置为结尾的最长回文子串就是这一个字符,由于奇根的 \(len\) 是 \(-1\),带入上面的式子会发现 \(s[i]=s[i-(-1)-1]=s[i]\) 是正确的。
而找 fail 没有找到,则说明它没有回文后缀,也就是说它回文后缀的长度是 \(0\),因此指向偶根是正确的。
3.2 代码
struct PAM {
int len, fail, son[26];
//基本信息
}pam[Maxn];
int getf(int x, int i) {//向上跳 fail 指针,找到满足条件的节点
while(i - p[x].len - 1 < 0 || s[i - p[x].len - 1] != s[i])
x = p[x].fail;
return x;
}
int lst, tot;
//记录上一位在 PAM 上的节点
void insert(int i) {
int pos = getf(lst, i), ch = s[i] - 'a';
if(pam[pos].son[ch] == 0) {//只有一千没有出现才添加
pam[++tot].fail = pam[getf(pam[pos].fail, i)].son[ch];
//从父亲向上跳 fail 找到当前节点的 fail
//同时注意找 fail 必须要在连接转移边之前写
p[pos].son[ch] = tot;//连转移边
p[tot].len = p[pos].len + 2;//计算长度
}
lst = p[pos].son[ch];//为下一个字符插入记录上一位的编号
return ;
}
3.3 复杂度分析
容易发现上述操作只有跳 fail 的复杂度不明晰,而剩余的部分复杂度显然是 \(O(n)\) 的。
我们考虑每一次加入字符后,当前字符在 fail 树上的深度。显然每次新增一个字符最坏可能是在 fail 树上深度加一,也就是说当前字符跳 fail 至多跳 \(2n\) 次。于是我们就得出,构造 PAM 的时间复杂度也是线性 \(O(n)\) 的。
4 应用
4.1 结尾回文串个数
这才是 PAM 的模板题:【模板】回文自动机(PAM)。
这个问题显然并不复杂,根据 fail 的定义,我们知道,从一个节点不断跳 fail 直到头,跳过的所有节点都是以当前位置结尾的回文串。所以当前位置结尾的回文串个数就是加入该位置后它在 fail 树上的深度。在结构体中维护一下即可。
代码:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 5e5 + 5;
const int Inf = 2e9;
string s, t;
int n;
struct PAM {
int len, fail, son[26];
int dep;
}pam[Maxn];
int getf(int x, int i) {
while(i - pam[x].len - 1 < 0 || s[i - pam[x].len - 1] != s[i])
x = pam[x].fail;
return x;
}
int lst, tot = 1;//初始时已经有 0 号和 1 号点了
void insert(int i) {
int pos = getf(lst, i), ch = s[i] - 'a';
if(pam[pos].son[ch] == 0) {
pam[++tot].fail = pam[getf(pam[pos].fail, i)].son[ch];
pam[pos].son[ch] = tot;
pam[tot].len = pam[pos].len + 2;
pam[tot].dep = pam[pam[tot].fail].dep + 1;//记录 fail 树上的深度
}
lst = pam[pos].son[ch];
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> t;
n = t.size();
pam[0].fail = 1;
pam[1].len = -1;//奇根偶根初始化
int lstans = 0;
for(int i = 0; i < n; i++) {
char ch = t[i];
if(lstans) {
ch = (char)(((int)ch - 97 + lstans) % 26 + 97);
}
s += ch;
insert(i);
cout << pam[lst].dep << " ";
lstans = pam[lst].dep;
}
return 0;
}
4.2 trans 数组
在回文自动机的题目中,还有一个非常常用的数组:trans 数组。它表示的是该节点对应的回文串的最长的,满足长度不超过该回文串 \(\dfrac 12\) 的后缀回文串对应的节点。也就是相当于在 fail 上新加了一个长度限制。
那么自然的,我们就可以套用求 fail 的方式来求 trans,同时仿照上面的复杂度分析,可以知道这样做的时间复杂度也是 \(O(n)\) 的。代码如下:
int getfail(int x, int i) {
while(i - pam[x].len - 1 < 0 || s[i - pam[x].len - 1] != s[i])
x = pam[x].fail;
return x;
}
int gettrans(int x, int i, int len) {
while((x != 1) && (i - pam[x].len - 1 < 0 || s[i - pam[x].len - 1] != s[i] || (pam[x].len + 2) * 2 > len/*长度限制,注意此时的长度还没有加上两边的字符,因此括号内要加 2*/){
x = pam[x].fail;
}
return x;
}
void insert(int i) {
int pos = getfail(lst, i), ch = s[i] - 'a';
if(pam[pos].son[ch] == 0) {
pam[++tot].fail = pam[getfail(pam[pos].fail, i)].son[ch];
pam[pos].son[ch] = tot;
pam[tot].len = pam[pos].len + 2;
pam[tot].trans = pam[gettrans(pam[pos].trans, i, pam[tot].len)].son[ch];
//从父亲的 trans 开始跳
}
lst = pam[pos].son[ch];
}
那么我们看一道例题:[SHOI2011] 双倍回文,看看 trans 数组有什么用。
我们发现题目中要求的条件其实就是说:一个字符串是偶数长度回文串,并且字符串的一半还是偶数长度回文串。那么我们假设枚举到节点 \(i\),如果 \(len(trans_i)\times 2=len(i)\),那么说明 \(i\) 节点上是偶数长度回文串,而 \(trans_i\) 就是这个回文串的一半,它也是一个回文串。
那么现在就只需要保证 \(trans_i\) 是偶数长度的即可,这个就非常好判断了。最后代码如下:
#include <bits/stdc++.h>
using namespace std;
const int Maxn = 6e5 + 5;
const int Inf = 2e9;
int n, num[Maxn];
string s;
struct PAM {
int len, fail, son[26];
int trans;
}pam[Maxn];
int getfail(int x, int i) {
while(i - pam[x].len - 1 < 0 || s[i - pam[x].len - 1] != s[i])
x = pam[x].fail;
return x;
}
int gettrans(int x, int i, int len) {
while((x != 1) && (i - pam[x].len - 1 < 0 || s[i - pam[x].len - 1] != s[i] || pam[x].len * 2 + 4 > len)){
x = pam[x].fail;
}
return x;
}
int lst, tot = 1;
void insert(int i) {
int pos = getfail(lst, i), ch = s[i] - 'a';
if(pam[pos].son[ch] == 0) {
pam[++tot].fail = pam[getfail(pam[pos].fail, i)].son[ch];
pam[pos].son[ch] = tot;
pam[tot].len = pam[pos].len + 2;
pam[tot].trans = pam[gettrans(pam[pos].trans, i, pam[tot].len)].son[ch];
}
lst = pam[pos].son[ch];
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> s;
s = ' ' + s;
pam[0].fail = 1;
pam[1].len = -1;
int ans = 0;
for(int i = 1; i <= n; i++) {
insert(i);
int p = pam[lst].trans;
if(p != 0 && p != 1 && pam[p].len % 2 == 0 && pam[p].len * 2 == pam[lst].len) {
ans = max(ans, pam[lst].len);
}
}
cout << ans;
return 0;
}
trans 数组的用处其实就在于,它所代表的回文后缀是没有跨过当前的回文中心的,因此具有更加良好的性质。

浙公网安备 33010602011771号