「笔记」回文自动机

写在前面

其实这东西学名叫 EER Tree,Palindromic Tree,直译是回文树,但本质上是一类有限状态自动机所以也可以叫 Palindromic Automaton,因为我很喜欢自动机所以以下都叫它回文自动机。

结构

类似后缀自动机的,回文自动机(以下简称 PAM)也是一类确定有限状态自动机。对于字符串 S,它的回文自动机是由以下五部分构成的五元组:

  • 状态集合 Q:每个状态对应 S 中的本质不同的回文子串。
  • 字符集 Σ
  • 转移函数δ:有:Q×ΣQ,转移函数 δ(u,c) 存在当且仅当在 u 对应的回文子串两端添加字符 c 得到的字符串也是 S 的一个子串。
  • 起始状态 start,代表空串。
  • 接受状态集合 F

特别地,对于每个状态定义 len 表示该状态对应的回文串的长度,定义 fail 指针指向该状态对应的回文串的最长的回文后缀。显然 fail 指针只会连向长度严格小于当前状态的回文串对应的状态,则由各状态和 fail 指针构成了一棵树,称为回文树。

看起来和 SAM 非常像,但需要注意的是回文串存在奇数和偶数长度的,按照上述定义的话转移和 fail 均只能转移到与当前状态对应字符串长度奇偶性相同的状态,于是钦定了 PAM 中存在两个代表空串的初始状态,分别代表长度为 -1 和 0 的回文串,可以称它们为奇根,偶根,并且钦定偶根的 fail 指针指向奇根,而我们并不关心奇根的 fail 指针,因为奇根不可能失配(奇根转移出的下一个状态长度为 1,即单个字符。一定是回文子串)。

另外,PAM 比 SAM 更直观的一点是每个状态仅代表唯一的本质不同的回文子串。

构造

与 SAM 类似地,考虑使用增量法构建 PAM,即在 s[1:i1] 的 PAM 基础上构造 s[1:i] 的 PAM。考虑维护一个 last 指针指向 s[1:i1] 的最长回文后缀,初始时 last 指向偶根。然后对 last 不断地跳 fail,即按长度递减不断枚举 s[1:i1] 的所有回文后缀,直到满足 last 对应的 s[1:i1] 的回文后缀的前一个字符si,则转移 δ(last,si) 转移到的状态即为 s[1:i] 的最长回文后缀,即 s[1:i] 的最长回文后缀。

若该转移存在则直接转移,令 last=δ(last,si) 更新 last 即可,否则考虑新建状态 x,其长度为 len(last)+2,然后再对 lastfail 直至找到满足上述条件的另一个回文后缀 last,则 fail(x)=δ(last,si),然后再进行转移更新 last

复杂度证明

详见 OI-wiki

字符串 s 的本质不同回文子串的数量至多只有 |s| 个,则 PAM 的状态数个数是 O(n) 级别的。证明考虑数学归纳,可证明每增加一个字符本质不同的回文子串数至多增加一个。

在 PAM 中对于某个状态,通过转移可以使状态对应的回文子串的长度 +2,通过跳 fail 可以使状态对应的回文子串的长度至少 1,构造 PAM 过程中状态对应的子串长度只会增加 n 次,则跳 fail 的过程至多只会进行 2×n 次,则构建 PAM 的时间复杂度也是 O(n) 级别。

模板题

这题会比 P5496 【模板】回文自动机(PAM) 更好写一点所以把这题放这里了。

P3649 [APIO2014] 回文串
给定一仅由小写字母组成的字符串 s,定义 s 的一个子串的存在值为这个子串在 s 中出现的次数乘以这个子串的长度,求 s 的所有回文子串的存在值的最大值。
1|s|3×105
1S,128MB。

考虑在构建 PAM 过程中对每个状态额外维护 cnt 表示该状态对应的回文子串作为 s 的某前缀 s[1:i] 的最长回文后缀时的出现次数,构建完 PAM 后考虑对回文树上所有状态求子树 cnt 之和即为该回文子串的实际出现次数。

上述过程没有必要通过 dfs 进行,直接倒序枚举所有状态 i,不断地将 cnti 累计到 cntfaili 中即可。

答案即 maxi(cnti×leni)

总时空复杂度均为 O(|s|) 级别。

代码实现时注意需要初始化,先向 PAM 中插入偶根和奇根,并更新偶根的 fail。另外需要在 PAM 维护一个指针 nown 表示当前输入的字符串的部分的最后一个位置。其他详见代码。

代码

复制复制
//P3649 [APIO2014] 回文串
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 3e5 + 10;
char s[kN];
int n;
//=============================================================
//=============================================================
inline int read() {
int f = 1, w = 0; char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
namespace PAM {
const int kNode = kN << 1;
int nown, nodenum, last, tr[kNode][26], len[kNode], fail[kNode];
int cnt[kNode];
char t[kN];
int Newnode(int len_) {
++ nodenum;
memset(tr[nodenum], 0, sizeof (tr[nodenum]));
len[nodenum] = len_;
fail[nodenum] = cnt[nodenum] = 0;
return nodenum;
}
void Init() {
nodenum = -1;
last = 0;
t[nown = 0] = '$';
Newnode(0), Newnode(-1);
fail[0] = 1;
}
int getfail(int x_) {
while (t[nown - len[x_] - 1] != t[nown]) x_ = fail[x_];
return x_;
}
void Insert(char ch_) {
t[++ nown] = ch_;
int now = getfail(last);
if (!tr[now][ch_ - 'a']) {
int x = Newnode(len[now] + 2);
fail[x] = tr[getfail(fail[now])][ch_ - 'a'];
tr[now][ch_ - 'a'] = x;
}
last = tr[now][ch_ - 'a'];
++ cnt[last];
}
LL Solve() {
LL ans = 0;
for (int i = nodenum; i >= 0; -- i) {
cnt[fail[i]] += cnt[i];
}
for (int i = 1; i <= nodenum; ++ i) {
ans = std::max(ans, 1ll * cnt[i] * len[i]);
}
return ans;
}
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
scanf("%s", s + 1); n = strlen(s + 1);
PAM::Init();
for (int i = 1; i <= n; ++ i) PAM::Insert(s[i]);
printf("%lld\n", PAM::Solve());
return 0;
}

例题

P5496 【模板】回文自动机(PAM)

给定一个字符串 s。保证每个字符为小写字母。对于 s 的每个位置,请求出以该位置结尾的回文子串个数。
强制在线。
1|s|5×105
500ms,256MB。

真板题。

由回文树的性质可知,对于某个回文子串,其所有回文后缀即为回文树上它的所有祖先节点。

于是在增量法动态构建 PAM 的同时不断输出 last 在回文树上的深度即可。

总时空复杂度均为 O(|s|) 级别。

//P5496 【模板】回文自动机(PAM)
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 5e5 + 10;
char s[kN];
int ans, n;
//=============================================================
//=============================================================
inline int read() {
int f = 1, w = 0; char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
namespace PAM {
const int kNode = kN << 1;
int nown, nodenum, last, tr[kNode][26], len[kNode], fail[kNode];
int dep[kNode];
char t[kN];
int Newnode(int len_) {
++ nodenum;
memset(tr[nodenum], 0, sizeof (tr[nodenum]));
len[nodenum] = len_;
fail[nodenum] = dep[nodenum] = 0;
return nodenum;
}
void Init() {
nodenum = -1;
last = 0;
t[nown = 0] = '$';
Newnode(0), Newnode(-1);
fail[0] = 1;
}
int getfail(int x_) {
while (t[nown - len[x_] - 1] != t[nown]) x_ = fail[x_];
return x_;
}
void Insert(char ch_) {
t[++ nown] = ch_;
int now = getfail(last);
if (!tr[now][ch_ - 'a']) {
int x = Newnode(len[now] + 2);
fail[x] = tr[getfail(fail[now])][ch_ - 'a'];
tr[now][ch_ - 'a'] = x;
}
last = tr[now][ch_ - 'a'];
dep[last] = dep[fail[last]] + 1;
printf("%d ", ans = dep[last]);
}
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
scanf("%s", s + 1); n = strlen(s + 1);
PAM::Init();
PAM::Insert(s[1]);
for (int i = 2; i <= n; ++ i) PAM::Insert((s[i] - 97 + ans) % 26 + 97);
return 0;
}

P4287 [SHOI2011] 双倍回文

对于某个字符串 w,定义其倒置为 wR=w|w|w|w|1w2w1;对于某个字符串 x,若可以表示成 x=wwRwwR 的形式,则称 x 是一个双倍回文串。
现给定字符串 s,求 s 的子串中最长的双倍回文串的长度。
1|s|5×105
1S,128MB。

显然双倍回文串也是一个回文串。可以发现某个串是双倍回文串,当且仅当存在一个长度为偶数的回文后缀满足长度为该串的一半。

先把 PAM 建出来,根据上述发现一个显然的想法是考虑枚举所有状态,若状态 x 回文树上的祖先中存在某个状态 y 满足 len(y) 为偶数且 len(x)=2×len(y),则 x 代表的回文串是双倍回文的。

显然不能暴力跳 fail 呃呃,于是考虑能不能在构建 PAM 的过程中顺便对每个状态 x 维护上述状态 y 位于何处。具体地设 transx 表示状态 x 对应回文子串的回文后缀中满足长度不大于 x 的一半的回文子串对应的状态。这东西显然可以和 fail 一块维护,考虑不断地对 translastfail 直到对应的 s[1:i1] 的回文后缀的前一个字符si,且加上两个字符后长度的两倍小于 lenx 即可。

总时空复杂度均为 O(|s|) 级别。

//P4287 [SHOI2011] 双倍回文
/*
By:Luckyblock
*/
#include <bits/stdc++.h>
#define LL long long
const int kN = 5e5 + 10;
char s[kN];
int ans, n;
//=============================================================
//=============================================================
inline int read() {
int f = 1, w = 0; char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
namespace PAM {
const int kNode = kN << 1;
int nown, nodenum, last, tr[kNode][26], len[kNode], fail[kNode];
int trans[kNode];
char t[kN];
int Newnode(int len_) {
++ nodenum;
memset(tr[nodenum], 0, sizeof (tr[nodenum]));
len[nodenum] = len_;
fail[nodenum] = 0;
trans[nodenum] = 0;
return nodenum;
}
void Init() {
nodenum = -1;
last = 0;
t[nown = 0] = '$';
Newnode(0), Newnode(-1);
fail[0] = 1;
}
int getfail(int x_) {
while (t[nown - len[x_] - 1] != t[nown]) x_ = fail[x_];
return x_;
}
int gettrans(int x_, int len_) {
x_ = trans[x_];
while (t[nown - len[x_] - 1] != t[nown] || (2 * len[x_] + 4) > len_) x_ = fail[x_];
return x_;
}
void Insert(char ch_) {
t[++ nown] = ch_;
int now = getfail(last);
if (!tr[now][ch_ - 'a']) {
int x = Newnode(len[now] + 2);
fail[x] = tr[getfail(fail[now])][ch_ - 'a'];
tr[now][ch_ - 'a'] = x;
if (len[x] <= 2) {
trans[x] = fail[x];
} else {
trans[x] = tr[gettrans(now, len[x])][ch_ - 'a'];
}
}
last = tr[now][ch_ - 'a'];
}
void Solve() {
for (int i = 2; i <= nodenum; ++ i) {
if (2 * len[trans[i]] == len[i] && len[trans[i]] % 2 == 0) {
ans = std::max(ans, len[i]);
}
}
}
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
n = read();
scanf("%s", s + 1);
PAM::Init();
for (int i = 1; i <= n; ++ i) PAM::Insert(s[i]);
PAM::Solve();
printf("%d\n", ans);
return 0;
}

写在最后

参考:

学习耗时:3 分钟。

OI-wiki 上直接就“类似后缀自动机”给我笑烂了,幸亏狠狠地学习过 SAM,然后 PAM 就成了傻逼。

话说 PAM 居然是 15 年才被发明的,居然有幸学到了本世纪的新知识,令人感慨。

另外本文居然是本博客的第 400 篇可见随笔,四年两个月前为了存点代码的建的小破站到现在居然有 82k 的阅读量了,令人感慨。

posted @   Luckyblock  阅读(257)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示