回文自动机与回文树
前言与约定
回文树(Palindromic Tree)是一个储存一个字符串所有回文子串的数据结构。
在本文中,作如下约定:
- \(\Sigma\) 表示字符集 。
- \(|s|\) 表示字符串 \(s\) 的长度 。
- \(s_i\) 表示字符串 \(s\) 的第 \(i\) 个位置的字符,编号从 \(1\) 开始 。
- \(s+t\) 表示将字符串 \(s\) 和字符串 \(t\) 进行拼接,即表示一个新的字符串 \(s_1\cdots s_{|s|}t_1\cdots t_{|t|}\) 。
- \(s[l,r]\) 表示字符串 \(s_ls_{l+1}\cdots s_{r-1}s_r\) 。
- 记 \(s^r\) 表示 \(s\) 的翻转:\(s_{|s|}s_{|s|-1}\cdots s_{2}\) 。
- \(s\) 是回文的,当且仅当 \(s = s^r\) 。
- \(\operatorname{pre}(s,x) = s[1,x]\),\(\operatorname{suf}(s,x) = s[|s|-x+1,|s|]\) 。
回文树的结构与构造
结构
字符串 \(s\) 的回文树是一个包含了两棵树的森林,其中除了两棵树的根以外的所有节点都代表了 \(s\) 的一个非空回文子串。
其中两棵树分别储存了长度为奇数的回文串和长度为偶数的回文串,我们记储存了长度为奇数的回文串的树的根为 \({\rm rt}_o\)(奇根),储存了长度为偶数的回文串的树的根为 \({\rm rt}_e\)(偶根),其代表的字符串为空串。
回文树上的转移边 \(\operatorname{trans}(u,c)\) 表示在 \(u\) 所代表的回文串左右两侧加上字符 \(c\) 对应的节点。特别地,\(\operatorname{trans}({\rm rt}_{o},c)\) 表示单个字符 \(c\) 所组成的字符串。
回文树上的失配指针 \(\operatorname{fail}(u)\) 指向了 \(u\) 所代表的字符串的最长真回文后缀。特别地,\(\operatorname{fail}({\rm rt}_e) = {\rm rt}_o\) ,而奇根不存在失配指针。
回文树上的失配指针也构成一棵树,我们称其为 \(\operatorname{fail}\) 树。
我们记 \(\operatorname{len}(u)\) 表示 \(u\) 所代表的字符串的长度,特别地,令 \(\operatorname{len}(\operatorname{rt}_o) = -1\) 。
有关节点数的证明
定理:\(s\) 的回文树至多有 \(|s| + 2\) 个节点。
证明:
这等价于:\(s\) 本质不同的非空回文子串至多有 \(|s|\) 个。
对 \(|s|\) 施归纳法:
当 \(|s| = 1\) 时显然成立。
否则,考虑右端点为 \(s_{|s|}\) 的所有回文子串,若存在至少一个这样的子串,则所有这样的子串必然是其中一个极大的回文子串的子串。对于该极大回文子串的真子串,由于回文串的性质,我们必然能在回文中心的另一侧找到一个和该子串一模一样的子串,故此类回文串至多贡献 \(1\)。也即 \(s\) 本质不同的非空回文子串至多有 \(|s|\) 个。
由此定理,我们容易可以推得回文树的转移数也是线性的。
构造
基础插入算法
回文树的构造是在线的。也就是说,当前我们有 \(s\) 的回文树,我们支持在 \(s\) 后加入一个字符 \(c\),求得 \(s + c\) 的回文树。
由先前的证明,我们可以得到:新的回文树至多将会增加一个节点。
显然,这样一个新节点所代表的字符串的形式为:\(c + s[i,|s|] + c\) ,则我们需要找到 \(s\) 最长的回文后缀 \(t\)(这可以在插入的时候顺便维护),使得 \(s_{|s|-|t|} = c\)(这个 \(t\) 也有可能是长度为 \(-1\) 或 \(0\) 的字符串,即奇根和偶根,若为奇根则不需要满足任何条件),若该节点已经存在,则我们不需要进行任何操作,否则我们需要新建一个节点,并求得其 \(\operatorname{fail}\) 指针。
对于求其 \(\operatorname{fail}\) 指针的操作,即相当于再找一个 \(t\) 的最长回文后缀 \(r\),使得 \(s_{|s|-|r|} = c\),我们继续跳 \(\operatorname{fail}\) 指针即可(若跳到奇根,则设为偶根,因为空串必然是回文后缀之一)。
可以发现,单次加入的时间复杂度最高是为 \(\mathcal O(|s|)\) 的。但我们可以证明,插入一整个串的总时间复杂度也是 \(\mathcal O(|s|)\) 的。
定理:基础插入算法的每一步是均摊 \(\mathcal O(1)\) 的。
证明:
类似 \(\text{KMP}\) 中的时间复杂度证明,我们考虑定义势能函数 \(\Phi(s)\) 为 \(s\) 最长回文后缀的长度加上 \(s\) 最长回文后缀的最长回文后缀的长度。
由于在 \(s\) 后新增一个字符至多使 \(s\) 最长回文后缀的长度和 \(s\) 的最长回文后缀的最长回文后缀的长度都加 \(1\),所以我们有 \(0\le \Phi(s + c) \le \Phi(s) + 2\) 。
在寻找 \(s + c\) 新的最长回文后缀的过程中,每次跳 \(\operatorname{fail}\) 指针必然说明了 \(s + c\) 最长回文后缀的长度需要减一,而寻找新节点的 \(\operatorname{fail}\) 指针的过程中,每次跳 \(\operatorname{fail}\) 指针必然说明了 \(s + c\) 最长回文后缀的最长回文后缀的长度需要减一。故我们每跳一次 \(\operatorname{fail}\) 指针,都会使 \(\Phi(s + c)\) 减少 \(1\) 。
在逐个插入字符的过程中,\(\Phi(s)\) 的总增加量是 \(\mathcal O(|s|)\) 的,故而总减少量也是 \(\mathcal O(|s|)\) 的,所以跳 \(\operatorname{fail}\) 指针的次数是 \(\mathcal O(|s|)\) 的,即时间复杂度是 \(\mathcal O(|s|)\) 的(忽略新建数组的开销)。
不基于势能分析的插入算法
在某些特定情况下(例如需要撤销 / 可持久化的场合),我们可以反复进行一项势能消耗大的操作,导致时间复杂度退化为 \(\mathcal O(|s|^2)\),于是我们考虑,是否存在一种不基于势能分析的插入算法呢?
考虑类似 \(\text{AC}\) 自动机中 “字典图” 的构建,我们对所有状态 \(u\),构建指针 \(\operatorname{fch}(u,c)\) 直接表示 \(u\) 之后加上 \(c\) 要跳 \(\operatorname{fail}\) 指针跳到哪个位置。
再加入字符 \(c\) 的时候,我们考虑 \(v\) 表示 \(s\) 的最长回文后缀所对应的节点,那么我们只需检查 \(\operatorname{trans}(\operatorname{fch}(v,c),c)\) 即可知道新的回文串是否存在,若该串不存在,我们新建一个节点 \(u\),然后将 \(\operatorname{trans}(\operatorname{fch}(v,c),c)\) 设为 \(u\)。
不妨设 \(p=\operatorname{fch}(v,c)\),对于新建的节点 \(u\),我们考虑先求其 \(\operatorname{fail}\) 指针,发现相当于对 \(v\) 的最长回文后缀加上一个 \(c\),故 \(\operatorname{fail}(u) = \operatorname{trans}(\operatorname{fch}(p,c),c)\)(当然,对 \(p = {\rm rt}_o\) 的情况,我们要令 \(\operatorname{fail}(u) = {\rm rt}_e\))。
我们考虑如何维护 \(\operatorname{fch}\) 指针:注意到 \(u\) 与 \(\operatorname{fail}(u)\) 的回文后缀集合唯一的差异在于 \(\operatorname{fail}(u)\),所以直接复制一遍 \(\operatorname{fail}(u)\) 的 \(\operatorname{fch}\) 指针,然后检查一下能步跳一部 \(\operatorname{fail}(u)\) 的部分就好了。
于是我们得以在 \(\mathcal O(|s||\Sigma|)\) 的时间内完成对其的构建,若使用可持久化线段树维护版本复制和修改,就可以做到 \(\mathcal O(|s|\log |\Sigma|)\) 的时间复杂度。
代码
朴素方法的代码如下:
namespace PAM{
int son[26][500005], fch[26][500005], l[500005], fa[500005], lst, cnt;
int s[500005], top, dis[500005];
inline void init(){
s[0] = l[0] = -1; cnt = 1;
}
inline void insert(int c){
int p = s[top-l[lst]] == c ? lst : fch[c][lst];
s[++top] = c;
if(son[c][p]) lst = son[c][p];
else {
lst = son[c][p] = ++cnt;
if(p) fa[lst] = son[c][fch[c][p]];
else fa[lst] = 1; l[lst] = l[p] + 2; dis[lst] = dis[fa[lst]] + 1;
for(int t = 0; t < 26; t++) fch[t][lst] = s[top - l[fa[lst]]] == t ? fa[lst] : fch[t][fa[lst]];
}
}
}
回文划分
周期
周期:对于字符串 \(s\),若正整数 \(p\) 满足 \(\forall i\in[1,|s|-p]\cap \mathbf N, s_{i} = s_{i + p}\) ,则称 \(p\) 为 \(s\) 的周期。
记 \(\operatorname{per}(u)\) 表示 \(u\) 的最小周期。
弱周期引理:若 \(p,q\) 为 \(s\) 的周期,\(p+q\le |s|\),则 \(\gcd(p,q)\) 为 \(s\) 的周期。
证明:
不妨设 \(q\ge p\),由 \(p+q\le |s|\) 可推得对任意 \(1\le i\le |s|\),有 \(i-p>0\) 或 \(i+q\le |s|\)。
对于第一种情况,由周期定义可知 \(s_i = s_{i-p} = s_{i-p+q}\) 。
对于第二种情况,由周期定义可知 \(s_i = s_{i+q} = s_{i+q-p}\) 。
故 \(d = q-p\) 为 \(s\) 的周期,如此辗转相除可得 \(\gcd(p,q)\) 为 \(s\) 的周期。
(周期引理:\(p,q\) 为 \(s\) 的周期,\(p+q-\gcd(p,q) \le |s|\),则 \(\gcd(p,q)\) 为 \(s\) 的周期)
匹配与 Border
Border: 对于一个字符串 \(s\),若 \(t = \operatorname{pre}(s,x) = \operatorname{suf}(s,x)\) ,则 \(t\) 为 \(s\) 的一个 \(\text{border}\) 。
引理:字符串 \(s\) 存在一个长度为 \(x\) 的 \(\text{border}\) 当且仅当 \(|s| - x\) 为 \(s\) 的周期。
证明: 均等价于 \(s_i = s_{|s| - |t| + i}\) 这个条件。
引理:若字符串 \(u,v\) 满足 \(|u|\le |v|\le 2|u|\),则 \(u\) 在 \(v\) 中的所有匹配位置构成一个等差数列。
证明:
只需要考虑匹配超过 \(3\) 次的情况。
记第一次匹配与第二次匹配的间距为 \(d\),第二次匹配与第 \(x\) 次匹配的间距为 \(q\) 。
由 \(|u|\le |v|\le 2|u|\),显然 \(u\) 在 \(v\) 中的两次匹配的位置集合 \(u_1,u_2\) 存在重叠部分,且长度为 \(|u| - d\)。
故 \(\operatorname{pre}(u,|u|-d)\) 为 \(u\) 的一个 \(\text{border}\),即 \(d\) 为 \(u\) 的一个周期,同理 \(q\) 为 \(u\) 的一个周期。
而显然第一次匹配和第 \(x\) 次匹配的位置集合 \(u_1,u_x\) 同样存在重叠部分,故 \(d+q\le |u|\),故 \(\gcd(d,q)\) 为 \(u\) 的周期。
若 \(d< \gcd(d,q)\),则我们可以将 \(u_2\) 向前移动构造在第一次第二次之间的匹配,矛盾。
故 \(d = \gcd(d,q)\),即 \(d\mid q\) 。
由 \(u_i = u_{i+d}\) ,我们可以得到从第二次匹配开始到第 \(x\) 次匹配,每隔 \(d\) 个字符均会存在一个匹配,故命题得证。
由此证明我们可以得到一个推论:
推论:若字符串 \(u,v\) 满足 \(|u|\le |v|\le 2|u|\),\(u\) 在 \(v\) 中的所有匹配位置构成了一个公差为 \(d\),超过三项的等差数列,则 \(\operatorname{per}(u) = d\) 。
接下来我们讨论 \(\text{Border}\) 的结构。
引理:字符串 \(s\) 的所有不小于 \(|s|/2\) 的 \(\text{border}\) 组成一个等差数列。
证明:对于最大的 \(\text{border}\) 和任意一个 \(\text{border}\),设其长度分别为 \(n-p,n-q\),可得 \(p,q\) 为 \(s\) 的周期,\(\gcd(p,q)\) 为 \(s\) 的周期,类似之前的方法可得 \(p\mid q\) 。
(\(\text{border}\) 本质上就是在和自己匹配)
引理:若 \(|u|=|v|\),记 \(\operatorname{LargePS}(u,v)=\{k\mid k\ge |u|/2,\operatorname{pre}(u,k)=\operatorname{suf}(v,k)\}\),则 \(\operatorname{LargePS}(u,v)\) 构成一个等差数列。
证明:设其中最大的元素为 \(x\),则 \(x/2\le |u|/2\),且其他元素均为 \(\operatorname{pre}(u,x)\) 的 \(\text{border}\) 的长度。接下来由前一个引理可证。
我们可以把一个字符串的所有 \(\text{border}\) 按照长度属于 \([1,2),[2,4),\cdots,[2^k,n)\) 分类,第 \(i\) 类的元素为 \(\operatorname{LargePS}(\operatorname{pre}(s,2^i),\operatorname{suf}(s,2^i))\) 。
于是我们得到了一个推论:
推论:字符串 \(s\) 的 \(\text{border}\) 可以被划分为 \(\mathcal O(\log n)\) 个等差数列。
slink 链与最小回文划分
slink 指针
记 \(\operatorname{diff}(x) = \operatorname{len}(x) - \operatorname{len}(\operatorname{fail}(x))\),而 \(\operatorname{slink}(x)\) 表示回文树中 \(\operatorname{fail}\) 树上离 \(x\) 最近的满足 \(\operatorname{diff}(y)\neq \operatorname{diff}(x)\) 的 \(y\)。
显然这可以很容易在建回文树的时候维护出来。
容易发现,这意味着从 \(x\) 开始跳 \(\operatorname{fail}\) 指针,一直到跳到 \(y\) 之前,都是在一个等差数列上跳的。
也就是说,根据我们之前的推论,从某个点开始跳 \(\text{slink}\) 指针直到根节点,至多跳 \(\mathcal O(\log n)\) 步。
回文划分计数
例:给定一个字符串 \(s\),求有多少划分 \(t_1t_2\cdots t_n = s\),使得 \(t_1,t_2,\cdots,t_n\) 均为回文串。对 \(998244353\) 取模。
\(1\le |s|\le 10^6\)
对于此题,我们考虑记 \(f_i\) 表示长度为 \(i\) 的前缀的回文划分方案数。
那么我们有一个显而易见的 \(dp\) 方程:
直接暴力转移是 \(\mathcal O(n^2)\) 的,我们考虑如何利用回文后缀的性质:
我们考虑在回文树上加入一个字符时,对应的节点所在的等差数列发生了什么样的变化:
显然,等差数列中的所有回文串都是当前节点的后缀。
由于回文后缀同时是 \(\text{border}\) 的优美性质,等差数列中的所有回文后缀都在恰好平移了 \(\operatorname{diff}(x)\)(公差)个字符后再次出现,我们再观察 \(dp\) 值,发现这些回文串的 \(dp\) 值之和在加入这个节点前后仅仅差了一个位置:\(f_{i-\operatorname{len}(\operatorname{slink}(x))-\operatorname{diff}(x)}\) 。
这启发我们,对回文树上的节点 \(x\) 维护 \(g_x\) 表示以 \(x\) 开始到 \(\operatorname{slink}(x)\) 以前,这条链上的回文子串最后一次出现所对应的 \(dp\) 值,那么我们可以很容易地从 \(g_{\operatorname{fail}(x)}\) 转移到 \(g_x\) 。
于是,对于一个新加入的节点,我们只需要暴力跳 \(\operatorname{slink}\) 链并修改我们在 \(\operatorname{slink}\) 链上跳过所有节点的 \(g\) 即可(即用 \(\operatorname{fail}(x)\) 更新 \(x\))。
为什么这样做是对的呢?我们注意到 \(\operatorname{fail}(x)\) 的上一次出现必然是在当前最长回文后缀的左端点处(否则与其是最长回文后缀的定义不符),故其 \(g\) 的值一定如我们想象的那样记录了从当前最长回文后缀的左端点开始的一串等差数列对应的 \(dp\) 值。
根据我们先前证明的结论,该算法的时间复杂度为 \(\mathcal O(n\log n)\) 。
namespace PAM{
int son[26][500005], fch[26][500005], l[500005], fa[500005], cnt, now, len, dis[500005], slink[500005];
int s[500005];
inline void init(){
l[0] = -1; s[0] = -1; cnt = 1;
}
inline void insert(int c){
int p=s[len - l[now]] == c ? now : fch[c][now];
s[++len] = c;
if(son[c][p]) now = son[c][p];
else {
now = son[c][p] = ++cnt;
int fi = fa[now] = p ? son[c][fch[c][p]] : 1;
l[now] = l[p] + 2; dis[now] = l[now] - l[fi]; slink[now] = dis[now] == dis[fi] ? slink[fi] : fi;
for(int i=0; i<26; i++) fch[i][now] = s[len - l[fi]] == i ? fi : fch[i][fi];
}
}
int dp[1000005],tmp[1000005];
const int md=998244353;
inline void build(char *c){
dp[0] = 1; init();
for(int i=1; i<=n; i++){
insert(c[i] - 'a');
for(int j = now; j > 1; j = slink[j]){
tmp[j] = dp[i - l[slink[j]] - dis[j]];
if(fa[j] != slink[j]) tmp[j] = (tmp[j] + tmp[fa[j]])%md;
if((i&1) == 0) dp[i] = (dp[i] + tmp[j])%md;
}
}
printf("%d\n", dp[n]);
}
}