我不会字符串

基本定义:

  • \(\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\) 证明略。

应用

  1. 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\)
  2. 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)由以下下五部分组成:

  1. 字符集 \(|\sum|\), 该自动机只能输入这些字符。
  2. 状态集合 \(Q\) , 如果把一个 DFA 看做一个 DAG,俺么状态集合就是图上的顶点。
  3. 起始状态 \(st \in Q\)
  4. 接受状态集合 \(F \subseteq Q\),是一组特殊的状态。
  5. 转移函数 \(\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 指针的性质

  1. 对于每一个 \(i\), 其 \(fail_i\) 唯一,因此,若将 \(fail_i \to i\) 边建出,得到的将会是一棵树。
  2. 对于节点 \(p\) 及其对应字符串 \(t_p\), 其子树内部所有节点 \(q\) 满足 \(t_p\)\(t_q\) 的后缀,Vice versa。
  3. 对于节点 \(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 次的情况。

1

如图所示,设 $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\) 之前,都是一个等差数列。

gd7acqi5.png (225×96) (luogu.com.cn)

如图所示,\(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\) 的最长回文后缀。

所以枚举断点,求出一个最长回文后缀和最长回文前缀拼起来即可。

Lyndon word

posted @ 2022-10-01 21:37  henrici3106  阅读(67)  评论(0编辑  收藏  举报