回文树
定义
回文树(EER Tree,Palindromic Tree,也被称为回文自动机)是一种可以存储一个串中所有回文子串的高效数据结构。最初由 Mikhail Rubinchik 和 Arseny M. Shur 在 2015 年发表。它的灵感来源于后缀树等字符串后缀数据结构,使用回文树可以简单高效地解决一系列涉及回文串的问题。
结构
回文树大概长这样
和其它自动机类似的,回文树也是由转移边和后缀链接(fail 指针)组成,每个节点都可以代表一个回文子串。
因为回文串长度分为奇数和偶数,我们可以像 manacher 那样加入一个不在字符集中的字符(如 '#')作为分隔符来将所有回文串的长度都变为奇数,但是这样过于麻烦了。有没有更好的办法呢?
答案自然是有。更好的办法就是建两棵树,一棵树中的节点对应的回文子串长度均为奇数,另一棵树中的节点对应的回文子串长度均为偶数。
和其它的自动机一样,一个节点的 fail 指针指向的是这个节点所代表的回文串的最长回文后缀所对应的节点,但是转移边并非代表在原节点代表的回文串后加一个字符,而是表示在原节点代表的回文串前后各加一个相同的字符(不难理解,因为要保证存的是回文串)。
我们还需要在每个节点上维护此节点对应回文子串的长度 len,这个信息保证了我们可以轻松地构造出回文树。
建造
回文树有两个初始状态,分别代表长度为
偶根的 fail 指针指向奇根,而我们并不关心奇根的 fail 指针,因为奇根不可能失配(奇根转移出的下一个状态长度为
类似后缀自动机,我们增量构造回文树。
考虑构造完前
我们从以上一个字符结尾的最长回文子串对应的节点开始,不断沿着 fail 指针走,直到找到一个节点满足
这里贴出论文中的那张图
我们通过跳 fail 指针找到 A 所对应的节点,然后两边添加 X
就到了现在的回文串了(即 XAX
),很显然,这个节点就是以 X
能匹配条件就是同一个位置的 X
的节点。)此时要判断一下:没有这个节点,就需要新建。
然后我们还需要求出新建的节点的 fail 指针。具体方法与上面的过程类似,不断跳转 fail 指针,从 A
出发,即可找到 XAX
的最长回文后缀 XBX
,将对应节点设为 fail 指针所指的对象即可。
显然,这个节点是不需新建的,A
的前 B
,前 X
,后面被钦定了是 X
,于是这个节点 XBX
肯定已经被包含了。
如果 fail 没匹配到,那么将它连向长度为
线性状态数证明
定理
对于一个字符串
证明
考虑使用数学归纳法。
-
当
时, 只有一个字符,同时也只有一个子串,并且这个子串是回文的,因此结论成立。 -
当
时,设 ,其中 表示 最后增加一个字符 后形成的字符串,假设结论对 串成立。考虑以最后一个字符 结尾的回文子串,假设它们的左端点由小到大排序为 。由于 是回文串,因此对于所有位置 ,有 。所以,对于 , 已经在 中出现过。因此,每次增加一个字符,本质不同的回文子串个数最多增加 个。
由数学归纳法,可知该定理成立。
因此回文树状态数是
正确性证明
以上图为例,增加当前字符 X
,由线性状态数的证明,我们只需要找到包含最后一个字符 X
的最长回文后缀,也就是 XAX
。继续寻找 XAX
的最长回文后缀 XBX
,建立后缀链接。XBX
对应状态已经在回文树中出现,包含最后一个字符的回文后缀就是 XAX
,XBX
本身及其对应状态在 fail 树上的所有祖先。
对于
加入字符时,在上一次的基础上,每次跳 fail 后对应节点在 fail 树的深度
因为只加入
因此,构造
应用
本质不同回文子串个数
由线性状态数的证明,容易知道一个串的本质不同回文子串个数等于回文树的状态数(排除奇根和偶根两个状态)。
回文子串出现次数
建出回文树,使用类似后缀自动机统计出现次数的方法。
由于回文树的构造过程中,节点本身就是按照拓扑序插入,因此只需要逆序枚举所有状态,将当前状态的出现次数加到其 fail 指针对应状态的出现次数上即可。
定义
参考代码
#include <algorithm>
#include <cstring>
#include <iostream>
#include <string>
using namespace std;
constexpr int MAXN = 300000 + 5;
namespace pam {
int sz, tot, last;
int cnt[MAXN], ch[MAXN][26], len[MAXN], fail[MAXN];
char s[MAXN];
int node(int l) { // 建立一个新节点,长度为 l
sz++;
memset(ch[sz], 0, sizeof(ch[sz]));
len[sz] = l;
fail[sz] = cnt[sz] = 0;
return sz;
}
void clear() { // 初始化
sz = -1;
last = 0;
s[tot = 0] = '$';
node(0);
node(-1);
fail[0] = 1;
}
int getfail(int x) { // 找后缀回文
while (s[tot - len[x] - 1] != s[tot]) x = fail[x];
return x;
}
void insert(char c) { // 建树
s[++tot] = c;
int now = getfail(last);
if (!ch[now][c - 'a']) {
int x = node(len[now] + 2);
fail[x] = ch[getfail(fail[now])][c - 'a'];
ch[now][c - 'a'] = x;
}
last = ch[now][c - 'a'];
cnt[last]++;
}
long long solve() {
long long ans = 0;
for (int i = sz; i >= 0; i--) {
cnt[fail[i]] += cnt[i];
}
for (int i = 1; i <= sz; i++) { // 更新答案
ans = max(ans, 1ll * len[i] * cnt[i]);
}
return ans;
}
} // namespace pam
string s;
int main() {
cin.tie(nullptr)->sync_with_stdio(false);
pam::clear();
cin >> s;
for (int i = 0; i < s.size(); i++) {
pam::insert(s[i]);
}
cout << pam::solve() << '\n';
return 0;
}
最小回文划分
给定一个字符串
,求最小的 ,使得存在 ,满足 均为回文串,且 依次连接后得到的字符串等于 。
考虑动态规划,记
由于一个字符串最多会有
记字符串
周期:若
border:若
周期和 border 的关系:
证明
若
若
引理一
证明
对于
对于
下图中,相同颜色的位置表示字符对应相同。
引理二
证明
若
若
引理三
引理四
-
; -
如果
,那么 ; -
如果
,那么 。
证明
- 由引理
的推论, 是 的最小周期, 是 的最小周期。考虑反证法,假设 ,因为 是 的后缀,所以 既是 的周期,也是 的周期,而 是 的最小周期,矛盾。所以 。 - 因为
是 的 border,所以 是 的前缀,设字符串 ,满足 (如下图所示),其中 是 的 border。考虑反证法,假设 ,那么 ,所以由引理 , 是回文串,由引理 , 是 的 border,又因为 ,所以 ,矛盾。所以 。 都是 的前缀, ,所以 。
推论
证明
设
该推论也可以通过使用弱周期引理,对
有了这个结论后,我们现在可以考虑如何优化
优化
回文树上的每个节点
根据上面证明的结论,如果使用
下面我们考虑如何更新
最后,上述做法的正确性依赖于:如果
证明
根据引理
假设
例题:Codeforces 932G Palindrome Partition
给定一个字符串
题解
构造字符串
参考代码
#include <cstring>
#include <iostream>
#include <string>
using namespace std;
using ll = long long;
constexpr int mod = 1e9 + 7;
constexpr int MAXN = 1000000 + 5;
int add(int x, int y) {
x += y;
return x >= mod ? x -= mod : x;
}
namespace pam {
int sz, tot, last;
int ch[MAXN][26], len[MAXN], fail[MAXN];
int cnt[MAXN], dep[MAXN], dif[MAXN], slink[MAXN];
char s[MAXN];
int node(int l) { // 建立一个长度为 l 的新节点
sz++;
memset(ch[sz], 0, sizeof(ch[sz]));
len[sz] = l;
fail[sz] = 0;
cnt[sz] = 0;
dep[sz] = 0;
return sz;
}
void clear() { // 初始化
sz = -1;
last = 0;
s[tot = 0] = '$';
node(0);
node(-1);
fail[0] = 1;
}
int getfail(int x) { // 找到后缀回文
while (s[tot - len[x] - 1] != s[tot]) x = fail[x];
return x;
}
void insert(char c) { // 建树
s[++tot] = c;
int now = getfail(last);
if (!ch[now][c - 'a']) {
int x = node(len[now] + 2);
fail[x] = ch[getfail(fail[now])][c - 'a'];
dep[x] = dep[fail[x]] + 1;
ch[now][c - 'a'] = x;
dif[x] = len[x] - len[fail[x]];
if (dif[x] == dif[fail[x]])
slink[x] = slink[fail[x]];
else
slink[x] = fail[x];
}
last = ch[now][c - 'a'];
cnt[last]++;
}
} // namespace pam
using pam::dif;
using pam::fail;
using pam::len;
using pam::slink;
int n, dp[MAXN], g[MAXN];
string s;
char t[MAXN];
int main() {
cin.tie(nullptr)->sync_with_stdio(false);
pam::clear();
cin >> s;
n = s.size();
s = " " + s;
for (int i = 1, j = 0; i <= n; i++) t[++j] = s[i], t[++j] = s[n - i + 1];
dp[0] = 1;
for (int i = 1; i <= n; i++) {
pam::insert(t[i]);
for (int x = pam::last; x > 1; x = slink[x]) {
g[x] = dp[i - len[slink[x]] - dif[x]];
if (dif[x] == dif[fail[x]]) g[x] = add(g[x], g[fail[x]]);
if (i % 2 == 0) dp[i] = add(dp[i], g[x]); // 在偶数位置更新 dp 数组
}
}
cout << dp[n];
return 0;
}
例题
相关资料
-
EERTREE: An Efficient Data Structure for Processing Palindromes in Strings
-
2017 年 IOI 国家候选队论文集 回文树及其应用 翁文涛
-
2019 年 IOI 国家候选队论文集 子串周期查询问题的相关算法及其应用 陈孙立
-
字符串算法选讲 金策
-
A Subquadratic Algorithm for Minimum Palindromic Factorization
创建日期: 2018年7月11日