字符串基础
0. 地图
| 结构 | 结构化了什么 | 典型询问 |
|---|---|---|
| 哈希 | 子串相等 | 任意两子串比较、LCP 二分 |
| KMP / Border | 前缀的周期结构 | 单模匹配、周期、失配树 |
| Z 函数 | 每个后缀与整串的 LCP | 匹配、前缀出现 |
| Manacher | 每个中心的最长回文 | 回文子串计数 |
| AC 自动机 | 多模式串的后缀关系(fail 树) | 多模匹配、自动机上 DP |
| 后缀数组 | 所有后缀的字典序 + 相邻 LCP | 不同子串、LCS、第 \(k\) 小子串 |
| 后缀自动机 | 所有子串的 endpos 等价类(parent 树) | 出现次数、在线子串、多串 LCS |
| 回文自动机 | 所有本质不同回文串 | 回文出现次数、回文划分 |
| Lyndon 分解 | 字典序意义下的分解 | 最小后缀、最小表示 |
1. 字符串哈希
1.1 多项式哈希
1.2 安全性
- 自然溢出(\(M = 2^{64}\))会被卡:Thue–Morse 串对任何奇数底数 \(B\) 都能构造碰撞。
- 单模 \(10^9+7\):生日攻击,\(\sqrt M \approx 3\times10^4\) 个串就有碰撞风险。
- 推荐:双模(\(10^9+7\) 与 \(998244353\)),或单模 \(2^{61}-1\)(用
__int128乘),底数随机。
1.3 哈希能做的"万能"事
- LCP:二分长度 + 哈希比较,\(O(\log n)\)。
- 后缀排序 \(O(n\log^2 n)\):比较两个后缀 = LCP + 下一字符。
- 回文判定:正反两个哈希。
- 循环同构:\(s\) 与 \(t\) 循环同构 \(\iff\) \(t\) 是 \(ss\) 的子串。
哈希是所有字符串题的暴力基线。 先想清楚哈希能不能做,再考虑专用结构。
2. Border 与周期理论
2.1 定义
- \(s\) 的 border:既是前缀又是后缀的真子串(长度 \(< n\))。
- \(p\) 是 \(s\) 的周期:\(s_i = s_{i+p}\) 对所有合法 \(i\)。
- 基本事实:\(p\) 是周期 \(\iff\) \(n - p\) 是 border 长度。
2.2 KMP 与 next 数组
\(\text{nxt}[i]\) = 前缀 \(s[1..i]\) 的最长 border 长度。
nxt[1] = 0;
for (int i = 2, j = 0; i <= n; i++) {
while (j && s[i] != s[j + 1]) j = nxt[j];
if (s[i] == s[j + 1]) j++;
nxt[i] = j;
}
均摊 \(O(n)\):\(j\) 每次至多增加 1,while 每次至少减少 1。
前缀 \(i\) 的所有 border:\(\text{nxt}[i], \text{nxt}[\text{nxt}[i]], \dots\)。最小周期 \(= n - \text{nxt}[n]\)(若整除 \(n\) 则是循环节)。
2.3 失配树(fail 树)
把 \(i \to \text{nxt}[i]\) 连边,得到以 0 为根的树。前缀 \(i\) 的 border 集合 = \(i\) 到根的路径。
例题:洛谷 P5829 【模板】失配树。求前缀 \(p\) 和前缀 \(q\) 的最长公共 border:即 fail 树上 \(\text{LCA}(p, q)\),但 LCA 若等于 \(p\) 或 \(q\) 本身要再跳一步(border 必须是真子串)。
2.4 周期引理(Fine–Wilf)
若 \(p, q\) 都是 \(s\) 的周期且 \(p + q - \gcd(p, q) \le n\),则 \(\gcd(p, q)\) 也是周期。
弱形式(\(p + q \le n\))的证明:对任意位置 \(i\),若 \(i + p - q \ge 1\) 或 \(i - p + q \le n\) 之一成立(由 \(p + q \le n\) 保证至少一个成立),可以走 \(+p\) 再 \(-q\)(或反之)得 \(s_i = s_{i+p-q}\),所以 \(|p - q|\) 是周期。对 \((p, q)\) 做辗转相减即得。
2.5 Border 的结构定理
\(s\) 的所有 border 长度,按大小排序后可以划分成 \(O(\log n)\) 个等差数列。
理由:长度 \(\ge n/2\) 的 border 对应周期 \(\le n/2\),由周期引理它们的周期都是最小周期 \(p_0\) 的倍数,形成一个等差数列(公差 \(p_0\))。长度 \(< n/2\) 的 border 是"\(s\) 的最长的、长度 \(< n/2\) 的 border"\(t\) 的 border,递归到 \(t\),长度至少减半。
应用:
- 前缀 \(i\) 的 border 数:fail 树深度。
- [NOI2014] 动物园(洛谷 P2375):\(\text{num}[i]\) = 前缀 \(i\) 中长度 \(\le i/2\) 的 border 个数。维护第二个指针 \(j\),每步在正常 KMP 之后额外
while (j * 2 > i) j = nxt[j],\(\text{num}[i] = \text{dep}[j]\)(fail 树深度,含自身)。均摊仍 \(O(n)\)。 - CF 1286E / P4482 Border 的四种求法:把 border 按等差数列分段后,每段可以用一次 \(O(1)\) 或 \(O(\log)\) 的查询处理,总 \(O(n\log n)\)。
2.6 Z 函数(扩展 KMP)
\(z[i]\) = \(s\) 与 \(s[i..n]\) 的 LCP 长度。维护当前最右的匹配段 \([l, r]\):
z[1] = n;
for (int i = 2, l = 0, r = 0; i <= n; i++) {
if (i <= r) z[i] = min(z[i - l + 1], r - i + 1);
while (i + z[i] <= n && s[z[i] + 1] == s[i + z[i]]) z[i]++;
if (i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
}
\(O(n)\):\(r\) 单调不减,while 每次成功都让 \(r\) 增大。
匹配:对 \(t\#s\) 求 Z,\(z[|t| + 1 + i] = |t|\) 处即匹配。与 KMP 的区别:KMP 给"每个前缀的最长 border",Z 给"每个后缀与整串的 LCP",两者可以互推但各有方便的场合。模板:洛谷 P5410。
3. Manacher
3.1 算法
在字符间插入 #(首尾加哨兵),使所有回文变成奇回文。\(p[i]\) = 以 \(i\) 为中心的最长回文半径(含中心)。维护最右回文的右端 \(r\) 及其中心 \(\text{mid}\):
// t = "^#a#b#a#$",m = strlen(t)
for (int i = 1, mid = 0, r = 0; i < m - 1; i++) {
p[i] = i < r ? min(p[2 * mid - i], r - i) : 1;
while (t[i + p[i]] == t[i - p[i]]) p[i]++;
if (i + p[i] > r) r = i + p[i], mid = i;
}
// 原串中以 t[i] 为中心的最长回文长度 = p[i] - 1
\(O(n)\):同 Z 函数,\(r\) 单调不减。
3.2 应用
- 回文子串总数:\(\sum\lfloor p[i]/2\rfloor\)(奇偶合并后)。
- 每个位置作为回文左端点 / 右端点的最长回文:由 \(p\) 数组差分或倒序取 \(\max\) 得到。
- 最长双回文串(洛谷 P4555):\(\max_i(\text{以 } i \text{ 结尾的最长回文} + \text{以 } i+1 \text{ 开头的最长回文})\)。
4. AC 自动机
4.1 构造
Trie 上每个节点的 fail 指向"它代表的串的最长真后缀中,在 Trie 里存在的那个"。BFS 逐层构造,并顺便补全转移(Trie 图):不存在的儿子指向 fail 的对应儿子。
int tr[N][26], fail[N], cnt[N], tot;
void insert(const string& s) {
int p = 0;
for (char ch : s) { int c = ch - 'a'; if (!tr[p][c]) tr[p][c] = ++tot; p = tr[p][c]; }
cnt[p]++;
}
void build() {
queue<int> q;
for (int c = 0; c < 26; c++) if (tr[0][c]) q.push(tr[0][c]);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int c = 0; c < 26; c++)
if (tr[u][c]) fail[tr[u][c]] = tr[fail[u]][c], q.push(tr[u][c]);
else tr[u][c] = tr[fail[u]][c];
}
}
4.2 fail 树
\(u \to \text{fail}[u]\) 构成一棵树。fail 树上 \(u\) 的子树 = 所有以 \(u\) 代表的串为后缀的 Trie 节点。
多模匹配计数的正确做法:文本串在 Trie 图上走,每到一个节点 \(v\) 给 \(v\) 打一个标记(\(+1\))。不要沿 fail 链跳(会退化到 \(O(|T|\cdot\text{模式串总长})\))。最后在 fail 树上做子树求和,节点 \(u\) 的子树和就是模式串 \(u\) 的出现次数。\(O(|T| + \sum|P_i|)\)。模板:洛谷 P5357。
4.3 AC 自动机上的 DP
状态 \((\text{自动机节点},\ \text{已走步数},\ \dots)\)。"不能包含任何模式串":预处理每个节点是否"危险"(自身是终点,或 fail 链上有终点,构造时 danger[u] |= danger[fail[u]]),DP 时不走危险节点。
- [JSOI2007] 文本生成器(洛谷 P4052):长 \(m\) 的串中至少含一个模式串的数量 $= 26^m - $ 不含任何模式串的数量。
- [POI2000] 病毒(洛谷 P2444):是否存在无限长的串不含模式串 \(\iff\) 非危险节点构成的图有环。
- [USACO12JAN] Video Game(洛谷 P3041):最大化出现次数之和,节点权值 = fail 树上祖先中终点数。
4.4 AC 自动机 + 数据结构
"动态加入模式串后查询":AC 自动机不支持在线插入,用二进制分组(数据结构讲义 III.5),CF 710F。
"查询某模式串在某文本前缀中的出现次数":离线,fail 树 DFS 序 + 树状数组([NOI2011] 阿狸的打字机,洛谷 P2414)。
5. 后缀数组
5.1 定义
\(\text{sa}[i]\) = 排名第 \(i\) 的后缀起始位置;\(\text{rk}[i]\) = 后缀 \(i\) 的排名;\(\text{height}[i] = \text{LCP}(\text{sa}[i-1], \text{sa}[i])\)。
5.2 倍增构造 \(O(n\log n)\)
第 \(k\) 轮按 \((\text{rk}[i], \text{rk}[i+k])\) 双关键字基数排序,得到长度 \(2k\) 前缀的排名。
int n, sa[N], rk[N << 1], old[N << 1], id[N], cnt[N];
char s[N];
void buildSA() {
int m = 127, p;
for (int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++;
for (int i = 2; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i; i--) sa[cnt[rk[i]]--] = i;
for (int k = 1; k < n; k <<= 1, m = p) {
p = 0;
for (int i = n - k + 1; i <= n; i++) id[++p] = i; // 第二关键字为空的排最前
for (int i = 1; i <= n; i++) if (sa[i] > k) id[++p] = sa[i] - k;
memset(cnt, 0, sizeof(int) * (m + 1));
for (int i = 1; i <= n; i++) cnt[rk[i]]++;
for (int i = 2; i <= m; i++) cnt[i] += cnt[i - 1];
for (int i = n; i; i--) sa[cnt[rk[id[i]]]--] = id[i]; // 按第一关键字稳定排序
memcpy(old, rk, sizeof(int) * (n + 1));
p = 0;
for (int i = 1; i <= n; i++)
rk[sa[i]] = (old[sa[i]] == old[sa[i - 1]] && old[sa[i] + k] == old[sa[i - 1] + k]) ? p : ++p;
if (p == n) break;
}
}
\(\text{rk}\) 数组开两倍,越界处为 0。SA-IS 可做到 \(O(n)\),竞赛中倍增足够。模板:洛谷 P3809。
5.3 height 数组
引理:\(\text{height}[\text{rk}[i]] \ge \text{height}[\text{rk}[i-1]] - 1\)。(后缀 \(i-1\) 与其前一名的 LCP 去掉首字符,就是后缀 \(i\) 与某个排名更靠前的后缀的 LCP。)
void buildHeight() {
for (int i = 1, k = 0; i <= n; i++) {
if (rk[i] == 1) { k = 0; continue; }
if (k) k--;
int j = sa[rk[i] - 1];
while (i + k <= n && j + k <= n && s[i + k] == s[j + k]) k++;
height[rk[i]] = k;
}
}
\(O(n)\)。
5.4 核心性质
所以任意两后缀的 LCP 是 height 上的 RMQ。排名相近的后缀 LCP 大,这是所有 SA 应用的出发点。
5.5 应用
| 问题 | 做法 |
|---|---|
| 本质不同子串数 | \(\frac{n(n+1)}{2} - \sum\text{height}[i]\) |
| 最长重复子串 | \(\max\text{height}\) |
| 可重叠出现 \(\ge k\) 次的最长子串 | height 连续 \(k-1\) 个的最小值的最大值 |
| 不可重叠重复子串 | 二分长度 \(L\),按 \(\text{height} \ge L\) 分组,组内 \(\max\text{sa} - \min\text{sa} \ge L\) |
| 两串最长公共子串 | 拼接(中间加分隔符),相邻且来自不同串的 height 最大值 |
| 多串(\(\ge k\) 个)公共子串 | 拼接 + 二分长度 + 分组检查来源种类 |
| 字典序第 \(k\) 小子串 | 按 sa 顺序,后缀 \(\text{sa}[i]\) 贡献 \(n - \text{sa}[i] + 1 - \text{height}[i]\) 个新子串 |
| 循环串排序 | 把 \(s\) 复制一遍再排序([JSOI2007] 字符加密,P4051) |
| \(\sum_{i<j}\text{LCP}(i,j)\) | height 上单调栈([AHOI2013] 差异,P4248) |
| 子串比较 / 子串的 LCP | RMQ + 比较下一字符 |
[NOI2015] 品酒大会(洛谷 P2178):对每个 \(r\) 求 LCP \(\ge r\) 的后缀对数与其权值乘积最大值。按 height 从大到小合并相邻排名(并查集维护集合大小、最大、次大、最小、次小),答案对 \(r\) 做后缀 \(\max\)。
6. 后缀自动机
6.1 endpos 与 parent 树
子串 \(t\) 的 \(\text{endpos}(t)\) = \(t\) 在 \(s\) 中所有出现的结束位置集合。
关键事实:
- 两个子串 endpos 相同或不交或包含。若 \(\text{endpos}(u) \subsetneq \text{endpos}(v)\),则 \(v\) 是 \(u\) 的后缀。
- endpos 相同的子串构成一个等价类,它们是一段连续长度的、互为后缀的串:\([\text{minlen}, \text{maxlen}]\)。
- 等价类按 endpos 的包含关系构成树(parent 树):父亲的 endpos 是儿子的超集,父亲的串是儿子的后缀,\(\text{minlen}(u) = \text{maxlen}(\text{link}(u)) + 1\)。
SAM 的状态 = endpos 等价类;转移 = 在串后加一个字符;link = parent 树的父边。
6.2 构造
struct SAM {
int len[N << 1], link[N << 1], nxt[N << 1][26], cnt[N << 1], sz, last;
void init() { sz = 1; last = 0; link[0] = -1; }
void extend(int c) {
int cur = sz++; len[cur] = len[last] + 1; cnt[cur] = 1;
int p = last;
while (p != -1 && !nxt[p][c]) nxt[p][c] = cur, p = link[p];
if (p == -1) link[cur] = 0;
else {
int q = nxt[p][c];
if (len[p] + 1 == len[q]) link[cur] = q;
else {
int cl = sz++;
len[cl] = len[p] + 1; link[cl] = link[q]; cnt[cl] = 0;
memcpy(nxt[cl], nxt[q], sizeof nxt[q]);
while (p != -1 && nxt[p][c] == q) nxt[p][c] = cl, p = link[p];
link[q] = link[cur] = cl;
}
}
last = cur;
}
};
为什么要 clone:\(q\) 的 endpos 需要加入新位置 \(n\),但只有 \(q\) 中长度 \(\le \text{len}(p) + 1\) 的那些串才真的以 \(n\) 结尾(更长的串在位置 \(n\) 处不出现)。所以把 \(q\) 拆成两个等价类。
状态数 \(\le 2n - 1\),转移数 \(\le 3n - 4\)。 构造均摊 \(O(n)\)(对固定字母表)。
6.3 应用
| 问题 | 做法 |
|---|---|
| 子串判定 | 从根走转移 |
| 本质不同子串数 | \(\sum_u(\text{len}(u) - \text{len}(\text{link}(u)))\),在线可维护([SDOI2016] 生成魔咒,P4070) |
| 子串出现次数 | \(\text{cnt}(u)\) = parent 树子树中非 clone 节点数(按 len 计数排序后自底向上累加) |
| 字典序第 \(k\) 小子串 | DAG 上 DP 每个状态可到达的子串数,逐位确定([TJOI2015] 弦论,P3975) |
| 两串最长公共子串 | 对 \(s\) 建 SAM,\(t\) 在上面走:失配时沿 link 回退,成功时长度 \(+1\) |
| endpos 集合本身 | parent 树上线段树合并(每个非 clone 叶子一个位置) |
| 某子串在区间 \([l,r]\) 内的出现次数 | 定位子串所在状态(parent 树倍增),查它的 endpos 线段树中 $[l + |
| 最小循环表示 | 对 \(ss\) 建 SAM,贪心走 \(n\) 步取最小字符 |
| 后缀树 | 反串的 SAM 的 parent 树即原串的后缀树 |
定位子串 \(s[l..r]\) 所在状态:先找到前缀 \(r\) 对应的状态(构造时记录),然后在 parent 树上倍增跳到 \(\text{len}(\text{link}) < r - l + 1 \le \text{len}\) 的祖先。
[HEOI2016] 字符串(洛谷 P4094):\(s[a..b]\) 的所有子串与 \(s[c..d]\) 的最长公共前缀。二分长度 \(L\),定位 \(s[c..c+L-1]\) 的状态,查其 endpos 是否在 \([a + L - 1, b]\) 内有元素(线段树合并 + 可持久化或倍增)。
6.4 广义 SAM(多串)
在线版:每插入一个新串把 last 重置为根。需要处理两个特判:
int extend(int last, int c) {
if (nxt[last][c]) { // 转移已存在
int q = nxt[last][c];
if (len[q] == len[last] + 1) return q; // 直接复用
int cl = sz++; // 只 clone,不新建 cur
len[cl] = len[last] + 1; link[cl] = link[q]; memcpy(nxt[cl], nxt[q], sizeof nxt[q]);
int p = last;
while (p != -1 && nxt[p][c] == q) nxt[p][c] = cl, p = link[p];
link[q] = cl;
return cl;
}
// 否则同普通 extend
}
离线版:把所有串插进 Trie,BFS 遍历 Trie,对每个 Trie 节点以其父节点对应的 SAM 状态为 last 做 extend。必须 BFS,DFS 会因为 last 的 len 不对导致状态数错误。
应用:多串最长公共子串(每个状态记录被哪些串到达,parent 树上传)、[USACO17DEC] Standing Out from the Herd(P4081,只属于一个串的子串数)、[ZJOI2015] 诸神眷顾的幻想乡(P3346,以每个叶子为根的 Trie 插入广义 SAM)。
6.5 SAM 与 SA 的选择
| SAM | SA | |
|---|---|---|
| 在线 | 是 | 否 |
| 空间 | \(O(n|\Sigma|)\) | \(O(n)\) |
| 字典序相关 | 需 DAG DP | 天然 |
| endpos / 出现位置 | parent 树 + 线段树合并 | 排名区间 + 主席树 |
| 多串 | 广义 SAM | 拼接 |
| 常数 | 小 | 倍增大,SA-IS 小 |
经验:涉及"出现次数 / 出现位置 / 在线"用 SAM;涉及"字典序第 \(k\) / 排序 / 分组"用 SA。
7. 回文自动机
7.1 结构
每个节点代表一个本质不同的回文子串。两个根:偶根(len 0)和奇根(len \(-1\))。转移 \(\text{nxt}[u][c]\):在 \(u\) 两侧各加一个 \(c\)。fail:最长的回文真后缀。
节点数 \(\le n\):每加一个字符最多产生一个新的本质不同回文串(最长的那个回文后缀)。
7.2 构造
int len[N], fail[N], nxt[N][26], cnt[N], tot, last, n;
char s[N];
int newnode(int l) { len[tot] = l; return tot++; }
void init() { tot = 0; newnode(0); newnode(-1); fail[0] = 1; fail[1] = 1; last = 0; n = 0; s[0] = -1; }
int getfail(int x) { while (s[n - len[x] - 1] != s[n]) x = fail[x]; return x; }
void insert(int c) {
s[++n] = c;
int x = getfail(last);
if (!nxt[x][c]) {
int y = newnode(len[x] + 2);
fail[y] = nxt[getfail(fail[x])][c]; // 先求 fail,再挂转移
nxt[x][c] = y;
}
last = nxt[x][c];
cnt[last]++;
}
均摊 \(O(n)\):getfail 的跳跃次数由 last 的深度势能支付。
7.3 应用
- 本质不同回文子串数:\(\text{tot} - 2\)。
- 每个回文串的出现次数:\(\text{cnt}\) 沿 fail 树自底向上累加(按节点编号倒序)。
- 回文子串总数:\(\sum\text{cnt}\)(累加后)。
- 以每个位置结尾的回文串数:插入后 \(\text{last}\) 在 fail 树的深度。
- [SHOI2011] 双倍回文(P4287):形如 \(ww^Rww^R\) 的回文。对每个节点维护 \(\text{trans}\) = 长度 \(\le \text{len}/2\) 的最长回文后缀,检查 \(\text{len}(\text{trans}) \cdot 2 = \text{len}\) 且 \(\text{len}(\text{trans})\) 为偶。
- 两串公共回文子串([HAOI2016] 找相同字符的回文版):分别建 PAM 同步 DFS。
7.4 回文划分与等差性质
CF 932G Palindrome Partition:把串划分成偶数段,\(s_1 = s_k^R\),\(s_2 = s_{k-1}^R\)……转化为:把 \(s_1 s_n s_2 s_{n-1}\cdots\) 交错串划分成若干偶长回文串的方案数。
\(f[i] = \sum_{j:\ s[j+1..i]\text{ 回文}}f[j]\),需要枚举以 \(i\) 结尾的所有回文后缀(fail 链),\(O(n^2)\)。
优化:fail 链上的回文后缀长度构成 \(O(\log n)\) 个等差数列(与 border 结构定理同理,回文后缀 = 回文 border)。定义 \(\text{diff}[u] = \text{len}[u] - \text{len}[\text{fail}[u]]\),\(\text{slink}[u]\) = fail 链上第一个 \(\text{diff}\) 不同的节点(等差数列的"上一段")。同一等差数列的贡献 \(g[u]\) 可以由 \(g[\text{fail}[u]]\) 加一项得到(因为位置 \(i\) 的这一段和位置 \(i - \text{diff}[u]\) 的同一段只差一个元素)。每个位置只处理 \(O(\log n)\) 段,总 \(O(n\log n)\)。
// 插入 s[i] 后,last 为当前节点
for (int u = last; len[u] > 0; u = slink[u]) {
g[u] = f[i - len[slink[u]] - diff[u]]; // 该等差数列最短的那个回文之前的位置
if (diff[u] == diff[fail[u]]) g[u] = (g[u] + g[fail[u]]) % MOD;
if (i % 2 == 0) f[i] = (f[i] + g[u]) % MOD; // 只在偶数位置累加(题目要求偶长)
}
这是 PAM 上最重要的高级技巧,也用于"最少回文划分"等问题。
8. Lyndon 分解与最小表示
8.1 Lyndon 串
\(s\) 是 Lyndon 串 \(\iff\) \(s\) 严格小于它的所有真后缀(等价:严格小于所有非平凡循环移位)。
Lyndon 分解定理:任意串唯一分解为 \(s = w_1w_2\cdots w_k\),每个 \(w_i\) 是 Lyndon 串且 \(w_1 \ge w_2 \ge \cdots \ge w_k\)。
8.2 Duval 算法 \(O(n)\)
// 输出每段 [i, j]
for (int i = 1; i <= n;) {
int j = i, k = i + 1;
while (k <= n && s[j] <= s[k]) { j = (s[j] < s[k]) ? i : j + 1; k++; }
while (i <= j) { printf("[%d,%d] ", i, i + k - j - 1); i += k - j; }
}
维护"当前近似 Lyndon 串 \(s[i..k-1]\) = 某 Lyndon 串的若干重复 + 一个前缀"。\(s[j] < s[k]\) 时整体变成一个新的 Lyndon 串;\(s[j] > s[k]\) 时把完整的重复段全部输出。模板:洛谷 P6114。
8.3 应用
- 最小后缀:Lyndon 分解的最后一段 \(w_k\)。
- 每个前缀的最小后缀:Duval 过程中可顺便得到。
- 最小表示法:对 \(ss\) 做 Lyndon 分解,第一个起始位置 \(\ge\) … 的段即为最小循环移位起点(也可用下面的双指针)。
- Runs 定理(了解):串的 runs(极大周期子串)数 \(< n\),可由 Lyndon 树 \(O(n)\) 求出,用于回答"所有周期子串"类问题。
8.4 最小表示法 \(O(n)\)
int minRep() { // 返回最小循环移位的起点(0-indexed)
int i = 0, j = 1, k = 0;
while (i < n && j < n && k < n) {
int a = s[(i + k) % n], b = s[(j + k) % n];
if (a == b) k++;
else {
if (a > b) i += k + 1; else j += k + 1;
if (i == j) j++;
k = 0;
}
}
return min(i, j);
}
正确性:若 \(s[i+k] > s[j+k]\),则以 \(i, i+1, \dots, i+k\) 开头的移位都不是最小(它们都被对应的 \(j\) 开头的移位打败),可以一起跳过。模板:洛谷 P1368。
9. 组合技巧
9.1 带通配符匹配(FFT)
模式 \(P\) 含通配符 *(匹配任意字符)。定义 \(P_i = 0\) 当通配符。匹配位置 \(x\) 的判据:
展开为三项卷积,FFT \(O(n\log n)\)。模板:洛谷 P4173。
9.2 Shift-And(bitset 匹配)
\(D\) 是长 \(m\) 的 bitset,\(D_j = 1\) 表示模式前 \(j\) 位匹配文本当前后缀。\(D' = ((D \ll 1) | 1)\ \&\ B[T_i]\),其中 \(B[c]\) 是模式中字符 \(c\) 出现位置的 bitset。\(O(nm/64)\),支持"每个位置是字符集合"的模式。
9.3 子序列自动机
\(\text{nxt}[i][c]\) = 位置 \(i\) 之后第一个字符 \(c\) 的位置。倒序 \(O(n|\Sigma|)\) 预处理。判断 \(t\) 是否为 \(s\) 的子序列 \(O(|t|)\);求本质不同子序列数:DAG 上 DP。
9.4 后缀数组 + 单调栈
\(\sum_{i<j}\text{LCP}(\text{sa}[i], \text{sa}[j]) = \sum_{k}\text{height}[k]\times(\text{以 }k\text{ 为最小值的区间数})\)。单调栈求每个 height 作为最小值的左右范围。
9.5 SAM + 线段树合并 + 倍增:区间子串问题
"\(s[l..r]\) 在 \(s[a..b]\) 中出现几次"、"\(s[a..b]\) 的本质不同子串数"(P6292,需要 LCT 维护 parent 树上的最右 endpos)。套路:定位状态 → 该状态的 endpos 线段树 → 区间查询。
9.6 哈希 + 二分 + 数据结构
当专用结构太重时,"哈希求 LCP + 二分 + 主席树 / 线段树"经常是 \(O(n\log^2 n)\) 的可行替代。例如动态修改的串求 LCP:线段树维护区间哈希,二分 LCP,\(O(\log^2 n)\)。
10. 常见错误清单
- 自然溢出哈希。 会被 Thue–Morse 卡,用双模或 \(2^{61}-1\)。
- KMP 的 border 必须是真子串。 失配树 LCA 等于查询点本身时要再跳一步。
- AC 自动机匹配时沿 fail 链累加。 退化为平方,改用 fail 树子树和。
- AC 自动机没有补全 Trie 图就做 DP。 转移必须对每个字符都定义。
- SA 倍增的第二关键字。 位置 \(> n - k\) 的后缀第二关键字为空,必须排最前。
- height 的引理用错下标。 引理是关于 \(\text{height}[\text{rk}[i]]\) 与 \(\text{height}[\text{rk}[i-1]]\),按原串位置递推。
- SAM 的 clone 节点 cnt 设为 1。 clone 不是真实结束位置,cnt 应为 0。
- SAM 状态数组只开 \(n\)。 需要 \(2n\)。
- 广义 SAM 离线用 DFS 建。 必须 BFS。
- 广义 SAM 在线版漏掉"转移已存在"的特判。 会产生 len 相同的重复状态。
- PAM 新节点的 fail 在挂转移之后求。 会指向自己。
- PAM 奇根的 fail。 设为奇根自身(或任何保证
getfail终止的值),偶根 fail 指向奇根。 - Lyndon 分解 Duval 内层
j的更新。 \(s[j] < s[k]\) 时 \(j\) 回到 \(i\),不是 \(i + 1\)。 - 最小表示法 \(i = j\) 时不跳。 会死循环。
- Z 函数 \(z[1]\) 未初始化为 \(n\)。 后续 \(z[i - l + 1]\) 可能用到。
11. 课堂练习
- 求
ababab的所有 border 和所有周期。(答:border 长度 4, 2;周期 2, 4, 6) - 求
aabaaab的 Z 数组。(答:\(7,1,0,2,3,1,0\)) - 证明周期引理的弱形式(\(p + q \le n\))。
- 手动构造
abab的 SAM,标出每个状态的 \([\text{minlen}, \text{maxlen}]\) 和 endpos。 banana的后缀数组和 height 数组。(答:sa \(= 6,4,2,1,5,3\);height \(= 0,1,3,0,0,2\))banana的本质不同子串数。(答:\(21 - 6 = 15\))- 用 PAM 求
abaab的本质不同回文子串数。(答:5:a b aba aa baab) bcaabca的 Lyndon 分解。(答:bc | aabc | a)cbabc的最小表示。(答:abccb,起点 2)- 为什么 SAM 的转移数上界是 \(3n - 4\) 而不是 \(2n\) 的倍数?(提示:生成树边 \(\le 2n - 2\),非树边每条对应一个不同的后缀)
12. 推荐题单
| 难度 | 题目 | 考点 |
|---|---|---|
| 基础 | 洛谷 P3375 / P5410 / P3805 | KMP / Z / Manacher 模板 |
| 基础 | 洛谷 P5357 AC 自动机(二次加强) | fail 树子树和 |
| 基础 | 洛谷 P3809 / P3804 / P5496 | SA / SAM / PAM 模板 |
| 基础 | 洛谷 P6114 / P1368 | Lyndon / 最小表示 |
| 提高 | 洛谷 P5829 失配树 | border 的 LCA |
| 提高 | 洛谷 P2375 [NOI2014] 动物园 | 双指针 KMP |
| 提高 | 洛谷 P4052 文本生成器 | AC 自动机 DP |
| 提高 | 洛谷 P2414 阿狸的打字机 | fail 树 + 树状数组离线 |
| 提高 | 洛谷 P4051 字符加密 | SA 循环串 |
| 提高 | 洛谷 P4248 差异 | SA + 单调栈 |
| 提高 | 洛谷 P2178 品酒大会 | SA + 并查集合并 |
| 提高 | 洛谷 P3975 弦论 | SAM 第 \(k\) 小 |
| 提高 | 洛谷 P4070 生成魔咒 | 在线 SAM |
| 提高 | 洛谷 P3181 找相同字符 | SAM / SA 两串统计 |
| 提高 | 洛谷 P6139 广义 SAM | 广义 SAM |
| 提高 | 洛谷 P4081 Standing Out from the Herd | 广义 SAM 归属 |
| 提高 | 洛谷 P4287 双倍回文 | PAM trans 指针 |
| 提高 | 洛谷 P4173 残缺的字符串 | 通配符 FFT |
| 挑战 | 洛谷 P4094 [HEOI2016] 字符串 | SAM + 二分 + 线段树合并 |
| 挑战 | 洛谷 P3346 诸神眷顾的幻想乡 | Trie 上广义 SAM |
| 挑战 | 洛谷 P4022 [CTSC2012] 熟悉的文章 | 广义 SAM + 二分 + 单调队列 DP |
| 挑战 | CF 932G Palindrome Partition | PAM 等差链 |
| 挑战 | 洛谷 P4482 Border 的四种求法 | border 等差结构 |
| 挑战 | 洛谷 P6292 区间本质不同子串个数 | SAM + LCT |
| 挑战 | 洛谷 P5284 [十二省联考2019] 字符串问题 | SAM + 拓扑 DP |
附录:一页纸总结
周期:\(p\) 周期 \(\iff n-p\) 是 border;\(p+q-\gcd\le n\Rightarrow\gcd\) 周期;border 分成 \(O(\log)\) 段等差数列。
KMP / Z:\(\text{nxt}[i]\) 最长 border;\(z[i]\) 后缀与整串 LCP;两者均摊线性靠"右端单调"。
AC 自动机:fail 树子树 = 以该串为后缀的节点;匹配打标记 + 子树和;DP 走 Trie 图避开危险点。
SA:\(\text{LCP}(\text{sa}_i,\text{sa}_j)=\min\text{height}(i,j]\);不同子串 \(=\frac{n(n+1)}2-\sum\text{height}\)。
SAM:状态 = endpos 等价类,parent 树 = 后缀关系,\(\text{minlen}=\text{len(link)}+1\);出现次数 = 子树非 clone 数;定位子串 = 前缀状态 + 倍增。
PAM:节点 \(\le n\);fail = 最长回文真后缀;回文后缀长度 \(O(\log)\) 段等差 → diff / slink。
Lyndon:Duval \(O(n)\);最小后缀 = 最后一段;最小表示双指针 \(O(n)\)。
选结构:相等 / 比较 → 哈希;周期 → KMP;多模 → AC;字典序 / 分组 → SA;出现次数 / 在线 / 多串 → SAM;回文 → PAM / Manacher。

浙公网安备 33010602011771号