文章目录
-
- 三、区域赛算法模版第三章---字符串相关算法
-
- [3.1 KMP / 前缀函数](#3.1 KMP / 前缀函数)
-
- [3.1.1 基本原理](#3.1.1 基本原理)
- [3.1.2 基础模板](#3.1.2 基础模板)
- [3.1.3 字符串周期与最小循环节](#3.1.3 字符串周期与最小循环节)
- [3.1.4 统计每个前缀出现次数](#3.1.4 统计每个前缀出现次数)
- [3.1.5 最长公共前后缀链](#3.1.5 最长公共前后缀链)
- [3.1.6 KMP 自动机](#3.1.6 KMP 自动机)
- [3.1.7 扩展 KMP(Z 函数)](#3.1.7 扩展 KMP(Z 函数))
- [3.1.8 前缀函数的其他性质](#3.1.8 前缀函数的其他性质)
- [3.1.9 常见应用](#3.1.9 常见应用)
- [3.1.10 时间复杂度总结](#3.1.10 时间复杂度总结)
- [3.1.11 注意事项](#3.1.11 注意事项)
- [3.1.12 区域赛题](#3.1.12 区域赛题)
- [3.2 Trie 字典树](#3.2 Trie 字典树)
-
- [3.2.1 基本原理](#3.2.1 基本原理)
- [3.2.2 基础模板(字符串统计)](#3.2.2 基础模板(字符串统计))
- [3.2.3 01Trie(求最大异或和)](#3.2.3 01Trie(求最大异或和))
- [3.2.4 常见应用](#3.2.4 常见应用)
- [3.2.5 时间复杂度总结](#3.2.5 时间复杂度总结)
- [3.2.6 注意事项](#3.2.6 注意事项)
- [3.2.7 字典树补充---可持续化字典树](#3.2.7 字典树补充---可持续化字典树)
- [3.2.7.1 基本原理](#3.2.7.1 基本原理)
- [3.2.7.2 基础模板:区间最大异或和](#3.2.7.2 基础模板:区间最大异或和)
- [3.2.7.3 树上路径最大异或和](#3.2.7.3 树上路径最大异或和)
- [3.2.7.4 子树最大异或和](#3.2.7.4 子树最大异或和)
- [3.2.7.5 次大值 + 可持久化 01Trie](#3.2.7.5 次大值 + 可持久化 01Trie)
- [3.2.7.6 常见应用总结](#3.2.7.6 常见应用总结)
- [3.2.7.7 注意事项](#3.2.7.7 注意事项)
- [3.2.8 区域赛题](#3.2.8 区域赛题)
- [3.3 Manacher算法](#3.3 Manacher算法)
-
- [3.3.1 基本原理](#3.3.1 基本原理)
- [3.3.2 基础模板](#3.3.2 基础模板)
- [3.3.3 最长回文子串(LeetCode 5)](#3.3.3 最长回文子串(LeetCode 5))
- [3.3.4 回文子串数量(LeetCode 647)](#3.3.4 回文子串数量(LeetCode 647))
- [3.3.5 不重叠回文子串的最多数目(LeetCode 2472)](#3.3.5 不重叠回文子串的最多数目(LeetCode 2472))
- [3.3.6 长度前 k 名的奇数长度回文子串长度乘积(洛谷 P1659)](#3.3.6 长度前 k 名的奇数长度回文子串长度乘积(洛谷 P1659))
- [3.3.7 最长双回文串长度(洛谷 P4555)](#3.3.7 最长双回文串长度(洛谷 P4555))
- [3.3.8 时间复杂度总结](#3.3.8 时间复杂度总结)
- [3.3.9 注意事项](#3.3.9 注意事项)
- [3.3.x 区域赛题](#3.3.x 区域赛题)
- [3.4 字符串哈希(多项式哈希)](#3.4 字符串哈希(多项式哈希))
-
- [3.4.1 基本原理](#3.4.1 基本原理)
- [3.4.2 基础实现(洛谷 P3370)](#3.4.2 基础实现(洛谷 P3370))
- [3.4.3 哈希冲突及工业优化](#3.4.3 哈希冲突及工业优化)
- [3.4.4 子串哈希(前缀哈希)](#3.4.4 子串哈希(前缀哈希))
- [3.4.5 常见应用](#3.4.5 常见应用)
- [3.4.6 哈希参数选择建议](#3.4.6 哈希参数选择建议)
- [3.4.7 时间复杂度总结](#3.4.7 时间复杂度总结)
- [3.5 AC自动机](#3.5 AC自动机)
-
- [3.5.1 基本原理](#3.5.1 基本原理)
- [3.5.2 基础模板(统计每个模式串出现次数)](#3.5.2 基础模板(统计每个模式串出现次数))
- [3.5.3 AC 自动机 + 数位 DP(洛谷 P3311)](#3.5.3 AC 自动机 + 数位 DP(洛谷 P3311))
- [3.5.4 AC 自动机的优化技巧](#3.5.4 AC 自动机的优化技巧)
- [3.5.5 常见应用](#3.5.5 常见应用)
- [3.5.6 时间复杂度总结](#3.5.6 时间复杂度总结)
- [3.5.7 注意事项](#3.5.7 注意事项)
- [3.6 后缀自动机](#3.6 后缀自动机)
-
- [3.6.1 基本原理](#3.6.1 基本原理)
- [3.6.2 基础模板](#3.6.2 基础模板)
- [3.6.3 不同子串个数](#3.6.3 不同子串个数)
- [3.6.4 子串出现次数](#3.6.4 子串出现次数)
- [3.6.5 最长公共子串](#3.6.5 最长公共子串)
- [3.6.6 字典序第 k 小子串](#3.6.6 字典序第 k 小子串)
- [3.6.7 最长重复子串](#3.6.7 最长重复子串)
- [3.6.8 后缀自动机 + DP](#3.6.8 后缀自动机 + DP)
- [3.6.9 广义后缀自动机(GSAM)](#3.6.9 广义后缀自动机(GSAM))
- [3.6.10 常见应用总结](#3.6.10 常见应用总结)
- [3.6.11 注意事项](#3.6.11 注意事项)
三、区域赛算法模版第三章---字符串相关算法
3.1 KMP / 前缀函数
适用场景: 模式匹配、周期串、前缀后缀相关、字符串最小循环节、前缀出现次数统计、KMP 自动机 + DP、字符串压缩
3.1.1 基本原理
核心思想: 利用已经匹配过的信息,避免暴力回溯。KMP 的核心是前缀函数(也叫 next 数组)。
前缀函数定义:
π i = max { k ∣ 0 ≤ k < i , s 0.. k − 1 = s i − k + 1.. i } \pii = \max\{k \mid 0 \le k < i, \; s0..k-1 = si-k+1..i\} πi=max{k∣0≤k<i,s0..k−1=si−k+1..i}
即 s 0.. i s0..i s0..i 的最长相等真前缀后缀的长度。
关键性质:
- π 0 = 0 \pi0 = 0 π0=0
- π i ≤ π i − 1 + 1 \pii \le \pii-1 + 1 πi≤πi−1+1
- 如果 s i = s π \[ i − 1 ] si = s\\pi\[i-1] si=sπ\[i−1],则 π i = π i − 1 + 1 \pii = \pii-1 + 1 πi=πi−1+1
- 否则不断跳 π \pi π,直到匹配或为 0
时间复杂度: 预处理 O(n),匹配 O(n + m)。
3.1.2 基础模板
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
// 前缀函数:pi[i] = s[0..i] 最长相等真前缀后缀长度
vector<int> get_pi(const string &s) {
int n = s.size();
vector<int> pi(n, 0);
for (int i = 1; i < n; i++) {
int j = pi[i - 1];
while (j > 0 && s[i] != s[j]) j = pi[j - 1];
if (s[i] == s[j]) j++;
pi[i] = j;
}
return pi;
}
// KMP 匹配:返回 t 中所有 p 出现的位置(0-indexed)
vector<int> kmp(const string &t, const string &p) {
vector<int> pi = get_pi(p);
vector<int> res;
int j = 0;
for (int i = 0; i < (int)t.size(); i++) {
while (j > 0 && t[i] != p[j]) j = pi[j - 1];
if (t[i] == p[j]) j++;
if (j == (int)p.size()) {
res.push_back(i - j + 1);
j = pi[j - 1];
}
}
return res;
}
};
思路要点:
- 前缀函数利用已匹配信息,避免暴力回溯
- 核心:失配时跳到
pi[j-1]继续比较 - 匹配成功后,
j = pi[j-1]继续找下一个匹配 - 时间复杂度 O(n + m)
3.1.3 字符串周期与最小循环节
结论:
- 若 n % ( n − π n − 1 ) = 0 n \% (n - \pin-1) = 0 n%(n−πn−1)=0,则字符串的最小循环节长度为 n − π n − 1 n - \pin-1 n−πn−1
- 循环次数为 n / ( n − π n − 1 ) n / (n - \pin-1) n/(n−πn−1)
- 否则字符串没有完整的循环节
代码:
cpp
class Solver {
public:
// 判断字符串是否由某个循环节重复构成
bool isPeriodic(const string &s) {
int n = s.size();
vector<int> pi = get_pi(s);
int len = n - pi[n - 1];
return n % len == 0;
}
// 最小循环节长度
int minPeriod(const string &s) {
int n = s.size();
vector<int> pi = get_pi(s);
return n - pi[n - 1];
}
};
思路要点:
- n − π n − 1 n - \pin-1 n−πn−1 是字符串的最小周期
- 如果 n n n 能被这个周期整除,则是完整循环
- 经典例题:POJ 2406、洛谷 P4391
3.1.4 统计每个前缀出现次数
题意: 给定字符串 s s s,统计每个前缀在 s s s 中作为子串出现的次数。
思路: 在 fail 树(前缀函数树)上从下往上累加。
- 每个节点 i i i 代表前缀 s 0.. i s0..i s0..i
- i i i 的父节点是 π i − 1 − 1 \pii-1 - 1 πi−1−1(即前缀函数指向的位置)
- 每个节点初始计数为 1(自身出现一次)
- 从叶子往上累加,得到每个前缀出现次数
代码:
cpp
class Solver {
public:
// 统计每个前缀出现次数
vector<long long> countPrefix(const string &s) {
int n = s.size();
vector<int> pi = get_pi(s);
vector<long long> cnt(n, 1); // 每个前缀自身算一次
// 建 fail 树:i 的父节点是 pi[i-1]-1
vector<vector<int>> tree(n);
for (int i = 1; i < n; i++) {
tree[pi[i - 1]].push_back(i);
}
// 从下往上累加(后序遍历)
function<void(int)> dfs = [&](int u) {
for (int v : tree[u]) {
dfs(v);
cnt[u] += cnt[v];
}
};
dfs(0);
return cnt;
}
};
思路要点:
- 每个前缀出现次数 = 自身 + 所有以它为后缀的前缀出现次数
- 在 fail 树上从下往上累加
- 经典例题:洛谷 P2375、CF 432D
3.1.5 最长公共前后缀链
题意: 给定字符串 s s s,求所有既是前缀又是后缀的子串长度。
思路: 从 π n − 1 \pin-1 πn−1 开始,不断跳 π \pi π,得到所有公共前后缀长度。
cpp
class Solver {
public:
// 求所有既是前缀又是后缀的子串长度(从大到小)
vector<int> border(const string &s) {
int n = s.size();
vector<int> pi = get_pi(s);
vector<int> res;
int j = pi[n - 1];
while (j > 0) {
res.push_back(j);
j = pi[j - 1];
}
return res;
}
};
思路要点:
- π n − 1 \pin-1 πn−1 是最长公共前后缀长度
- 不断跳 π \pi π 得到所有公共前后缀长度
- 经典例题:洛谷 P3435、CF 126B
3.1.6 KMP 自动机
适用场景: 多文本匹配、DP 转移、字符串计数
思路: 把前缀函数数组扩展成自动机,状态是当前匹配长度,转移是下一个字符。
cpp
class KMP {
public:
// KMP 自动机
// nxt[i][c] = 当前匹配长度为 i,下一个字符为 c 时,新的匹配长度
vector<array<int, 26>> buildKMPAutomaton(const string &p) {
int m = p.size();
vector<int> pi = get_pi(p);
vector<array<int, 26>> nxt(m + 1);
for (int i = 0; i <= m; i++) {
for (int c = 0; c < 26; c++) {
if (i < m && p[i] - 'a' == c) {
nxt[i][c] = i + 1;
} else {
nxt[i][c] = (i == 0) ? 0 : nxt[pi[i - 1]][c];
}
}
}
return nxt;
}
};
思路要点:
- 状态是当前匹配长度 i ∈ 0 , m i \in 0, m i∈0,m
- 转移:如果 p i = = c pi == c pi==c,则 i + 1 i+1 i+1;否则跳 π \pi π
- 可以用于 DP: d p i j dpij dpij 表示长度为 i i i、匹配长度为 j j j 的方案数
- 经典例题:CF 808G、洛谷 P3193
3.1.7 扩展 KMP(Z 函数)
适用场景: 求字符串 s s s 的每个后缀与 s s s 的最长公共前缀(LCP)
Z 函数定义:
z i = LCP ( s , s i . . n − 1 ) zi = \text{LCP}(s, si..n-1) zi=LCP(s,si..n−1)
即 s s s 与 s i . . n − 1 si..n-1 si..n−1 的最长公共前缀长度。约定 z 0 = 0 z0 = 0 z0=0(或 n n n)。
与 KMP 的关系:
- KMP 前缀函数是前缀与后缀的匹配
- Z 函数是前缀与任意后缀的匹配
- 两者可以互相转化
代码:
cpp
class KMP {
public:
// Z 函数:z[i] = LCP(s, s[i..n-1])
vector<int> get_z(const string &s) {
int n = s.size();
vector<int> z(n, 0);
int l = 0, r = 0;
for (int i = 1; i < n; i++) {
if (i <= r) z[i] = min(r - i + 1, z[i - l]);
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) z[i]++;
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
}
return z;
}
};
思路要点:
- 维护当前已知的最右 Z-box l , r l, r l,r
- 若 i ≤ r i \le r i≤r,利用对称性初始化 z i = min ( r − i + 1 , z i − l ) zi = \min(r - i + 1, zi - l) zi=min(r−i+1,zi−l)
- 然后暴力扩展
- 时间复杂度 O(n)
Z 函数求模式匹配:
将模式串 p p p 和文本串 t t t 拼接成 p + # + t p + \# + t p+#+t,求 Z 函数,若某个位置的 z i ≥ ∣ p ∣ zi \ge |p| zi≥∣p∣,则匹配成功。
cpp
class KMP {
public:
// Z 函数求模式匹配
vector<int> zMatch(const string &t, const string &p) {
string s = p + "#" + t;
vector<int> z = get_z(s);
vector<int> res;
int m = p.size();
for (int i = m + 1; i < (int)s.size(); i++) {
if (z[i] >= m) res.push_back(i - m - 1);
}
return res;
}
};
3.1.8 前缀函数的其他性质
| 性质 | 说明 |
|---|---|
| 前缀函数链 | π i , π π \[ i − 1 ] , ... \pii, \pi\\pi\[i-1], \dots πi,ππ\[i−1],... 是所有公共前后缀长度 |
| 最小周期 | n − π n − 1 n - \pin-1 n−πn−1 |
| 前缀出现次数 | fail 树上从下往上累加 |
| 字符串压缩 | 最小周期循环 |
| 回文串判断 | 对 s + # + reverse ( s ) s + \# + \text{reverse}(s) s+#+reverse(s) 求 π \pi π |
3.1.9 常见应用
| 应用 | 做法 |
|---|---|
| 模式匹配 | KMP 匹配 |
| 最小循环节 | n − π n − 1 n - \pin-1 n−πn−1 |
| 统计前缀出现次数 | fail 树上从下往上累加 |
| 所有公共前后缀 | 不断跳 π \pi π |
| KMP 自动机 + DP | 状态是匹配长度,转移是字符 |
| 字符串压缩 | 最小循环节 |
| 回文串前缀函数 | 对 s + # + reverse ( s ) s + \# + \text{reverse}(s) s+#+reverse(s) 求 π \pi π |
| 求每个后缀与前缀的 LCP | Z 函数 |
| 字符串匹配(Z 函数版) | 拼接后求 Z 函数 |
3.1.10 时间复杂度总结
| 操作 | 复杂度 |
|---|---|
| 求前缀函数 | O(n) |
| KMP 匹配 | O(n + m) |
| 求最小循环节 | O(n) |
| 统计前缀出现次数 | O(n) |
| 求所有公共前后缀 | O(n) |
| 构建 KMP 自动机 | O(n × 字符集) |
| KMP 自动机 + DP | O(状态数 × 转移数) |
| 求 Z 函数 | O(n) |
| Z 函数匹配 | O(n + m) |
3.1.11 注意事项
- 前缀函数的下标从 0 开始, π 0 = 0 \pi0 = 0 π0=0
- 失配时跳 π j − 1 \pij-1 πj−1,不是 π j \pij πj
- 匹配成功后要跳 π j − 1 \pij-1 πj−1 继续找下一个匹配
- 最小循环节要求 n n n 能被 n − π n − 1 n - \pin-1 n−πn−1 整除
- KMP 自动机的状态数是 m + 1 m+1 m+1,转移是字符集大小
- Z 函数的 z 0 z0 z0 通常定义为 0 或 n n n,看具体实现
- Z 函数拼接时,分隔符 # \# # 必须是不在字符集中的字符
- 字符串下标从 0 开始时,前缀函数和 KMP 的写法与从 1 开始略有不同
- 多测试用例时注意清空数组
3.1.12 区域赛题
- kmp前缀函数 ---题目链接:2020年第六届全国大学生程序设计竞赛K
- 题目描述及思路:
text
【题目描述】
对于字符串 u = u1 u2 ... un,定义:
- pre(u, i) = u1 u2 ... ui,即 u 的长度为 i 的前缀。
- suf(u, i) = u_{n-i+1} ... un,即 u 的长度为 i 的后缀。
- 特别地,pre(u, 0) 和 suf(u, 0) 都是空串。
- u + v 表示字符串 u 和 v 的拼接。
- presuf(u, v) = max{ i | i < |u| 且 i ≤ |v| 且 pre(u, i) = suf(v, i) }。
给定两个字符串 s = s1 s2 ... sn 和 t = t1 t2 ... tm。
定义 f(i) = presuf(s, pre(s, i) + t),其中 0 ≤ i ≤ n-1。
要求输出 f(0), f(1), ..., f(n-1)。
【输入】
输入包含多组测试数据,以 EOF 结束。每组数据:
- 第一行:字符串 s
- 第二行:字符串 t
- 1 ≤ n, m ≤ 10^6
- 字符集为小写字母 a ~ z
- 所有输入的 max(n, m) 之和 ≤ 10^6
【输出】
对于每组数据,输出 n 个整数,表示 f(0), f(1), ..., f(n-1)。
【样例1】
输入:
aaa
a
ababa
a
ab
cd
输出:
1 2 2
1 1 3 1 3
0 0
【样例说明】
第二组:s = ababa, t = a
f(4) = presuf(s, pre(s, 4) + t) = presuf(ababa, abab + a) = presuf(ababa, ababa)
pre(ababa, 3) = aba, suf(ababa, 3) = aba,匹配长度为 3。
所以 f(4) = 3。
【思路解说】
【一、问题转化】
我们要对每个 i 求:
f(i) = presuf(s, pre(s, i) + t)
= max{ k | k < n, k ≤ i + m, pre(s, k) = suf(pre(s, i) + t, k) }
也就是说,对于字符串 pre(s, i) + t,它的后缀与 s 的前缀匹配的最长长度是多少(且匹配长度必须小于 n)。
【二、匹配长度 k 的分类】
把匹配长度 k 分成两类:
1) k ≤ m:匹配的后缀完全落在 t 中。
此时 pre(s, k) = suf(t, k),与 i 无关。
设 b0 = 最长的「s 的前缀等于 t 的后缀」的长度。
则这类匹配的最大值就是 b0。
2) k > m:匹配的后缀跨越了 pre(s, i) 和 t 的边界。
此时需要满足:
- t = s[k-m+1..k](即 t 出现在 s 中以 k-m+1 开头、长度为 m 的位置)
- k - m 在 i 的失配链上(即 k-m 是 pre(s, i) 的某个 border)
设 val[j] = j + m,当 t 恰好出现在 s[j+1..j+m] 位置时。
则这类匹配的最大值就是「在 i 的失配链上,val 的最大值」。
【三、预处理】
1) 计算 s 的前缀函数 piS。
2) 计算 matchEnd[j]:当 t 出现在 s[j+1..j+m] 位置时,记录结束位置 j+m。
做法:构造字符串 t + '\1' + s,求前缀函数,找到所有 pi 值为 m 的位置。
3) 计算 overlap = 最长的「s 的前缀等于 t 的后缀」的长度。
做法:构造字符串 s + '\1' + t,求前缀函数,最后一个值就是 overlap。
4) 计算 best[i] = 在 s 的前 i 个字符的失配链上,matchEnd 的最大值。
递推:best[i] = max(matchEnd[i], best[piS[i]])。
【四、答案计算】
对于每个 i:
q = max(overlap, best[i])
ans = (q < n) ? q : piS[n]
其中 piS[n] 是 s 的最长真 border(因为匹配长度必须小于 n)。
【五、复杂度】
- 预处理前缀函数:O(n + m)
- 计算 matchEnd:O(n + m)
- 计算 best:O(n)
- 输出答案:O(n)
- 总复杂度:O(n + m),可以过。
【六、注意事项】
- 多组测试数据,以 EOF 结束。
- 前缀函数的下标从 0 开始,pi[k] 表示 x[0..k-1] 的最长真 border。
- 构造 t + '\1' + s 时,分隔符 '\1' 不能出现在原字符串中。
- 匹配长度必须小于 n,所以当 q == n 时要取 piS[n]。
- 所有输入的 max(n, m) 之和 ≤ 10^6,所以 O(n + m) 可以过。
- 答案之间用空格分隔,每组数据输出一行。
- 根据以上思路代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
// 前缀函数:pi[k] = x[0..k-1] 的最长真 border
// pi[0] = 0, pi[k] 表示 x 的前 k 个字符的最长真前缀后缀长度
static vector<int> prefixFunction(const string& x) {
int L = (int)x.size();
vector<int> pi(L + 1, 0); // 多开一位,方便后面取 pi[L]
for (int i = 1; i < L; i++) {
int j = pi[i]; // 从前一个位置的最长 border 开始尝试
while (j > 0 && x[i] != x[j]) j = pi[j]; // 失配时跳 border
if (x[i] == x[j]) j++; // 匹配成功,border 长度加 1
pi[i + 1] = j; // 记录前 i+1 个字符的最长 border
}
return pi;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s, t, combined;
while (cin >> s >> t) { // 多组测试数据,以 EOF 结束
int n = (int)s.size();
int m = (int)t.size();
// s 的前缀函数
vector<int> piS = prefixFunction(s);
// matchEnd[j] = j + m,当 t 恰好出现在 s[j+1..j+m] 位置(1-based)
// 即 s 中以 j 开头、长度为 m 的子串等于 t 时,记录其结束位置
vector<int> matchEnd(n + 1, 0);
combined.clear();
combined.reserve(m + 1 + n);
combined += t;
combined.push_back('\1'); // 分隔符,避免 t 和 s 之间产生额外的匹配
combined += s;
{
// 对 t + '\1' + s 求前缀函数
// 若某个位置的前缀函数值等于 m,说明 t 出现在该位置之前
vector<int> pi = prefixFunction(combined);
for (int i = m + 1; i < (int)combined.size(); i++) {
if (pi[i + 1] == m) { // 匹配到完整的 t
int endPos = i - m; // t 在 s 中的结束位置(1-based)
int startPos = endPos - m; // t 在 s 中的起始位置减一(1-based)
if (startPos >= 1) matchEnd[startPos] = endPos;
}
}
}
// overlap = 最长的「s 的前缀」等于「t 的后缀」的长度
// 构造 s + '\1' + t,求前缀函数,最后一个值就是 overlap
combined.clear();
combined.reserve(n + 1 + m);
combined += s;
combined.push_back('\1');
combined += t;
int overlap = prefixFunction(combined)[(int)combined.size()];
// best[i] = 在 s 的前 i 个字符的失配链上,matchEnd 的最大值
// 即:从 i 开始不断跳 piS,取经过的所有位置的 matchEnd 最大值
vector<int> best(n + 1, 0);
best[0] = matchEnd[0];
for (int i = 1; i < n; i++) {
best[i] = max(matchEnd[i], best[piS[i]]);
}
// 输出答案
// 对于每个 i,q = max(overlap, best[i]) 就是 pre(s,i)+t 的后缀与 s 的前缀的最长匹配长度
// 但匹配长度必须小于 n,所以如果 q == n,要取 s 的最长真 border piS[n]
for (int i = 0; i < n; i++) {
int q = max(overlap, best[i]);
int ans = (q < n) ? q : piS[n];
cout << ans << (i + 1 < n ? ' ' : '\n');
}
}
return 0;
}
- kmp前缀函数+二分 --- 题目链接:第七届ccpc威海站D
- 题目描述及思路:
text
【题目描述】
给定一个长度为 n 的字符串 s(只含小写字母),以及 q 次查询。
每次查询给出一个整数 i,表示假设把 s 的第 i 个字符改成 '#'(注意:只是假设,
不会真的修改原串),问修改后的新字符串有多少个周期。
周期的定义:整数 T 是字符串 s 的周期,当且仅当:
- 1 ≤ T < |s|
- 对于所有 i ∈ (T, |s|],都有 s[i] = s[i - T]
【输入】
第一行:字符串 s(1 ≤ |s| ≤ 10^6)
第二行:整数 q(1 ≤ q ≤ 10^6)
接下来 q 行:每行一个整数 i(1 ≤ i ≤ |s|)
【输出】
对于每次查询,输出一个整数,表示修改后的新字符串的周期个数。
【样例1】
输入:
ccpc
4
1
2
3
4
输出:
0
1
1
0
【样例解释】
原串 "ccpc" 的周期只有 T = 3(因为 n - T = 1,即长度为 1 的 border)。
- 查询 i=1:修改后为 "#cpc",周期 T=3 被破坏,答案 0。
- 查询 i=2:修改后为 "c#pc",周期 T=3 仍然成立,答案 1。
- 查询 i=3:修改后为 "cc#c",周期 T=3 仍然成立,答案 1。
- 查询 i=4:修改后为 "ccp#",周期 T=3 被破坏,答案 0。
【思路解说】
【一、原串的周期与 border 的关系】
一个字符串 s 的周期 T 与它的 border(真前缀等于真后缀)有对应关系:
T 是 s 的周期 ⇔ n - T 是 s 的 border。
所以,求 s 的所有周期,等价于求 s 的所有 border 长度 b,然后 T = n - b。
用 KMP 前缀函数 pi 可以求出所有 border:
- pi[n-1] 是最长 border 长度。
- 不断跳 pi 可以得到所有 border 长度。
【二、修改一个字符对周期的影响】
把第 i 个字符改成 '#' 后,周期 T 是否仍然成立?
周期 T 要求:对于所有 j ∈ (T, n],都有 s'[j] = s'[j-T]。
修改后,只有位置 i 变成了 '#'。所以:
- 如果 j = i 且 i > T,那么需要 s'[i] = s'[i-T],
即 '#' = s'[i-T]。但 '#' 不是小写字母,所以这个等式不成立。
因此,只要 i > T,周期 T 就被破坏。
- 如果 j = i + T 且 i + T ≤ n,那么需要 s'[i+T] = s'[i],
即 s[i+T] = '#'。这也不成立。
因此,只要 i + T ≤ n,周期 T 就被破坏。
所以,周期 T 被破坏,当且仅当:
i > T 或 i + T ≤ n。
【三、周期 T 仍然成立的条件】
周期 T 不被破坏,当且仅当:
i ≤ T 且 i + T > n。
即:
T ≥ i 且 T > n - i。
合并得:
T > max(i - 1, n - i)。
所以,对于查询 i,答案就是:原串的周期集合中,满足
T > max(i - 1, n - i)
的周期个数。
【四、算法流程】
1) 用 KMP 求 s 的前缀函数 pi。
2) 从 pi[n-1] 开始不断跳 pi,收集所有 border 长度 b,
则周期 T = n - b。把所有周期存入数组 periods。
3) 对 periods 排序。
4) 对于每个查询 i:
- 计算 threshold = max(i - 1, n - i)。
- 用二分查找(upper_bound)统计 periods 中 > threshold 的个数。
- 输出这个个数。
【五、复杂度】
- 求前缀函数:O(n)
- 收集周期:O(n)
- 排序:O(n log n)
- 每次查询:O(log n)
- 总复杂度:O(n log n + q log n),可以过。
【六、注意事项】
- 周期 T 必须满足 1 ≤ T < n。
- 收集 border 时,从 pi[n-1] 开始不断跳 pi,直到 0。
- 查询时用 upper_bound 找第一个 > threshold 的位置。
- 答案可能为 0,表示修改后没有周期。
- 多组测试要清空数组。
- 字符串长度可达 10^6,要用 O(n) 的 KMP。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
// 求前缀函数 pi
// pi[i] = s[0..i] 的最长相等真前缀后缀长度
vector<int> get_pi(const string &s) {
int n = s.size();
vector<int> pi(n, 0);
for (int i = 1; i < n; i++) {
int j = pi[i - 1]; // 从前一个位置的最长 border 开始尝试
while (j > 0 && s[i] != s[j]) j = pi[j - 1]; // 失配时跳 border
if (s[i] == s[j]) j++; // 匹配成功,border 长度加 1
pi[i] = j;
}
return pi;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s;
cin >> s;
int n = s.size();
// 求前缀函数
vector<int> pi = get_pi(s);
// 收集所有周期 T = n - border
// border 是 s 的真前缀等于真后缀的长度
// 周期 T 与 border b 的关系:T = n - b
vector<int> periods;
int j = pi[n - 1]; // 最长 border
while (j > 0) {
periods.push_back(n - j); // 对应周期 T = n - j
j = pi[j - 1]; // 跳到下一个 border
}
sort(periods.begin(), periods.end()); // 排序,方便二分
int q;
cin >> q;
while (q--) {
int i;
cin >> i;
// 修改第 i 个字符后,周期 T 仍然成立的条件:
// i ≤ T 且 i + T > n
// 即 T > max(i - 1, n - i)
int threshold = max(i - 1, n - i);
// 统计 periods 中 > threshold 的个数
// upper_bound 返回第一个 > threshold 的位置
int ans = periods.end() - upper_bound(periods.begin(), periods.end(), threshold);
cout << ans << '\n';
}
return 0;
}
- KMP失配树 ---题目链接:2025年ccpc重庆站K
- 题目描述及思路:
text
Reverse KMP(QOJ 15429 / The 4th Universal Cup Extra Stage 4: Chongqing K)
【题目描述】
在 KMP 算法中,next 数组(也叫 LPS 数组,最长公共前后缀,或失配函数)是一个核心概念。
给定一个长度为 n 的字符串 S = s1s2...sn,它的 next 数组 next[1..n] 定义为:
next[i] 表示子串 S[1..i] 的最长相等真前缀和真后缀的长度。
特别地,next[1] = 0。
形式化地:
next[i] = max{ k | 0 ≤ k ≤ i-1, S[1..k] = S[i-k+1..i] }
给定一个长度为 n 的数组 A[1..n]。你需要统计有多少个长度为 n、定义在字符集 Σ 上的字符串 S,
使得它的 next 数组恰好等于给定的数组 A。字符集大小为 m。
答案对 998244353 取模。
【输入】
第一行:T(1 ≤ T ≤ 10^5),测试用例个数。
每个测试用例:
第一行:n, m(2 ≤ n ≤ 10^5,1 ≤ m ≤ 10^6)
第二行:n 个整数 A[1..n](0 ≤ A[i] ≤ i-1)
保证所有测试用例的 n 之和不超过 5 × 10^5。
【输出】
每个测试用例输出一行,一个整数,表示满足条件的字符串数量对 998244353 取模。
【样例1】
输入:
3
3 3
0 1 2
7 3
0 0 0 1 2 3 0
7 3
0 0 0 1 2 3 1
输出:
3
24
0
【思路解说】
【一、从 next 数组到字符约束】
给定 next 数组 A,我们要构造字符串 S,使得 S 的 next 数组恰好等于 A。
关键约束来自 next 数组的定义:
1) 如果 A[i] = j > 0,则 S[1..j] = S[i-j+1..i],即 S[i] = S[j](因为 S[1..j] 是 S[1..i] 的后缀)。
所以 S[i] 和 S[j] 必须相同。
2) 如果 A[i] = 0,则 S[i] 不能等于任何会导致更长匹配的字符。
具体来说,S[i] 不能等于失配链上任何已确定字符。
【二、合法性检查(必要条件)】
next 数组有一个重要性质:
next[i] ≤ next[i-1] + 1
如果给定的 A 不满足这个性质,则答案为 0。
**启发式:** 这个性质是 KMP 算法正确性的基础。如果 next[i] 增长得太快,
说明它"跳过了"某些必须匹配的前缀,这在真实字符串中是不可能的。
【三、失配树与等价类】
根据 next 数组,可以建立一棵**失配树**:
- 每个节点 i 的父亲是 next[i]。
- 根节点是 0。
在失配树中,如果 next[i] = j > 0,则 S[i] 和 S[j] 必须相同。
所以我们可以用**并查集**维护字符的等价类:
- 初始时每个位置自成一个等价类。
- 对于每个 i,如果 next[i] = j > 0,则把 i 和 j 合并到同一个等价类。
合并完成后,每个等价类代表一个"字符"。同一个等价类中的位置必须填相同的字符。
**启发式:** 这里的等价类合并是"必须相同"的约束。
但要注意,等价类合并的顺序会影响最终的代表元,所以我们需要用失配树的根
作为等价类的代表元。
【四、计数:g 数组的定义】
设 g[p] 表示位置 1..p+1 中已经锁定的不同字符类数。
**为什么是 1..p+1?**
因为当我们考虑 next[i] = 0 时,S[i] 必须是"全新字符",
它不能等于任何已经出现在前缀中的字符。
而前缀中最长的可能匹配长度是 p = next[i-1],
所以我们需要排除的字符是出现在位置 1..p+1 中的字符。
**g 数组的递推:**
g[0] = 1(位置 1 已经锁定了一个字符)
g[p] = g[fail[p]] + (p+1 <= n && fail[p+1] == 0 ? 1 : 0)
**启发式:** g[p] 统计的是"在位置 1..p+1 中,有多少个不同的字符类"。
当 next[p+1] = 0 时,说明位置 p+1 是一个"全新字符",所以字符类数 +1。
否则,位置 p+1 与之前的某个位置属于同一个字符类,字符类数不变。
【五、逐个位置计数】
初始化 ans = m(S[1] 有 m 种选择)。
对于 i 从 2 到 n:
p = fail[i-1](前一个位置的 next 值)
q = fail[i](当前位置的 next 值)
**情况 1:q == p + 1**
说明 S[i] = S[p+1],字符唯一确定,无额外选择。
**启发式:** 这是"直接扩展"的情况,next 值加 1,字符必须匹配。
**情况 2:q == 0**
说明 S[i] 必须是全新字符,不能等于位置 1..p+1 中出现的任何字符。
选择数 = m - g[p]。
如果 ≤ 0,返回 0。
ans *= 选择数。
**启发式:** 这是"一路失配到 0"的情况,S[i] 必须是一个全新的字符。
**情况 3:1 ≤ q ≤ p**
说明失配若干步后匹配到 q。
需要验证合法性:
- q-1 必须在 p 的失配链上。
- 链上从 p 到 q 的每个位置 c,S[c+1] 必须与 S[q] 不同。
如果不满足,返回 0。
**启发式:** 这是"失配后部分匹配"的情况,需要确保不会产生更长的匹配。
【六、算法流程】
1) 读入 n, m 和数组 A。
2) 检查 A 的合法性:
- A[1] 必须为 0。
- 对于 i ≥ 2,A[i] ≤ A[i-1] + 1。
如果不满足,输出 0。
3) 建立失配树,计算每个节点的根(等价类代表)。
4) 计算 g[p]:位置 1..p+1 中已锁定的不同字符类数。
5) 初始化 ans = m(S[1] 有 m 种选择)。
6) 对于 i 从 2 到 n:
- p = A[i-1], q = A[i]。
- 根据 q 的值分三种情况处理。
7) 输出 ans % MOD。
【七、复杂度】
- 每个测试用例 O(n)。
- 总复杂度 O(Σn) ≤ 5 × 10^5,可以过。
【八、注意事项】
- next[1] 必须为 0。
- next[i] ≤ next[i-1] + 1。
- 用失配树和 g 数组来统计禁选字符数。
- 对于 q > 0 且 q ≤ p 的情况,需要验证失配链的合法性。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
class ReverseKmp {
public:
static const long long MOD = 998244353LL;
void run() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int T;
cin >> T;
while (T--) {
int n;
long long m;
cin >> n >> m;
A.resize(n);
for (int i = 0; i < n; i++) cin >> A[i];
cout << solveCase(n, m) << "\n";
}
}
private:
vector<int> A; // 输入的 next 数组(1-indexed 存储)
long long solveCase(int n, long long m) {
// fail[i] = A[i-1],1-indexed
fail.assign(n + 1, 0);
for (int i = 1; i <= n; i++) fail[i] = A[i - 1];
// ===== 合法性检查 =====
// KMP 的 next 数组必须满足:
// next[1] = 0
// next[i] <= next[i-1] + 1
if (fail[1] != 0) return 0;
for (int i = 2; i <= n; i++) {
if (fail[i] > fail[i - 1] + 1) return 0;
}
// ===== 失配树:找每个位置的"根"(等价类代表) =====
// 如果 fail[x] == 0,说明 x 是一个新等价类的根
// 否则,x 和 fail[x] 属于同一个等价类,根是 root[fail[x]]
root.assign(n + 1, 0);
for (int x = 1; x <= n; x++) {
root[x] = (fail[x] == 0) ? x : root[fail[x]];
}
// ===== g[p] = 位置 1..p+1 中已锁定的不同字符类数 =====
// g[0] = 1:位置 1 已经锁定了一个字符类
// 对于 p >= 1:
// 如果 p+1 <= n 且 fail[p+1] == 0,说明位置 p+1 是一个新字符类,g[p] = g[fail[p]] + 1
// 否则,位置 p+1 和之前的某个位置属于同一类,g[p] = g[fail[p]]
g.assign(n + 1, 0);
g[0] = 1;
for (int p = 1; p <= n; p++) {
long long add = (p + 1 <= n && fail[p + 1] == 0) ? 1 : 0;
g[p] = g[fail[p]] + add;
}
// ===== 逐个位置计数 =====
long long ans = m % MOD; // S[1] 有 m 种选择
for (int i = 2; i <= n; i++) {
int p = fail[i - 1]; // 前一个位置的 next 值
int q = fail[i]; // 当前位置的 next 值
if (q == p + 1) {
// 情况 1:直接扩展
// S[i] = S[p+1],字符唯一确定,无额外选择
continue;
}
if (q == 0) {
// 情况 2:一路失配到 0
// S[i] 必须是全新字符,不能等于位置 1..p+1 中出现的任何字符
// 选择数 = m - g[p]
long long f = m - g[p];
if (f <= 0) return 0;
ans = ans * (f % MOD) % MOD;
} else {
// 情况 3:失配若干步后匹配到 q(1 <= q <= p)
// 需要验证合法性:
// (a) q-1 必须在 p 的失配链上
// (b) 链上从 p 到 q 的每个位置 c,S[c+1] 必须与 S[q] 不同
// 验证 (a):q-1 是否在 p 的失配链上
bool ok = (q - 1 == 0);
int c = p;
while (!ok && c > 0) {
if (c == q - 1) { ok = true; break; }
c = fail[c];
}
if (!ok) return 0;
// 验证 (b):链上每个 S[c+1] 是否与 S[q] 属于同一等价类
int rq = root[q]; // S[i] 必须与 S[q] 属于同一等价类
c = p;
while (c >= q) {
if (root[c + 1] == rq) return 0; // 冲突:S[c+1] 与 S[q] 同类
c = fail[c];
}
}
}
return ans % MOD;
}
vector<int> fail; // next 数组(1-indexed)
vector<int> root; // 每个位置的等价类代表
vector<long long> g; // g[p] = 位置 1..p+1 中已锁定的不同字符类数
};
int main() {
ReverseKmp solver;
solver.run();
return 0;
}
3.2 Trie 字典树
适用场景: 前缀查询、字符串统计、异或最值(01Trie)、最长公共前缀、字典序第 k 小
3.2.1 基本原理
核心思想: Trie 是一棵多叉树,每条边代表一个字符,从根到某个节点的路径拼成一个字符串(前缀)。
- 根节点代表空串
- 每个节点代表一个前缀
- 从根到某节点的路径上所有字符拼起来,就是该节点代表的前缀
- 节点上的标记
cnt[u]表示有多少个字符串以该节点结尾
关键性质:
- 插入、查询的时间复杂度都是 O(len),与字符串数量无关
- 空间复杂度 O(总字符数 × 字符集大小)
- 多个字符串共享公共前缀,节省空间
时间复杂度: 单次插入 / 查询 O(len),len 为字符串长度。
3.2.2 基础模板(字符串统计)
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
static constexpr int MAXN = 100005; // 节点数上限 = 所有字符串总字符数 + 1
struct Trie {
int ch[MAXN][26]; // 每个节点的 26 个儿子
int cnt[MAXN]; // 以该节点结尾的字符串数量
int idx; // 节点计数器
Trie() { idx = 1; } // 0 号是根节点,从 1 开始分配
// 插入字符串 s
void insert(const string &s) {
int u = 0;
for (char c : s) {
int v = c - 'a';
if (!ch[u][v]) ch[u][v] = idx++;
u = ch[u][v];
}
cnt[u]++;
}
// 查询字符串 s 出现的次数
int query(const string &s) {
int u = 0;
for (char c : s) {
int v = c - 'a';
if (!ch[u][v]) return 0;
u = ch[u][v];
}
return cnt[u];
}
// 查询以 s 为前缀的字符串数量
int queryPrefix(const string &s) {
int u = 0;
for (char c : s) {
int v = c - 'a';
if (!ch[u][v]) return 0;
u = ch[u][v];
}
// 需要额外维护 sz[u] = 以 u 为根的子树中的字符串数量
return sz[u];
}
};
};
思路要点:
- 每个节点代表一个前缀
- 根节点为空串,路径上的字符组成字符串
- 插入、查询都是 O(len)
cnt[u]表示以 u 结尾的字符串数量- 若要查询前缀数量,需要额外维护
sz[u](子树中的字符串总数)
3.2.3 01Trie(求最大异或和)
核心思想: 把每个整数看成二进制串(从高位到低位),插入 01Trie。查询时,尽量走与当前位相反的子树,这样异或结果的当前位就是 1,最终得到最大异或值。
关键性质:
- 01Trie 是一棵二叉树,每个节点最多两个儿子:0 和 1
- 从高位到低位插入,保证高位优先
- 查询时贪心:能走相反位就走,否则只能走相同位
适用场景:
- 给定一个数组,多次询问某个数与数组中某个数的最大异或和
- 求数组中任意两个数的最大异或和
- 可持久化 01Trie(区间最大异或和)
模板:
cpp
#include <bits/stdc++.h>
using namespace std;
class Trie {
public:
static constexpr int MAXN = 100005; // 数的个数
static constexpr int LOG = 31; // 二进制位数(int 最多 31 位,从高位到低位)
struct Trie01 {
int ch[MAXN * LOG][2]; // 每个节点的两个儿子
int cnt[MAXN * LOG]; // 以该节点结尾的数的数量
int idx; // 节点计数器
Trie01() { idx = 1; }
// 插入一个数 x
void insert(int x) {
int u = 0;
for (int i = LOG; i >= 0; i--) {
int bit = (x >> i) & 1;
if (!ch[u][bit]) ch[u][bit] = idx++;
u = ch[u][bit];
cnt[u]++;
}
}
// 查询与 x 异或最大的数(返回异或值)
int queryMaxXor(int x) {
int u = 0;
int res = 0;
for (int i = LOG; i >= 0; i--) {
int bit = (x >> i) & 1;
// 优先走相反位
if (ch[u][bit ^ 1]) {
res |= (1 << i); // 当前位异或结果为 1
u = ch[u][bit ^ 1];
} else {
u = ch[u][bit];
}
}
return res;
}
};
};
思路要点:
- 每个数按二进制从高位到低位插入
- 查询时贪心走相反位,使异或结果的当前位为 1
- 如果相反位不存在,只能走相同位
- 时间复杂度 O(LOG),LOG 通常为 31
示例:求数组中任意两个数的最大异或和
cpp
class Trie {
public:
int findMaximumXOR(vector<int>& nums) {
Trie01 trie;
int ans = 0;
for (int x : nums) {
trie.insert(x);
ans = max(ans, trie.queryMaxXor(x));
}
return ans;
}
};
思路要点:
- 边插入边查询
- 对于每个数 x,查询与它异或最大的已插入数
- 取所有结果的最大值
示例:区间最大异或和(可持久化 01Trie)
cpp
class Trie {
public:
// 可持久化 01Trie
// root[i] 表示插入前 i 个数后的根节点
int root[MAXN];
int ch[MAXN * LOG][2];
int cnt[MAXN * LOG];
int idx;
void insert(int pre, int &cur, int x) {
cur = ++idx;
int u = cur, v = pre;
for (int i = LOG; i >= 0; i--) {
int bit = (x >> i) & 1;
ch[u][bit ^ 1] = ch[v][bit ^ 1]; // 复制另一棵子树
ch[u][bit] = ++idx; // 新建当前子树
cnt[u] = cnt[v] + 1;
u = ch[u][bit];
v = ch[v][bit];
}
cnt[u] = cnt[v] + 1;
}
// 查询区间 [l, r] 中与 x 异或最大的值
int query(int l, int r, int x) {
int u = root[r], v = root[l - 1];
int res = 0;
for (int i = LOG; i >= 0; i--) {
int bit = (x >> i) & 1;
int want = bit ^ 1;
if (cnt[ch[u][want]] - cnt[ch[v][want]] > 0) {
res |= (1 << i);
u = ch[u][want];
v = ch[v][want];
} else {
u = ch[u][bit];
v = ch[v][bit];
}
}
return res;
}
};
思路要点:
- 可持久化 01Trie:每次插入新建 O(LOG) 个节点
root[i]表示插入前 i 个数后的版本- 查询区间 l, r 时,用
root[r] - root[l-1]做差 - 时间复杂度 O(LOG)
3.2.4 常见应用
| 应用 | 做法 |
|---|---|
| 字符串统计 | 插入时 cnt[u]++,查询时返回 cnt[u] |
| 前缀查询 | 维护 sz[u] 表示子树中字符串数量 |
| 最大异或和 | 01Trie + 贪心走相反位 |
| 区间最大异或和 | 可持久化 01Trie |
| 最长公共前缀 | 两个字符串在 Trie 上的 LCA 深度 |
| 字典序第 k 小 | 在 Trie 上按子树大小二分 |
| 异或路径计数 | 树上 DFS + 01Trie |
3.2.5 时间复杂度总结
| 操作 | 复杂度 |
|---|---|
| 插入字符串 | O(len) |
| 查询字符串 | O(len) |
| 查询前缀 | O(len) |
| 插入整数(01Trie) | O(LOG) |
| 查询最大异或 | O(LOG) |
| 可持久化插入 | O(LOG) |
| 区间最大异或 | O(LOG) |
3.2.6 注意事项
-
字符集大小决定
ch[u][c]的维度,小写字母是 26,数字是 10 -
01Trie 的 LOG 通常取 31(int 范围)或 62(long long 范围)
-
可持久化 01Trie 要注意空间:O(n × LOG)
-
插入整数时要从高位到低位,保证贪心正确
-
多测试用例时注意清空
ch、cnt、idx -
Trie 的节点数上限 = 所有字符串总字符数 + 1
3.2.7 字典树补充---可持续化字典树
适用场景: 区间最大异或和、树上路径最大异或和、子树最大异或和、区间内选数使异或最大、按位贪心 + 区间计数
3.2.7.1 基本原理
核心思想: 普通 01Trie 只能维护"当前集合"的信息。如果要对任意区间 [l, r] 查询,就需要可持久化 :对每个前缀建一棵 01Trie,版本 root[i] 表示插入前 i 个数后的 Trie。
关键性质:
- 可持久化 Trie 每次插入只新建 O(BIT) 个节点,BIT 为二进制位数
root[i] - root[l-1]可以表示区间[l, r]的信息- 查询时同时从
root[r]和root[l-1]向下走,做差判断某条子树是否有数 - 适合解决"区间内选一个数与 x 异或最大"这类问题
与可持久化线段树的区别:
- 可持久化线段树:值域二分
- 可持久化 01Trie:按二进制位贪心
- 两者本质相同,都是"前缀版本做差"
3.2.7.2 基础模板:区间最大异或和
问题: 给定数组 arr,每次查询 [l, r] 中选一个数与 x 异或,求最大值。
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
root[i] = insert(x, root[i - 1]);
}
while (m--) {
int x, l, r;
cin >> x >> l >> r;
cout << query(x, root[l - 1], root[r]) << '\n';
}
return 0;
}
private:
static constexpr int MAXN = 200005;
static constexpr int MAXT = MAXN * 32;
static constexpr int BIT = 30;
int root[MAXN];
int tree[MAXT][2];
int pass[MAXT];
int cnt;
// 插入 num,返回新版本根节点
int insert(int num, int i) {
int rt = ++cnt;
tree[rt][0] = tree[i][0];
tree[rt][1] = tree[i][1];
pass[rt] = pass[i] + 1;
for (int b = BIT, path, pre = rt, cur; b >= 0; b--, pre = cur) {
path = (num >> b) & 1;
i = tree[i][path];
cur = ++cnt;
tree[cur][0] = tree[i][0];
tree[cur][1] = tree[i][1];
pass[cur] = pass[i] + 1;
tree[pre][path] = cur;
}
return rt;
}
// 查询 num 在区间 [u, v] 中的最大异或值
// u = root[l-1], v = root[r]
int query(int num, int u, int v) {
int ans = 0;
for (int b = BIT, path, best; b >= 0; b--) {
path = (num >> b) & 1;
best = path ^ 1;
// 优先走相反位
if (pass[tree[v][best]] > pass[tree[u][best]]) {
ans += (1 << b);
u = tree[u][best];
v = tree[v][best];
} else {
u = tree[u][path];
v = tree[v][path];
}
}
return ans;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
思路要点:
root[i]表示插入前 i 个数后的版本- 插入时只新建 O(BIT) 个节点,复制旧版本信息
- 查询时从高位到低位贪心,优先走相反位
pass[tree[v][best]] > pass[tree[u][best]]判断区间内是否有该位为best的数- 时间复杂度 O(BIT),BIT 通常取 30 或 31
3.2.7.3 树上路径最大异或和
问题: 树上每个节点有权值,查询 u 到 v 的路径上任选一个点,与 z 异或最大。
思路:
- 对每个节点建从根到它的可持久化 01Trie
- 路径
(u, v)的信息 =root[u] + root[v] - root[lca] - root[fa[lca]] - 用 LCA 拆路径
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> arr[i];
for (int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfs(1, 0);
while (m--) {
int u, v, z;
cin >> u >> v >> z;
int lcafa = stjump[lca(u, v)][0];
int ans = max(query(z, root[lcafa], root[u]),
query(z, root[lcafa], root[v]));
cout << ans << '\n';
}
return 0;
}
private:
static constexpr int MAXN = 100005;
static constexpr int MAXT = MAXN * 32;
static constexpr int MAXP = 17;
static constexpr int BIT = 29;
int n, m;
int arr[MAXN];
vector<int> g[MAXN];
int deep[MAXN], stjump[MAXN][MAXP];
int root[MAXN];
int tree[MAXT][2];
int pass[MAXT];
int cnt;
int insert(int num, int i) {
int rt = ++cnt;
tree[rt][0] = tree[i][0];
tree[rt][1] = tree[i][1];
pass[rt] = pass[i] + 1;
for (int b = BIT, path, pre = rt, cur; b >= 0; b--, pre = cur) {
path = (num >> b) & 1;
i = tree[i][path];
cur = ++cnt;
tree[cur][0] = tree[i][0];
tree[cur][1] = tree[i][1];
pass[cur] = pass[i] + 1;
tree[pre][path] = cur;
}
return rt;
}
int query(int num, int u, int v) {
int ans = 0;
for (int b = BIT, path, best; b >= 0; b--) {
path = (num >> b) & 1;
best = path ^ 1;
if (pass[tree[v][best]] > pass[tree[u][best]]) {
ans += (1 << b);
u = tree[u][best];
v = tree[v][best];
} else {
u = tree[u][path];
v = tree[v][path];
}
}
return ans;
}
void dfs(int u, int fa) {
deep[u] = deep[fa] + 1;
stjump[u][0] = fa;
for (int p = 1; p < MAXP; p++) {
stjump[u][p] = stjump[stjump[u][p - 1]][p - 1];
}
root[u] = insert(arr[u], root[fa]);
for (int v : g[u]) {
if (v != fa) dfs(v, u);
}
}
int lca(int a, int b) {
if (deep[a] < deep[b]) swap(a, b);
for (int p = MAXP - 1; p >= 0; p--) {
if (deep[stjump[a][p]] >= deep[b]) a = stjump[a][p];
}
if (a == b) return a;
for (int p = MAXP - 1; p >= 0; p--) {
if (stjump[a][p] != stjump[b][p]) {
a = stjump[a][p];
b = stjump[b][p];
}
}
return stjump[a][0];
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
思路要点:
- 从根 DFS,每个节点继承父亲的 Trie 并插入自己的值
- 路径
(u, v)的 Trie 信息 =root[u] + root[v] - root[lca] - root[fa[lca]] - 查询时用两个版本做差,等价于区间查询
- 经典题型:洛谷 P4592
3.2.7.4 子树最大异或和
问题: 查询以 u 为根的子树中任选一个点,与 y 异或最大。
思路:
- 按 DFS 序展开树,子树
u对应区间[dfn[u], dfn[u] + sz[u] - 1] - 用可持久化 01Trie 维护 DFS 序上的前缀版本
- 查询
root[dfn[u] + sz[u] - 1] - root[dfn[u] - 1]
cpp
class Solver {
public:
// 在 dfs 时记录 dfn 和 sz,按 dfn 顺序插入
void dfs(int u, int fa) {
dfn[u] = ++cntd;
siz[u] = 1;
root1[dfn[u]] = insert(arr[u], root1[dfn[u] - 1]);
for (int v : g[u]) {
if (v != fa) {
dfs(v, u);
siz[u] += siz[v];
}
}
}
// 查询子树 u 中与 y 异或最大值
int querySubtree(int u, int y) {
return query(y, root1[dfn[u] - 1], root1[dfn[u] + siz[u] - 1]);
}
};
思路要点:
- 子树在 DFS 序上是连续区间
- 用可持久化 01Trie 维护 DFS 序前缀
- 查询时做差即可
- 经典题型:洛谷 P4592 操作 1
3.2.7.5 次大值 + 可持久化 01Trie
问题: 选择一个长度 ≥ 2 的子数组,取其次大值与子数组中另一个数异或,求最大结果。
思路:
- 枚举每个数作为次大值
- 用单调栈或链表找出每个数的"作为次大值"的合法区间
- 在区间内查询与它异或最大的数
- 用可持久化 01Trie 支持区间查询
cpp
class Trie {
public:
// 对每个位置 i,找它作为次大值的区间 [l, r]
// 用链表维护,从大到小删数,每次查询相邻两个位置的范围
int compute() {
int ans = 0;
for (int i = 1, index, value, l1, l2, r1, r2; i <= n; i++) {
index = arr[i].first;
value = arr[i].second;
l1 = last[index];
l2 = last[l1];
r1 = nxt[index];
r2 = nxt[r1];
if (l1 != 0) {
ans = max(ans, query(value, root[l2], root[r1 - 1]));
}
if (r1 != n + 1) {
ans = max(ans, query(value, root[l1], root[r2 - 1]));
}
nxt[l1] = r1;
last[r1] = l1;
}
return ans;
}
};
思路要点:
- 从大到小枚举,用链表维护未删除位置
- 对每个数作为次大值,限定区间
- 区间内查询异或最大值
- 经典题型:洛谷 P4098
3.2.7.6 常见应用总结
| 应用 | 做法 | 经典题 |
|---|---|---|
| 区间最大异或和 | 可持久化 01Trie + 版本做差 | 洛谷 P4735 |
| 树上路径最大异或和 | 从根 DFS + LCA 拆路径 | 洛谷 P4592 |
| 子树最大异或和 | DFS 序 + 可持久化 01Trie | 洛谷 P4592 |
区间内选数使 b ^ (x + a[i]) 最大 |
可持久化权值线段树 | 洛谷 P3293 |
| 次大值 + 区间异或最大 | 链表 + 可持久化 01Trie | 洛谷 P4098 |
| 树上字符串前缀查询 | 可持久化字典树 + LCA | 洛谷 P6088 |
统一思路:
- 建版本:对前缀 / 根到节点路径建可持久化 Trie
- 查询做差 :
root[r] - root[l-1]或root[u] + root[v] - root[lca] - root[fa[lca]] - 按位贪心:从高位到低位,优先走相反位
- 路径拆解:用 LCA 把路径拆成两段
复杂度:
- 插入:O(BIT)
- 查询:O(BIT)
- 空间:O(n × BIT)
- 树上路径查询:O(BIT + log n)
3.2.7.7 注意事项
- BIT 取值要覆盖所有数的二进制位数,通常 30 或 31
root[0]是空版本,所有指针为 0- 插入时
pass[rt] = pass[i] + 1要放在复制之后 - 查询时
pass[tree[v][best]] > pass[tree[u][best]]判断区间内是否有该位的数 - 树上路径查询要处理 LCA 的父节点
- 空间 O(n × BIT),注意开大数组
- 多组测试要清空
tree、pass、cnt - 对比可持久化权值线段树适合"值域区间计数"的问题,可持久化字典树适合"区间版本做差"的问题
3.2.8 区域赛题
- 题目链接: 第47届西安站G
- 题目描述及思路如下:
text
Perfect Word(2022 ICPC 西安区域赛 G / CF Gym 104077 G)
【题目描述】
给定 n 个字符串,要求找出最长的「完美单词」的长度。
一个字符串 t 被称为「完美单词」,当且仅当 t 的每一个非空子串都出现在给定的字符串中。
一个字符串 s 被称为 t 的子串,当且仅当 s 可以通过删除 t 的开头或结尾的若干个(可能为零个)字符得到。
【输入】
第一行包含一个整数 n(1 ≤ n ≤ 10^5)。
接下来 n 行,每行一个由小写英文字母组成的字符串。
保证所有给定字符串的总长度不超过 10^5。
【输出】
输出一个整数,表示最长的完美单词的长度。
【样例1】
输入:
4
a
t
b
ab
输出:
2
【样例解释】
字符串 "ab" 的子串有 "a", "b", "ab",它们都出现在给定字符串中,
所以 "ab" 是完美单词,长度为 2。没有更长的完美单词,输出 2。
【思路解说】
【一、核心引理】
一个字符串 t 是完美单词,当且仅当:
1) t 本身出现在给定字符串中;
2) |t| = 1,或者 t 去掉最后一个字符的前缀 t[1..L-1] 是完美单词,
并且 t 去掉第一个字符的后缀 t[2..L] 是完美单词。
**证明思路:**
- 必要性:如果 t 是完美单词,那么 t 的所有子串都出现在给定字符串中。
特别地,t[1..L-1] 和 t[2..L] 都是 t 的子串,所以它们也必须是完美单词。
- 充分性:如果 t[1..L-1] 和 t[2..L] 都是完美单词,那么它们的所有子串都出现在给定字符串中。
而 t 的任意真子串要么是 t[1..L-1] 的子串,要么是 t[2..L] 的子串(或两者都是)。
所以 t 的所有真子串都出现在给定字符串中,再加上 t 本身也出现,故 t 是完美单词。
【二、算法设计】
根据引理,我们可以按字符串长度从小到大进行 DP:
- 用正串 Trie 判断前缀 t[1..L-1] 是否存在且完美。
- 用反串 Trie 判断后缀 t[2..L] 是否存在且完美。
具体步骤:
1) 把所有给定字符串插入正串 Trie(F),并记录每个字符串的结束节点。
2) 把所有给定字符串反转后插入反串 Trie(R),并记录每个字符串的结束节点。
3) 把所有字符串按长度从小到大排序。
4) 按顺序处理每个字符串:
- 如果长度为 1:只要它出现在给定字符串中,就是完美单词。
- 否则:检查它的前缀(在 F 中)和后缀(在 R 中)是否都是完美单词。
- 如果是,则标记该字符串为完美单词,并更新答案。
5) 输出最长的完美单词长度。
【三、复杂度】
- 建 Trie:O(总长度 × 26)
- 排序:O(n log n)
- DP:O(n)
- 总复杂度:O(总长度 × 26 + n log n),可以过。
【四、注意事项】
- 正串 Trie 的节点编号从 1 开始(1 为根),0 表示空。
- 反串 Trie 同理。
- 处理长度为 1 的字符串时,不需要检查前缀和后缀。
- 用 char 数组标记 perfect 状态,节省空间。
- 答案初始为 0,如果没有完美单词则输出 0。
- 多组测试要清空 Trie 和 perfect 数组。
- 基于以上思路,代码如下:
cpp
#include <bits/stdc++.h>
using namespace std;
// Trie 字典树结构体
struct Trie {
vector<array<int, 26>> ch; // ch[u][c] 表示节点 u 走字符 c 到达的儿子编号
// 1 为根,0 表示空;节点编号 1..cnt
vector<int> par; // par[u] = u 的父亲节点编号
// 构造函数:初始化根节点
Trie() {
ch.resize(2); // 分配根节点(编号 1)和空节点(编号 0)
for (auto& a : ch) a.fill(0); // 所有儿子初始化为 0
par.assign(2, 0); // 根的父亲为 0
}
int size() const { return (int)ch.size(); }
// 插入字符串 s,返回结束节点编号到 endNode
void add(const string& s, int& endNode) {
int u = 1; // 从根节点 1 开始
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) { // 如果儿子不存在,新建节点
ch[u][x] = (int)ch.size();
ch.emplace_back();
ch.back().fill(0);
par.push_back(u); // 记录父亲
}
u = ch[u][x]; // 走到儿子
}
endNode = u; // 返回结束节点编号
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<string> s(n);
for (int i = 0; i < n; i++) cin >> s[i];
Trie F, R; // F: 正串 Trie;R: 反串 Trie
vector<int> nf(n), nr(n); // nf[i]: s[i] 在 F 中的结束节点
// nr[i]: s[i] 的反串在 R 中的结束节点
for (int i = 0; i < n; i++) {
F.add(s[i], nf[i]); // 正串插入 F
string r(s[i]);
reverse(r.begin(), r.end());
R.add(r, nr[i]); // 反串插入 R
}
// 按字符串长度从小到大排序
vector<int> ord(n);
iota(ord.begin(), ord.end(), 0);
sort(ord.begin(), ord.end(), [&](int a, int b) {
return s[a].size() < s[b].size();
});
// perfectF[u]: F 中节点 u 代表的字符串是否是完美单词
// perfectR[u]: R 中节点 u 代表的字符串是否是完美单词
vector<char> perfectF(F.size(), 0), perfectR(R.size(), 0);
int ans = 0;
for (int idx : ord) {
int L = (int)s[idx].size();
int uf = nf[idx]; // 正串结束节点
int ur = nr[idx]; // 反串结束节点
bool ok;
if (L == 1) {
// 单字符:只要它出现在给定字符串中,就是完美单词
ok = true;
} else {
// 前缀 t[1..L-1] 在 F 中的节点 = uf 的父亲
int pp = F.par[uf];
// 后缀 t[2..L] 的反串在 R 中的节点 = ur 的父亲
int qq = R.par[ur];
// 前后缀都必须是完美单词
ok = perfectF[pp] && perfectR[qq];
}
if (ok) {
perfectF[uf] = 1; // 标记正串为完美单词
perfectR[ur] = 1; // 标记反串为完美单词
ans = max(ans, L); // 更新答案
}
}
cout << ans << '\n';
return 0;
}
3.3 Manacher算法
适用场景: 求最长回文子串、回文子串数量、回文子串长度分布、双回文串等
3.3.1 基本原理
核心思想: 在字符串的每个字符之间以及两端插入特殊字符(如 #),把奇数长度和偶数长度的回文串统一成奇数长度,然后利用回文串的对称性,避免重复扩展。
扩展串构造:
原串 s = "aba",扩展后 ss = "#a#b#a#",长度 n = 2 * |s| + 1。
- 原串下标
i对应扩展串下标2 * i + 1 - 扩展串下标
i为偶数时是#,奇数时是原串字符
回文半径数组 p[i]:
p[i] 表示以 ss[i] 为中心的最长回文半径(包含中心本身)。
- 若
ss[i] == '#',p[i] - 1是以该位置为中心的偶数长度回文串长度 - 若
ss[i] != '#',p[i] - 1是以该位置为中心的奇数长度回文串长度 - 原串中以
i为中心的最长回文子串长度 =p[2 * i + 1] - 1 - 原串中以
i和i+1为中心的最长偶数回文子串长度 =p[2 * i + 2] - 1
核心变量:
c:当前已知的最长回文串的中心r:当前已知的最长回文串的右边界(不包含)i:当前遍历到的位置2 * c - i:i关于c的对称点
状态转移:
len = r > i ? min(p[2 * c - i], r - i) : 1
- 若
i < r,利用对称点2 * c - i的半径,但不超过r - i - 若
i >= r,从 1 开始暴力扩展
然后暴力向两边扩展,更新 p[i],并尝试更新 c 和 r。
时间复杂度: O(n),每个字符最多被扩展一次。
3.3.2 基础模板
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
static constexpr int MAXN = 1000005;
char ss[MAXN << 1];
int p[MAXN << 1];
int n;
void manacherss(const string &s) {
n = s.size() * 2 + 1;
for (int i = 0, j = 0; i < n; i++) {
ss[i] = (i & 1) == 0 ? '#' : s[j++];
}
}
void manacher(const string &s) {
manacherss(s);
for (int i = 0, c = 0, r = 0, len; i < n; i++) {
len = r > i ? min(p[2 * c - i], r - i) : 1;
while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
len++;
}
if (i + len > r) {
r = i + len;
c = i;
}
p[i] = len;
}
}
};
思路要点:
- 扩展串长度为
2 * |s| + 1 p[i]是回文半径(含中心)r是当前最右回文边界(不包含)- 利用对称点
2 * c - i加速,避免重复扩展 - 时间复杂度 O(n),空间复杂度 O(n)
3.3.3 最长回文子串(LeetCode 5)
题意: 找到字符串 s 中最长的回文子串并返回。
cpp
class Solver {
public:
class Solution {
public:
static const int MAXN = 1005;
char ss[MAXN << 1];
int p[MAXN << 1];
int n, maxLen, endPos;
void manacherss(const string &s) {
n = s.size() * 2 + 1;
for (int i = 0, j = 0; i < n; i++) {
ss[i] = (i & 1) == 0 ? '#' : s[j++];
}
}
void manacher(const string &s) {
manacherss(s);
maxLen = endPos = 0;
for (int i = 0, c = 0, r = 0, len; i < n; i++) {
len = r > i ? min(p[2 * c - i], r - i) : 1;
while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
len++;
}
if (i + len > r) {
r = i + len;
c = i;
}
if (len > maxLen) {
maxLen = len - 1;
endPos = (i + len - 1) / 2;
}
p[i] = len;
}
}
string longestPalindrome(string s) {
manacher(s);
return s.substr(endPos - maxLen, maxLen);
}
};
};
思路要点:
maxLen记录最长回文子串长度endPos记录最长回文子串的结束位置(原串下标)- 最终答案 =
s.substr(endPos - maxLen, maxLen)
3.3.4 回文子串数量(LeetCode 647)
题意: 返回字符串 s 的回文子串数量。
cpp
class Solver {
public:
class Solution {
public:
static const int MAXN = 1005;
char ss[MAXN << 1];
int p[MAXN << 1];
int n;
void manacherss(const string &s) {
n = s.size() * 2 + 1;
for (int i = 0, j = 0; i < n; i++) {
ss[i] = (i & 1) == 0 ? '#' : s[j++];
}
}
void manacher(const string &s) {
manacherss(s);
for (int i = 0, c = 0, r = 0, len; i < n; i++) {
len = r > i ? min(p[2 * c - i], r - i) : 1;
while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
len++;
}
if (i + len > r) {
r = i + len;
c = i;
}
p[i] = len;
}
}
int countSubstrings(string s) {
manacher(s);
int ans = 0;
for (int i = 0; i < n; i++) {
ans += p[i] / 2;
}
return ans;
}
};
};
思路要点:
- 每个位置
i的p[i]表示以i为中心的回文半径 - 以
i为中心的回文子串数量 =p[i] / 2 - 累加所有位置的贡献即为答案
3.3.5 不重叠回文子串的最多数目(LeetCode 2472)
题意: 给定字符串 str 和正数 k,把 str 切分成多个长度 ≥ k 且互不重叠的回文子串,返回最多能划分出几个。
cpp
class Solver {
public:
class Solution {
public:
static const int MAXN = 2005;
char ss[MAXN << 1];
int p[MAXN << 1];
int n;
void manacherss(const string &s) {
n = s.size() * 2 + 1;
for (int i = 0, j = 0; i < n; i++) {
ss[i] = (i & 1) == 0 ? '#' : s[j++];
}
}
// 从扩展串位置 l 开始,找最近的半径 > k 的回文中心
// 返回最右下标(一定命中 '#')
int find(int l, int k) {
for (int i = l, c = l, r = l, len; i < n; i++) {
len = r > i ? min(p[2 * c - i], r - i) : 1;
while (i + len < n && i - len >= l && ss[i + len] == ss[i - len]) {
if (++len > k) {
return i + k + (ss[i + k] != '#' ? 1 : 0);
}
}
if (i + len > r) {
r = i + len;
c = i;
}
p[i] = len;
}
return -1;
}
int maxPalindromes(string s, int k) {
manacherss(s);
int ans = 0;
int next = 0;
while ((next = find(next, k)) != -1) {
ans++;
}
return ans;
}
};
};
思路要点:
- 贪心:从左到右找最近的、长度 ≥ k 的回文子串,切掉后继续
find(l, k)从扩展串位置l开始,找半径 > k 的回文中心- 返回最右下标,保证命中
#,便于下一次查找 - 时间复杂度 O(n)
3.3.6 长度前 k 名的奇数长度回文子串长度乘积(洛谷 P1659)
题意: 给定字符串 s 和数值 k,只关心所有奇数长度的回文子串,返回其中长度前 k 名的回文子串的长度乘积。若奇数长度回文子串个数不够 k 个,返回 -1。答案对 19930726 取模。
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
int m;
long long k;
string s;
cin >> m >> k >> s;
cout << compute(s, k) << '\n';
return 0;
}
private:
static constexpr int MOD = 19930726;
static constexpr int MAXN = 1000005;
char ss[MAXN << 1];
int p[MAXN << 1];
int cnt[MAXN];
int n;
void manacherss(const string &s) {
n = s.size() * 2 + 1;
for (int i = 0, j = 0; i < n; i++) {
ss[i] = (i & 1) == 0 ? '#' : s[j++];
}
}
void manacher(const string &s) {
manacherss(s);
for (int i = 0, c = 0, r = 0, len; i < n; i++) {
len = r > i ? min(p[2 * c - i], r - i) : 1;
while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
len++;
}
if (i + len > r) {
r = i + len;
c = i;
}
p[i] = len;
}
}
long long power(long long x, long long n) {
long long ans = 1;
while (n > 0) {
if (n & 1) ans = ans * x % MOD;
x = x * x % MOD;
n >>= 1;
}
return ans;
}
int compute(const string &s, long long k) {
manacher(s);
int m = s.size();
// 统计每种奇数长度的回文子串数量
for (int i = 1; i < n; i += 2) {
cnt[p[i] - 1]++;
}
long long ans = 1;
long long sum = 0;
for (int len = (m & 1) ? m : (m - 1); len >= 1 && k > 0; len -= 2) {
sum += cnt[len];
ans = ans * power(len, min(k, sum)) % MOD;
k -= sum;
}
return k > 0 ? -1 : (int)ans;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
思路要点:
- 只关心奇数长度回文子串,对应扩展串中
i为奇数的位置 cnt[len]统计长度为len的奇数回文子串数量- 从大到小枚举长度,用快速幂累乘
- 若 k 还有剩余,返回 -1
3.3.7 最长双回文串长度(洛谷 P4555)
题意: 输入字符串 s,求 s 的最长双回文子串 t 的长度。双回文子串就是可以分成两个回文串的字符串。
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
string s;
cin >> s;
cout << compute(s) << '\n';
return 0;
}
private:
static constexpr int MAXN = 100005;
char ss[MAXN << 1];
int p[MAXN << 1];
int lef[MAXN << 1], rig[MAXN << 1];
int n;
void manacherss(const string &s) {
n = s.size() * 2 + 1;
for (int i = 0, j = 0; i < n; i++) {
ss[i] = (i & 1) == 0 ? '#' : s[j++];
}
}
void manacher(const string &s) {
manacherss(s);
for (int i = 0, c = 0, r = 0, len; i < n; i++) {
len = r > i ? min(p[2 * c - i], r - i) : 1;
while (i + len < n && i - len >= 0 && ss[i + len] == ss[i - len]) {
len++;
}
if (i + len > r) {
r = i + len;
c = i;
}
p[i] = len;
}
}
int compute(const string &s) {
manacher(s);
// 计算每个位置作为回文串右端点时,向左能延伸的最长回文长度
for (int i = 0, j = 0; i < n; i++) {
while (i + p[i] > j) {
lef[j] = j - i;
j += 2;
}
}
// 计算每个位置作为回文串左端点时,向右能延伸的最长回文长度
for (int i = n - 1, j = n - 1; i >= 0; i--) {
while (i - p[i] < j) {
rig[j] = i - j;
j -= 2;
}
}
int ans = 0;
for (int i = 2; i <= n - 3; i += 2) {
ans = max(ans, lef[i] + rig[i]);
}
return ans;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
思路要点:
lef[i]:以位置i为右端点时,向左能延伸的最长回文长度rig[i]:以位置i为左端点时,向右能延伸的最长回文长度- 枚举分割点
i,答案 =lef[i] + rig[i]的最大值 - 注意分割点必须在
#上,且两个回文串都不能为空
3.3.8 时间复杂度总结
| 操作 | 复杂度 |
|---|---|
| 扩展串构造 | O(n) |
| Manacher 预处理 | O(n) |
| 最长回文子串 | O(n) |
| 回文子串数量 | O(n) |
| 不重叠回文子串最多数目 | O(n) |
| 长度前 k 名奇数回文子串乘积 | O(n log MOD) |
| 最长双回文串 | O(n) |
3.3.9 注意事项
-
扩展串长度是原串的 2 倍 + 1,数组要开两倍以上
-
p[i]是回文半径(含中心),不是回文长度 -
原串回文长度 =
p[i] - 1 -
r是右边界(不包含),不是最右下标 -
对称点
2 * c - i可能越界,要用min(p[2 * c - i], r - i)限制 -
多测试用例时注意清空
p数组(或用时间戳优化)
3.3.x 区域赛题
-
题目链接 :2025ccpc哈尔滨站J
-
题目描述及核心思路:
text【题目描述】 给定一个只由 'o'、'v'、'w' 三种字符组成的字符串 s。 这些字符在视觉上有"连字"效果: - 两个连续的 'v' 看起来像一个 'w'。 - 连续的 'v' 和 'w' 会连成一条折线。 - 'o' 是一个圆,不产生尖角。 题目定义一个字符串是"好的",当且仅当它写在纸上时, 整个图形是左右镜像对称的(即视觉上是回文)。 你需要找到给定字符串 s 中最长的"好的"子串,并输出这个子串。 如果存在多个最长,输出任意一个即可。 【输入】 第一行:T,表示测试用例个数。 每个测试用例: 第一行:n,表示字符串长度。 第二行:一个长度为 n 的字符串 s(只含 'o'、'v'、'w')。 【输出】 每个测试用例输出一行,表示最长的"好的"子串。 【样例1】 输入: 3 8 wwovvvv 16 wwwoooooooooovwww 11 wwvovvovvvv 输出: wwovvvv oooooooo vvvv 【思路解说】 【一、核心观察】 题目中的"视觉回文"不能直接在原串上判断, 因为 'w' 在视觉上相当于两个 'v', 所以需要把原串映射成一个"视觉序列": - 'v' -> 1 个下尖角 - 'w' -> 2 个下尖角 - 'o' -> 0 个下尖角(或视为分隔符) 映射后的序列是一个整数序列(每个数表示该位置贡献的尖角数量)。 在这个序列上,一个子串是"好的"当且仅当它本身是回文的。 【二、映射与边界处理】 直接把 'w' 展开成两个 'v' 会丢失原串的下标信息, 所以需要记录每个视觉位置对应的原串下标(或原串字符的起始位置)。 具体做法: 1) 遍历原串,把 'w' 展开成两个 1,'v' 保持 1,'o' 视为 2(或 0)。 2) 用一个数组 bidx[] 记录每个视觉位置是否对应原串某个字符的起始位置, 这样在找到回文区间后,可以快速映射回原串区间。 3) bidx 的前缀和可以用来把视觉下标转换成原串下标。 【三、Manacher 求最长视觉回文】 在映射后的视觉序列 T 上跑 Manacher: - 因为 T 中每个元素是 1 或 2,不一定是字符, 所以需要用"整数回文"的 Manacher 写法。 - 分别处理奇数长度回文和偶数长度回文。 - 每扩展一步,就用当前的回文区间 [a, b] 去更新答案: 检查 a 和 b 是否都是合法的原串边界(即 isGood(a) 和 isGood(b))。 若是,则用 bidx[b] - bidx[a] 作为原串中的长度,更新最优解。 【四、为什么不能直接在原串上跑 Manacher】 因为原串的 'w' 在视觉上是两个 'v', 直接对原串做 Manacher 会把 'w' 当成一个字符, 从而漏掉 'w' 和 'v' 之间的视觉对称关系。 必须先把 'w' 展开成两个视觉单位,再跑 Manacher。 【五、算法流程】 1) 读入字符串 s。 2) 构造视觉序列 T: 'v' -> 1 'w' -> 1, 1 'o' -> 2(或 0,视具体定义) 同时记录每个视觉位置是否对应原串的字符起始位置(bidx)。 3) 对 bidx 做前缀和,方便把视觉下标映射回原串下标。 4) 在 T 上跑 Manacher(奇回文 + 偶回文): 每次扩展回文半径时,检查当前区间 [a, b] 的两端是否都是原串边界。 若是,则用 bidx[b] - bidx[a] 更新最长长度。 5) 根据最优的视觉区间 [bestA, bestB],映射回原串区间 [st, st + sublen)。 6) 输出对应的子串。 【六、复杂度】 构造视觉序列:O(n) Manacher:O(n) 映射回原串:O(1) 总复杂度:O(n),可以过。 【七、注意事项】 - 'w' 必须展开成两个视觉单位,不能当成一个字符处理。 - Manacher 过程中每次扩展都要检查边界合法性, 否则可能把视觉上对称但原串上不对齐的区间误判为答案。 - bidx 的前缀和用于快速计算原串长度,避免 substr 拷贝。 - 多测试用例时注意只清空用到的部分,不要 memset 整个大数组。 - 输出时用 cout.write 直接输出区间,避免额外拷贝。-
基于以上思路代码如下:
cpp#include <bits/stdc++.h> using namespace std; // 视觉序列总长度上限:n 最大约 1e7,w 展开后最多翻倍 const int MAXTOK = 20000010; int T[MAXTOK]; // 视觉序列:v->1, w->1,1, o->2 int bidx[MAXTOK + 1]; // bidx[i] = 视觉序列前 i 个位置对应原串的字符数 int rad[MAXTOK]; // Manacher 回文半径数组 int main(){ ios::sync_with_stdio(false); cin.tie(nullptr); int t; cin >> t; while (t--) { int n; string s; cin >> n >> s; // 计算视觉序列的总长度 N // 'w' 展开成两个 1,所以贡献 2;'v' 和 'o' 各贡献 1 int N = 0; for (char ch : s) { if (ch == 'w') N += 2; else N += 1; } // 只清空用到的部分,避免 memset 整个大数组 // bidx[0..N] 都需要置 0 for (int p = 0; p <= N; p++) bidx[p] = 0; // 构造视觉序列 T,同时标记原串的字符边界 int idx = 0; for (char ch : s) { if (ch == 'w') { T[idx++] = 1; // w 展开成两个视觉单位 1 T[idx++] = 1; } else if (ch == 'v') { T[idx++] = 1; // v 是一个视觉单位 1 } else // 'o' { T[idx++] = 2; // o 用 2 表示(与 1 区分开,避免误判) } // 每个原串字符结束后,标记一个边界 bidx[idx] = 1; } // bidx 前缀和:bidx[p] 表示视觉序列前 p 个位置对应原串多少个字符 for (int p = 1; p <= N; p++) bidx[p] += bidx[p - 1]; int best = 0, bestA = 0, bestB = 0; // 判断视觉位置 p 是否是原串的字符边界 // p == 0 是起点,或者 bidx[p] > bidx[p-1] 说明 p 处有原串字符结束 auto isGood = [&](int p) -> bool { return p == 0 || bidx[p] > bidx[p - 1]; }; // 尝试用视觉区间 [a, b) 更新答案 // 要求 a 和 b 都是原串的字符边界,这样映射回原串才是完整的字符 auto consider = [&](int a, int b) { if (a >= 0 && b <= N && isGood(a) && isGood(b)) { int len = bidx[b] - bidx[a]; // 原串中的长度 if (len > best) { best = len; bestA = a; bestB = b; } } }; // 先考虑长度为 1 的视觉区间(单个字符本身) for (int i = 0; i < N; i++) consider(i, i + 1); // ===== 奇数长度回文 Manacher ===== // 以 i 为中心,向两边扩展 { int l = 0, r = -1; for (int i = 0; i < N; i++) { // 利用对称点初始化半径 int k = (i > r) ? 1 : min(rad[l + r - i], r - i + 1); // 暴力扩展 while (i - k >= 0 && i + k < N && T[i - k] == T[i + k]) { k++; consider(i - k + 1, i + k); } rad[i] = k - 1; consider(i - k + 1, i + k); // 更新最右回文边界 if (i + rad[i] > r) { l = i - rad[i]; r = i + rad[i]; } } } // ===== 偶数长度回文 Manacher ===== // 以 i-1 和 i 之间为中心,向两边扩展 { int l = 0, r = -1; for (int i = 0; i < N; i++) { // 利用对称点初始化半径 int k = (i > r) ? 0 : min(rad[l + r - i + 1], r - i + 1); // 暴力扩展 while (i - k - 1 >= 0 && i + k < N && T[i - k - 1] == T[i + k]) { k++; consider(i - k, i + k); } rad[i] = k; consider(i - k, i + k); // 更新最右回文边界 if (i + rad[i] - 1 > r) { l = i - rad[i]; r = i + rad[i] - 1; } } } // 把最优的视觉区间 [bestA, bestB) 映射回原串区间 int st = bidx[bestA]; // 原串起始下标 int sublen = bidx[bestB] - st; // 原串子串长度 // 直接输出,避免 substr 拷贝 cout.write(&s[st], sublen); cout << '\n'; } return 0; }
-
3.4 字符串哈希(多项式哈希)
适用场景: 快速判断两个字符串是否相等、求最长公共子串、回文判断、子串匹配等
3.4.1 基本原理
核心思想: 把字符串看成一个 base 进制的超大数字,每一位字符对应进制中的一位,最后对一个大质数取模,压缩成一个整数,这个整数就是字符串的哈希值。
滚动递推公式:
Hash = Hash × BASE + c \text{Hash} = \text{Hash} \times \text{BASE} + c Hash=Hash×BASE+c
其中 c 为当前字符的值(通常取 ASCII 码),BASE 一般取质数(如 131、13331)。
原理来源: 类比十进制数字的递推计算:
计算 123:
1. num = 1
2. num = 1 * 10 + 2 = 12
3. num = 12 * 10 + 3 = 123
把 10 换成 BASE,把数字换成字符,就得到字符串哈希的递推式。
示例: 以字符串 "aba" 为例,a = 97, b = 98,BASE = 131
第一个字符 a:hash = 0 * 131 + 97 = 97
第二个字符 b:hash = 97 * 131 + 98 = 12805
第三个字符 a:hash = 12805 * 131 + 97 = 1677552
3.4.2 基础实现(洛谷 P3370)
题意: 给定 n 个字符串,求其中不同字符串的个数。
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
int n;
cin >> n;
for (int i = 0; i < n; i++) {
string s;
cin >> s;
nums[i] = getHash(s);
}
sort(nums, nums + n);
int ans = (n == 0 ? 0 : 1);
for (int i = 1; i < n; i++) {
if (nums[i] != nums[i - 1]) ans++;
}
cout << ans << '\n';
return 0;
}
private:
static constexpr int MAXN = 10005;
static constexpr long long BASE = 131;
long long nums[MAXN];
long long getHash(const string &s) {
long long h = 0;
for (char c : s) {
h = h * BASE + c;
}
return h;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
思路要点:
- 对每个字符串滚动求哈希值,存入数组
- 排序后统计不同哈希值的个数
- 不取模时
long long会溢出,但溢出等价于对 2 64 2^{64} 264 取模(自然溢出),某些场景下可用,但容易被构造冲突 - 建议显式取模,更安全
3.4.3 哈希冲突及工业优化
哈希冲突: 不同的字符串可能算出相同的哈希值。例如在未取模的自然溢出下:
A = "\x02\x05"
B = "\x01\x0B"
两者算出的值可能相同。
优化一:引入大质数模数
cpp
class Solver {
public:
static constexpr long long BASE = 131;
static constexpr long long MOD = 998244353LL; // 大质数
long long getHash(const string &s) {
long long h = 0;
for (char c : s) {
h = (h * BASE + c) % MOD;
}
return h;
}
};
- 取模防止数字过大溢出
BASE与MOD互质时,哈希值分布更均匀,冲突概率更低- 常用模数:
998244353、1000000007、1000000009、19260817
优化二:双重哈希 / 多重哈希
用两套独立的 (BASE, MOD) 参数分别计算哈希值,只有两个哈希值都相同才认为字符串相同,冲突概率大幅降低。
cpp
class Solver {
public:
static constexpr long long B1 = 131, M1 = 998244353LL;
static constexpr long long B2 = 13331, M2 = 1000000007LL;
pair<long long, long long> getHash(const string &s) {
long long h1 = 0, h2 = 0;
for (char c : s) {
h1 = (h1 * B1 + c) % M1;
h2 = (h2 * B2 + c) % M2;
}
return {h1, h2};
}
};
优化三:自然溢出 + 无符号类型
使用 unsigned long long,溢出时自动对 2 64 2^{64} 264 取模,速度快,但容易被卡。
cpp
class Solver {
public:
using ull = unsigned long long;
static constexpr ull BASE = 131;
ull getHash(const string &s) {
ull h = 0;
for (char c : s) {
h = h * BASE + c;
}
return h;
}
};
3.4.4 子串哈希(前缀哈希)
核心用途: 在 O(1) 时间内求任意子串 s[l..r] 的哈希值。
预处理:
cpp
class Solver {
public:
static constexpr long long BASE = 131;
static constexpr long long MOD = 998244353LL;
static constexpr int MAXN = 1000005;
long long h[MAXN]; // h[i] 表示前 i 个字符的哈希值
long long pw[MAXN]; // pw[i] = BASE^i % MOD
void init(const string &s) {
int n = s.size();
pw[0] = 1;
h[0] = 0;
for (int i = 1; i <= n; i++) {
h[i] = (h[i - 1] * BASE + s[i - 1]) % MOD;
pw[i] = pw[i - 1] * BASE % MOD;
}
}
// 求 s[l..r] 的哈希值,下标从 1 开始
long long subHash(int l, int r) {
return ((h[r] - h[l - 1] * pw[r - l + 1]) % MOD + MOD) % MOD;
}
};
公式推导:
Hash ( s l . . r ) = h r − h l − 1 × BASE r − l + 1 ( m o d M O D ) \text{Hash}(sl..r) = hr - hl-1 \times \text{BASE}^{r-l+1} \pmod{MOD} Hash(sl..r)=hr−hl−1×BASEr−l+1(modMOD)
思路要点:
h[i]是前缀s[1..i]的哈希值- 求子串哈希时,把
h[l-1]左移r-l+1位对齐后相减 - 由于取模后可能为负,需
+ MOD再% MOD - 预处理 O(n),单次查询 O(1)
3.4.5 常见应用
| 应用 | 做法 |
|---|---|
| 判断两字符串是否相等 | 比较哈希值 |
| 求最长公共子串 | 二分长度 + 哈希 + 哈希表 |
| 回文串判断 | 正反两遍哈希,比较对应子串哈希 |
| 字符串匹配(KMP 替代) | 比较模式串与文本串子串哈希 |
| 求不同子串个数 | 后缀数组 / 后缀自动机(哈希也可,但需 O(n²)) |
| 判断循环节 | 枚举长度 + 子串哈希比较 |
3.4.6 哈希参数选择建议
| 参数 | 推荐值 | 说明 |
|---|---|---|
| BASE | 131、13331、13131、10007 | 质数,且大于字符集大小 |
| MOD | 998244353、1000000007、1000000009 | 大质数 |
| 自然溢出 | unsigned long long |
快但易被卡 |
| 双重哈希 | 两套独立参数 | 冲突概率极低 |
注意事项:
BASE和MOD必须互质,否则哈希值分布不均匀- 若字符集含小写字母,
BASE > 26即可;若含 ASCII 全字符,BASE > 128 - 单哈希在竞赛中可能被构造数据卡掉,重要题目建议双重哈希
- 自然溢出虽然快,但 Codeforces 等平台有专门的 hack 数据
3.4.7 时间复杂度总结
| 操作 | 复杂度 |
|---|---|
| 单串哈希计算 | O(len) |
| 前缀哈希预处理 | O(n) |
| 子串哈希查询 | O(1) |
| n 个串去重 | O(n log n)(排序)或 O(n)(哈希表) |
3.5 AC自动机
适用场景: 多模式串匹配、敏感词过滤、统计每个模式串出现次数、AC 自动机 + DP 数位统计等
3.5.1 基本原理
核心思想: AC 自动机 = Trie 树 + KMP 的 fail 指针。
- 把所有模式串插入一棵 Trie 树。
- 对 Trie 上的每个节点,建立 fail 指针,指向当前节点代表的前缀的最长后缀,且这个后缀也是某个模式串的前缀。
- 匹配时,如果当前字符无法继续走,就沿着 fail 指针跳转,直到能走或回到根。
关键性质:
- 每个节点的 fail 指针指向的节点,其代表的字符串是当前节点代表字符串的最长真后缀。
- 所有 fail 指针构成一棵树(fail 树),根是节点 0。
- 在 fail 树上,一个节点的所有祖先都是它的后缀。
时间复杂度: 建树 O(总模式串长度),匹配 O(文本串长度 + 匹配次数)。
3.5.2 基础模板(统计每个模式串出现次数)
题意: 给定 n 个模式串和一篇文章,返回每个模式串在文章中出现的次数。
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
string s;
cin >> s;
insert(i, s);
}
setFail();
string text;
cin >> text;
for (int u = 0, i = 0; i < (int)text.size(); i++) {
u = tree[u][text[i] - 'a'];
times[u]++;
}
// 建 fail 树
for (int i = 1; i <= cnt; i++) {
addEdge(fail[i], i);
}
// 汇总词频
f2(0);
for (int i = 1; i <= n; i++) {
cout << times[endpos[i]] << '\n';
}
return 0;
}
private:
static constexpr int MAXN = 200005; // 模式串数量
static constexpr int MAXS = 200005; // 所有模式串总字符数
int tree[MAXS][26];
int fail[MAXS];
int cnt = 0;
int endpos[MAXN]; // 每个模式串的结尾节点
int times[MAXS]; // 每个节点的匹配次数
// fail 树(反图)
int head[MAXS], nxt[MAXS], to[MAXS], edge = 0;
int box[MAXS]; // 队列 / 栈
bool visited[MAXS];
// 插入模式串
void insert(int id, const string &s) {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!tree[u][c]) tree[u][c] = ++cnt;
u = tree[u][c];
}
endpos[id] = u;
}
// 构建 fail 指针 + 直通表优化
void setFail() {
int l = 0, r = 0;
for (int i = 0; i < 26; i++) {
if (tree[0][i]) box[r++] = tree[0][i];
}
while (l < r) {
int u = box[l++];
for (int i = 0; i < 26; i++) {
if (!tree[u][i]) {
tree[u][i] = tree[fail[u]][i]; // 直通表
} else {
fail[tree[u][i]] = tree[fail[u]][i];
box[r++] = tree[u][i];
}
}
}
}
void addEdge(int u, int v) {
nxt[++edge] = head[u];
head[u] = edge;
to[edge] = v;
}
// 非递归遍历 fail 树,汇总词频
void f2(int u) {
int r = 0;
box[r++] = u;
while (r > 0) {
int cur = box[r - 1];
if (!visited[cur]) {
visited[cur] = true;
for (int i = head[cur]; i; i = nxt[i]) {
box[r++] = to[i];
}
} else {
r--;
for (int i = head[cur]; i; i = nxt[i]) {
times[cur] += times[to[i]];
}
}
}
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
思路要点:
- Trie 树中每个节点代表一个前缀
- fail 指针指向最长后缀,且该后缀是某个模式串的前缀
- 匹配时沿 Trie 走,走不动就沿 fail 跳
- 每个节点被访问的次数就是它代表的字符串在文本中出现的次数
- 但一个模式串可能作为其他模式串的后缀出现,所以要在 fail 树上从下往上累加词频
- fail 树是一棵树,用非递归遍历避免爆栈
3.5.3 AC 自动机 + 数位 DP(洛谷 P3311)
题意: 给定数字串集合 S,求不大于 n 的正整数中,十进制表示不包含 S 中任意元素作为子串的数的个数,答案对 1e9+7 取模。
思路: AC 自动机 + 数位 DP。
- 把 S 中所有模式串插入 AC 自动机。
- 在 AC 自动机上标记命中节点(即某个模式串的结尾)。
- 由于 fail 指针的存在,一个节点如果 fail 链上有命中节点,那么它也是命中节点(命中标记前移)。
- 数位 DP:从高位到低位枚举,状态为
(位置 i, AC 自动机节点 j, 是否顶上限 free, 是否已选数字 has)。
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
int run() {
string s;
cin >> s;
n = s.size();
for (int i = 0; i < n; i++) num[i] = s[i];
int m;
cin >> m;
for (int i = 0; i < m; i++) {
string t;
cin >> t;
insert(t);
}
setFail();
clear();
cout << dfs(0, 0, 0, 0) << '\n';
return 0;
}
private:
static constexpr int MOD = 1000000007;
static constexpr int MAXN = 1301; // 数字长度
static constexpr int MAXS = 2001; // 总字符数
int tree[MAXS][10];
int fail[MAXS];
int cnt = 0;
bool alert[MAXS]; // 命中标记
int queue_[MAXS];
int dp[MAXN][MAXS][2][2];
char num[MAXN];
int n;
void insert(const string &s) {
int u = 0;
for (char ch : s) {
int c = ch - '0';
if (!tree[u][c]) tree[u][c] = ++cnt;
u = tree[u][c];
}
alert[u] = true;
}
void setFail() {
int l = 0, r = 0;
for (int i = 0; i < 10; i++) {
if (tree[0][i]) queue_[r++] = tree[0][i];
}
while (l < r) {
int u = queue_[l++];
for (int i = 0; i < 10; i++) {
if (!tree[u][i]) {
tree[u][i] = tree[fail[u]][i];
} else {
fail[tree[u][i]] = tree[fail[u]][i];
queue_[r++] = tree[u][i];
}
}
// 命中标记前移
alert[u] |= alert[fail[u]];
}
}
void clear() {
for (int i = 0; i <= n; i++)
for (int j = 0; j <= cnt; j++)
for (int a = 0; a < 2; a++)
for (int b = 0; b < 2; b++)
dp[i][j][a][b] = -1;
}
// i: 当前位
// j: AC 自动机节点
// free: 是否顶上限(0 顶,1 不顶)
// has: 是否已经选过数字(0 没选,1 选了)
int dfs(int i, int j, int free, int has) {
if (alert[j]) return 0;
if (i == n) return has;
if (dp[i][j][free][has] != -1) return dp[i][j][free][has];
int ans = 0;
int cur = num[i] - '0';
if (!has) {
if (!free) {
// 没选过数字,且顶上限
ans = (ans + dfs(i + 1, 0, 1, 0)) % MOD; // 继续不选
for (int pick = 1; pick < cur; pick++)
ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
ans = (ans + dfs(i + 1, tree[j][cur], 0, 1)) % MOD;
} else {
// 没选过数字,且不顶上限
ans = (ans + dfs(i + 1, 0, 1, 0)) % MOD;
for (int pick = 1; pick <= 9; pick++)
ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
}
} else {
if (!free) {
// 选过数字,且顶上限
for (int pick = 0; pick < cur; pick++)
ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
ans = (ans + dfs(i + 1, tree[j][cur], 0, 1)) % MOD;
} else {
// 选过数字,且不顶上限
for (int pick = 0; pick <= 9; pick++)
ans = (ans + dfs(i + 1, tree[j][pick], 1, 1)) % MOD;
}
}
return dp[i][j][free][has] = ans;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
Solver solver;
return solver.run();
}
思路要点:
alert[u]标记节点 u 是否命中模式串- fail 链上的命中标记要前移:
alert[u] |= alert[fail[u]] - 数位 DP 状态:
(位置, AC 节点, 是否顶上限, 是否已选数字) - 已选数字才能选 0,否则 0 表示"还没开始选"
- 答案对 1e9+7 取模
3.5.4 AC 自动机的优化技巧
| 技巧 | 说明 |
|---|---|
| 直通表 | tree[u][c] = tree[fail[u]][c],匹配时无需沿 fail 跳 |
| 命中标记前移 | `alert[u] |
| fail 树 | 所有 fail 指针构成一棵树,词频统计在 fail 树上从下往上累加 |
| 非递归遍历 | fail 树深度可能很大,用栈模拟递归避免爆栈 |
| 时间戳清空 | 多测试用例时用时间戳代替 memset,避免反复清空大数组 |
3.5.5 常见应用
| 应用 | 做法 |
|---|---|
| 多模式串匹配 | 文本串在 AC 自动机上跑,统计命中 |
| 每个模式串出现次数 | fail 树上从下往上累加词频 |
| 敏感词过滤 | 命中标记 + 替换 |
| AC 自动机 + DP | 数位 DP、状态机 DP |
| AC 自动机 + 矩阵快速幂 | 长度为 L 的串中不包含模式串的方案数 |
| AC 自动机 + 高斯消元 | 期望步数问题 |
3.5.6 时间复杂度总结
| 操作 | 复杂度 |
|---|---|
| 建 Trie | O(总模式串长度) |
| 构建 fail | O(总模式串长度 × 字符集大小) |
| 匹配文本串 | O(文本串长度) |
| fail 树汇总 | O(节点数) |
| AC 自动机 + DP | O(状态数 × 转移数) |
3.5.7 注意事项
-
字符集大小决定
tree[u][c]的维度,小写字母是 26,数字是 10 -
fail 指针的构建用 BFS,保证按层构建
-
直通表优化后,匹配时不需要沿 fail 跳,直接走
tree[u][c] -
命中标记前移后,匹配时不需要沿 fail 链检查命中,直接看
alert[u] -
多测试用例时注意清空
tree、fail、alert、times等数组 -
fail 树深度可能很大,Java 等语言要用非递归遍历避免爆栈
3.6 后缀自动机
适用场景: 子串出现次数、不同子串个数、最长公共子串、字典序第 k 小子串、子串匹配、后缀树相关
3.6.1 基本原理
核心思想: 后缀自动机是一个能识别字符串所有子串 的最小 DFA(确定性有限自动机)。它把字符串的所有子串压缩成一个有向无环图(DAG),每个节点代表一组 endpos 相同的子串。
关键概念:
- endpos(S):子串 S 在原串中所有出现位置的结束下标集合
- 状态(节点):一组 endpos 相同的子串
- lenu:状态 u 代表的最长子串长度
- linku:状态 u 的后缀链接,指向 endpos 集合严格更大的状态
- 转移边:从状态 u 出发,加字符 c 到达状态 v
关键性质:
- 从根出发的任意路径都对应一个子串
- 从根出发到某个状态的路径长度范围是
(len[link[u]], len[u]] - 后缀链接构成一棵树(parent 树),每个状态代表一个子串集合
- SAM 的节点数 ≤ 2n - 1,边数 ≤ 3n - 4
- 每个状态代表的不同子串个数 =
len[u] - len[link[u]]
3.6.2 基础模板
cpp
#include <bits/stdc++.h>
using namespace std;
class Solver {
public:
static constexpr int MAXN = 1000005;
struct SAM {
int tree[MAXN << 1][26]; // 转移边
int link[MAXN << 1]; // 后缀链接
int len[MAXN << 1]; // 最长子串长度
int cnt[MAXN << 1]; // 每个状态的 endpos 大小
int sz, last; // 状态数、最后状态
SAM() {
sz = 0;
last = 0;
// 初始化根节点
memset(tree[0], 0, sizeof(tree[0]));
link[0] = -1;
len[0] = 0;
sz = 1;
}
// 插入字符 c
void insert(int c) {
int cur = sz++;
len[cur] = len[last] + 1;
cnt[cur] = 1;
memset(tree[cur], 0, sizeof(tree[cur]));
int p = last;
while (p != -1 && !tree[p][c]) {
tree[p][c] = cur;
p = link[p];
}
if (p == -1) {
link[cur] = 0;
} else {
int q = tree[p][c];
if (len[p] + 1 == len[q]) {
link[cur] = q;
} else {
// 分裂节点
int clone = sz++;
len[clone] = len[p] + 1;
memcpy(tree[clone], tree[q], sizeof(tree[q]));
link[clone] = link[q];
cnt[clone] = 0;
while (p != -1 && tree[p][c] == q) {
tree[p][c] = clone;
p = link[p];
}
link[q] = link[cur] = clone;
}
}
last = cur;
}
// 建 parent 树后统计 endpos 大小
void buildParentTree() {
// 按 len 排序,从大到小累加
vector<int> order(sz);
iota(order.begin(), order.end(), 0);
sort(order.begin(), order.end(), [&](int a, int b) {
return len[a] > len[b];
});
for (int u : order) {
if (link[u] != -1) {
cnt[link[u]] += cnt[u];
}
}
}
};
};
思路要点:
- 插入字符时新建节点
cur - 沿后缀链接跳,若没有转移则加上
- 若
len[p] + 1 == len[q],直接连link - 否则分裂出
clone节点 - 每个节点代表的不同子串数 =
len[u] - len[link[u]]
3.6.3 不同子串个数
结论: 所有状态代表的子串数之和 = ∑ u ( len u − len link \[ u ] ) \sum_u (\text{len}u - \text{len}\\text{link}\[u]) ∑u(lenu−lenlink\[u])
cpp
class Solver {
public:
long long countDistinctSubstrings() {
long long ans = 0;
for (int i = 1; i < sz; i++) {
ans += len[i] - len[link[i]];
}
return ans;
}
};
思路要点:
- 每个状态代表
(len[link[u]], len[u]]长度的子串 - 累加所有状态即可
- 时间复杂度 O(n)
3.6.4 子串出现次数
思路: 在 parent 树上从下往上累加 cnt。
cpp
class Solver {
public:
void buildParentTree() {
// 按 len 从大到小排序
vector<int> order(sz);
iota(order.begin(), order.end(), 0);
sort(order.begin(), order.end(), [&](int a, int b) {
return len[a] > len[b];
});
for (int u : order) {
if (link[u] != -1) {
cnt[link[u]] += cnt[u];
}
}
}
// 查询子串 s 的出现次数
int queryCount(const string &s) {
int u = 0;
for (char ch : s) {
int c = ch - 'a';
if (!tree[u][c]) return 0;
u = tree[u][c];
}
return cnt[u];
}
};
思路要点:
- 每个前缀节点的
cnt初始为 1 - parent 树上从下往上累加,得到每个状态的 endpos 大小
- 查询时沿转移边走,返回最终状态的
cnt - 经典题型:洛谷 P3804
3.6.5 最长公共子串
问题: 给定两个字符串 s 和 t,求它们的最长公共子串。
cpp
class Solver {
public:
int longestCommonSubstring(const string &s, const string &t) {
// 对 s 建 SAM
SAM sam;
for (char c : s) sam.insert(c - 'a');
int v = 0, l = 0, ans = 0;
for (char ch : t) {
int c = ch - 'a';
while (v && !sam.tree[v][c]) {
v = sam.link[v];
l = sam.len[v];
}
if (sam.tree[v][c]) {
v = sam.tree[v][c];
l++;
}
ans = max(ans, l);
}
return ans;
}
};
思路要点:
- 对 s 建 SAM
- 用 t 在 SAM 上跑
- 失配时沿后缀链接跳,更新当前匹配长度
- 时间复杂度 O(|s| + |t|)
3.6.6 字典序第 k 小子串
问题: 求字符串中字典序第 k 小的子串。
cpp
class Solver {
public:
// 预处理每个状态能到达的子串数
void dfsCount(int u, vector<long long> &dp) {
if (dp[u]) return;
dp[u] = 1; // 空串
for (int c = 0; c < 26; c++) {
if (tree[u][c]) {
dfsCount(tree[u][c], dp);
dp[u] += dp[tree[u][c]];
}
}
}
// 求第 k 小子串
string kthSubstring(int k) {
vector<long long> dp(sz, 0);
dfsCount(0, dp);
string ans;
int u = 0;
while (k > 0) {
for (int c = 0; c < 26; c++) {
if (!tree[u][c]) continue;
int v = tree[u][c];
if (dp[v] >= k) {
ans += char('a' + c);
u = v;
k--;
break;
} else {
k -= dp[v];
}
}
}
return ans;
}
};
思路要点:
- 每个状态出发能形成的子串数(含空串)
- 按字典序枚举字符,判断第 k 小在哪个分支
- 时间复杂度 O(n × 字符集)
3.6.7 最长重复子串
结论: 出现次数 ≥ 2 的最长子串长度 = max { len u ∣ cnt u ≥ 2 } \max\{\text{len}u \mid \text{cnt}u \ge 2\} max{lenu∣cntu≥2}
cpp
class Solver {
public:
int longestRepeatedSubstring() {
buildParentTree();
int ans = 0;
for (int i = 1; i < sz; i++) {
if (cnt[i] >= 2) {
ans = max(ans, len[i]);
}
}
return ans;
}
};
思路要点:
- 统计每个状态的 endpos 大小
- 取 endpos ≥ 2 的状态中最长长度
3.6.8 后缀自动机 + DP
适用场景: 统计不同子串个数、方案数、匹配长度
cpp
class Solver {
public:
// 统计不同子串个数(DP 版)
long long countDistinctDP() {
vector<long long> dp(sz, -1);
function<long long(int)> dfs = [&](int u) -> long long {
if (dp[u] != -1) return dp[u];
long long res = 1; // 空串
for (int c = 0; c < 26; c++) {
if (tree[u][c]) {
res += dfs(tree[u][c]);
}
}
return dp[u] = res;
};
return dfs(0) - 1; // 减去空串
}
};
思路要点:
dp[u]表示从状态 u 出发能形成的子串数- 记忆化搜索
- 答案减去空串
3.6.9 广义后缀自动机(GSAM)
适用场景: 多个字符串的子串问题
思路: 把多个字符串依次插入同一个 SAM,每次插入新串时把 last 重置为根。
cpp
class GSAM {
public:
// 广义 SAM:插入多个字符串
void insertString(const string &s) {
last = 0;
for (char c : s) {
insert(c - 'a');
}
}
};
思路要点:
- 每次插入新串前把
last = 0 - 节点复用,注意 clone 的处理
- 经典题型:洛谷 P6139、CF 204E
3.6.10 常见应用总结
| 应用 | 做法 | 经典题 |
|---|---|---|
| 不同子串个数 | 累加 len[u] - len[link[u]] |
洛谷 P2408 |
| 子串出现次数 | parent 树上累加 cnt | 洛谷 P3804 |
| 最长公共子串 | 对 s 建 SAM,t 在 SAM 上跑 | SPOJ LCS |
| 字典序第 k 小子串 | DP + 按字典序枚举 | 洛谷 P3975 |
| 最长重复子串 | cnt ≥ 2 的最大 len | 洛谷 P2408 |
| 多个字符串公共子串 | 广义 SAM | 洛谷 P6139 |
| 子串匹配 | SAM 上跑文本串 | 多题 |
| 后缀树 | SAM 的 parent 树 | 多题 |
统一思路:
- 建 SAM:插入字符串,得到转移边和后缀链接
- 建 parent 树:按 len 排序,从下往上累加
- 查询:在 SAM 上走转移边,或利用 parent 树性质
- 统计 :累加
len[u] - len[link[u]]或cnt[u]
复杂度:
- 建 SAM:O(n × 字符集)
- 查询:O(m)
- 统计不同子串:O(n)
- 统计出现次数:O(n)
3.6.11 注意事项
- SAM 节点数 ≤ 2n - 1,数组要开 2 倍
link[0] = -1,注意边界判断- 插入时 clone 节点的
cnt初始为 0 - parent 树按 len 排序,从大到小累加
- 广义 SAM 每次插入前
last = 0 - 字符集大时用
map或哈希 - 多组测试要清空
tree、link、len、cnt - SAM 的 parent 树等价于后缀树的反向
- 不同子串个数可能很大,要用
long long - 字典序第 k 小子串注意是否包含重复子串