我不会字符串
基本定义:
- \(\operatorname{lcp}(x,y)\) 表示两个字符串 \(x\) 和 \(y\) 的最长公共前缀 longest common prefix,类似定义 \(\operatorname{lcs}(x,y)\) 表示 \(x\) 和 \(y\) 的最长公共后缀。
- \(s[l,r]\)表示字符串 \(s\) 位置 \(l \sim r\) 字符串拼接而成的字串。
- \(|s|\) 表示字符串 \(s\) 的长度。
kmp 算法:
在线性时间内计算一个模式串在一个字符串的出现次数。
对于每个 \(i\) 维护 \(nxt_i\) 表示最大的 \(j\) 满足 \(s[1,j] = s[j,i](j < i)\),也称为 border,可以类似 dp 的思想求出 \(nxt_i\):
假设当前已求出 \([1,n -1]\) 的 \(nxt\) ,维护一个指针 \(j\) 表当前最大的 \(s[1,j] = s[j,n - 1]\) 即 \(nxt_{n - 1}\),若此时 \(s[n] = s[j+1]\),令 \(j\) 自增 1;否则不断 \(j \leftarrow nxt_{j}\) 直到满足条件。
我们先对模式串自身做一遍 kmp,求得 \(nxt_i\),然后类似的思路即可求解。
scanf("%s", s1 + 1), scanf("%s", s2 + 1);
n = strlen(s1 + 1), m = strlen(s2 + 1);
for(int i = 2, j = 0; i <= m; ++i) {
while(j && s2[i] != s2[j + 1]) j = nxt[j];
if(s2[i] == s2[j + 1]) j ++; nxt[i] = j;
}
for(int i = 1, j = 0; i <= n; ++i) {
while(j && s1[i] != s2[j + 1]) j = nxt[j];
if(s1[i] == s2[j + 1]) j ++;
if(j == m) std :: cout << i - m + 1 << '\n';
}
KMP 自动机:
对一个长度为 \(n\) 的字符串 \(s\) 建立的 KMP 自动机, 每个节点 \(i\) 表示与当前给定的模式串匹配长度为 \(i\) ,转移函数为:
\(\delta(i,c) = \begin{cases} i+1 \ \ \ \ \ \ \ \ \ \ s_{i+1} = c \\ \delta(nxt_i, c) \ \ \operatorname{otherwise}\end{cases}\)
manacher 算法:
在线性时间内计算以每个位置为中心的最长回文半径。
首先将字符串所有位置之间(包括)头尾插入相同分隔符, 保证所有的回文串都是一某一个位置为中心的回文串。
对于每个 \(i\) 维护 \(i\) 为中心的最长回文半径 \(p_i\) ,维护当前最右端点 \(r\) 和对应的位置 \(pos\),若 \(i \leq pos\), 则其关于 \(pos\) 的对称点 \(j = 2 \times i - pos\) 的回文中心已求出, 直接令 \(p_i \to \min(p_j, r - i +1)\), 取 \(\min\) 的原因是可能 \(j\) 的回文半径超过了 \(pos\) 的最左端点。此时暴力更新 \(p_i\), 最后更新 \(pos\) 和 \(r_i\)。
scanf("%s", s1 + 1);
int cnt = 0; s2[++cnt] = '#';
for(int i = 1, n = strlen(s1 + 1); i <= n; ++i) s2[++cnt] = s1[i], s2[++cnt] = '#';
s2[++cnt] = '?';
int ans = 0;
for(int i = 1; i <= cnt; ++i) {
if(i <= maxr) p[i] = std :: min(p[2 * pos - i], maxr - i + 1);
while(s2[i - p[i]] == s2[i + p[i]]) p[i] ++;
if(i + p[i] - 1 > maxr) maxr = i + p[i] - 1, pos = i;
ans = std :: max(ans, p[i] - 1);
}
cout << ans;
`
根据 manacher 算法,我们可以求以每个字符串开头或结尾的最长回文字串,对于 \(i\) 和 \(p_i\), 则以 \(j \in [r + 1, i + p_i - 1]\) 的结尾的最长回文长度为 \(j - i +1\)。
后缀数组 SA
一些定义:
- \(suf_i\) 表示字符串 \(s\) 在以 \(i\) 开头的后缀。
- \(rk_i\) 表示第 \(i\) 个后缀在所有后缀的字典序排名,两两不同。
- \(sa_i\) 表示排名为 \(i\) 的字符的开始位置,与 \(rk\) 互逆。
- 设 后缀 \(i\) 和 \(j\) 的最长公共前缀为 \(\operatorname{lcp}(i, j)\)。
- \(height_i\) 表示排名为 \(i\) 的后缀和 \(i - 1\) 的后缀的 \(\operatorname{lcp}\)。
后缀排序
运用的倍增的思想在 \(O(n \log n)\) 的时间内计算出 \(rk_i\)和 \(sa_i\)。
大部分时候可以与后缀自动机互换。优势在空间线性,劣处时间线性对数。
算法流程:假设我们知道了所有长度为 \(2^{len - 1}\) 的字串的排名(即\(s_{i, i+2^{len - 1} - 1}\),超过 \(n\) 的部分为空),我们能够计算出长度为 \(2^{len}\) 的排名:对于两个位置 \(i, j\),若 \((rk_{i}, rk_{i+2^{len - 1}})<(rk_j, rk_{j+2^{len - 1}})\) ,则新的 \(rk_i < rk_j\)。 可以直接排序,但复杂度为 \(O(n \log n^2)\), 但注意到所有的值 \(\leq n\),考虑使用基数排序。
每个 \(i\) 的第一关键字为 \(rk_i\), 第二关键字为 \(rk_{i+2^{len - 1}}\)
具体地,每个 \(i\) 的初始排名为 \(s_i\)。若当前已知 \(2^{len - 1}\) 的 rk,则我们可以直接将第二位排序(先将没有第二关键字的加入,然后通过已经算出的 rk 加入第一关键字)。 基数排序后得到新的 sa 和 rk。不断重复直到不同的排名为 \(n\) 即可。
代码实现:
int sa[M], rk[M], ork[M << 1], height[M], id[M];
int buc[M];
char str[M];
inline bool cmp(int x, int y, int len) {
return ork[x] == ork[y] && ork[x + len] == ork[y + len];
}
inline void SA() {
int m = 1 << 7, cur = 0;
for(int i = 1; i <= n; ++i) buc[rk[i] = str[i]] ++;
for(int i = 1; i <= m; ++i) buc[i] += buc[i - 1];
for(int i = 1; i <= n; ++i) sa[buc[rk[i]] --] = i;
for(int len = 1; len <= n; len <<= 1, m = cur, cur = 0) {
for(int i = n - len + 1; i <= n; ++i) id[++cur] = i;
for(int i = 1; i <= n; ++i) if(sa[i] > len) id[++cur] = sa[i] - len;
memset(buc, 0, sizeof(int) * (m + 5));
for(int i = 1; i <= n; ++i) buc[rk[id[i]]] ++;
for(int i = 1; i <= m; ++i) buc[i] += buc[i - 1];
for(int i = n; i >= 1; --i) sa[buc[rk[id[i]]] --] = id[i];
memcpy(ork, rk, sizeof(int) * (n + 5)), cur = 0;
for(int i = 1; i <= n; ++i) rk[sa[i]] = cmp(sa[i - 1], sa[i], len) ? cur : ++cur;
if(cur == n) break;
}
}
重要工具:height 数组。
绝大部分关于 SA 的题目需要我们求出 \(height\) 数组,而我们有结论(以下的 \(i\) 都是 sa 数组的下标):
定理:\(height_{rk_i} \geq height_{rk_{i - 1}} - 1\)
证明:设 \(p\) 为 \(i - 1\) 后缀排名的前一名所在位置,当 \(height_{i - 1} > 1\) 时,必定有 \(s_i = s_{p+1}\), 那么显然 \(rk_p<rk_i\), 又因为 \(\operatorname{lcp}(p+1, i) \geq height_{i - 1} - 1\), 而对于 \(j \in (p,i)\), \(j\) 和 \(i\) 的 LCP 不会短于 \(height_{i - 1} - 1\) 。结论成立。
定理:\(\operatorname{lcp}(i, j) = \min_{k = i+1}^{j}height_k\) 证明略。
应用
- SA 求本质不同字串个数:考虑每次新加一个后缀,减去这个后缀和已经添加的后缀的所有重复子串,即 \(\max_{j \in S}\operatorname{lcp}(s_j, s_i)\), 考虑按照 \(sa_1, sa_2, \dots, sa_n\) 的顺序添加,这个显然为 \(height_i\) , 故答案为 \(\dbinom n 2 - \sum\limits_{i = 2}^{n}height_i\) 。
- SA 结合单调栈:\(height\) 数组可以形象地理解成一个矩形柱状图,类似于询问后缀两两 LCP 之和,就可以看成所有就矩形的面积,此时直接用单调栈维护即可。
由于本人更熟悉 SAM instead of SA,于是所有的例题都在 SAM 里面(
Z Algorithrm
别名扩展 KMP 算法,用于求出给定字符串 \(s\), 每个位置 \(suf_i\) 和 \(s\) 的 LCP 长度,时间复杂度线性。设 \(suf_i\) 和 \(s\) 的 LCP 为 \(z_i\), 一般定义 \(z_1 = 0\)。
算法流程和 manacher 几乎一模一样。
实时维护最靠右的匹配段 \([l,l+z_l - 1]\) , 匹配到 \(i\) 时,若 \(l+z_l - 1<i\), 暴力匹配;若 \(l+z_l - 1 \geq i\), 根据定义,有 \(s_{1, r - l+1} = s_{l, r}\),故 \(s_{i, r} = s_{i - l +1, r - l+1}\) ,故首先令 \(z_i = \min(r -i+1,z_{i - l+1})\), 然后暴力匹配。
应用:求解字符串 \(t\) 的每一个后缀与 \(s\) 的 LCP 长度,类似地维护即可。
【模板】扩展 KMP(Z 函数)代码如下:
scanf("%s", s + 1), scanf("%s", t + 1);
n = strlen(s + 1), m = z1[1] = strlen(t + 1);
for(int i = 2, l = 0, r = 0; i <= m; ++i) {
if(i <= r) z1[i] = std :: min(r - i + 1, z1[i - l + 1]);
while(z1[i] < m && t[1 + z1[i]] == t[i + z1[i]]) z1[i] ++;
if(i + z1[i] - 1 >= r) r = i + z1[i] - 1, l = i;
} ll s1 = 0; for(int i = 1; i <= m; ++i) s1 ^= ((ll)(z1[i] + 1) * i);
for(int i = 1, l = 0, r = 0; i <= n; ++i) {
if(i <= r) z2[i] = std :: min(r - i + 1, z1[i - l + 1]);
while(z2[i] < m && t[1 + z2[i]] == s[i + z2[i]]) z2[i] ++;
if(i + z2[i] - 1 >= r) r = i + z2[i] - 1, l = i;
} ll s2 = 0; for(int i = 1; i <= n; ++i) s2 ^= ((ll)(z2[i] + 1) * i);
cout << s1 << '\n' << s2;
AC 自动机 ACAM
AC 自动机,全称 Aho-Corasick Automaton, 属于确定有限状态自动机。
什么是 确定有限状态自动机 ?
形式化定义:一个确定有限状态自动机 (DFA)由以下下五部分组成:
- 字符集 \(|\sum|\), 该自动机只能输入这些字符。
- 状态集合 \(Q\) , 如果把一个 DFA 看做一个 DAG,俺么状态集合就是图上的顶点。
- 起始状态 \(st \in Q\)。
- 接受状态集合 \(F \subseteq Q\),是一组特殊的状态。
- 转移函数 \(\delta\), \(\delta\) 是一个接受两个参数返回一个值的函数,其中 \(\delta(x, c)\), \(x\) 和 \(\delta(x,c)\) 为一个状态, \(c\) 为一个字符集中的字符。
DFA 的作用是识别字符串,对于一个自动机 \(A\), 若它能识别字符串 \(S\), 则 \(A(S) = \operatorname{true}\), 否则 \(A(S) = \operatorname{false}\)。
当一个 DFA 读入一个字符串时,从初始状态 \(st\) 按照转移函数一个个转移。若能成功转移,则称这个 DFA 能识别 \(S\)。
AC 自动机用于解决多模式串匹配问题。
先对模式串 \(t_1, \dots, t_k\) 建出 trie 树,对于 tire 上的每一个节点 \(q\) 而言,类似 KMP 算法,我们求出最长的真后缀 \(p\), 使得 \(s_{root \to p}\) 为 \(q\) 的一段后缀,称为 fail 指针。
求得 fail 指针是容易的,我们先初始化根以及其所有儿子的 fail 为根,每次 BFS 把同一层的 fail 指针求出来。对于一个节点 \(x\), 若不存在 \(x \to y\), 则令 \(ch_{x, y} = ch_{fail_x, y}\), 否则 \(fail_y = ch_{fail_x, y}\) 。
在匹配文本串 \(S\) 时,我们只需要不断走 AC 自动机上的转移边,若走到 \(p\), 我们只需要不断跳 \(p\) 的 \(fail\) 指针,统计有多少节点为给定模式串的终止节点即可。实质是对于每一个 \(p \in [1, n]\), 我们计算了有多少个模式串能和 \(s[1 \dots p]\) 后缀匹配,因此对于每一个 \(p \in [1,n]\), 求和即是答案。
因此, ACAM 接受且仅接受以给定字典串中以某一个单词结尾的字符串。
当节点数过大时,每次跳 fail 指针容易超时,我们接下来分析 fail 指针的性质:
- 对于每一个 \(i\), 其 \(fail_i\) 唯一,因此,若将 \(fail_i \to i\) 边建出,得到的将会是一棵树。
- 对于节点 \(p\) 及其对应字符串 \(t_p\), 其子树内部所有节点 \(q\) 满足 \(t_p\) 为 \(t_q\) 的后缀,Vice versa。
- 对于节点 \(p\) 及其对应字符串 \(t_p\), 从 \(p\) 到 根节点的节点为 \(p\) 的后缀,也就是我们暴力跳 fail 统计的子串内容。
【模板】AC 自动机(二次加强版)代码如下:
int n, tot = 1, rt = 1;
int ch[M][26], fail[M], sz[M], end[M];
char str[N];
inline void ins(int t, char *str) {
int l = strlen(str), p = 1, c;
for(int i = 0; i < l; ++i) c = str[i] - 'a', p = (ch[p][c] ? ch[p][c] : ch[p][c] = ++tot);
end[t] = p;
}
inline void build() {
std :: queue < int > q;
for(int i = 0; i < 26; ++i) ch[0][i] = 1; q.push(1);
while(!q.empty()) {
int x = q.front(); q.pop();
for(int i = 0; i < 26; ++i) {
int y = ch[x][i];
if(!y) ch[x][i] = ch[fail[x]][i];
else fail[y] = ch[fail[x]][i], q.push(y);
}
}
}
int d[M];
vector < int > adj[M];
inline void add(char *str) {
int l = strlen(str), p = 1, c;
for(int i = 0; i < l; ++i) c = str[i] - 'a', p = ch[p][c], d[p] ++;
}
inline void dfs(int x) {
for(int &y : adj[x]) dfs(y), d[x] += d[y];
}
inline void mian() {
n = read();
for(int i = 1; i <= n; ++i) scanf("%s", str), ins(i, str);
build(); for(int i = 2; i <= tot; ++i) adj[fail[i]].push_back(i);
scanf("%s", str), add(str);
dfs(1); for(int i = 1; i <= n; ++i) printf("%d\n", d[end[i]]);
}
例题:
[JSOI2007]文本生成器
建出 AC 自动机后,对每个节点计算它及 fail 树上的祖先的标记,然后直接 DP 即可。
[SDOI2014] 数数
在上一道题的基础上,改成数位 DP 即可。
CF1202E You Are Given Some Strings...
显然枚举拼接点 \(p\),对前缀和后缀分别建 ACAM,在 fail 树上统计答案即可。
[NOI2011] 阿狸的打字机
在 trie 树上记录下每个点的父亲,可以模拟题目中对于字符串的操作。对于一次询问而言,显然不能直接遍历 \(y\) 的所有前缀信息。但考虑当前字符串都是由上一次经过若干次变化得来的,将所有询问按照 \(y\) 排序后,我们可以动态维护前缀信息。查询就是子树查,修改就是单点加。
P3041 [USACO12JAN]Video Game G
建出 ACAM 后直接暴力 dp。
CF547E Mike and Friends
差分询问,变成询问 \(s_k\) 在一些前缀字符中出现次数,按照右端点排序后就变成单点加,子树查询。
\(O(|S|\log |S| +q \log |S|)\)
CF86C Genetic engineering
设 \(dp_{i, j, k}\) 表示长度为 \(i\) 的合法串,目前在节点 \(j\) ,上一次合法位置在 \(k\) 的方案,预处理出每个节点最长的字符串后直接 \(O(n^2|S|)\) 转移。
[COCI2015]Divljak
对 \(S_i\) 建出 ACAM 后,每次找到 \(P\) 在 ACAM 上所有的节点,相当于树链并加,可以直接建出虚树后树状数组维护。但考虑我们只需要找到链并的节点,根据经典结论,将所有点按照 dfs 序排序后,只用对所有单点+1 以及相邻点的 lca 减 1即可。
CF710F String Set Queries
显然加入和删除可以用两个 ACAM 分开维护。但是不能得到快速得到加入一个字符串后的新的 ACAM。朴素想法是考虑根号重构,但注意不同 ACAM 互不影响,可以直接二进制分组维护。合并时可以直接将 trie 树合并。
CF1483F Exam
首先对每个节点预处理出到根的最长长度及编号。枚举大串,对于大串的所有前缀节点,显然只有这些最长节点才有可能 成为答案。
同时,若将所有串覆盖的范围看做一个区间,被完全包含的字符串显然不是答案。对于剩下的串,形成了左端点和右端点都递增的区间,若一个字符串 \(p\) 满足条件,则 \(p\) 在大串中出现次数等于 \(p\) 作为区间的次数。也可以发现不满足条件的 \(q\),一定存在一个右端点 \(r\),使得 \(r\) 结尾的最长字符串不是 \(q\)。因此对每个可能成为答案的串 check 一下即可,用树状数组维护出现次数。
CF587F Duff is Mad
直接做不太好做,考虑对 \(s_k\) 的长度进行根号分治。设总串长为 \(S\)
- 若 \(|s_k| > \sqrt S\),显然这样的串不超过 \(O(\sqrt S)\) 个,直接暴力枚举这一类串计算答案,将所有串都插入 ACAM 中计算前缀和即可。修改是 \(O(n \log n)\) 的,查询是 \(O(n\sqrt n \log n)\)。
- 若 \(|s_k| \leq \sqrt S\) ,先将询问差分,变成前缀串在 \(s_k\) 中出现次数,直接 \(O(|s_k|)\) 枚举 \(s_k\) 所有的节点,变成单点加,子树询问,修改 \(O(n\log n)\),查询 \(O(n\sqrt n \log n)\)。
考虑修改和询问平衡,用 \(O(\sqrt n) - O(1)\) 的分块替代树状数组即可。
总时间复杂度 \(O(|S|\sqrt {|S|})\)。
P8203 DDOSvoid 的馈赠
若一个大小为 \(x\) 的 \(t_i\) 和一个大小为 \(y\) 的 \(t_j\),我们能在 \(\min(x, y)\) 的时间内得到答案并记忆化,时间复杂度即是 \(O(q\sqrt S||)\),这一技巧称为自然根号。
证明是简单的,只用考虑前 \(q\) 大的对 $ = \sum\limits_{i = 1}^{\sqrt q}i \times |t_i|$,显然一个长度只会被贡献 \(O(\sqrt q)\) 次。
对于 \(t_i, t_j\) 标记上 fail 树上的所有节点,问题变成虚树交。可以枚举一个虚树上的点,找到另一棵虚树上 dfs 序前驱后继上的点,深度较大的 lca 构成的虚树大小就是答案。
当存在 \(> \sqrt{|S|}\) 的串时,枚举串,对 fail 树上所有节点预处理前驱后继。
否则两个串都 \(< \sqrt {|S|}\),直接双指针即可。
时间复杂度 \(O(|S\sqrt {|S|}|)\)。
P8147 [JRKSJ R4] Salieri
二分答案 \(val\),对 \(S\) 建出虚树,则在虚树上相邻点之间的链出现次数都是相同的,则 \(v_i\) 满足 \(v_i \times cnt \geq val\)。用主席树维护链查询即可。
时间复杂度 \(O(n\log^3 n)\)。
CF585F Digits of Number Pi
对 \(s\) 直接建出 ACAM(或 SAM 本质相同)后数位 dp:设 \(dp_{i, j,k, 0 / 1}\) 表示当前考虑到前 \(i\) 位,目前在 \(j\) 号节点,当前后缀与 \(s\) lcs 为 \(j\),是否已经存在 $ \geq \dfrac d 2$ 的字串的答案。
每次转移枚举一个 \(c\) 然后不断跳 fail 即可。
时间复杂度 \(O(ns^2)\)。
P7582 「RdOI R2」风雨(rain)
分块。每 \(\sqrt n\) 个字符串分一个块,每个块内部建出 ACAM 统计答案。每块维护加 tag,区间覆盖 tag,类似线段树一样合并标记即可。
对于一次询问 \(S\) 中区间 \([l, r]\) 的出现次数,对于散块,先下传标记,再直接暴力 KMP 统计答案,只对 \(|s_i| \leq |S|\) 的字符串进行暴力,这部分总时间复杂度 \(O(|S|\sqrt{|S|})\)。
对于整块,先定位 \(S\) 在整块 ACAM 的所有节点,求出 \(p\) 处对应的权值和个数,若当前串存在覆盖标记则用 \(cnt\) 统计答案,否则用 \(val\) 加上 \(cnt\) 乘上tg 即可。暴力下推标记时,\(O(\sqrt n \log n)\) 用树状数组更新。
总时间复杂度 \(O(n\sqrt n\log n)\)。
后缀自动机 SAM
后缀自动机全称 Suffix Automaton, 简称 SAM, 是一个接受给定字符串 \(S\) 的所有后缀的最小的有限状态自动机,可以强有力地维护字符串子串问题,是字符串领域的真正魔王。
SAM 的定义和定理十分多,需要充分理解。
基本定义和引理
SAM 最重要,最基本的性质为:从起点 \(st\) 的所有路径都是 \(s\) 的子串。
- 定义 \(\mathrm{endpos}(t)\) 为 \(t\) 在 \(s\) 所有出现位置的结束位置构成的集合。
- $\mathrm{substr}(p) $ 状态 \(p\) 所有子串的集合。
- \(\mathrm{longest}(p)\) 和 \(\mathrm{shortest}(p)\) 分别表示状态 \(p\) 所对应的子串中,长度最长和最短的集合。
两个字符串 \(t_1, t_2\) 的 \(\mathrm{endops}\) 集合可能相等,因此我们可以将 \(s\) 的子串划分成若干个等价类,每一个等价类用一个状态表示。
引理 1:两个子串的 \(\mathrm{endpos}\) 集合要么没有交集,要么互相包含,而且长度更短的子串的 \(\operatorname{endpos}\) 包含更大的,且为它的后缀。
引理 2:对于同一个状态 \(p\), \(p\) 所表示的所有子串长度连续,且互为后缀关系。
推论 1:对于子串 \(t\) 的所有后缀,其 \(\mathrm{endpos}\) 集合大小随后缀长度减小而单调不降,且较小的 \(\mathrm{endpos}\) 集合完全包含更大的。
- 定义状态 \(p\) 的后缀链接 \(\mathrm{link}(p)\) 指向最长的后缀 \(w\) 满足 \(w \neq \mathrm{substr}(p)\), 容易发现有 \(\operatorname{minlen}(p) = \operatorname{maxlen}(p)+1\)。
引理 3:所有后缀链形成一棵以 \(st\) 为根的树,简称 \(\mathrm{parent}\) 树。
接下来会有大量结论:
结论 1:从任意状态 \(p\) 出发跳后缀链接直到 \(T\), 所有状态的 \([\mathrm{minlen}(q), \mathrm{maxlen}(q)]\) 不交,单调递减并形成区间 \([0, \mathrm{len}(p)]\)。
结论 2:\(\forall t_p \in \mathrm{substr}(p)\), 若存在 \(p \to q\) 的转移边,则 \(t_p+c_p \in \mathrm{substr}(q)\) ,Vice versa。
结论 3:对于状态 \(q\), 不存在转移 \(p \to q\) 使得 \(\mathrm{len}(p)+1 > \mathrm{len}(q)\) 。
构建 SAM
SAM 使用增量法构建,若我们得到了 \(s[1 \dots p - 1]\) 的 SAM,插入 \(s_p\) 后即可得到 \(s[1 \dots p]\) 的 SAM。因此 SAM 是一个在线算法。
设 \(s[1, i - 1]\) 在 \(A_{i - 1}\) 的状态为 \(lst\), 当前状态数量为 \(cnt\)。新加 \(s_i\) 时,新建初始状态 \(cur \leftarrow cnt+1\), 表示这是 \(s[1 \dots p]\) 的节点,其 \(\mathrm{endpos}(cur) ={i}\)。
我们不断跳 \(lst\), 因为 \(lst\) 及其祖先为 \(s[1 \dots p - 1 ]\) 的后缀,若此时没有 \(lst \to c\) 的转移,直接加上 \(lst \to cur\) 的转移即可,表示 \(lst\) 的 \(\mathrm{endpos}\) 集合并上 \(\{p\}\)。
若跳到 1 时仍然没有 \(c\) 的转移,直接令 \(\mathrm{link}_{cur} = 1\) 即可。
否则,设当前在 \(p\), \(\delta(p, c) = q\), 分两种情况讨论:
- 若 \(len_q = len_p+1\),说明 $q $ 的转移都是由 \(p\) 转移过来的,符合 \(\mathrm{link}_{cur}\) 的定义,故直接令 \(\mathrm{link}_{cur} = q\) 即可。
- 若 \(len_q \neq len_p +1\), 等价于 \(len_q > len_p+1\), 此时 \(q\) 所在节点不止含有 \(p\) 的转移,将 \(q\) 分裂成两个节点分别表示 \(len_q = len_p+1\) 和 \(len_q > len_p+1\) 的节点,设为 \(clone\), 将 \(\mathrm{link}_{clone} \leftarrow \mathrm{link}_q,\mathrm{link_{cur}} = clone\), 并把 \(q\) 的所有转移拷贝给 \(clone\),最后,把所有到 \(q\) 的转移改为转移到 \(clone\) 即可。正确性显然。
模板代码如下:
inline void extend(int c) {
int p = lst, cur = ++tot; siz[cur] = 1;
len[cur] = len[p] + 1; lst = cur;
for(; p && !ch[p][c]; p = fa[p]) ch[p][c] = cur;
if(!p) fa[cur] = 1;
else {
int q = ch[p][c];
if(len[q] == len[p] + 1) fa[cur] = q;
else {
int clone = ++tot;
len[clone] = len[p] + 1;
for(int i = 0; i < 26; ++i) ch[clone][i] = ch[q][i];
fa[clone] = fa[q]; fa[q] = fa[cur] = clone;
for(; ch[p][c] == q; p = fa[p]) ch[p][c] = clone;
}
}
}
可以证明(不会证明),SAM 的点数上限为 \(2n\) 级别, 边数为 \(3n - 4\) 级别
应用
求本质不同的子串个数:由于 SAM 的每个节点都代表一个等价类,个数为 \(len_x -len_{fa_x}\), 故总的本质不同子串个数为 \(\sum\limits_{x } len_x - len_{fa_x}\)。
字符串匹配:当一个文本串 \(t\) 在 \(s\) 的 SAM 上跑时,得到了 \(t\) 的每一个以 \(i\) 结尾的后缀中最长的 \(j\), 满足 \(t[j, \dots i]\) 是 \(s\) 的子串。
线段树合并维护 \(\mathrm{endpos}\) 集合:根据 \(\mathrm{endpos}\) 的性质,父亲节点的 \(\mathrm{endpos}\) 完全包含儿子的 \(\mathrm{endpos}\) 集合,若将一个节点是否有 \(i\) 位置 \(\mathrm{endpos}\) 作为一个下标的话,那么就可以直接线段树合并维护了。
定位 \(s[l, r]\) 所对应的 SAM 上的节点:先找到 \(s[1, \dots r]\) 所对应的节点 \(p\), 然后不断跳父亲直到 \(r - len_{x}+1 \leq l \leq r - len_{fa_x}\), 可以用倍增加速这个过程。
[HEOI2016/TJOI2016]字符串
对反串建出 SAM,二分 LCS 长度 \(l\), 就要求 \(s[d - l+1, d]\) 在 \(s[a, b]\) 中出现过,相当于询问这个字符串的 \(\mathrm{endpos}\) 是否包含 \([a+l - 1, b]\),线段树合并即可。
CF666E Forensic Examination
建出广义后缀自动机后把 \(S\) 在自动机上匹配,算出匹配长度,设以 \(i\) 结尾的最长匹配长度为 \(p_i\)。
对于询问,倍增定位子串长度后,就变成线段树最大值查询。
[CTSC2012]熟悉的文章
同样对标准作文库建出广义后缀自动机后算出每一个 \(s_i\) 的后缀匹配长度 \(p_i\),二分 \(L\), 就变成对于每一个 \(x\), 选择一个 \(j \in [1, x - L]\), 使得 \(x - j \leq p_x\) \(f_x = \max(f_j+x - j)\)。
而显然 \(x - p_x\) 单调不降,可选择的 \(j\) 随着 \(x\) 的增加单调不降,单调队列优化 dp 即可。
[NOI2018] 你的名字
对 \(S\) 和询问的每个 \(T\) 都建一个 SAM,对于每一个 \(i \in [1,|T|]\) , 算出 $s[l, r] $ 中与 \(t[1,i]\) 最长后缀匹配长度。
这个可以通过线段树合并预处理出每个点的 \(\mathrm{endpos}\) 集合,当匹配到 \(p\), 匹配长度为 \(len\) 时,检查这个 \(\mathrm{endpos}\) 中 是否含有 \([l+len - 1, r]\) ,若不满足减少匹配长度 \(len\) 直到匹配。付给 \(T[1, i]\) 所对应的节点。
现在就变成链覆盖,求和,对每一条边统计贡献即可。
核心代码:
for(int i = 1; i <= L; ++i) {
v = str[i] - 'a';
while(p != 1 && !ch[p][v]) p = fa[p], nowlen = len[p];
if(ch[p][v]) p = ch[p][v], nowlen ++;
else nowlen = 0;
while(nowlen) {
if(Q(rt[p], 1, n, l + nowlen - 1, r)) break;
nowlen --; if(nowlen == len[fa[p]]) p = fa[p];
}
chkmx(mxlen[end[i]], nowlen);
}
for(int i = tot; i >= 1; --i) {
int p = radix[i];
chkmx(mxlen[fa2[p]], mxlen[p]);
}
for(int i = 1; i <= tot; ++i) {
mxlen[i] = std :: min(mxlen[i], len2[i]);
ans += len2[i] - len2[fa2[i]] - std :: max(0, mxlen[i] - len2[fa2[i]]);
}
[NOI2015] 品酒大会
对反串建出 SAM 后,在 \(\mathrm{parent}\) 树上的两个点 \(x, y\),他们能对 \([0,len_{\mathrm{lca}_{x,y}}]\) 有 \(a_x \times a_y\) 的贡献。
由于两个乘积最大只有可能是两个最大或者两个最小取得,直接线性维护一下即可。
最后就是一个后缀加。
[TJOI2015]弦论
建出 SAM 后根据 type 计算出这个节点对应字符串的出现次数。
设 \(f_x\) 表示目前到 \(x\) 所能形成的字符串个数 (DAG)上,根据 \(k\) 和 \(f_x\) 的大小来决定下一位应该加哪一位字符。
具体来说,从小到大枚举当前 \(x\) 的转移 \(c\), 设 \(y = \delta(x, c)\) 。
若 \(k > f_y\) 说明后面接 \(c\) 能够形成所有字符串,否则一定是 \(c\) 结尾的,依次枚举后输出即可。
核心代码:
inline void dfs(int x) {
if(siz[x] >= k) return ;
k -= siz[x];
for(int i = 0; i < 26; ++i) {
if(!ch[x][i]) continue ;
if(k > sum[ch[x][i]]) k -= sum[ch[x][i]];
else {
putchar(char(i + 'a'));
dfs(ch[x][i]);
return ;
}
}
}
LG P6292 区间本质不同子串个数
离线询问并按照右端点排序,对于每一个节点 \(x\) 维护最后一次覆盖 \(x\) 的时间 \(lst\)。
当加入到 \(r\) 端点时对于 \(\mathrm{parent}\) 树上 \(r\) 对应节点的到根节点的链全部将 \(lst\) 赋为 \(r\)。 那么询问就是所有 \(lst \geq l\) 的长度之和。
发现链上赋值很像 LCT 中的 access 操作,于是我们开一颗 LCT,每次更新 \(x\) 到根节点时,暴力对于每一个相同颜色段减去 \(lst\) 的答案,再把这个连续段对应的 Splay 区间赋值为 \(lst\), 最后询问就是一个线段树 / 树状数组的问题。
CF700E Cool Slogans
回文自动机 PAM
回文自动机全称 Palindromic Automaton,用于处理回文串问题。用处相对于前两者而言很小。
基本定义和引理
- 节点:原字符串中每个本质不同的回文串为一个节点,有 \(len_x\) 保存当前串的长度。
- 转移边 trans:一个节点状态的一个转移 \(tr(x, c)\) 表示在 \(x\) 两边同时加上字符 \(c\) 得到的新的回文串。因此 \(len_{tr(x,c)} = len_x+2\)。
- fail 指针:类似于 SAM,PAM 的 fail 指针 \(fail_x\) 指向的是 \(x\) 最长的回文后缀。例如 ababa 指向的是 aba。从一个点 \(x\) 不断跳 \(fail_x\) 会得到以它作为结尾的回文子串的个数。
- 起始节点 st:由于回文串分为奇回文串和偶回文串,trans 的转移不会改变字符串的奇偶性,因此 PAM 中有奇数根 \(odd\) 和偶数根 \(even\)。为了方便,定义 \(len_{odd} = -1\),\(len_{even} = 0\)。
引理 1:字符串 \(s\) 本质不同回文串个数为 \(O(|s|)\) 级别。
解释:数学归纳法。
- 当 \(|s| = 1\) 时显然成立。
- 当 \(|s|>1\) 时,设 \(s = tc\),则考虑以 \(c\) 结尾的所有回文子串,若其不为最长的显然可以通过以最长的翻转得到,因此最多会增加 1 个,为最长的回文子串。
构建 PAM
算法 1(依靠势能):
使用增量法。初始化两个根 \(odd = 1\) 和 \(even =0\),\(fail_{0} = fail_{1} = 1\),记录当前插入的最后一个字符对应的节点 \(lst\)。
插入 \(c\) 时,需要寻找以 \(c\) 为结尾的最长回文子串作为当前节点。从 \(lst\) 开始不断跳 \(fail\) 直到 \(str[n] = str[n - len_x - 1]\),设为 \(p\),\(q = tr_{q, c}\)。若 \(q\) 不存在则新建,否则将 \(sz_q\) 更新后直接退出。然后先更新 \(q\) 的 \(fail\),再令 \(tr_{p, c} = q\),寻找 \(fail_q\) 可以从 \(fail_p\) 开始也继续跳直到 \(str[n] = str[n - len_x - 1]\),然后 \(fail_q = x\)。
正确性显然。
时间复杂度用势能分析,每次跳 fail 都会使势能 -1,而插入一个字符最多使势能 + 1,因此线性。
【模板】回文自动机代码如下:
int tot = 1, lst = 1, n;
int fa[M], len[M], dep[M], ch[M][26];
char str[M];
inline int find(int p, int l) {
while(str[l] != str[l - len[p] - 1]) p = fa[p];
return p;
}
inline void ins(int n, int c) {
int p = find(lst, n), q = ch[p][c];
if(!q) q = ++tot, len[q] = len[p] + 2, fa[q] = ch[find(fa[p], n)][c], dep[q] = dep[fa[q]] + 1, ch[p][c] = q;
lst = q;
}
inline void mian() {
scanf("%s", str + 1), n = strlen(str + 1);
len[1] = -1, fa[0] = fa[1] = 1; int lans = 0;
for(int i = 1; i <= n; ++i) {
if(i > 1) str[i] = char((str[i] - 97 + lans) % 26 + 97);
ins(i, str[i] - 'a');
printf("%d ", lans = dep[lst]);
}
}
算法 2(严格 \(O(n|\sum|)\)):
考虑优化暴力跳 fail 的过程。本质上是寻找 \(x\) 到根的链上第一个 \(z\) 满足 \(c = str[n - len_z - 1]\),因此可以预处理 \(qc[x][c]\) 表示 \(x\) 到根路径上的一个点表表示当前字符为 \(c\) 时存在 \(c\) 转移的点。
发现 \(qc[x][c]\) 可以通过 \(qc[fail_x][c]\) 继承得到,于是只用更改 1 个点的值即可。
代码:
inline int find(int p, int l) {
return str[l - len[p] - 1] == str[l] ? p : qf[p][str[l] - 'a'];
}
inline void ins(int n) {
int c = str[n] - 'a', p = find(lst, n), q = ch[p][c];
if(!q) {
q = ++tot, len[q] = len[p] + 2; int f = qf[p][c]; f = ch[f][c];
dep[q] = dep[fa[q] = f] + 1, memcpy(qf[q], qf[f], sizeof(qf[f]));
qf[q][str[n - len[f]] - 'a'] = f; assert(f != q);
ch[p][c] = q;
}
lst = q;
}
inline void mian() {
scanf("%s", str + 1), n = strlen(str + 1);
len[1] = -1, fa[0] = tot = 1;
for(int i = 0; i < 26; ++i) qf[0][i] = 1;
for(int i = 1; i <= n; ++i) {
if(i > 1) str[i] = char((str[i] - 97 + lans) % 26 + 97);
ins(i);
printf("%d ", lans = dep[lst]);
}
}
由于不依赖势能,可以用来可持久化 / 在末尾增 / 删等操作。
例题
【APIO2014】回文串
显然,建出 PAM 后累加子树 siz,答案就是 \(\max(len_i \times siz_i)\)。
【hdu6599】我喜欢回文串I Love Palindrome String
此题要寻找半回文串,设 \(x\) 节点对应的长度 \(\leq \dfrac{len_x}2\),且最长的回文后缀为 \(half_x\),只要保证 \(half_x = \dfrac{len_x+1}2\) 即可累加 \(siz_x\) 的答案,于是问题变为快速求 \(half_x\)。
由于 \(half_x\) 和 \(len_x\) 的相似性,我们仍然可以暴力求解,即根据转移前的节点的 \(half\) 不断跳,直到满足条件即可。
【BZOJ4044】病毒的合成Virus synthesis
先对给定串建出 PAM,设 \(dp_x\) 表示从空串得到 \(x\) 的最小次数,答案就是 \(\min(n - len_x+dp_x)\)。
对于一个操作序列而言,将每次的操作 2 分段,考虑最后一次操作 2,得到的一定是偶回文串。
所以我们只用考虑操作 2 之间的转移,也就是说,长度为奇数的串可以忽略。
求出 \(half_x\),则 \(x\) 的祖先 \(y\) (\(len_y \leq half_x\))的贡献是:\(dp_y+\dfrac{len_x}2 - len_y +1\)。
若有 \(y \to x\),则还可以从 \(dp_y+1\) 转移过来。
初始化 \(dp_{even} = 1\) 计算即可。
【BZOJ2565】最长双回文串
《Border 和回文后缀》应用 2。
【BZOJ5384】有趣的字符串题
离线后扫描线,若对于每个回文串维护最靠右的位置,然后树状数组即可。
如图所示,根据 《Border 和回文后缀》的内容,在同一个等差数列中,第一个串的贡献为其上一次出现位置 + 1 至当前位置,\(fail_x\) 的贡献为第二个红色起始位置 + 1至黑色位置...。以此类推,总贡献为 \([lst_x- len_x+2, i - len_x+(dep_x - dep_{slink_x}-1) \times diff_x+1]\)。而 \(x\) 的所有出现位置等于其子树的出现位置,用线段树维护。
【GDKOI2013】 大山王国的城市规划
若两个回文串 \(x,y\)(\(|x|<|y|\)) 为包含关系,则 \(x\) 可以通过走 fail 边,或者走 trans 边得到 \(y\)。
所以建出这个图,是个 DAG,然后要求最长反链,根据 Dilworth 定理,等于最小链覆盖,跑网络流即可。
Border 理论
抄写于 金策《字符串算法选讲》。
补充定义:
- 定义正整数 \(p\) 是串 \(S\) 的周期,当且仅当 \(p \leq |S|\) 且 \(\forall i\in [1, |S| - p]\),\(S_i = S_{i+p}\)。若 \(p\) 整除 \(S\) 则称为 \(p\) 是 \(S\) 的整周期。
- 定义正整数 \(r\) 是串 \(S\) 的 border 当且仅当 \(\mathrm{pre}(s, r) = \mathrm{suf}(s, r)\)。
推论 1:\(p\) 是 \(S\) 的周期 \(\Leftrightarrow |S| - p\) 是 \(S\) 的 border。
弱周期引理(Weak Periodicity Lemma):若 \(p, q\) 是 \(S\) 的周期,\(p+q \leq |S|\),则 \(\gcd(p, q)\) 也是 \(S\) 的周期。
证明:设 \(p < q\),\(d = q - p\)。则 \(\forall i > q\),\(S_i = S_{i - q} = S_{i - q +p}\)。
对于 \(q - p +1 \leq i \leq q\),只要满足 \(i+p \leq |S|\),同样有 \(S_i = S_{i+p} = S_{i- q+p}\)。
利用数学归纳法归纳 \((q - p, p)\) 直到一方为 0,即是辗转相除法,因此 $ \gcd(p, q)$ 是周期。
周期引理(Periodicity Lemma) :若 \(p, q\) 是 \(S\) 的周期, \(p+q - \gcd(p, q) \leq S\),则 \(gcd(p, q)\) 也是 \(S\) 的周期。
证明:好难,不会。
border 的结构
引理 3:字符串 \(u, v\) 满足 \(2|u| \geq |v|\),则 \(u\) 在 \(v\) 出现的位置构成一个等差数列。
证明:只用考虑至少出现 3 次的情况。
如图所示,设 $u_1, u_2 $ 为前两次出现的位置, \(u_3\) 为任意一次出现位置。则 \(d, q\) 都为 \(u\) 的周期,因此 \(r = \gcd(d, q)\) 也为 \(u\) 的周期。设 \(u\) 的最小周期为 \(p \leq r\)。
因为 \(p \leq r \leq q \leq |u_1 \cap u_2|\),因此 \(p\) 也是 \(u_1 \cap u_2\) 的周期,若 \(p < d\),则会出现更靠前的匹配,因此 \(p = d\)。
因此 \(p = d \leq \gcd(d, q) \Rightarrow q \mid d\)。
引理 4:串 \(s\) 的所有不小于 \(\dfrac{|s|}2\) 的 border 组成一个等差数列。
证明:设 \(s\) 最大 border 长度为 \(n - p\),另外一个 border 长度为 \(n - q\)(\(p, q \leq \dfrac{|s|}2\)),则根据弱周期引理, \(\gcd(p, q)\) 为 \(s\) 的周期 \(\Rightarrow \gcd(p, q) = p \Rightarrow q \mid p\)。
因此,将 \(s[1...n]\) 的所有 border 按照长度分类后:\(x \in [1, 2), [2, 4), \dots, [2^{k - 1}, 2^k), [2^k, n]\)。
有两种情况:
-
\(x \in [2^k,n]\),已经讨论过这种情况。
-
\(x \in[2^{i - 1}, 2^i)\)
当 \(|u| = |v|\) 时,设 \(\mathrm{PS}(u, v) = \{k \ | \ \mathrm{pre}(u, k) = \mathrm{suf}(u, k\}\) , \(\mathrm{LargePS}(u, v) = \{k \ | \ k \in \mathrm{PS}(u,v), k \geq \dfrac{|u|}2 \}\) 。
则 \([2^{i - 1}, 2^i)\) 内 border 长度集合为 \(\mathrm{LargePS}(\mathrm{pre}(s, 2^i), \mathrm{suf}(s, 2^i))\)。
引理 5 :\(\mathrm{LargePS}(u, v)\) 构成一个等差数列。
证明:设其中最大元素为 \(x\),则剩下的元素都是 \(x\) 的 border,根据引理 4 显然成立。
定理 1:串 \(s\) 的所有 border 按照长度排序后,可以划分成 \(\log |s|\) 个不交的段,每一段都是一个等差数列。
子串周期查询
给定串 \(s\),多次询问 \(s[l,r]\) 的所有周期,用 \(\log |s|\) 个等差数列表示。
Case1:当 \(x \in [2^{i - 1}, 2^i)\),即计算 \(\mathrm{LargePS}(\mathrm{pre}(t, 2^i), \mathrm{suf}(t, 2^i))\)。
若 \(u\) 是一个 \(\mathrm{Large \ \ Prefix-Suffix}\),则 \(\mathrm{pre}(t, 2^{i - 1})\) 是 \(u\) 的前缀, \(\mathrm{suf}(t, 2^{i - 1})\) 是 \(u\) 的后缀。
求出 \(\mathrm{pre}(t, 2^{i - 1})\) 在 \(\mathrm{suf}(t,2^i)\) 的所有出现位置, \(\mathrm{suf}(t, 2^{i - 1})\) 在 \(\mathrm{pre}(t, 2^i)\) 的所有出现位置,,则 border 等于后者移位后取交集。
而 \(|v| \geq \dfrac{|w|}2\),\(v\) 在 \(w\) 中所有匹配位置构成了一个等差数列,于是只用将首项和公差求出来即可。
即:求出 \(v\) 在 \(w\) 左边的第一,二次匹配和最后一次匹配。相当于实现一个 \(\mathrm{succ}(v, i)\) 表示 \(v\) 在原串的不小于 \(i\) 的第一次匹配和反过来的 \(\mathrm{pred}(v, i)\)。
可以在倍增求后缀数组时把每一轮结束后的结果都记录下来。
Case2:\(x \in [2^k, r - l+1]\) ,做法一样。
于是问题变成如何对两个等差数列求交。
引理 6:四个串满足 \(|x_1| = |y_1| \geq |x_2| = |y_2|\),且 \(x_1\) 在 \(y_2y_1\) 出现了至少 3 次, \(y_2\) 在 \(x_1x_2\) 出现了至少 3 次,则 \(x_1\) 和 \(y_1\) 的最小周期相等。
证明:反证法。不妨设 \(per(x_1) > per(y_1)\),考虑 \(x_1\) 在 \(y_2y_1\) 中最靠右的一次匹配,设与 \(y_1\) 重叠部分为 \(z_1\) 。
则 \(|z| \geq 2per(x_1) > per(x_1)+per(y_1)\),根据弱周期引理, \(z\) 具有周期 \(d = \gcd(per(x_1), per(y_1)) \mid per(x_1)\)。
于是 \(d\) 也是 \(x_1\) 的周期,但 \(d < per(x_1)\),矛盾。
所以我们合并的两个等差数列要么长度 \(\leq 3\),要么公差相等,所以可以 \(O(1)\) 合并。
于是做到了 \(O(n \log n) - O(\log^2n)\)。
当然,可以继续对 \(succ(v, i)\) 的计算优化做到 \(O(n \log n) - O(\log n)\),不过上面的算法已足够。
例题
P4156 [WC2016]论战捆竹竿
Loj#6681 yww 与树上的回文串
CF1286E Fedya the Potter Strikes Back
P5287 [HNOI2019]JOJO
Border 与回文后缀
引理 1:\(s\) 是回文串,则 \(t\) 是 \(s\) 的 border \(\Leftrightarrow\) \(t\) 是回文串。
证明:显然。
推论 1 :串 \(s\) 的所有回文后缀的长度可以表示成 \(\log |s|\) 个等差数列。
证明:根据 border 的结构 定理 1 即可。
应用 1:最小回文拆分
将字符串 \(s\) 分解成 \(s = s_1s_2\dots s_k\),使得 \(s_i\) 都是回文串,且 \(k\) 最小。
设 \(diff_x = len_x - len_{fail_x}\),\(slink_x\) 表示 fail 树上距离 \(x\) 最近的 \(y\) 满足 \(diff_y \neq diff_x\) 。
则从 \(x\) 开始跳 \(fail\),一直跳到 \(y\) 之前,都是一个等差数列。
如图所示,\(x\) 是当前的最长回文后缀,由于对称性,\(fail_x\) 上一次出现的位置为红色区域 \(x - diff_x\),而这恰好对应了 \(x\) 的起始位置,同理第二个红色区域对应了当前 \(fail_x\) 的位置,只剩下绿色区域没有计算,等于 \(i - len_x+(dep_x - dep_{slink_x} - 1) \times diff_x\)。然后不断跳 \(slink\) 依次更新即可。
应用 2:双回文串
若 \(s = ab\),\(a,b\) 都是回文串,则称 \(s\) 是双回文串。现给定 \(s\),求 \(s\) 子串中最长双回文串长度。
引理 2:若 \(s = x_1 x_2=y_1 y_2= z_1 z_2\),且 \(|x_1|<|y_1|<|z_1|\),\(x_2, y_1, y_2, z_1\) 是回文串,则 \(x_1, y_2\) 是回文串。
证明:略。
定理 2:若 \(s\) 是一个双回文串,则存在一种拆分方式 \(s = ab\),使得 \(a\) 是 \(s\) 的最长回文前缀,或 \(b\) 是 \(s\) 的最长回文后缀。
所以枚举断点,求出一个最长回文后缀和最长回文前缀拼起来即可。