「笔记」回文自动机
写在前面
其实这东西学名叫 EER Tree,Palindromic Tree,直译是回文树,但本质上是一类有限状态自动机所以也可以叫 Palindromic Automaton,因为我很喜欢自动机所以以下都叫它回文自动机。
结构
类似后缀自动机的,回文自动机(以下简称 PAM)也是一类确定有限状态自动机。对于字符串 ,它的回文自动机是由以下五部分构成的五元组:
- 状态集合 :每个状态对应 中的本质不同的回文子串。
- 字符集 。
- 转移函数:有:,转移函数 存在当且仅当在 对应的回文子串两端添加字符 得到的字符串也是 的一个子串。
- 起始状态 ,代表空串。
- 接受状态集合 。
特别地,对于每个状态定义 表示该状态对应的回文串的长度,定义 指针指向该状态对应的回文串的最长的回文后缀。显然 指针只会连向长度严格小于当前状态的回文串对应的状态,则由各状态和 指针构成了一棵树,称为回文树。
看起来和 SAM 非常像,但需要注意的是回文串存在奇数和偶数长度的,按照上述定义的话转移和 均只能转移到与当前状态对应字符串长度奇偶性相同的状态,于是钦定了 PAM 中存在两个代表空串的初始状态,分别代表长度为 -1 和 0 的回文串,可以称它们为奇根,偶根,并且钦定偶根的 指针指向奇根,而我们并不关心奇根的 指针,因为奇根不可能失配(奇根转移出的下一个状态长度为 1,即单个字符。一定是回文子串)。
另外,PAM 比 SAM 更直观的一点是每个状态仅代表唯一的本质不同的回文子串。
构造
与 SAM 类似地,考虑使用增量法构建 PAM,即在 的 PAM 基础上构造 的 PAM。考虑维护一个 指针指向 的最长回文后缀,初始时 指向偶根。然后对 不断地跳 ,即按长度递减不断枚举 的所有回文后缀,直到满足 对应的 的回文后缀的前一个字符为 ,则转移 转移到的状态即为 的最长回文后缀,即 的最长回文后缀。
若该转移存在则直接转移,令 更新 即可,否则考虑新建状态 ,其长度为 ,然后再对 跳 直至找到满足上述条件的另一个回文后缀 ,则 ,然后再进行转移更新 。
复杂度证明
详见 OI-wiki。
字符串 的本质不同回文子串的数量至多只有 个,则 PAM 的状态数个数是 级别的。证明考虑数学归纳,可证明每增加一个字符本质不同的回文子串数至多增加一个。
在 PAM 中对于某个状态,通过转移可以使状态对应的回文子串的长度 ,通过跳 可以使状态对应的回文子串的长度至少 ,构造 PAM 过程中状态对应的子串长度只会增加 次,则跳 的过程至多只会进行 次,则构建 PAM 的时间复杂度也是 级别。
模板题
这题会比 P5496 【模板】回文自动机(PAM) 更好写一点所以把这题放这里了。
P3649 [APIO2014] 回文串
给定一仅由小写字母组成的字符串 ,定义 的一个子串的存在值为这个子串在 中出现的次数乘以这个子串的长度,求 的所有回文子串的存在值的最大值。
。
1S,128MB。
考虑在构建 PAM 过程中对每个状态额外维护 表示该状态对应的回文子串作为 的某前缀 的最长回文后缀时的出现次数,构建完 PAM 后考虑对回文树上所有状态求子树 之和即为该回文子串的实际出现次数。
上述过程没有必要通过 dfs 进行,直接倒序枚举所有状态 ,不断地将 累计到 中即可。
答案即 。
总时空复杂度均为 级别。
代码实现时注意需要初始化,先向 PAM 中插入偶根和奇根,并更新偶根的 。另外需要在 PAM 维护一个指针 表示当前输入的字符串的部分的最后一个位置。其他详见代码。
代码
复制复制//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)
给定一个字符串 。保证每个字符为小写字母。对于 的每个位置,请求出以该位置结尾的回文子串个数。
强制在线。
。
500ms,256MB。
真板题。
由回文树的性质可知,对于某个回文子串,其所有回文后缀即为回文树上它的所有祖先节点。
于是在增量法动态构建 PAM 的同时不断输出 在回文树上的深度即可。
总时空复杂度均为 级别。
//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] 双倍回文
对于某个字符串 ,定义其倒置为 ;对于某个字符串 ,若可以表示成 的形式,则称 是一个双倍回文串。
现给定字符串 ,求 的子串中最长的双倍回文串的长度。
。
1S,128MB。
显然双倍回文串也是一个回文串。可以发现某个串是双倍回文串,当且仅当存在一个长度为偶数的回文后缀满足长度为该串的一半。
先把 PAM 建出来,根据上述发现一个显然的想法是考虑枚举所有状态,若状态 回文树上的祖先中存在某个状态 满足 为偶数且 ,则 代表的回文串是双倍回文的。
显然不能暴力跳 呃呃,于是考虑能不能在构建 PAM 的过程中顺便对每个状态 维护上述状态 位于何处。具体地设 表示状态 对应回文子串的回文后缀中满足长度不大于 的一半的回文子串对应的状态。这东西显然可以和 一块维护,考虑不断地对 跳 直到对应的 的回文后缀的前一个字符为 ,且加上两个字符后长度的两倍小于 即可。
总时空复杂度均为 级别。
//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 的阅读量了,令人感慨。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】