ACM 算法模板与详解大全

汇集 ACM/ICPC、OI 与算法竞赛中常用算法与数据结构的完整模板、详细中文注释、易错点与拓展方向
覆盖:基础算法、数据结构、图论、数论、字符串、动态规划、计算几何、数学与多项式、搜索与其他技巧、附录与 STL 速查,共 10 大部分、100+ 个小节。

使用说明

每个算法小节都按固定结构编写,方便速查与背诵:

小节 内容
一句话用途 该算法解决什么问题、适用题目类型
算法思想 核心思路简述
复杂度 时间 / 空间
模板代码 完整可用的 C++17 模板,带详细中文注释
注意点 / 坑点 易错细节、边界条件、实现陷阱
可拓展的地方 变体、优化、与其他算法结合的方向
典型例题类型 对应题目形态与经典例题(如 洛谷 P3366)

全局约定

  • 语言:C++17;所有代码统一 #include <bits/stdc++.h>using namespace std;using ll = long long;
  • 除非特殊说明:线段树、树状数组等结构下标从 1 开始;字符串题目下标约定会在代码注释中注明。
  • 复杂度记号:n 为数据规模、m/q 为边数/询问数;均摊复杂度会特别说明。
  • 目录中的链接可点击跳转(GitHub / Typora / VS Code 预览均可)。

内容总览

部分 内容 代表算法
一、算法基础 输入输出优化、二分、三分、前缀和差分、高精度、离散化、双指针、归并排序 二分答案、二维差分
二、数据结构 并查集、树状数组、线段树、主席树、ST 表、单调栈/队列、莫队、平衡树、Trie、ODT 线段树、主席树、FHQ Treap
三、图论 最短路、MST、LCA、树链剖分、SCC/割点桥、欧拉路、二分图、网络流、差分约束、2-SAT Dinic、匈牙利、Tarjan
四、数论 快速幂、exgcd、素数筛、Pollard-Rho、CRT、Lucas、莫比乌斯反演、线性基、高斯消元、BSGS、SG 欧拉筛、莫反、SG 函数
五、字符串 字符串哈希、KMP、Z 算法、Manacher、AC 自动机、后缀数组、后缀自动机 SAM、AC 自动机
六、动态规划 背包、线性/区间/树形/状压/数位/概率 DP、斜率优化、插头 DP 数位 DP、斜率优化
七、计算几何 向量运算、线段相交、凸包、旋转卡壳、半平面交、最近点对、最小圆覆盖 Andrew 凸包、旋转卡壳
八、数学与多项式 组合数学、生成函数、FFT、NTT、多项式求逆、拉格朗日插值、矩阵递推 NTT、多项式 exp
九、搜索与其他技巧 DFS/BFS、双向 BFS、IDA*、A*、剪枝、模拟退火、位运算、贪心模型、CDQ 迭代加深、模拟退火
十、附录 STL 速查、常用数学公式、时间复杂度参考、比赛与调试技巧

如何高效使用本文档

  1. 先看各部分的「本部分速查表」定位算法,再跳到对应小节看模板。
  2. 模板以「能直接抄」为标准编写,赛前建议背诵核心数据结构(线段树、Dinic、Tarjan、SAM 等)。
  3. 「注意点 / 坑点」与「可拓展的地方」是拉开差距的关键,建议通读。
  4. 第十部分附录包含 STL 速查、调试、对拍、卡常等实战技巧,比赛前过一遍。

目录

一、算法基础

本部分速查表

算法 复杂度 适用场景
1.1 快速输入输出 O(读入字符数) 输入量 ≥ 1e6 时提速,fread 块读最快
1.2 二分查找 O(log n) 有序序列查找、二分答案(最优化问题转判定问题)
1.3 三分法 O(log n) 单峰 / 单谷函数在区间上求极值
1.4 前缀和与差分 预处理 O(n)(二维 O(nm)),查询 O(1) 区间 / 子矩阵和快速查询、区间批量加最后一次性还原
1.5 高精度运算 加减 O(n),乘 O(n²),除低精 O(n) 数据超出 long long(约 9.2e18)范围的大整数运算
1.6 离散化 O(n log n) 值域很大(如 1e9)但不同取值很少,映射到 0..m-1
1.7 双指针与尺取法 O(n) 区间统计量随右端点单调变化的滑动窗口、有序数组两数之和
1.8 归并排序与逆序对 O(n log n) 排序、统计逆序对数量(答案需用 long long)

1.1 快速输入输出

一句话用途:当输入数据量很大(通常 ≥ 1e6 个数)时,用 getchar / fread 手写快读代替 scanf、cin,可显著减少 IO 时间开销,是卡常题(时间限制苛刻的题目)的必备手段;快写模板则避免逐字符 cout 的开销。

算法思想:cin / scanf 内部有格式化解析、类型检查等额外开销,且 cin 默认与 scanf 保持同步、每次读入都涉及缓冲刷新。手写快读的本质是直接用 getchar 读入单个字符,自己完成「跳过空白、处理负号、逐位累加」的解析;fread 块读则一次性把一大块数据读进内存缓冲区,再在内存里逐字符解析,把系统调用次数降到最低。

复杂度:时间 O(读入字符数);空间 O(1)(fread 版额外 O(缓冲区大小))。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

/* ========== 模板一:getchar 逐字符快读(推荐日常使用) ========== */
inline int read() {
int x = 0, f = 1;
char c = getchar();
while (c < '0' || c > '9') { // 跳过空白符、字母等一切非数字字符
if (c == '-') f = -1; // 遇到负号记录符号
c = getchar();
}
while (c >= '0' && c <= '9') { // 逐位累加:x = x * 10 + digit
x = x * 10 + (c - '0');
c = getchar();
}
return x * f;
}

/* ========== 模板二:fread 块读(数据量极大时最快) ========== */
const int BS = 1 << 20; // 缓冲区大小 1MB
char buf[BS], *p1 = buf, *p2 = buf; // p1 当前读位置,p2 缓冲末尾
inline char getc() {
// 缓冲读空时用 fread 重新填满;读到文件尾返回 EOF
if (p1 == p2 && (p2 = (p1 = buf) + fread(buf, 1, BS, stdin)) == p1)
return EOF;
return *p1++;
}
inline int read2() {
int x = 0, f = 1;
char c = getc();
while (c < '0' || c > '9') {
if (c == '-') f = -1;
c = getc();
}
while (c >= '0' && c <= '9') {
x = x * 10 + (c - '0');
c = getc();
}
return x * f;
}

/* ========== 快写模板(先写进缓冲区,最后一次性输出) ========== */
char obuf[BS], *op = obuf;
inline void writeInt(int x) {
if (x < 0) { *op++ = '-'; x = -x; }
char s[12]; int top = 0; // int 最多 10 位,12 足够
if (x == 0) s[top++] = '0'; // 特判 0,否则不会输出任何数字
while (x > 0) { s[top++] = char('0' + x % 10); x /= 10; }
while (top > 0) *op++ = s[--top]; // 倒序写入缓冲区
*op++ = '\n'; // 末尾换行
}

int main() {
int n = read(); // 样例:读入 n 个数并输出
while (n--) writeInt(read());
fwrite(obuf, 1, op - obuf, stdout); // 主函数结束前一次性刷出
return 0;
}

注意点 / 坑点

  1. 快读遇到 x = -2147483648(INT_MIN)时 -x 会溢出;此时应改用 long long 读入,或单独特判。绝大多数题目的数据用 int 快读即可。
  2. 快读依赖字符判断,有负数时必须处理 -,否则负号会被当作非数字跳过;若输入含小数、字母或特殊标记,需要自己扩展解析逻辑。
  3. fread 缓冲区建议 ≥ 1<<20;过小会导致频繁调用 fread 反而变慢。getc 里的三元表达式依赖短路求值,切勿拆行改写。
  4. 快写缓冲区在 main 结束前必须 fwrite(obuf, 1, op - obuf, stdout) 刷出,否则数据丢失;op - obuf 是本次写入的字节数。
  5. 只有输入量足够大(约 ≥ 1e6)才值得用快读。数据量小时 scanf / cin 更简洁不易错;ios::sync_with_stdio(false); cin.tie(nullptr); 关闭同步后 cin 的速度接近 scanf,多数题目够用且能直接读 string,比快读方便。

可拓展的地方

  1. 可扩展为读 long long、读 double、按行读入(配合 fgets)等变体:把 int 换成 ll 并同步修改累加式即可。
  2. fread 缓冲区可开到 1<<22 甚至更大,Linux 下可换成 mmap 进一步减少系统调用,适合极限卡常。
  3. 可将快读封装成类并重载 >> 运算符,调用方式和 cin 一样自然,团队代码更整洁。

典型例题类型

  1. 输入规模上百万甚至千万的模拟题 / 图论题(如「洛谷 P1957 口算练习题」可练习快读)。
  2. 多组大数据、大量查询的题目;时间限制 1s 内的卡常题。

1.2 二分查找

一句话用途:在单调序列中快速定位满足条件的第一个 / 最后一个位置;更常用的是「二分答案」——把求最值问题转化为「判定某个答案是否可行」,是竞赛中出现频率最高的基础算法之一。

算法思想:二分要求搜索空间具有单调性(数组有序,或判定函数 check(x) 随 x 单调变化)。每次取区间中点 mid,根据 a[mid] 与目标的关系丢掉不可能的一半区间,区间长度每次减半,因此 O(log n) 次收敛。整数二分的核心坑点是「mid 的取整方向必须与区间收缩方式配套」:收缩写为 r = mid(mid 可能是答案、要保留)时 mid 必须向下取整 (l+r)/2;收缩写为 l = mid 时 mid 必须向上取整 (l+r+1)/2,否则当 l 与 r 相邻时区间不缩小,死循环。

复杂度:时间 O(log n);空间 O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

/* ===== 整数二分模板一:求第一个 >= x 的位置(数组 a 升序,返回下标) =====
收缩规则:a[mid] >= x 时 mid 可能是答案,令 r = mid 保留它;
此时 mid 必须用 (l + r) / 2 向下取整。若错误地向上取整,当
l + 1 == r 时 mid == r,条件成立则 r = mid 不变,死循环。 */
int firstGE(const vector<int>& a, int x) {
int l = 0, r = (int)a.size() - 1;
if (a.empty()) return -1; // 空数组特判,防止 a[l] 越界
while (l < r) {
int mid = (l + r) / 2; // 向下取整,配套 r = mid
if (a[mid] >= x) r = mid; // 答案在 [l, mid] 内
else l = mid + 1; // a[mid] < x,mid 及左侧全部排除
}
return a[l] >= x ? l : -1; // 结束时 l == r,检查是否满足
}

/* ===== 整数二分模板二:求最后一个 <= x 的位置(数组 a 升序) =====
收缩规则:a[mid] <= x 时 mid 可能是答案,令 l = mid 保留它;
此时 mid 必须用 (l + r + 1) / 2 向上取整。若错误地向下取整,当
l + 1 == r 时 mid == l,条件成立则 l = mid 不变,死循环。 */
int lastLE(const vector<int>& a, int x) {
int l = 0, r = (int)a.size() - 1;
if (a.empty()) return -1;
while (l < r) {
int mid = (l + r + 1) / 2; // 向上取整,配套 l = mid
if (a[mid] <= x) l = mid; // 答案在 [mid, r] 内
else r = mid - 1; // a[mid] > x,mid 及右侧全部排除
}
return a[l] <= x ? l : -1;
}

/* ===== 实数二分:求单调函数 f 满足 check 的临界点 ===== */
// 写法一:按 eps 控制精度(精度要求 1e-6 时 eps 取 1e-8 才稳)
bool check(double mid); // 按题目实现的判定函数
double bsEps(double l, double r) {
while (r - l > 1e-8) { // 区间长度小于 eps 停止
double mid = (l + r) / 2;
if (check(mid)) r = mid; // 满足条件的区间在左侧
else l = mid;
}
return (l + r) / 2;
}
// 写法二:固定迭代 60~100 次(推荐,避免 eps 与浮点误差纠缠)
double bsIter(double l, double r) {
for (int i = 0; i < 80; ++i) { // 每次区间减半,80 次足够精确到 1e-20
double mid = (l + r) / 2;
if (check(mid)) r = mid;
else l = mid;
}
return (l + r) / 2;
}

/* ===== 二分答案两种套路(check 是 O(n) 的判定函数) ===== */
// 套路一:最大化最小值 —— 求最大的 x 使 check(x) 为真(x 越大越难满足)
// 答案是「可行域的上界」,收缩用 l = mid,mid 需向上取整
int maxMin() {
int l = 0, r = 1e9; // 上下界按题目范围设定
while (l < r) {
int mid = (l + r + 1) / 2; // 配套 l = mid,向上取整
if (check(mid)) l = mid; // mid 可行,答案至少为 mid
else r = mid - 1;
}
return l;
}
// 套路二:最小化最大值 —— 求最小的 x 使 check(x) 为真(x 越大越容易满足)
// 答案是「可行域的下界」,收缩用 r = mid,mid 需向下取整
int minMax() {
int l = 0, r = 1e9;
while (l < r) {
int mid = (l + r) / 2; // 配套 r = mid,向下取整
if (check(mid)) r = mid; // mid 可行,答案至多为 mid
else l = mid + 1;
}
return l;
}

注意点 / 坑点

  1. 死循环是整数二分第一大坑:牢记「l = mid(l+r+1)/2r = mid(l+r)/2」。拿不准时用「ans 记录法」——满足条件就记录答案并收缩,最后返回 ans,从根上避免死循环。
  2. 边界问题:l + r 可能溢出 int(l、r 都接近 2e9 时),写成 l + (r - l) / 2 更安全;区间端点(r 取 n-1 还是 n)必须与检查逻辑一致,建议写完后用 n=1、n=2 的小数据手推一遍。
  3. 二分答案前先确认 check 的单调性:「最大化最小值」要求「x 可行 ⇒ 更小的 x 也可行」,「最小化最大值」要求「x 可行 ⇒ 更大的 x 也可行」。单调性不成立就不能二分,需换三分或 DP。
  4. 实数二分用 eps 时,eps 过小会导致循环次数暴增甚至因浮点误差死循环;固定迭代 60~100 次最稳,且精度更高。
  5. 二分答案总复杂度是 O(log V × check 复杂度)(V 为值域),check 必须实现到 O(n) 或更优,否则直接 TLE。

可拓展的地方

  1. 整数二分可改用 STL 的 lower_bound / upper_bound(返回迭代器),代码更简洁;但「值域上二分答案」必须手写。
  2. 二分答案常与贪心结合(check 用贪心判定,如分段、装水、派分),也可与 DP 判定结合(check 里跑一次 DP)。
  3. 可推广到「凸函数 / 凹函数」的求值域问题;多维参数可对每个参数分别二分(多次嵌套二分)。

典型例题类型

  1. 有序序列查找类:「洛谷 P2249 查找」(求第一次出现的位置,即 firstGE)。
  2. 最大化最小值:「洛谷 P1824 进击的奶牛」(牛棚间最小距离最大,二分答案 + 贪心 check)。
  3. 最小化最大值:「洛谷 P1182 数列分段 Section II」(分段和最大值最小);实数二分:「POJ 1064 Cable master」(切绳子)。

1.3 三分法

一句话用途:在单峰(先增后减)或单谷(先减后增)函数上求极值点,典型应用如求「最优角度、最优位置」使某个距离、时间、代价函数取到最值。

算法思想:三分针对「先严格递增后严格递减」(或相反)的函数:取区间两个三等分点 m1、m2,比较 f(m1) 与 f(m2)。若 f(m1) < f(m2),说明峰在 m1 右侧,丢掉 [l, m1);否则峰在 m2 左侧,丢掉 (m2, r]。每轮区间长度缩小为原来的 2/3,因此 O(log n) 收敛。注意前提是函数严格单峰:若函数存在平台(相等的连续段)或本身单调,三分可能失效。

复杂度:时间 O(log n)(每轮区间变为 2/3);空间 O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

/* 示例:开口向下的二次函数,峰在 x = 3,极大值为 5 */
double f(double x) {
return -(x - 3) * (x - 3) + 5;
}

/* ========== 实数三分(求极大值点) ========== */
double ternaryReal(double l, double r) {
for (int i = 0; i < 100; ++i) { // 固定迭代 100 次,比 eps 写法稳
double m1 = l + (r - l) / 3; // 左三等分点
double m2 = r - (r - l) / 3; // 右三等分点
if (f(m1) < f(m2)) l = m1; // 峰在 m1 右侧,丢掉 [l, m1)
else r = m2; // 峰在 m2 左侧,丢掉 (m2, r]
}
return (l + r) / 2; // 极值点;要极值就再算一次 f
}

/* ========== 整数三分(定义域是整数点,求极大值) ========== */
int g(int x) {
return -(x - 3) * (x - 3) + 5; // 离散的单峰函数示例
}
int ternaryInt(int l, int r) {
while (r - l > 3) { // 区间长度超过 3 时继续三分
int m1 = l + (r - l) / 3;
int m2 = r - (r - l) / 3;
if (g(m1) < g(m2)) l = m1;
else r = m2;
}
int ans = g(l); // 剩余长度 ≤ 3,暴力扫一遍取最值
for (int i = l + 1; i <= r; ++i) ans = max(ans, g(i));
return ans;
}

int main() {
double x = ternaryReal(0, 10); // 求极大值点
printf("peak at x = %.6f, f = %.6f\n", x, f(x));
printf("int peak = %d\n", ternaryInt(0, 10));
return 0;
}

注意点 / 坑点

  1. 三分要求函数严格单峰(单谷):函数值不能出现平台,否则 f(m1) == f(m2) 时不知道往哪边收缩,可能丢掉极值点。
  2. 先确认是「求极大值」还是「求极小值」:模板中 f(m1) < f(m2) 对应极大值;求极小值时改成 f(m1) > f(m2) 即可(或对函数取负号)。写反会收敛到错误的端点。
  3. 整数三分结束时区间还可能剩 2~3 个点,必须暴力枚举剩余点取最值,不能直接返回 l;实数三分迭代次数太少精度不够,建议 100 次。
  4. 三分只适合单峰函数:函数单调时用二分;多峰时三分不保证全局最优,需考虑模拟退火等启发式方法。

可拓展的地方

  1. 三分套三分:二元函数求极值(如平面上找一点到多个点距离和最小)可对 x、y 两个维度分别三分。
  2. 与二分结合:先三分找极值点,再在两侧二分求零点;也可把三分结果作为初值做牛顿迭代加速收敛。
  3. 整数三分可处理「凸函数 / 凹函数」上的离散最值问题;多维凸函数的坐标下降思想也可视为三分思想的推广。

典型例题类型

  1. 「洛谷 P3382 三分法」(模板题:给定单峰函数求极大值)。
  2. 最优角度 / 最优速度类物理题(如发射角度使射程最大,函数单峰)。
  3. 「平面上找一点到多个点距离和最小」(三分套三分,费马点类问题)。

1.4 前缀和与差分

一句话用途:前缀和 O(1) 回答任意区间 / 子矩阵的求和查询;差分 O(1) 完成区间 / 子矩阵的批量加(最后用前缀和还原)。两者互为逆运算:对差分数组求前缀和还原出原数组,对原数组求前缀和得到可 O(1) 查询的求和结构。

算法思想:前缀和 pre[i] 记录 a[1..i] 的和,区间 [l, r] 的和 = pre[r] - pre[l-1];二维用容斥:S(x2,y2) - S(x1-1,y2) - S(x2,y1-1) + S(x1-1,y1-1)。差分数组 d[i] = a[i] - a[i-1](定义 d[1] = a[1]),对 d 求前缀和即可还原 a;于是「区间 [l, r] 加 v」等价于「d[l] += v; d[r+1] -= v」两个 O(1) 修改,全部操作结束后一次性求前缀和还原,把多次区间修改从 O(n) 降到 O(1) 一次。

复杂度:一维前缀和预处理 O(n)、查询 O(1);二维预处理 O(nm)、查询 O(1);差分修改 O(1)、还原 O(n)(二维 O(nm));空间 O(n)(二维 O(nm))。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 100005;

/* ========== 一维前缀和:区间求和 O(1) ========== */
int n, q;
ll a[MAXN], pre[MAXN]; // 数据可能很大,用 long long

void build1D() {
pre[0] = 0; // 哨兵:pre[0] = 0 使公式统一
for (int i = 1; i <= n; ++i)
pre[i] = pre[i - 1] + a[i];
}
ll rangeSum(int l, int r) { // 下标从 1 开始
return pre[r] - pre[l - 1]; // 容斥:减去 l 之前的和
}

/* ========== 二维前缀和:子矩阵求和 O(1) ========== */
const int MAXM = 1005;
ll s[MAXN][MAXM];

void build2D(int n, int m) { // a[i][j] 从 (1,1) 开始存
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j)
s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + a[i][j];
}
ll subSum(int x1, int y1, int x2, int y2) { // 子矩阵 (x1,y1)-(x2,y2) 的和
return s[x2][y2] - s[x1 - 1][y2] - s[x2][y1 - 1] + s[x1 - 1][y1 - 1];
}

/* ========== 一维差分:区间 [l, r] 加 v,最后统一还原 ========== */
ll d[MAXN]; // 差分数组,初始全 0
void addRange(int l, int r, ll v) { // 每次区间加 O(1)
d[l] += v;
d[r + 1] -= v; // r+1 处减掉,保证只影响 [l, r]
}
void restore1D() { // 全部操作结束后还原
for (int i = 1; i <= n; ++i) {
d[i] += d[i - 1]; // 差分求前缀和得到每个位置的增量
a[i] += d[i];
}
}

/* ========== 二维差分:子矩阵 (x1,y1)-(x2,y2) 加 v ========== */
ll d2[MAXN][MAXM];
void addRect(int x1, int y1, int x2, int y2, ll v) {
d2[x1][y1] += v;
d2[x2 + 1][y1] -= v;
d2[x1][y2 + 1] -= v;
d2[x2 + 1][y2 + 1] += v; // 四个角的修改,容斥原理
}
void restore2D(int n, int m) { // 对 d2 求二维前缀和还原
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= m; ++j) {
d2[i][j] += d2[i - 1][j] + d2[i][j - 1] - d2[i - 1][j - 1];
a[i][j] += d2[i][j];
}
}

注意点 / 坑点

  1. 下标从 1 开始存,0 位置留作哨兵:pre[0] = 0、差分越界处(如 r+1 恰好是 n+1)天然安全,避免一堆 if 判断;从 0 开始存时边界极易写错。
  2. 求和结果可能远超 int:n 个数每个最大 1e9,区间和最大 1e14,必须用 long long(差分还原的增量多次累加也可能很大)。
  3. 二维前缀和 / 差分的容斥符号极易写错:用 2×2 小矩阵手推一遍验证;注意多减的 s[x1-1][y1-1] 要加回来。
  4. 差分只适合「先全部修改、后统一查询」的离线场景;若修改与查询交替出现,需用树状数组 / 线段树(BIT 配合差分可支持区间加 + 单点查询)。
  5. 下标从 1 开始时查询 [l, r] 是 pre[r] - pre[l-1],不是 pre[r] - pre[l](后者是 (l, r] 的和),这是经典笔误。

可拓展的地方

  1. 前缀和思想可推广到「前缀最大值 / 前缀 gcd / 前缀异或」等满足结合律的运算,二维同样适用。
  2. 树状数组 + 差分 = 「区间加 + 单点查询」;两个树状数组可支持「区间加 + 区间求和」,比线段树短小。
  3. 差分思想可用于树上(树上差分:路径加转成四个点的差分修改)与字符串哈希(前缀哈希 O(1) 判断子串相等)。
  4. 可推广到三维差分(体素批量加),思路一致,竞赛中偶见于立体覆盖类题目。

典型例题类型

  1. 「洛谷 P8218 求区间和」(一维前缀和模板题)。
  2. 「洛谷 P3397 地毯」(二维差分模板:多次矩形加后求每个点被覆盖次数)。
  3. 「洛谷 P4552 IncDec Sequence」(差分思维题:用区间加把序列变相等,求最小操作次数);树上差分见「洛谷 P3128 最大流」。

1.5 高精度运算

一句话用途:当数字超出 long long(约 ±9.2e18)的范围时,用「大整数」支持任意长度的加减乘除取模。常见于大数阶乘、组合数、大数斐波那契,以及需要精确计算的小数(转成整数运算)类题目。

算法思想:用 vector<int> 按「低位在前」存储大整数(a[0] 是个位,a.back() 是最高位),每一位只存 0~9 的一个十进制数字。所有运算都模拟手算竖式:加法从低位逐位相加并处理进位;减法逐位相减并处理借位;乘法双重循环累加后统一进位;除法(除低精)从最高位逐位试商。输出时倒序打印,并去掉前导零。

复杂度:设 a、b 位数分别为 n、m:加减 O(max(n,m)),高精乘高精 O(nm),高精乘低精 / 除低精 / 取模 O(n);空间 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

using BigInt = vector<int>; // 低位在前:a[0] 是个位

/* ---------- 字符串读入 ---------- */
BigInt fromString(const string& s) {
BigInt a;
for (int i = (int)s.size() - 1; i >= 0; --i)
a.push_back(s[i] - '0'); // 倒序逐位存入
return a;
}

/* ---------- 比较大小:a < b 返回 -1,a == b 返回 0,a > b 返回 1 ---------- */
int cmp(const BigInt& a, const BigInt& b) {
if (a.size() != b.size())
return a.size() < b.size() ? -1 : 1; // 位数多的大
for (int i = (int)a.size() - 1; i >= 0; --i) // 从最高位往下比
if (a[i] != b[i])
return a[i] < b[i] ? -1 : 1;
return 0;
}

/* ---------- 高精 + 高精 ---------- */
BigInt add(const BigInt& a, const BigInt& b) {
BigInt c;
int carry = 0; // 进位
for (size_t i = 0; i < max(a.size(), b.size()); ++i) {
int s = carry;
if (i < a.size()) s += a[i];
if (i < b.size()) s += b[i];
c.push_back(s % 10);
carry = s / 10;
}
if (carry) c.push_back(carry); // 最高位进位,如 99 + 1 = 100
return c;
}

/* ---------- 高精 - 高精(要求 a >= b,调用前用 cmp 判断) ---------- */
BigInt sub(const BigInt& a, const BigInt& b) {
BigInt c;
int borrow = 0; // 借位
for (size_t i = 0; i < a.size(); ++i) {
int s = a[i] - borrow - (i < b.size() ? b[i] : 0);
if (s < 0) { s += 10; borrow = 1; } // 不够减,向高位借 1
else borrow = 0;
c.push_back(s);
}
while (c.size() > 1 && c.back() == 0) c.pop_back(); // 去前导零
return c;
}

/* ---------- 高精 * 低精(b 用 long long,b 本身不能溢出) ---------- */
BigInt mulSmall(const BigInt& a, ll b) {
BigInt c;
ll carry = 0;
for (size_t i = 0; i < a.size(); ++i) {
ll s = a[i] * b + carry; // 单次乘积 ≤ 9e9,必须 long long
c.push_back((int)(s % 10));
carry = s / 10;
}
while (carry) { c.push_back((int)(carry % 10)); carry /= 10; }
while (c.size() > 1 && c.back() == 0) c.pop_back();
return c;
}

/* ---------- 高精 * 高精(朴素 O(nm),先累加后统一进位) ---------- */
BigInt mul(const BigInt& a, const BigInt& b) {
BigInt c(a.size() + b.size() + 1, 0); // 多留一位装最高位进位
for (size_t i = 0; i < a.size(); ++i)
for (size_t j = 0; j < b.size(); ++j)
c[i + j] += a[i] * b[j];
for (size_t i = 0; i + 1 < c.size(); ++i) { // 逐位进位,一次扫描足够
c[i + 1] += c[i] / 10;
c[i] %= 10;
}
while (c.size() > 1 && c.back() == 0) c.pop_back(); // 去掉多余的最高位
return c;
}

/* ---------- 高精 / 低精:返回商,余数通过引用带出 ---------- */
BigInt divSmall(const BigInt& a, ll b, ll& rem) {
BigInt q(a.size(), 0); // 商的位数 ≤ 被除数位数
rem = 0;
for (int i = (int)a.size() - 1; i >= 0; --i) { // 从最高位开始试商
rem = rem * 10 + a[i];
q[i] = (int)(rem / b); // 商直接写在对应位
rem %= b;
}
while (q.size() > 1 && q.back() == 0) q.pop_back(); // 去前导零
return q;
}

/* ---------- 高精 % 低精(取模) ---------- */
ll modSmall(const BigInt& a, ll b) {
ll r = 0;
for (int i = (int)a.size() - 1; i >= 0; --i)
r = (r * 10 + a[i]) % b; // 边除边取余即可
return r;
}

/* ---------- 输出 ---------- */
void print(const BigInt& a) {
for (int i = (int)a.size() - 1; i >= 0; --i)
putchar('0' + a[i]); // 从最高位打印
putchar('\n');
}

int main() {
string sa, sb;
cin >> sa >> sb;
print(add(fromString(sa), fromString(sb))); // 大整数加法示例
return 0;
}

注意点 / 坑点

  1. 必须低位在前(a[0] 个位):加减法需要从低位对齐,乘法下标 c[i+j] 的推导也依赖它;高位在前会导致下标错位、进位方向混乱。
  2. 减法、乘法、除法做完都要去前导零(while (size > 1 && back == 0) pop_back()),否则会输出 00123 这类错误;但结果真的为 0 时要保留一位 0。
  3. 减法必须保证 a ≥ b,调用前用 cmp 判断,必要时交换并记录符号;乘法 c[i+j] += a[i]*b[j] 累加值最大约 81×min(n,m),位数在 1e6 以内 int 够用,再大建议用 long long 累加或压位。
  4. 高精乘低精 / 除低精时 b 若超过 1e9,需把 b 拆成多段处理;读入字符串要注意处理负号与前导零(如 “00012” 应存成 12)。
  5. 复杂度陷阱:朴素高精乘高精是 O(n²),位数到 1e5 级别必须换 FFT / NTT;竞赛中「压位」(每 4 位存一个 int,基 10000)能常数级提速,是常用优化。

可拓展的地方

  1. 压位优化:每位存 4 个十进制位(基 10000),进位用 /10000、%10000,乘法用 long long 累加,速度提升约 4~10 倍。
  2. 高精除高精:倍增 / 二分试商实现;高精开方、高精 gcd(二进制法)也有成熟套路。
  3. 与快速幂结合做「大数快速幂」(如 2^1000);与组合数、卡特兰数、大数斐波那契结合出题。
  4. 用 FFT / NTT 把高精乘法优化到 O(n log n),处理十万位以上乘法(如「洛谷 P1919 模板 A×B Problem 升级版」)。

典型例题类型

  1. 「洛谷 P1601 A+B Problem 高精版」「洛谷 P1303 A×B Problem」——大整数加减乘模板题。
  2. 大数阶乘(如「洛谷 P1009 阶乘之和」:高精乘低精 + 高精加法)。
  3. 大数取模(配合快速幂算大指数取模,底数是 10^100 级别的大数)。

1.6 离散化

一句话用途:当数值范围很大(如 1e9)但不同取值个数很少(如 ≤ 1e5)时,把每个值映射成 0..m-1 的连续整数,从而能用「值域下标」的数组、树状数组、线段树来处理,是值域压缩的基础手段。

算法思想:把所有出现过的值收集起来,排序后去重得到有序数组 vals,那么「x 的排名」就是 lower_bound(vals, x) - vals.begin()(x 必然在 vals 中)。映射保持相对大小顺序不变,因此任何只关心大小关系的问题(求逆序对、找第 k 小、值域计数)都可以先离散化再做;反查原值只需 vals[id]

复杂度:时间 O(n log n)(排序 + 去重 + n 次 lower_bound);空间 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

vector<int> vals; // 去重后的有序值,全局使用

void discretize(const vector<int>& a) {
vals = a;
sort(vals.begin(), vals.end()); // 1. 排序
vals.erase(unique(vals.begin(), vals.end()), vals.end()); // 2. 去重(unique + erase 是标准写法)
}

int id(int x) { // 3. 原值 -> 排名 0..m-1
return (int)(lower_bound(vals.begin(), vals.end(), x) - vals.begin());
}

int main() {
vector<int> a = {1000000000, 5, 5, -3, 42};
discretize(a); // vals = {-3, 5, 42, 1000000000}
for (int x : a) printf("%d -> %d\n", x, id(x)); // 5->1, -3->0, 42->2, 1e9->3
return 0;
}

配合树状数组 / 线段树(典型:离散化 + 树状数组求逆序对)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* 思路:树状数组下标范围是 1..m(m 为不同值个数)。从前往后扫描原数组,
每次先查询「已插入且比 x 大的元素个数」累加进答案,再把 x 的排名插入。
值域 1e9 无法直接开数组,必须先离散化。 */
const int MAXN = 100005;
int bit[MAXN];
void add(int i, int v) { for (; i < MAXN; i += i & (-i)) bit[i] += v; }
int query(int i) { int s = 0; for (; i > 0; i -= i & (-i)) s += bit[i]; return s; }
// 逆序对计数主流程(框架,m = vals.size()):
// ll ans = 0;
// for (int i = 0; i < n; ++i) {
// int rk = id(a[i]) + 1; // 树状数组下标从 1 开始
// ans += query(m) - query(rk); // 已插入的、比 a[i] 大的元素个数
// add(rk, 1);
// }

注意点 / 坑点

  1. unique 只把重复元素移到末尾并返回新末尾,必须配合 erase 才能真正删除;忘了 erase 会导致 lower_bound 结果错乱。
  2. 离散化后排名从 0 开始,配合树状数组 / 线段树(下标通常从 1 开始)要记得 +1;若查询的 x 可能不在 vals 中,需先判断 lower_bound 结果是否越界、值是否相等。
  3. 离散化只保留相对大小,丢失绝对数值:后续若还需要原值参与运算(如求和),要用 vals[id] 反查,不要直接拿 id 算。
  4. 去重后 m 最大为 n,树状数组要开到 m 的大小;涉及值域差的中间量用 long long。
  5. 若题目要求离线处理(先读全部输入再回答查询),离散化必须在读入全部数据后一次性完成;在线场景需用动态开点线段树或平衡树。

可拓展的地方

  1. 离散化 + 树状数组:求逆序对、求「比 x 小的个数」(配合二分求第 k 小)、区间不同元素个数(配合按右端点离线排序)。
  2. 离散化 + 权值线段树 / 主席树:静态区间第 k 小(可持久化线段树)是离散化最经典的应用。
  3. 可与莫队、扫描线结合:坐标离散化后线段树维护覆盖次数;也可把浮点坐标离散化处理实数位置问题。

典型例题类型

  1. 「洛谷 P1908 逆序对」(离散化 + 树状数组经典模板,或归并排序)。
  2. 「洛谷 P3834 可持久化线段树 2」(静态区间第 k 小,先离散化再建主席树)。
  3. 「洛谷 P1955 程序自动分析」(离散化 + 并查集:值域 1e9 但约束只有 1e5 个)。

1.7 双指针与尺取法

一句话用途:用两个指针(一前一后滑动或一左一右相向)在 O(n) 时间内扫描数组,解决「满足条件的连续子段」类问题,如最短 / 最长子段、子段和、两数之和;核心前提是区间的某种统计量随指针移动单调变化。

算法思想:尺取法(滑动窗口)维护区间 [l, r]:右指针 r 不断右移「扩张」窗口,一旦窗口满足条件就尝试左移 l「收缩」窗口并统计答案。因为统计量单调(如区间和随 r 增大而增大、随 l 增大而减小),每个指针最多移动 n 次,总复杂度 O(n)。「两数之和」类则是左右指针相向移动:和太小右移左指针增大,和太大左移右指针减小。指针永远单向移动、绝不回退,是 O(n) 复杂度的根本保证。

复杂度:时间 O(n)(每个指针至多移动 n 次);空间 O(1)(不计输入存储)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

/* ===== 尺取法:求「和 >= S」的最短连续子段长度(POJ 3061) ===== */
int shortestSubarray(vector<int>& a, int S) {
int n = (int)a.size();
int l = 0, sum = 0, ans = n + 1; // ans 初始为不可能值,表示暂无答案
for (int r = 0; r < n; ++r) { // 右指针扩张窗口
sum += a[r]; // 窗口变为 [l, r]
while (sum >= S) { // 当前窗口满足条件
ans = min(ans, r - l + 1); // 记录长度,并尝试更短
sum -= a[l]; // 左指针收缩:去掉 a[l]
++l;
}
}
return ans == n + 1 ? -1 : ans; // 无解返回 -1
}

/* ===== 两数之和:有序数组找 i < j 使 a[i] + a[j] == target ===== */
bool twoSum(vector<int>& a, int target) {
int l = 0, r = (int)a.size() - 1; // 左右指针相向移动
while (l < r) {
ll s = (ll)a[l] + a[r]; // long long 防止溢出
if (s == target) return true;
else if (s < target) ++l; // 和太小:左指针右移使和变大
else --r; // 和太大:右指针左移使和变小
}
return false;
}

/* ===== 滑动窗口通用框架:求「满足条件的最长子段」 =====
例:窗口内不同字符不超过 K 个的最长子串(cnt 统计频次) */
int longestWindow(const string& s, int K) {
int cnt[26] = {0}, diff = 0, l = 0, ans = 0;
for (int r = 0; r < (int)s.size(); ++r) {
if (++cnt[s[r] - 'a'] == 1) ++diff; // 新字符出现,不同种类 +1
while (diff > K) { // 不满足条件:收缩左端点
if (--cnt[s[l] - 'a'] == 0) --diff;
++l;
}
ans = max(ans, r - l + 1); // 收缩后窗口重新满足条件
}
return ans;
}

注意点 / 坑点

  1. 适用前提是「区间单调性」:固定 l 时,随着 r 右移,统计量(和、种类数等)单调不减(或不增)。若统计量可能回退(如区间最大值),就不能用尺取,需考虑单调队列 / 线段树。
  2. 想清楚「满足条件时收缩」还是「不满足时收缩」:求「最短满足条件」是满足时收缩左端点;求「最长满足条件」是不满足时收缩。写反会得到错误答案。
  3. while 中每收缩一次 l 都要同步更新统计量(sum -= a[l]、cnt 减 1),漏更新会导致后续判断错误;收缩到空区间(l > r)是合法的,空窗口一般不会误触发条件。
  4. 和可能溢出 int:a[l] + a[r] 建议转 long long;「无解」的返回值要选不可能达到的值(如 n+1 或 -1)来区分,别和合法答案混淆。
  5. 两数之和要求数组有序:无序时要么先排序(O(n log n)),要么用哈希表 O(n) 记录已见元素。

可拓展的地方

  1. 与单调队列结合维护窗口内「最大值 / 最小值」(滑动窗口最值,洛谷 P1886),此时统计量无需满足尺取的单调性。
  2. 双指针还可用于「合并两个有序数组」「链表找环(快慢指针)」「原地去重」「区间覆盖最少点」等,是一大类技巧。
  3. 尺取可配合「容斥(f(≤K) - f(≤K-1))」处理「恰好 K 个不同元素」类约束;也可与「前缀和 + 二分」结合。
  4. 双指针嵌套可解「三数之和」类问题:固定一个指针后对剩余部分双指针,O(n²)。

典型例题类型

  1. 「POJ 3061 Subsequence」(尺取法模板题:和 ≥ S 的最短子段)。
  2. 「洛谷 P1638 逛画展」「洛谷 P1381 单词背诵」(滑动窗口统计类)。
  3. 「LeetCode 3 无重复字符的最长子串」(最长满足条件窗口);「洛谷 P1102 A-B 数对」(双指针 / 二分统计类)。

1.8 归并排序与逆序对

一句话用途:归并排序是稳定、O(n log n) 的排序算法,同时能在排序过程中顺便统计「逆序对」数量(i < j 且 a[i] > a[j] 的数对),是求解逆序对问题的两大经典方法之一(另一方法是树状数组 + 离散化,见 1.6)。

算法思想:归并排序采用分治:把区间 [l, r] 从中间分成两半,递归排序左右,再合并两个有序段。合并时左右两段都已有序:若左段元素 a[i] <= 右段 a[j],正常放 a[i];若 a[i] > a[j],说明左段从 i 到 mid 的所有元素(共 mid - i + 1 个)都比 a[j] 大,且它们的原始下标都小于 j,这 (mid - i + 1) 个数对全是逆序对,一次性累加进答案。整个分治过程恰好不重不漏地统计了所有逆序对。

复杂度:时间 O(n log n);空间 O(n)(辅助数组 tmp)。逆序对数量最多 n(n-1)/2(n = 1e5 时约 5e9),答案必须用 long long。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 500005;

int a[MAXN], tmp[MAXN];
ll invCnt = 0; // 逆序对计数,必须 long long

void mergeSort(int l, int r) {
if (l >= r) return; // 空区间或单元素,天然有序
int mid = (l + r) >> 1; // 中间点(>>1 即除以 2)
mergeSort(l, mid); // 递归排序左半 [l, mid]
mergeSort(mid + 1, r); // 递归排序右半 [mid+1, r]

// ===== 合并两个有序段 [l, mid] 与 [mid+1, r] =====
int i = l, j = mid + 1, k = l; // i 扫左段,j 扫右段,k 写 tmp
while (i <= mid && j <= r) {
if (a[i] <= a[j]) { // 用 <= 保证稳定排序,相等元素不算逆序对
tmp[k++] = a[i++];
} else { // a[i] > a[j]:左段剩余元素全部大于 a[j]
tmp[k++] = a[j++];
invCnt += (mid - i + 1); // a[i..mid] 共 mid-i+1 个都与 a[j] 构成逆序对
}
}
while (i <= mid) tmp[k++] = a[i++]; // 左段有剩余,直接复制
while (j <= r) tmp[k++] = a[j++]; // 右段有剩余,直接复制
for (int p = l; p <= r; ++p) a[p] = tmp[p]; // 写回原数组
}

int main() {
int n;
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); // 下标从 1 开始
mergeSort(1, n);
printf("%lld\n", invCnt);
return 0;
}

注意点 / 坑点

  1. 逆序对数量最大 n(n-1)/2,n = 1e5 时约 5e9 超出 int(约 21 亿),计数变量和输出都必须用 long long(%lld)。
  2. 合并比较用 a[i] <= a[j] 而不是 <:用 < 会把相等的元素误判为逆序对,且破坏稳定性。题目若要求「严格大于才计逆序对」,等于的情况绝不能计数。
  3. 递归边界 if (l >= r) return; 处理空区间和单元素;合并完成后必须把 tmp 写回 a,否则上层合并时用的是未排序数据,结果错乱。
  4. 递归深度 O(log n) 不会爆栈,但辅助数组 tmp 要与 a 等大;输入量很大时配合快读(见 1.1)。
  5. 也可用「树状数组 + 离散化」求逆序对(见 1.6):按原顺序扫描,查询已插入的比当前元素大的个数累加,再插入当前元素,复杂度同为 O(n log n);树状数组法可在线处理。

可拓展的地方

  1. 归并排序可统计「顺序对」:合并时每放一个左段元素 a[i],右段已放入的 j - mid - 1 个元素都与它构成顺序对(或直接用总数 n(n-1)/2 减去逆序对数)。
  2. 「使序列有序的最少相邻交换次数」等于逆序对数(冒泡排序交换次数),因此归并排序可用于求解最少相邻交换类题目。
  3. 可推广到「归并树」:线段树每个节点存有序数组,支持「区间内比 x 小的元素个数」等查询;逆序对思想还可扩展到二维(按一维排序后对另一维求逆序对)。
  4. 树状数组法支持在线插入、实时查询,适合动态场景;两者结合(分块 + BIT)可处理带修改的逆序对问题。

典型例题类型

  1. 「洛谷 P1908 逆序对」(模板题,归并排序或树状数组 + 离散化均可)。
  2. 「POJ 2299 Ultra-QuickSort」(求使序列有序的最少相邻交换数 = 逆序对数)。
  3. 「洛谷 P3157 动态逆序对」(逆序对 + 数据结构进阶,先掌握静态做法)。

二、数据结构

本部分速查表

算法 复杂度 适用场景
并查集 均摊 O(α(n))(可视为常数) 分组、连通性、带权种类关系、离线回滚
树状数组 单次操作 O(log n) 单点改区间查、差分区间改、双树状数组区间改区间查、逆序对
线段树 单次操作 O(log n),空间 O(4n) 区间加/乘/赋值/查询和与最值,懒标记
权值线段树与动态开点 单次操作 O(log V) 值域统计、动态第 k 小、主席树的基础
可持久化线段树 主席树 预处理 O(n log n),查询 O(log n) 静态区间第 k 小、可持久化问题
ST 表 预处理 O(n log n),查询 O(1) 静态区间最值 RMQ(不支持修改)
单调栈 O(n) 左右第一个更大/更小位置、最大矩形面积
单调队列 O(n) 滑动窗口最值、单调性 DP 优化
堆与对顶堆 push/pop O(log n),取顶 O(1) 动态最值、动态中位数
左偏树 merge O(log n) 可并堆、贪心合并堆
分块 单次操作 O(√n) 区间加区间求和、区间众数等暴力平衡法
莫队 O((n+q)√n),带修 O(n^(5/3)) 离线区间询问
字典树与 01-Trie 插入/查询 O(长度) 前缀统计、最大异或对
珂朵莉树 ODT 均摊 O(m log n)(依赖推平+随机) 区间推平类操作
FHQ Treap 期望 O(log n) 平衡树全家桶、区间翻转(懒标记)
Splay 均摊 O(log n) 区间操作、LCT 基础

2.1 并查集

一句话用途

维护元素的”分组 / 属于哪个集合”关系,支持把两个集合合并、查询两个元素是否同属一个集合;扩展出的带权版本可维护元素之间的相对关系(种类、距离),可撤销版本支持把操作按时间回滚,用于离线问题。

算法思想

把每个集合看成”以根为代表的一棵树”,fa[x] 指向 x 的父节点。find(x) 沿父链找到根,并在返回时做路径压缩:把路径上所有节点直接挂到根上,使后续查找几乎 O(1)。合并时按秩(树的大小 / 高度)把小树挂到大树上,避免树退化成链。带权版本在 find 时同步累加 x 到根的距离;可撤销版本禁止路径压缩,改为把每一步合并记录进栈,回滚时按栈还原。

复杂度

时间:单次 find / unite 均摊 O(α(n))(反阿克曼函数,可视为常数);带权、可撤销版本相同。
空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// ========== 1. 基础并查集:路径压缩 + 按秩合并 ==========
struct DSU {
vector<int> fa, sz; // fa[x] 父节点;sz[x] 集合大小(作秩)
DSU(int n) { // 元素编号 1..n
fa.resize(n + 1);
sz.assign(n + 1, 1);
iota(fa.begin(), fa.end(), 0); // 初始每个元素自成一个集合
}
int find(int x) { // 查根 + 路径压缩
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
bool unite(int x, int y) { // 合并,返回是否真的合并了
x = find(x); y = find(y);
if (x == y) return false; // 已在同一集合
if (sz[x] < sz[y]) swap(x, y); // 按秩合并:小树挂到大树上
fa[y] = x;
sz[x] += sz[y];
return true;
}
bool same(int x, int y) { return find(x) == find(y); }
};

// ========== 2. 带权并查集:维护 x 到根的距离(模 M),用于食物链/种类题 ==========
// 约定:同集合内任意 x, y 满足 (d[y] - d[x]) % M == y 相对 x 的关系值
struct WeightedDSU {
vector<int> fa, d; // d[x] = x 到根的距离(模 M)
int M; // 关系种类数,如食物链 M = 3
WeightedDSU(int n, int mod) : M(mod) {
fa.resize(n + 1);
d.assign(n + 1, 0);
iota(fa.begin(), fa.end(), 0);
}
int find(int x) { // 注意:先递归找根,再累加距离
if (fa[x] == x) return x;
int root = find(fa[x]); // 此时 d[fa[x]] 已是 fa[x] 到根的距离
d[x] = (d[x] + d[fa[x]]) % M; // x 到根 = x 到父 + 父到根
return fa[x] = root; // 路径压缩
}
// 断言 y 相对 x 的关系为 val,即 (d[y] - d[x]) % M == val
void unite(int x, int y, int val) {
int fx = find(x), fy = find(y);
if (fx == fy) return;
// 把 fx 挂到 fy 下,需确定 d[fx]:
// 目标 (d[y] - (d[x] + d[fx])) % M == val
// 解得 d[fx] = d[y] - d[x] - val
fa[fx] = fy;
d[fx] = ((d[y] - d[x] - val) % M + M) % M; // 取正模
}
int relation(int x, int y) { // 查询 y 相对 x 的关系(需同集合)
find(x); find(y); // 先压缩,保证 d 是到根的距离
return ((d[y] - d[x]) % M + M) % M;
}
};

// ========== 3. 可撤销并查集:按秩合并 + 栈记录(离线回滚)==========
struct RollbackDSU {
vector<int> fa, sz;
struct Op { int y, x, szX; }; // 记录:y 被挂到 x 下,合并前 sz[x]
vector<Op> stk;
RollbackDSU(int n) {
fa.resize(n + 1);
sz.assign(n + 1, 1);
iota(fa.begin(), fa.end(), 0);
}
int find(int x) { // 不能路径压缩!否则无法撤销
while (fa[x] != x) x = fa[x];
return x;
}
void unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) { stk.push_back({-1, -1, -1}); return; } // 无效合并也占位
if (sz[x] < sz[y]) swap(x, y);
stk.push_back({y, x, sz[x]});
fa[y] = x;
sz[x] += sz[y];
}
int checkpoint() { return (int)stk.size(); } // 记录当前时间点
void rollback(int tp) { // 回滚到该时间点
while ((int)stk.size() > tp) {
auto [y, x, szX] = stk.back(); stk.pop_back();
if (y == -1) continue; // 跳过无效合并
fa[y] = y; // 还原父子关系与秩
sz[x] = szX;
}
}
};

注意点 / 坑点

  1. find 递归深度在最坏情况下可能达到树高,n 很大(如 1e6)时递归可能爆栈;可改用循环实现 find,或先用按秩合并控制高度。
  2. 带权并查集 find 中必须先递归到根再累加距离,若先压缩再累加会算错;合并公式 d[fx] = d[y] - d[x] - val 建议画图推导、别背错符号;减法结果要取正模((x % M + M) % M)。
  3. 可撤销并查集绝不能用路径压缩,否则回滚无法还原中间状态;必须按秩合并保证单次 find O(log n)。
  4. 食物链类题目关系数取模后,要搞清楚”谁吃谁”对应的 val 方向;断言前先判是否同根,不同根用 unite 建立关系,同根用 relation 核对。
  5. 多组数据记得重新构造结构体,fa 数组要全部重置,否则残留上一组的父子关系。

可拓展的地方

  1. 种类并查集(开 k 倍空间,i、i+n、i+2n 分别表示不同状态)可解”奇偶游戏””关押罪犯”等带矛盾检测的题。
  2. 并查集 + 离线处理:按边权排序后动态加边、配合询问离线,可解决最小生成树性质的连通性问题(Kruskal 重构树的基础)。

典型例题类型

  1. 纯连通性 / 分组:洛谷 P3367【模板】并查集。
  2. 带权种类关系(取模)与带权距离:洛谷 P2024【NOI2001】食物链、POJ 1182;洛谷 P1196【NOI2002】银河英雄传说(维护真实距离与列大小)。
  3. 可撤销并查集(离线回滚 / 二分判定):CF 891C Envy。

2.2 树状数组

一句话用途

维护可”前缀合并”的序列信息(和、异或、最值等),支持单点修改 + 区间查询,一次操作 O(log n);用差分数组实现区间修改;双树状数组实现区间修改 + 区间查询;经典应用是求逆序对。

算法思想

利用 lowbit(i) = i & (-i) 把下标 1..n 组织成若干条链:c[i] 维护区间 (i - lowbit(i), i] 的和。修改 a[i] 时沿 i += lowbit(i) 向上传播;查询前缀和时沿 i -= lowbit(i) 向下累加。任意区间和 = 两个前缀和相减。差分思想把”区间加”变成”两个单点改”,再配合第二棵树维护 i*d[i] 即可同时支持区间改与区间查。

复杂度

时间:单次 add / sum 均 O(log n);求逆序对 O(n log n)。
空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// ========== 1. 单点改 + 区间查 ==========
struct BIT {
int n;
vector<ll> c; // c[i] 维护 (i-lowbit(i), i] 的和
BIT(int n_) : n(n_), c(n_ + 1, 0) {}
void add(int i, ll v) { // a[i] += v,向上传播
for (; i <= n; i += i & -i) c[i] += v;
}
ll sum(int i) { // 前缀和 a[1..i],向下累加
ll s = 0;
for (; i > 0; i -= i & -i) s += c[i];
return s;
}
ll rangeSum(int l, int r) { return sum(r) - sum(l - 1); }
};

// ========== 2. 差分实现区间改 + 单点查 ==========
// 差分思想:d[i] = a[i] - a[i-1],区间 [l, r] 加 v 等价于 d[l] += v, d[r+1] -= v
struct BITDiff {
int n;
vector<ll> d;
BITDiff(int n_) : n(n_), d(n_ + 2, 0) {} // 开 n+2 防止 r+1 越界
void init(const vector<ll>& a) { // 用原数组建差分树
for (int i = 1; i <= n; i++) add(i, a[i] - a[i - 1]);
}
void add(int i, ll v) { for (; i <= n; i += i & -i) d[i] += v; }
void rangeAdd(int l, int r, ll v) { // 区间 [l, r] 加 v
add(l, v);
add(r + 1, -v);
}
ll pointQuery(int i) { // 单点值 = 差分前缀和
ll s = 0;
for (; i > 0; i -= i & -i) s += d[i];
return s;
}
};

// ========== 3. 双树状数组实现区间改 + 区间查 ==========
// 记差分 d[i],则 a[i] = sum(d[1..i]),
// a 的前缀和 = sum_{i=1..x} (x-i+1)*d[i] = (x+1)*sum(d) - sum(i*d[i])
struct BIT2 {
int n;
vector<ll> b1, b2; // b1 存 d[i],b2 存 i*d[i]
BIT2(int n_) : n(n_), b1(n_ + 2, 0), b2(n_ + 2, 0) {}
void add(vector<ll>& b, int i, ll v) {
for (; i <= n; i += i & -i) b[i] += v;
}
ll sum(vector<ll>& b, int i) {
ll s = 0;
for (; i > 0; i -= i & -i) s += b[i];
return s;
}
void rangeAdd(int l, int r, ll v) { // 区间加
add(b1, l, v); add(b1, r + 1, -v);
add(b2, l, v * l); add(b2, r + 1, -v * (r + 1)); // 同步维护 i*d[i]
}
ll preSum(int x) { // a[1..x] 的前缀和
return (x + 1) * sum(b1, x) - sum(b2, x);
}
ll rangeSum(int l, int r) { return preSum(r) - preSum(l - 1); }
};

// ========== 4. 应用:求逆序对 ==========
// 权值树状数组:统计每个值出现次数,按原顺序插入并查询
ll inversionCount(vector<int>& a) {
vector<int> b = a;
sort(b.begin(), b.end());
b.erase(unique(b.begin(), b.end()), b.end()); // 离散化(值压缩到 1..m)
BIT bit((int)b.size());
ll ans = 0;
for (int i = 0; i < (int)a.size(); i++) {
int id = lower_bound(b.begin(), b.end(), a[i]) - b.begin() + 1;
ans += i - bit.sum(id); // 已插入个数 - 小于等于它的个数 = 前面比它大的个数
bit.add(id, 1);
}
return ans;
}

注意点 / 坑点

  1. 树状数组下标必须从 1 开始,0 号位置不使用(lowbit(0) = 0 会死循环);数组大小至少 n+1,差分写法要开到 n+2。
  2. 区间查询 [l, r] 是 sum(r) - sum(l-1),不要把 l 写成 l+1;查询与修改的循环方向(+= / -= lowbit)不能写反。
  3. 差分初始化是 d[i] = a[i] - a[i-1] 而不是直接插入 a[i];r = n 时修改 r+1 处会越界,故数组开大一位。
  4. 逆序对、区间和类答案可能超过 int,务必用 long long;离散化先去重再 lower_bound,保证 id 从 1 开始连续。
  5. 树状数组天然只支持”前缀可合并”信息(和、异或、max 且单调方向固定),求区间 max 时只支持前缀方向,不要与 ST 表混淆。

可拓展的地方

  1. 二维树状数组(把 c 换成二维数组)支持子矩阵加 / 和查询;权值树状数组上倍增可在 O(log n) 内求第 k 小。
  2. 树状数组维护前缀最值可优化 LIS 类 DP;与离线 CDQ 分治结合可处理三维偏序 / 动态逆序对。

典型例题类型

  1. 单点改 + 区间查模板:洛谷 P3374【模板】树状数组 1。
  2. 差分区间改 + 单点查、双树状数组区间改 + 区间查:洛谷 P3368【模板】树状数组 2、P3372(树状数组做法)。
  3. 逆序对:洛谷 P1908 逆序对、POJ 2299 Ultra-QuickSort。

2.3 线段树

一句话用途

把序列的区间信息(和、最值、gcd 等)组织成二叉树,支持单点 / 区间修改与区间查询,每次操作 O(log n);通过懒标记(lazy)可高效完成区间加、区间赋值、区间乘等批量修改。

算法思想

线段树把区间 [1, n] 递归二分,每个节点存对应区间的信息(如区间和)。修改 / 查询时若节点区间被询问区间完全覆盖,则直接返回节点信息(或打懒标记);否则下传懒标记并递归左右儿子,最后 pushup 合并儿子信息。懒标记把”区间整体修改”延迟到必要时才下传到儿子,保证单次操作 O(log n)。

复杂度

时间:建树 O(n),单次修改 / 查询 O(log n)。
空间:O(4n)(4 倍空间;也可动态开点)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 100005;
int n, m;
ll a[MAXN];

// ========== 模板一:区间加 + 区间乘 + 区间和(双懒标记)==========
struct SegTree {
ll sum[MAXN << 2], add[MAXN << 2], mul[MAXN << 2];
void pushup(int p) { sum[p] = sum[p << 1] + sum[p << 1 | 1]; }
void build(int p, int l, int r) {
mul[p] = 1; add[p] = 0; // 乘法标记初始为 1(单位元)
if (l == r) { sum[p] = a[l]; return; }
int mid = (l + r) >> 1;
build(p << 1, l, mid);
build(p << 1 | 1, mid + 1, r);
pushup(p);
}
// 对节点 p 代表的 len 长区间整体执行"先乘 m 再加 a"
void apply(int p, int len, ll m, ll a) {
sum[p] = sum[p] * m + a * len; // 区间和:每个元素乘 m 再加 a
mul[p] = mul[p] * m; // 乘法标记累积
add[p] = add[p] * m + a; // 关键:已有加法标记要先乘 m 再加 a
}
void pushdown(int p, int l, int r) { // 下传懒标记(顺序:先乘后加)
if (mul[p] != 1 || add[p] != 0) {
int mid = (l + r) >> 1;
apply(p << 1, mid - l + 1, mul[p], add[p]);
apply(p << 1 | 1, r - mid, mul[p], add[p]);
mul[p] = 1; add[p] = 0;
}
}
// 区间 [ql, qr] 整体执行"乘 m 再加 a"(区间加传 m=1;区间乘传 a=0)
void update(int p, int l, int r, int ql, int qr, ll m, ll a) {
if (ql <= l && r <= qr) { apply(p, r - l + 1, m, a); return; }
pushdown(p, l, r);
int mid = (l + r) >> 1;
if (ql <= mid) update(p << 1, l, mid, ql, qr, m, a);
if (qr > mid) update(p << 1 | 1, mid + 1, r, ql, qr, m, a);
pushup(p);
}
ll query(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return sum[p];
pushdown(p, l, r); // 查询前也要下传,保证儿子信息最新
int mid = (l + r) >> 1;
ll res = 0;
if (ql <= mid) res += query(p << 1, l, mid, ql, qr);
if (qr > mid) res += query(p << 1 | 1, mid + 1, r, ql, qr);
return res;
}
} seg;
// 用法:seg.update(1,1,n,l,r,1,v) 区间加 v
// seg.update(1,1,n,l,r,v,0) 区间乘 v
// seg.query(1,1,n,l,r) 查询区间和

// ========== 模板二:区间赋值 + 区间最大值(懒标记)==========
struct SegMax {
int mx[MAXN << 2], tag[MAXN << 2]; // tag = -1 表示该节点无赋值标记
void build(int p, int l, int r) {
tag[p] = -1;
if (l == r) { mx[p] = a[l]; return; }
int mid = (l + r) >> 1;
build(p << 1, l, mid);
build(p << 1 | 1, mid + 1, r);
mx[p] = max(mx[p << 1], mx[p << 1 | 1]);
}
void apply(int p, int v) { mx[p] = v; tag[p] = v; } // 整段赋值并打标记
void pushdown(int p) {
if (tag[p] != -1) { // 有赋值标记才下传
apply(p << 1, tag[p]);
apply(p << 1 | 1, tag[p]);
tag[p] = -1;
}
}
void assign(int p, int l, int r, int ql, int qr, int v) {
if (ql <= l && r <= qr) { apply(p, v); return; }
pushdown(p);
int mid = (l + r) >> 1;
if (ql <= mid) assign(p << 1, l, mid, ql, qr, v);
if (qr > mid) assign(p << 1 | 1, mid + 1, r, ql, qr, v);
mx[p] = max(mx[p << 1], mx[p << 1 | 1]); // pushup
}
int query(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return mx[p];
pushdown(p);
int mid = (l + r) >> 1, res = INT_MIN;
if (ql <= mid) res = max(res, query(p << 1, l, mid, ql, qr));
if (qr > mid) res = max(res, query(p << 1 | 1, mid + 1, r, ql, qr));
return res;
}
};

注意点 / 坑点

  1. 数组开 4 倍空间(MAXN << 2)而不是 2 倍;下标从 1 开始,build 从 (1, 1, n) 进入,根 1 的左儿子 2、右儿子 3。
  2. 双懒标记(加 + 乘)下传顺序必须先乘后加:apply 里 add[p] = add[p] * m + a,顺序写反结果全错(洛谷 P3373 经典坑)。
  3. 查询函数里也要 pushdown,否则标记没下传、儿子信息是旧的导致答案错误;递归返回前别忘了 pushup。
  4. 完全覆盖判断写 ql <= l && r <= qr;左右递归分支条件是 ql <= mid 与 qr > mid,二者可能同时成立(两个儿子都要进)。
  5. 区间赋值用 tag = -1 表示”无标记”时,要保证真实赋值不会等于该哨兵值(或用独立的 hasTag 布尔量)。
  6. 数值范围大用 long long;取模题目每次运算后取模,防止中间量溢出。

可拓展的地方

  1. 动态开点线段树(不建满树)可用于值域巨大或稀疏区间;线段树合并可处理树上信息合并(DSU on tree 的替代)。
  2. 扫描线(矩形面积并 / 周长并)本质是维护区间覆盖次数的线段树;可持久化线段树(主席树)基于”逐版本复制路径”;zkw 线段树是非递归、常数更小的写法。

典型例题类型

  1. 区间加 + 区间和:洛谷 P3372【模板】线段树 1。
  2. 区间加 + 区间乘 + 区间和:洛谷 P3373【模板】线段树 2。
  3. 区间最大子段和(pushup 维护 lm/rm/mx):洛谷 P4513 小白逛公园;区间赋值混合操作:HDU 4578 Transformation。

2.4 权值线段树与动态开点

一句话用途

权值线段树以”值的大小”为下标建线段树,每个节点存”该值域内元素出现次数”,用于值域统计、动态第 k 小;动态开点写法只给用到的节点分配内存,是主席树(可持久化线段树)的基础。

算法思想

与普通线段树结构相同,只是维护的是值域而非位置:插入值 x 时对包含 x 的值域区间计数 +1。查询第 k 小时比较左子树元素个数与 k 的大小决定走向。值域很大时先离散化压缩,或动态开点:每个节点存左右儿子下标,走到空节点才分配新节点,使总节点数只与插入次数有关(约 O(n log V))。

复杂度

时间:插入 / 删除 / 查询第 k 小均 O(log V)(V 为值域大小;离散化后 O(log n))。
空间:普通写法 O(4V);动态开点 O(n log V),n 为插入次数。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;

// ========== 动态开点权值线段树(节点池数组写法)==========
// 值域 [1, V],V 很大时也可不离散化直接开点
struct Node { int l, r, cnt; } tr[MAXN * 20]; // 节点池,上限约 n * logV
int tot = 0, root = 0;

int newNode() { return ++tot; } // 申请新节点

void insert(int& p, int l, int r, int pos, int v) {
if (!p) p = newNode(); // 走到空节点就开新点(动态开点核心)
tr[p].cnt += v; // v=1 插入,v=-1 删除
if (l == r) return;
int mid = (l + r) >> 1;
if (pos <= mid) insert(tr[p].l, l, mid, pos, v);
else insert(tr[p].r, mid + 1, r, pos, v);
}
int queryCnt(int p, int l, int r, int ql, int qr) { // 值域区间内元素个数
if (!p || ql > r || qr < l) return 0; // 空节点直接返回 0
if (ql <= l && r <= qr) return tr[p].cnt;
int mid = (l + r) >> 1;
return queryCnt(tr[p].l, l, mid, ql, qr) +
queryCnt(tr[p].r, mid + 1, r, ql, qr);
}
int kth(int p, int l, int r, int k) { // 查询第 k 小(k 从 1 开始)
if (l == r) return l;
int mid = (l + r) >> 1;
int leftCnt = tr[tr[p].l].cnt; // 左子树元素个数
if (k <= leftCnt) return kth(tr[p].l, l, mid, k);
return kth(tr[p].r, mid + 1, r, k - leftCnt);
}

// ========== 配合离散化(值域压缩)==========
// 思路:把出现的值去重排序得到 val[1..m],插入时 pos = lower_bound 得到的下标,
// 查询第 k 小返回下标 idx,原值为 val[idx];离散化把 O(log V) 变成 O(log m)。
// 动态开点的另一种写法:struct Node { Node *l, *r; int cnt; }(指针 + new),
// 逻辑相同,注意内存管理与递归深度。

注意点 / 坑点

  1. 动态开点时节点池数组要开够:每次插入走 log V 层,n 次插入约 n * logV 个节点(n = 1e5、V = 1e9 时约 3e6 个),开小会越界。
  2. queryCnt 里先判 !p(空节点直接返回 0),否则访问 tr[0] 会得到错误计数。
  3. 第 k 小要求 k ≤ 总元素个数;删除后 cnt 减到 0 的节点可以保留(空间换实现简单)。
  4. 离散化后存的是 1..m 的排名,输出答案时记得映射回原值;unique 之后用 lower_bound 求 id,id 从 1 开始。
  5. insert 的 p 是引用参数,调用处传 root;递归深度 O(log V) 一般安全(V = 1e9 时约 30 层)。

可拓展的地方

  1. 主席树 = 权值线段树的可持久化版本(每个前缀一棵树、版本间共享节点),动态开点是它的前提。
  2. 线段树合并(两棵权值线段树按节点合并)可做树上众数、DSU on tree 类问题;权值线段树还能维护”前驱 / 后继 / 排名”(平衡树功能)。

典型例题类型

  1. 动态第 k 小 / 值域统计:洛谷 P1801 黑匣子(动态开点权值线段树或对顶堆)。
  2. 用权值线段树实现平衡树操作:洛谷 P3369【模板】普通平衡树(权值线段树做法)。
  3. 大值域 + 动态开点:CF 1354D Multiset。

2.5 可持久化线段树 主席树

一句话用途

可持久化线段树(主席树):把序列每个前缀都建一棵权值线段树,版本之间共享节点,从而 O(log n) 回答”静态区间第 k 小 / 大”等区间值域问题。

算法思想

对下标 i 维护”前 i 个元素的值域计数树”root[i]。root[i] 在 root[i-1] 的基础上复制被修改路径上的 O(log n) 个节点(复制 + 计数 +1),其余节点共享。查询区间 [l, r] 时用 root[r] 与 root[l-1] 两棵树做差分:每个节点的 cnt 差 = 区间内该值域的元素个数,据此二分走向即可定位第 k 小。

复杂度

时间:预处理(离散化 + 逐版本插入)O(n log n),单次查询 O(log n)。
空间:O(n log n),节点数组约开 n * 25(2e5 数据开 5e6 级别保险)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005, MAXM = MAXN * 25; // 节点上限:n * (log2 n + 1) 左右
int n, m, tot;
int a[MAXN], root[MAXN];
vector<int> v; // 离散化数组
int getid(int x) { return lower_bound(v.begin(), v.end(), x) - v.begin() + 1; }

struct Node { int l, r, cnt; } tr[MAXM]; // 只存左右儿子下标与计数

int build(int l, int r) { // 建空树(初始版本 root[0])
int p = ++tot;
if (l == r) return p;
int mid = (l + r) >> 1;
tr[p].l = build(l, mid);
tr[p].r = build(mid + 1, r);
return p;
}
// 在 pre 版本的基础上插入 pos(复制经过的路径节点,cnt+1),返回新版本根
int update(int pre, int l, int r, int pos) {
int p = ++tot;
tr[p] = tr[pre]; // 复制旧节点(关键:共享未修改部分)
tr[p].cnt++;
if (l == r) return p;
int mid = (l + r) >> 1;
if (pos <= mid) tr[p].l = update(tr[pre].l, l, mid, pos); // 只改一边,另一边共享
else tr[p].r = update(tr[pre].r, mid + 1, r, pos);
return p;
}
// 查询区间 [l, r] 第 k 小:两棵树 root[l-1] 与 root[r] 差分
int query(int u, int v, int l, int r, int k) {
if (l == r) return l; // 返回的是离散化后的排名
int mid = (l + r) >> 1;
int cnt = tr[tr[v].l].cnt - tr[tr[u].l].cnt; // 区间内左值域元素个数
if (k <= cnt) return query(tr[u].l, tr[v].l, l, mid, k);
return query(tr[u].r, tr[v].r, mid + 1, r, k - cnt);
}
// main 中:
// sort + unique 得 v;m = v.size()
// root[0] = build(1, m);
// for (int i = 1; i <= n; i++) root[i] = update(root[i-1], 1, m, getid(a[i]));
// 询问 [l, r] 第 k 小:int idx = query(root[l-1], root[r], 1, m, k);
// cout << v[idx - 1] << '\n'; // 排名映射回原值

注意点 / 坑点

  1. 节点数组大小:每次 update 新建 log n + 1 个节点、共 n 次,故开 n * 25(约 log2(2e5) + 2)比较稳,开小必 RE。
  2. 查询差分一定是 root[r] 减 root[l-1],u 是左版本、v 是右版本,方向不能反;进入右子树时 k 要减去左儿子数量。
  3. 返回的是离散化排名,输出前映射回原值 v[idx - 1];离散化数组去重后共 m 个值。
  4. update 中 tr[p] = tr[pre] 是整体复制(含 cnt)再 cnt++,递归只改一侧、另一侧共享 pre 的指针;忘记共享会导致节点爆炸。
  5. 下标从 1 开始;多组数据要把 tot 清零并重建 root[0],否则版本链错乱。

可拓展的地方

  1. 区间第 k 大 = 第 (len - k + 1) 小;带修改的区间第 k 小需树状数组套主席树(BIT 维护版本增量)。
  2. 树上路径第 k 小(SPOJ COT):每个节点基于父节点建版本,查询用四个根的差分;还可做静态区间 mex、区间不同数个数。

典型例题类型

  1. 静态区间第 k 小:洛谷 P3834【模板】可持久化线段树 2、POJ 2104 K-th Number。
  2. 树上路径第 k 小:SPOJ COT Count on a tree。
  3. 带修改区间第 k 小(树套树):洛谷 P2617 Dynamic Rankings。

2.6 ST 表

一句话用途

静态区间最值查询(RMQ):O(n log n) 预处理后每次查询 O(1),适用于”只查询、不修改”的区间最值问题(幂等操作 max/min/gcd 均可)。

算法思想

st[i][j] 表示从下标 i 开始、长度 2^j 的区间最值,递推 st[i][j] = max(st[i][j-1], st[i + 2^(j-1)][j-1])。查询 [l, r] 时取 k = floor(log2(r - l + 1)),用两个可重叠的长度 2^k 区间 [l, l+2^k-1] 与 [r-2^k+1, r] 覆盖整个区间,二者并集即答案。因为最值是幂等操作(重叠不影响结果),所以允许这样覆盖。

复杂度

时间:预处理 O(n log n),单次查询 O(1)。
空间:O(n log n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005, LOG = 17; // 2^17 = 131072 > 1e5
int n, m;
int a[MAXN];
int st[MAXN][LOG]; // st[i][j]: [i, i+2^j-1] 的最大值
int lg2[MAXN]; // lg2[i] = floor(log2(i))

void build() {
lg2[1] = 0;
for (int i = 2; i <= n; i++) lg2[i] = lg2[i >> 1] + 1; // 递推 log2,避免浮点误差
for (int i = 1; i <= n; i++) st[i][0] = a[i]; // 长度 1 的区间
for (int j = 1; (1 << j) <= n; j++) // 枚举长度倍增
for (int i = 1; i + (1 << j) - 1 <= n; i++) // 右端点不越界
st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
}
int query(int l, int r) { // 区间 [l, r] 最大值
int k = lg2[r - l + 1]; // 覆盖区间所需的最大 2^k 长度
return max(st[l][k], st[r - (1 << k) + 1][k]); // 两个区间允许重叠
}
// 最小值把 max 换成 min 即可;幂等操作(max/min/gcd/and/or)都可用 ST 表

注意点 / 坑点

  1. 数组边界:第二维 j 满足 (1 << j) <= n 即可,第二维大小开 log2(n) + 1;内层 i 要保证 i + (1<<j) - 1 <= n,否则越界访问。
  2. log2 用递推 lg2[i] = lg2[i >> 1] + 1 预处理,不要用浮点 log 函数(有精度问题);查询时 k = lg2[r - l + 1]。
  3. ST 表只支持静态查询,任何修改(哪怕单点改)后整张表失效需要重建;有修改的题目换线段树。
  4. 两个覆盖区间重叠是设计行为,只适用于 max/min/gcd 这类幂等操作,求和类信息不能这样写。
  5. 1 << j 当 j ≥ 31 会溢出 int;边界判断写 (1 << j) <= n,且注意 r - (1 << k) + 1 ≥ 1。

可拓展的地方

  1. RMQ 转 LCA:对树的欧拉序建 ST 表求深度最小值,可 O(1) 求任意两点 LCA。
  2. 二维 ST 表(四维数组)做子矩阵最值;ST 表思想与倍增结合:树上倍增、后缀数组 height 区间 RMQ。

典型例题类型

  1. 静态区间最值模板:洛谷 P3865【模板】ST 表。
  2. 区间最大最小值之差:POJ 3264 Balanced Lineup。
  3. RMQ 求 LCA:洛谷 P3379【模板】最近公共祖先(欧拉序 + ST 表做法)。

2.7 单调栈

一句话用途

O(n) 求出每个位置左边 / 右边第一个比它大(或小)的元素位置,常用于最大矩形面积、接雨水、”贡献法”计数等依赖”以某个元素为极值的区间”的题目。

算法思想

维护一个栈,栈内元素(按下标)的值保持单调(递增或递减)。扫描到新元素 x 时,不断弹出栈顶破坏单调性的元素——被弹出的元素恰好以 x 为”右边第一个更小 / 更大”,其”左边第一个更小 / 更大”就是弹出它之后新的栈顶。每个元素入栈出栈恰好一次,总复杂度 O(n)。

复杂度

时间:O(n)(每个元素入栈一次、出栈至多一次)。
空间:O(n)(栈)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, a[MAXN];
int L[MAXN], R[MAXN]; // L[i]: 左边第一个比 a[i] 小的位置(没有则 0)
// R[i]: 右边第一个比 a[i] 小的位置(没有则 n+1)

void calcFirstSmaller() { // 单调递增栈:找"第一个更小"
stack<int> s; // 栈底到栈顶值递增
for (int i = 1; i <= n; i++) { // 正着扫:求左边
while (!s.empty() && a[s.top()] >= a[i]) s.pop(); // 弹出不小于当前值的
L[i] = s.empty() ? 0 : s.top(); // 新栈顶即左边第一个更小
s.push(i);
}
while (!s.empty()) s.pop(); // 清空复用
for (int i = n; i >= 1; i--) { // 反着扫:求右边
while (!s.empty() && a[s.top()] >= a[i]) s.pop();
R[i] = s.empty() ? n + 1 : s.top();
s.push(i);
}
}
// 求"第一个更大":把 while 里的 >= 改成 <=(单调递减栈)即可。

// ========== 应用:柱状图最大矩形面积 ==========
// 以 a[i] 为高的最大矩形,左右边界是"第一个比它矮"的位置,
// 宽 = R[i] - L[i] - 1,面积 = a[i] * (R[i] - L[i] - 1)
long long maxRectangleArea() {
calcFirstSmaller();
long long ans = 0;
for (int i = 1; i <= n; i++)
ans = max(ans, 1LL * a[i] * (R[i] - L[i] - 1));
return ans;
}
// 也可以一遍扫描完成(弹出时结算),思想相同:
// stack<int> s; a[n+1] = 0; // 哨兵,避免最后清栈特判
// for (int i = 1; i <= n + 1; i++) {
// while (!s.empty() && a[s.top()] > a[i]) {
// int h = a[s.top()]; s.pop();
// int w = (s.empty() ? i - 1 : i - s.top() - 1);
// ans = max(ans, 1LL * h * w);
// }
// s.push(i);
// }

注意点 / 坑点

  1. 先想清楚单调方向:”找第一个更小”用递增栈(弹出 >= 当前值的),”找第一个更大”用递减栈(弹出 <= 当前值的);弹不弹出相等元素决定”严格 / 非严格”边界。
  2. 边界哨兵:左边没有记 0、右边没有记 n+1,计算宽度 R-L-1 时自然处理,不需要特判。
  3. 最大矩形面积答案可能超过 int(如 1e5 个 1e9),用 long long;一遍扫描写法在数组末尾加哨兵 a[n+1] = 0 可省去清栈特判。
  4. 栈里存的是下标不是值,比较时用 a[top];顺序固定:先弹出、再取栈顶、最后入栈。
  5. 同值元素在”严格更小”语义下:用 >= 弹出会把相同高度处理为边界,矩形面积仍正确,但”第一个更小”的语义变成”≤”,读题确认要求。

可拓展的地方

  1. 贡献法:统计”以 a[i] 为最小值的子数组个数” = (i - L[i]) * (R[i] - i),可配合乘法原理做区间计数类题。
  2. 接雨水、天际线问题(POJ 1904 类)都是单调栈典型应用;单调栈与笛卡尔树一一对应,可用于理解”第一个更小”的树形结构。

典型例题类型

  1. 左右第一个更大位置:洛谷 P5788【模板】单调栈。
  2. 柱状图最大矩形:POJ 2559 / HDU 1506 Largest Rectangle in a Histogram。
  3. 子区间最值贡献和(用 L[i] / R[i] 计数):洛谷 P1823 音乐会的等待。

2.8 单调队列

一句话用途

O(n) 求滑动窗口(固定长度区间)内的最值;也常用于”决策单调性”DP 优化(如多重背包、烽火传递),核心是维护一个值单调、下标递增的队列。

算法思想

用 deque 存下标,保证下标递增、队首到队尾对应的值单调(求最大值时递减)。每步:① 弹出队首中已滑出窗口的下标(过期);② 从队尾弹出所有值 ≤ 新元素的下标(维护单调性);③ 新元素入队尾;④ 队首即当前窗口最值。每个下标入队出队各一次,总 O(n)。

复杂度

时间:O(n)。
空间:O(n)(deque 最大长度 = 窗口大小)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
int n, k, a[MAXN];

void slidingWindowMax() { // 输出每个长度为 k 的窗口的最大值
deque<int> q; // 存下标,队首到队尾值递减(队首最大)
for (int i = 1; i <= n; i++) {
while (!q.empty() && q.front() <= i - k) q.pop_front(); // ① 队首过期弹出
while (!q.empty() && a[q.back()] <= a[i]) q.pop_back(); // ② 队尾维护单调:弹出 ≤ 当前值的
q.push_back(i); // ③ 新元素入队
if (i >= k) cout << a[q.front()] << ' '; // ④ 窗口成型后输出
}
}
void slidingWindowMin() { // 最小值:单调方向反过来(队首最小)
deque<int> q;
for (int i = 1; i <= n; i++) {
while (!q.empty() && q.front() <= i - k) q.pop_front();
while (!q.empty() && a[q.back()] >= a[i]) q.pop_back(); // 弹出 ≥ 当前值的
q.push_back(i);
if (i >= k) cout << a[q.front()] << ' ';
}
}
// 关键口诀:"队首过期、队尾单调":
// 队首只负责按下标删除过期元素,队尾只负责按值维护单调性,
// 二者各司其职,保证队首永远是当前窗口内的最值。

注意点 / 坑点

  1. 队列存下标不存值:值的比较要用 a[q.back()] 取;存下标才能判断是否过期(q.front() <= i - k)。
  2. 步骤顺序固定:先清过期队首,再清队尾单调,最后入队,最后输出;若先入队再清过期,刚入队的元素可能被误删。
  3. 窗口成型条件 i >= k 才开始输出;窗口长度 k 时第一个输出在 i = k 处。
  4. 单调方向:求最大值队列值递减、求最小值队列值递增;弹不弹出相等元素(<= 或 <)决定”严格”语义,通常取等号弹出即可。
  5. 用 std::deque 而非手写队列时注意 front/back 操作;下标从 1 开始,过期条件 q.front() <= i - k(等于 i-k 时窗口已滑过它)。

可拓展的地方

  1. 单调队列优化 DP:形如 dp[i] = max(dp[j]) + cost(j 落在滑动区间内)的转移可降到 O(n),如烽火传递、滑动窗口型 DP。
  2. 多重背包优化:按剩余量对 v 取模分组,每组用单调队列维护 max(dp - j*w),复杂度 O(nV);树上”距离限制”类 DP 也可用单调队列。

典型例题类型

  1. 滑动窗口最值模板:洛谷 P1886 滑动窗口 / POJ 2823 Sliding Window。
  2. 区间最小值相关变体:洛谷 P1440 求 m 区间内的最小值。
  3. 单调队列优化 DP:POJ 3017 Cut the Sequence、洛谷 P2629 好消息坏消息(环形前缀和 + 单调队列)。

2.9 堆与对顶堆

一句话用途

堆能 O(1) 取最值、O(log n) 插入 / 删除,priority_queue 是 C++ 标准库实现;对顶堆(一个大根堆 + 一个小根堆)可动态维护中位数或第 k 小。

算法思想

对顶堆:大根堆 low 存”较小的一半”,小根堆 high 存”较大的一半”,始终保证 low.size() = high.size() 或 low.size() = high.size() + 1。插入时按与 low.top() 的大小关系放入一侧,再平衡两侧大小,中位数就是 low.top()(偶数个时是下中位数)。

复杂度

时间:插入 / 删除 O(log n),取最值 O(1)。
空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// ========== priority_queue 用法与坑 ==========
priority_queue<int> q1; // 默认:大根堆(取最大值)
priority_queue<int, vector<int>, greater<int>> q2; // 小根堆(取最小值)
// 注意:priority_queue 的第三个模板参数是"比较器",小根堆必须写 greater<int>

// 自定义类型 / 自定义比较:重载 operator() 的仿函数
struct Node { int w, id; };
struct Cmp { // 注意!堆的比较器与 sort 的 cmp 方向相反
bool operator()(const Node& x, const Node& y) const {
return x.w > y.w; // 返回 true 表示 x 优先级更低
} // 想取最小的 w 就写 >(与 sort 相反)
};
priority_queue<Node, vector<Node>, Cmp> q3;

// 坑:priority_queue 不支持删除任意元素;"懒删除"技巧:
// 另开一棵堆记录待删元素,取顶时先把与删除堆顶相等的元素清掉。

// ========== 对顶堆:动态维护中位数 ==========
struct MedianKeeper {
priority_queue<int> low; // 大根堆:较小一半
priority_queue<int, vector<int>, greater<int>> high; // 小根堆:较大一半
void insert(int x) {
if (low.empty() || x <= low.top()) low.push(x); // 小于等于中位数进 low
else high.push(x); // 否则进 high
balance();
}
void balance() { // 平衡:low 至多比 high 多 1 个
if ((int)low.size() > (int)high.size() + 1) {
high.push(low.top()); low.pop();
} else if ((int)high.size() > (int)low.size()) {
low.push(high.top()); high.pop();
}
}
int median() { return low.top(); } // 当前中位数(下中位数)
// 若要"上中位数":把平衡条件改成 high 至多多 1 个,返回 high.top() 即可;
// 偶数个元素取平均时注意用 double 或 (a + b) / 2。
};

注意点 / 坑点

  1. priority_queue 默认大根堆;小根堆要写 priority_queue<int, vector, greater>,漏掉 vector 参数会编译失败。
  2. 自定义比较器方向与 sort 相反:仿函数返回 true 表示”x 优先级更低(排在后面)”,写反会得到相反顺序的堆。
  3. 对顶堆平衡后 low.top() 是中位数;奇数个元素直接输出,偶数个时题目可能要求较小 / 较大 / 平均值,先确认题意再定平衡策略。
  4. priority_queue 无法删除非堆顶元素;”懒删除”需另开堆记录删除,取顶时循环清理,两堆的比较器必须一致。
  5. 合并果子等贪心题注意答案是 long long(每次合并的和会累积);堆中元素重复时不要误判为空。

可拓展的地方

  1. 动态第 k 小:把 low 限制为恰好 k 个元素,low.top() 即第 k 小;与权值线段树 / 主席树互为替代。
  2. 堆 + 贪心:合并果子(洛谷 P1090)、超级钢琴(洛谷 P1631 类)用堆维护候选;可删堆 + 离线排序可做”动态中位数 + 删除”。

典型例题类型

  1. 动态中位数:洛谷 P1168 中位数、POJ 3784 Running Median。
  2. 堆基本操作 / 合并果子:洛谷 P3378【模板】堆、P1090 合并果子。
  3. 堆 + 贪心 / 排序:洛谷 P1801 黑匣子(对顶堆维护第 k 小)。

2.10 左偏树

一句话用途

可并堆(Mergeable Heap):支持 O(log n) 合并两个堆,配合”并查集找堆顶”可删除任意节点所在堆的堆顶,常用于需要不断合并堆的贪心题(如猴子打架、Huffman 变体)。

算法思想

左偏树是一棵二叉树,每个节点维护值 val、左右儿子与 dist(该节点到最近”空儿子”的距离,空节点 dist = 0)。合并时始终把值较大(大根堆)的树根作为根,递归合并它的右儿子与另一棵树,再保证左儿子的 dist ≥ 右儿子的 dist(不满足就交换),最后 dist[根] = dist[右儿子] + 1。这样树形”向左偏”,任何节点的右链长度 O(log n),合并复杂度 O(log n)。

复杂度

时间:merge O(log n)(均摊更优),插入 / 删除堆顶 O(log n)。
空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;

// ========== 左偏树(大根堆)模板 ==========
// 题目通常要求删除"某个节点所在堆"的堆顶,故额外维护 fa 并查集式找根
int lch[MAXN], rch[MAXN], val[MAXN], dis[MAXN], fa[MAXN];
// dis[x]: x 到最近空节点的距离,空节点 dis = 0,叶子 dis = 1

int find(int x) { // 并查集式找堆顶(可路径压缩)
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
int merge(int x, int y) { // 合并两个堆,返回新堆根
if (!x || !y) return x + y; // 一个为空直接返回另一个
if (val[x] < val[y]) swap(x, y); // 大根堆:值大的做根
rch[x] = merge(rch[x], y); // 递归合并右儿子
if (dis[lch[x]] < dis[rch[x]]) swap(lch[x], rch[x]); // 保证左儿子 dist 更大(左偏性质)
dis[x] = dis[rch[x]] + 1; // 更新 dist
return x;
}
int popTop(int x) { // 删除 x 所在堆的堆顶,返回新堆根
x = find(x);
int nr = merge(lch[x], rch[x]); // 左右子树合并成新堆
fa[lch[x]] = fa[rch[x]] = fa[x] = nr; // 更新并查集指向(nr 可能为 0)
lch[x] = rch[x] = 0; dis[x] = 1; // 隔离被删节点
val[x] = -1; // 标记已删除(依题目而定)
return nr;
}
// 把单节点堆 y 并入 x 所在堆:int rx = find(x); nr = merge(rx, y); fa[rx] = fa[y] = nr;
// 注意:fa 数组初始化 fa[i] = i;merge 的返回值要更新到 fa。
// 若题目允许删除后重新插入同一个节点,隔离时不要改 val,popTop 后单独维护即可。

注意点 / 坑点

  1. dist 的定义是”到最近空节点的距离”,与普通树高不同:空节点 dist = 0、叶子 dist = 1,合并后 dis[x] = dis[rch[x]] + 1。
  2. 合并前必须比较值决定谁做根(大根堆取大);递归合并的是右儿子,合并完检查左右 dist 是否需要交换,这是”左偏”性质的关键。
  3. 删除堆顶后要同时更新三个 fa(左儿子、右儿子、被删节点),否则后续 find 会指向已删除节点;nr 可能为 0(堆空)要处理。
  4. 并查集 find 可带路径压缩(左偏树形态由 merge 决定,find 只用于定位根);但 merge 内部不依赖 fa,别在 merge 里混入并查集逻辑。
  5. 节点池数组开 n 个即可(每个节点始终存在,删除只是隔离);若”删了又插”要确认 val 未被破坏。

可拓展的地方

  1. 小根堆只需把 merge 里的比较方向反过来;可持久化左偏树(merge 时复制节点)可做”区间合并堆”类题。
  2. 斜堆(无 dist 的简化版)、配对堆(均摊 O(1) 插入)是同族数据结构;左偏树常用于贪心”每次合并最小的两堆”(Huffman 树)与树上 DP 合并优化。

典型例题类型

  1. 合并堆 + 删除堆顶:洛谷 P3377【模板】左偏树(可并堆)。
  2. 猴子打架类(每次取两只猴子所在堆的堆顶减半再合并):洛谷 P1456 Monkey King、HDU 1512。
  3. 贪心合并(Huffman 变体 / 堆合并 DP):BZOJ 1367 类题。

2.11 分块

一句话用途

把序列分成大小约 √n 的若干块,整块统一处理(打懒标记)、散块暴力,使区间操作复杂度平衡到 O(√n);是”暴力 + 预处理”的通用框架,可扩展解决众数、区间统计等线段树难做的问题。

算法思想

预处理每块的左右边界与整块信息(如块内和)。区间操作 [l, r]:若 l、r 在同一块则直接暴力;否则左边散块暴力、右边散块暴力、中间整块打懒标记(lazy)或整体更新。区间查询时整块用”块内和 + lazy × 块长”,散块逐元素累加。

复杂度

时间:单次操作 O(√n)(散块 ≤ 2√n 个元素,整块 ≤ √n 块);m 次操作 O(m√n)。
空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 100005;
int n, m, block, num; // block 块大小, num 块数
ll a[MAXN]; // 原数组(散块修改直接改这里)
ll sum[MAXN], lazy[MAXN]; // 每块和、每块懒标记
int belong[MAXN], L[MAXN], R[MAXN]; // 每个元素所属块、每块左右边界

void build() {
block = sqrt(n); // 块大小取 √n 平衡散块与整块
num = (n + block - 1) / block; // 向上取整块数
for (int i = 1; i <= num; i++) {
L[i] = (i - 1) * block + 1;
R[i] = min(i * block, n); // 最后一块可能不满
}
for (int i = 1; i <= n; i++) belong[i] = (i - 1) / block + 1;
for (int i = 1; i <= n; i++) sum[belong[i]] += a[i];
}
void update(int l, int r, ll v) { // 区间 [l, r] 加 v
int bl = belong[l], br = belong[r];
if (bl == br) { // 同块:直接暴力
for (int i = l; i <= r; i++) a[i] += v;
sum[bl] += v * (r - l + 1);
return;
}
for (int i = l; i <= R[bl]; i++) a[i] += v; // 左边散块暴力
sum[bl] += v * (R[bl] - l + 1);
for (int i = L[br]; i <= r; i++) a[i] += v; // 右边散块暴力
sum[br] += v * (r - L[br] + 1);
for (int i = bl + 1; i <= br - 1; i++) lazy[i] += v; // 中间整块打懒标记
}
ll query(int l, int r) { // 区间 [l, r] 求和
int bl = belong[l], br = belong[r];
ll res = 0;
if (bl == br) {
for (int i = l; i <= r; i++) res += a[i] + lazy[bl];
return res;
}
for (int i = l; i <= R[bl]; i++) res += a[i] + lazy[bl]; // 散块:元素值 + 该块 lazy
for (int i = L[br]; i <= r; i++) res += a[i] + lazy[br];
for (int i = bl + 1; i <= br - 1; i++) res += sum[i] + lazy[i] * (R[i] - L[i] + 1);
return res;
}

注意点 / 坑点

  1. 块大小默认 √n,实测可微调(如 n / sqrt(m));块编号从 1 开始,belong[i] = (i-1) / block + 1,最后一块的 R 要 min(i*block, n)。
  2. 散块修改要”真实修改” a[i] 并同步 sum[belong];整块只动 lazy;查询散块时是 a[i] + lazy[该块],不要只加 a[i] 或只加 lazy。
  3. 同块分支容易漏:l、r 在同一块时按”散块 + 整块”逻辑会重复计数(整块区间不合法),必须特判。
  4. sum 数组存的是”不含 lazy 的真实和”,所以整块查询用 sum[i] + lazy[i] * len;整块修改后忘记更新 sum 会出错。
  5. 涉及取模的题目注意 long long 与取模位置;散块暴力次数 ≤ 2 * block,块大小选择不当会 TLE。

可拓展的地方

  1. 块状链表:把”分块数组”换成”块内用链表”,支持 O(√n) 的插入 / 删除与区间操作,可当平衡树平替(如 LOJ 6282 数列分块入门 7)。
  2. 分块可维护区间众数(预处理块间众数 + 散块枚举)、区间 mex、区间按值计数(值域分块)等线段树难以实现的问题。

典型例题类型

  1. 区间加 + 区间求和:洛谷 P3372【模板】线段树 1(分块做法)、LOJ 6277 数列分块入门 1。
  2. 区间加 + 区间最值 / 乘除:LOJ 6279、6281 数列分块入门系列。
  3. 区间众数:LOJ 6285 数列分块入门 9(块间众数预处理)。

2.12 莫队

一句话用途

离线处理”只询问、可快速维护 add/del”的区间问题(如区间不同数个数、区间和、区间众数),把询问按分块排序后双指针移动,总复杂度 O((n+q)√n);带修莫队加一个时间维处理带修改的版本。

算法思想

把询问按(左端点所在块, 右端点)排序,维护两个指针 l、r 和一个可增删的”当前区间答案”。每处理一个询问,把 l、r 从上一个位置移动到目标位置,移动时调用 add(pos) / del(pos) 增量更新答案。排序后指针总移动量是 O((n+q)√n)(同块内右端点单调、块间跳转 O(√n)),故总复杂度可控;奇偶排序优化可进一步减少右指针往返。

复杂度

时间:普通莫队 O((n+q)√n)(n、q 同阶时约 O(n√n));带修莫队 O(n^(5/3))(块大小取 n^(2/3))。
空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 50005;
int n, q, block;
int a[MAXN], cnt[MAXN]; // a 原序列,cnt 值出现次数
ll cur, ans[MAXN]; // cur 当前区间答案,ans 每问答案
struct Query { int l, r, id; } qs[MAXN];

bool cmp(const Query& x, const Query& y) {
int bx = x.l / block, by = y.l / block;
if (bx != by) return bx < by; // 第一关键字:左端点所在块
// 奇偶优化:块号奇偶交替排列右端点方向,减少右指针移动
return (bx & 1) ? x.r > y.r : x.r < y.r;
}
void add(int pos) { // 把 a[pos] 加入当前区间
if (cnt[a[pos]] == 0) cur++; // 出现次数从 0 变 1:不同数 +1
cnt[a[pos]]++;
}
void del(int pos) { // 把 a[pos] 移出当前区间
cnt[a[pos]]--;
if (cnt[a[pos]] == 0) cur--; // 减到 0:不同数 -1
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> q;
block = sqrt(n); // 块大小常取 n / sqrt(q) 调优
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= q; i++) {
cin >> qs[i].l >> qs[i].r;
qs[i].id = i;
}
sort(qs + 1, qs + q + 1, cmp);
int l = 1, r = 0; // 初始空区间
for (int i = 1; i <= q; i++) {
while (l > qs[i].l) add(--l); // 先扩后缩:先加再减,防越界
while (r < qs[i].r) add(++r);
while (l < qs[i].l) del(l++);
while (r > qs[i].r) del(r--);
ans[qs[i].id] = cur;
}
for (int i = 1; i <= q; i++) cout << ans[i] << '\n';
return 0;
}

// ========== 带修莫队(简述思路)==========
// 增加"时间维" t(已执行修改次数):询问排序关键字 (l/block, r/block, t),
// 块大小取 n^(2/3),复杂度 O(n^(5/3))。
// 指针移动时除了 l、r,还要把 t 移动到目标值:
// 前移 t:执行第 t 次修改(若修改位置在 [l, r] 内则先 del 旧值再 add 新值);
// 回退 t:撤销第 t 次修改(同理处理)。
// 修改操作记录"位置、改前值、改后值"以便前进 / 回退;
// 关键:修改位置恰好在当前区间内时,才需要同步更新答案 cnt。

// ========== 树上莫队(一句话带过)==========
// 对树做欧拉序(进出各记一次),把树上路径询问转化为序列上的区间询问,
// 用"奇偶次出现"技巧(出现两次的节点不算)套普通莫队即可。

注意点 / 坑点

  1. 指针移动顺序:先 add 扩区间(–l、++r)再 del 缩区间(l++、r–),避免中间出现 l > r 的非法区间。
  2. 排序必须按块排序(第一关键字左端点块、第二关键字右端点),直接按 l 排序会退化成 O(nq);奇偶优化只改变右端点顺序,不影响正确性。
  3. add/del 的更新逻辑要与答案定义严格对应(如”不同数个数”看 cnt 从 0 变 1 / 1 变 0),写错会整体错位。
  4. 答案可能超过 int(区间和、区间内 pair 计数等),用 long long。
  5. 带修莫队的块大小是 n^(2/3) 而不是 √n;修改的”时间”要单独记录每次修改的 (pos, 旧值, 新值),回退时按序撤销。
  6. 莫队是离线算法,无法处理强制在线问题;改变询问处理顺序不影响答案本身。

可拓展的地方

  1. 回滚莫队(不支持删除的维护方式,如区间最值)与二次离线莫队(把指针移动产生的差分问题再离线一次,常数更小)。
  2. 树上莫队(欧拉序转化)、带修莫队、可持久化莫队组合可覆盖大部分区间统计问题;分块 + 莫队思想也可用于字符串哈希区间题。

典型例题类型

  1. 区间不同数个数:SPOJ DQUERY、洛谷 P1972 HH 的项链。
  2. 区间值统计(平方和等):洛谷 P2709 小 B 的询问。
  3. 带修莫队:洛谷 P1903【国家集训队】数颜色。

2.13 字典树与 01-Trie

一句话用途

Trie(字典树)按字符把字符串组织成树,支持 O(长度) 的插入、查询、删除,用于前缀统计、单词查找;01-Trie 把整数按二进制位组织成二叉树,可 O(位数) 求最大异或对。

算法思想

Trie:根节点表示空前缀,每个节点有 26 个(或字符集大小)儿子指针,从根到某节点路径上的字符拼成一个前缀;插入时沿途维护 cnt 计数可实现前缀统计。01-Trie:每个节点只有 0 / 1 两个儿子,从高位到低位插入整数;求与 x 异或最大值时贪心:每层优先走与 x 当前位相反的边(0 走 1、1 走 0),走不通才走同向边。

复杂度

时间:插入 / 查询 / 删除 O(len)(len 为串长或位数 31)。
空间:O(总字符数)(节点池 = 串数 × 最长串长)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005; // 节点池:总字符数上限(如 1e5 个串 × 10 长度)

// ========== 1. 字典树:字符串前缀统计 ==========
struct Trie {
int ch[MAXN][26]; // ch[p][k]:p 的第 k 个儿子节点编号,0 表示空
int cnt[MAXN]; // cnt[p]:经过节点 p 的字符串个数(前缀计数)
int tot; // 已分配节点数(根为 0)
void init() {
memset(ch[0], 0, sizeof(ch[0]));
cnt[0] = 0;
tot = 0;
}
void insert(const string& s) {
int p = 0;
for (char c : s) {
int k = c - 'a';
if (!ch[p][k]) { // 没有该子节点则新建
ch[p][k] = ++tot;
memset(ch[tot], 0, sizeof(ch[tot])); // 清空新节点
cnt[tot] = 0;
}
p = ch[p][k];
cnt[p]++; // 沿途计数:有多少串经过此前缀
}
}
int query(const string& s) { // 以 s 为前缀的串个数
int p = 0;
for (char c : s) {
int k = c - 'a';
if (!ch[p][k]) return 0; // 途中断了:无此前缀
p = ch[p][k];
}
return cnt[p];
}
void remove(const string& s) { // 删除一个串(计数减一)
int p = 0;
for (char c : s) {
p = ch[p][c - 'a'];
if (cnt[p]) cnt[p]--; // 注意:不真正删节点,只减计数
}
}
} trie;

// ========== 2. 01-Trie:求最大异或对 ==========
struct Trie01 {
int ch[MAXN * 31][2]; // 每个数 31 位,节点数上限 ≈ n * 31
int tot;
void init() {
memset(ch[0], 0, sizeof(ch[0]));
tot = 0;
}
void insert(int x) { // 从高位(30)到低位(0)插入
int p = 0;
for (int i = 30; i >= 0; i--) {
int b = (x >> i) & 1; // 取出第 i 位
if (!ch[p][b]) {
ch[p][b] = ++tot;
memset(ch[tot], 0, sizeof(ch[tot]));
}
p = ch[p][b];
}
}
int maxXor(int x) { // 与 x 异或的最大值
int p = 0, res = 0;
for (int i = 30; i >= 0; i--) {
int b = (x >> i) & 1, want = b ^ 1; // 贪心:优先走相反位
if (ch[p][want]) { // 存在相反位:该位异或得 1
res |= (1 << i);
p = ch[p][want];
} else {
p = ch[p][b]; // 否则只能走同向位(该位为 0)
}
}
return res;
}
} trie01;
// 最大异或对:把每个数插入后依次取 maxXor(a[i]) 的最大值;
// "与当前集合中某个数异或最大"即可 O(31) 回答。

注意点 / 坑点

  1. 节点池大小要开”总字符数”级别:n 个长度 L 的串需要 n * L + 1 个节点;01-Trie 每个数 31 位要 n * 31 个节点,开小必 RE。
  2. 新建节点要 memset 清空该行(ch[tot] 全 0),否则残留上次数据;多组数据记得 init 并重置 tot。
  3. 删除是”计数减一”而不是物理删除节点;查询时注意区分”节点存在但计数 0”与”节点不存在”。
  4. 01-Trie 从高位到低位插入;数据含负数时符号位(第 31 位)处理要统一(如整体偏移或固定遍历 31 位)。
  5. 前缀统计的 cnt 要在插入路径沿途 +1(表示”经过”,即前缀次数),而不是只在结尾 +1;否则”前缀统计”结果错误。
  6. 字符集大小决定第二维:小写字母 26、大小写 52、数字 10;用 map / unordered_map 可支持任意字符集但常数大。

可拓展的地方

  1. 可持久化 Trie:按版本建 01-Trie,可回答”区间内与 x 异或最大”(洛谷 P4735 最大异或和)。
  2. Trie + DP:字符串分组 / 最长公共前缀统计;AC 自动机(Trie 加 fail 指针做多模式匹配);01-Trie 还可求异或第 k 大、最小异或对。

典型例题类型

  1. 前缀统计 / 点名:洛谷 P2580 于是他错误的点名开始了、POJ 2001 Shortest Prefixes。
  2. 最大异或对:洛谷 P4551 最长异或路径(树上前缀异或 + 01-Trie)。
  3. 可持久化 Trie:洛谷 P4735 最大异或和。

2.14 珂朵莉树 ODT

一句话用途

ODT(Old Driver Tree,珂朵莉树)用 set 维护”值相等的连续区间段”,对区间推平(assign)类操作非常快;注意其复杂度依赖于”区间推平操作 + 数据随机”的前提,适用范围有限,只适合有大量推平操作的题。

算法思想

把序列划分成若干段 [l, r],每段值相同,存进按左端点排序的 set。核心操作 split(pos):把包含 pos 的段拆成两段并返回 pos 所在段的迭代器。区间操作统一”先 split(r+1) 再 split(l)”取出区间内所有段,然后暴力遍历每段处理(区间加、幂和)或用 assign 把它们合并成一段。当数据随机且有大量推平时,段数均摊 O(log n),复杂度接近 O(m log n)。

复杂度

时间:均摊 O(m log n)(m 次操作)——依赖”推平操作多 + 数据随机”,否则最坏 O(nm)。
空间:O(n)(段数)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 1000000007;

struct Node {
int l, r;
mutable ll v; // mutable:允许在 set 里直接改值
Node(int l_, int r_ = -1, ll v_ = 0) : l(l_), r(r_), v(v_) {}
bool operator<(const Node& o) const { return l < o.l; } // 按左端点排序
};
set<Node> odt;

// 把包含 pos 的段拆成 [l, pos-1] 与 [pos, r],返回 pos 所在段的迭代器
auto split(int pos) {
auto it = odt.lower_bound(Node(pos));
if (it != odt.end() && it->l == pos) return it; // pos 正好是某段左端点
--it; // pos 在 it 这一段内
int l = it->l, r = it->r;
ll v = it->v;
odt.erase(it);
odt.insert(Node(l, pos - 1, v));
return odt.insert(Node(pos, r, v)).first; // 返回后一段
}
// 区间推平:把 [l, r] 内所有段合并成一段
void assign(int l, int r, ll v) {
auto itr = split(r + 1), itl = split(l); // 注意顺序:先右后左!
odt.erase(itl, itr); // 删除区间内所有段
odt.insert(Node(l, r, v));
}
// 区间加:对区间内每段暴力加
void addRange(int l, int r, ll v) {
auto itr = split(r + 1), itl = split(l);
for (auto it = itl; it != itr; ++it) it->v += v;
}
// 区间第 k 小:取出所有段排序
ll kth(int l, int r, int k) {
auto itr = split(r + 1), itl = split(l);
vector<pair<ll, int>> tmp;
for (auto it = itl; it != itr; ++it) tmp.push_back({it->v, it->r - it->l + 1});
sort(tmp.begin(), tmp.end());
for (auto& [v, c] : tmp) {
if (k <= c) return v;
k -= c;
}
return -1;
}
// 区间幂和:sum (v^y mod MOD) * 段长,每段快速幂
ll powSum(int l, int r, ll y) {
auto itr = split(r + 1), itl = split(l);
ll res = 0;
for (auto it = itl; it != itr; ++it) {
ll p = 1, base = it->v % MOD, e = y; // 快速幂
while (e) { if (e & 1) p = p * base % MOD; base = base * base % MOD; e >>= 1; }
res = (res + p * (it->r - it->l + 1)) % MOD;
}
return res;
}
// 初始化:odt.insert(Node(1, n, 初始值));需要 0 位置时可插哨兵 Node(0, 0, 0)。
// 使用前提:题目有大量 assign 操作且数据随机(如 CF 896C 用随机数生成操作)。

注意点 / 坑点

  1. 复杂度依赖”区间推平 + 数据随机”:若没有大量推平(段数不会减少)或数据被构造为最坏情况(每次 split 都切出新段),会退化成 O(nm) 可能 TLE——这是 ODT 最大的坑。
  2. 先 split(r + 1) 再 split(l):先拆左边再拆右边时,拆左边产生的迭代器可能因拆右边时的 erase 失效,顺序反了会 RE / UB。
  3. split 返回的迭代器在后续 erase / insert 后可能失效,不要在 split 后继续使用旧迭代器;v 声明为 mutable 才能在 set 中直接改值。
  4. 初始化别忘了把整段插入 set;split(n+1) 需要 set 中存在左端点 ≥ n+1 的段(或先插入哨兵),否则 lower_bound 会越界到 end()。
  5. 区间幂和要每段单独快速幂,基数先 %MOD;kth 的 k 保证 ≤ 区间长度,否则返回哨兵值会误导。

可拓展的地方

  1. 可持久化珂朵莉树(版本化 set 节点)与”树剖 + ODT”处理树上链推平;ODT 也常用于 CF 随机数据构造题(如 CF 896C)。
  2. 与分块 / 线段树对比:ODT 适合”推平”主导的题;没有推平时换分块或线段树更稳。ODT 还能套”区间次大值”等较难维护的信息(每段暴力算)。

典型例题类型

  1. 区间推平 + 区间加 + 第 k 小 + 区间幂和(ODT 出处题):CF 896C Willem, Chtholly and Seniorious。
  2. 区间推平类模拟 / 染色计数:洛谷 P2572【SCOI2010】序列操作(ODT 可做)、Codeforces 区间赋值类题目。

2.15 FHQ Treap

一句话用途

无旋 Treap(FHQ Treap):用 split / merge 两个操作实现平衡树全部功能(插入、删除、排名、第 k 小、前驱后继),还能轻易支持区间操作(如文艺平衡树的区间翻转),且天然可持久化。

算法思想

每个节点存值 val 与随机优先级 pri,树满足”中序遍历 = 有序序列”且按 pri 满足堆性质。split(p, val, a, b) 把树 p 按值(或按大小)拆成两棵;merge(a, b) 把两棵树合并(要求 a 中所有值 ≤ b 中所有值)。插入 = split 后 merge 三棵;删除 = split 两次取出目标节点再合并左右子树。区间操作用”按大小 split”把区间 [l, r] 独立出来打懒标记再合并回去。

复杂度

时间:单次操作期望 O(log n)(随机优先级保证树高期望 log n)。
空间:O(n);可持久化版本 O(操作数 × log n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <bits/stdc++.h>
using namespace std;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
const int MAXN = 200005;
struct Node {
int l, r, sz, val, pri; // 左右儿子、子树大小、值、随机优先级
bool rev; // 区间翻转懒标记
} tr[MAXN];
int tot, root;
int newNode(int v) {
tr[++tot] = {0, 0, 1, v, (int)rng(), false};
return tot;
}
void pushup(int p) { tr[p].sz = tr[tr[p].l].sz + tr[tr[p].r].sz + 1; }
void pushdown(int p) { // 下传翻转标记(区间操作用)
if (tr[p].rev) {
swap(tr[p].l, tr[p].r); // 交换左右儿子
tr[tr[p].l].rev ^= 1; // 懒标记传递给儿子
tr[tr[p].r].rev ^= 1;
tr[p].rev = false;
}
}
// 按值分裂:p 中所有 val <= v 的进 a,其余进 b
void splitVal(int p, int v, int& a, int& b) {
if (!p) { a = b = 0; return; }
pushdown(p);
if (tr[p].val <= v) { // p 及左子树全在 a
a = p;
splitVal(tr[p].r, v, tr[a].r, b); // 右子树继续分
pushup(a);
} else { // p 及右子树全在 b
b = p;
splitVal(tr[p].l, v, a, tr[b].l);
pushup(b);
}
}
// 按大小分裂:前 k 个节点进 a,其余进 b(区间操作用)
void splitSize(int p, int k, int& a, int& b) {
if (!p) { a = b = 0; return; }
pushdown(p);
if (tr[tr[p].l].sz >= k) { // 左子树就够前 k 个
b = p;
splitSize(tr[p].l, k, a, tr[b].l);
pushup(b);
} else { // 前 k 个包含 p 与部分右子树
a = p;
splitSize(tr[p].r, k - tr[tr[p].l].sz - 1, tr[a].r, b);
pushup(a);
}
}
// 合并:a 中所有值严格小于 b 中所有值(按大小分裂时 a 在前 b 在后)
int merge(int a, int b) {
if (!a || !b) return a + b;
if (tr[a].pri < tr[b].pri) { // 优先级小的做根(堆性质)
pushdown(a);
tr[a].r = merge(tr[a].r, b); // 递归合并右儿子与 b
pushup(a);
return a;
} else {
pushdown(b);
tr[b].l = merge(a, tr[b].l);
pushup(b);
return b;
}
}
void insert(int v) { int a, b; splitVal(root, v, a, b); root = merge(merge(a, newNode(v)), b); }
void erase(int v) { // 删除一个值为 v 的节点
int a, b, c;
splitVal(root, v, a, b); // a: <=v, b: >v
splitVal(a, v - 1, a, c); // c: ==v 的子树
root = merge(merge(a, merge(tr[c].l, tr[c].r)), b); // 删掉 c 的根,合并其左右
}
int kth(int p, int k) { // 第 k 小(k 从 1 开始)
while (true) {
pushdown(p);
if (k <= tr[tr[p].l].sz) p = tr[p].l;
else if (k == tr[tr[p].l].sz + 1) return tr[p].val;
else { k -= tr[tr[p].l].sz + 1; p = tr[p].r; }
}
}
// 区间翻转 [l, r]:按大小切出该区间,打标记,再合并回去
void reverseRange(int l, int r) {
int a, b, c;
splitSize(root, l - 1, a, b); // a: 前 l-1 个
splitSize(b, r - l + 1, b, c); // b: 区间 [l, r]
tr[b].rev ^= 1; // 打懒标记(不必立刻交换)
root = merge(merge(a, b), c);
}

注意点 / 坑点

  1. 两种 split 别混用:按值分裂用于插入 / 删除(有序集合),按大小分裂用于区间操作(维护序列);区间翻转必须用 splitSize。
  2. merge 前要保证 a 的所有值 < b 的所有值(按大小分裂时 a 在前);递归 merge 时先 pushdown 再改儿子,返回前 pushup。
  3. pushdown 必须在递归下降(split / merge / kth 访问儿子)之前调用,否则懒标记漏传导致区间操作结果错乱。
  4. 随机优先级用 mt19937(随机数发生器),别用 rand()(可能重复导致退化);插入相同值多次时按”可重集合”处理(每个节点一个值)。
  5. erase 拆分后 c 子树的值全相等,把 c 的左右子树合并回去即可;合并顺序 a < c 的左右 < b,别搞反。
  6. 懒标记 rev 是”异或”语义(翻两次抵消),pushdown 里对两个儿子都 ^= 1;非区间题不用写 rev。

可拓展的地方

  1. 可持久化 FHQ Treap:split / merge 时复制路径节点,天然支持历史版本(洛谷 P3835 可持久化平衡树)。
  2. 区间操作全家桶:区间翻转(文艺平衡树)、区间加 / 赋值 / 求和、区间平移(split 后换序 merge),配合懒标记即可实现”序列之王”。

典型例题类型

  1. 平衡树基本操作(插入 / 删除 / 排名 / 第 k 小 / 前驱后继):洛谷 P3369【模板】普通平衡树。
  2. 区间翻转:洛谷 P3391【模板】文艺平衡树(FHQ 版)。
  3. 可持久化平衡树:洛谷 P3835【模板】可持久化平衡树。

2.16 Splay

一句话用途

伸展树(Splay):每次访问一个节点都把它旋转到树根,均摊 O(log n);除了平衡树基本操作,最大的价值是区间操作(文艺平衡树)与 Link-Cut Tree(LCT)的基础。

算法思想

rotate(x) 把 x 上旋一层并保持中序遍历不变;splay(x, goal) 把 x 旋转到 goal 的儿子位置(双旋:zig-zig 先转父、zig-zag 先转 x),保证均摊 O(log n)。区间操作要点:在序列两端加哨兵节点,翻转 / 修改区间 [l, r] 时先把 l 的前驱 splay 到根、r 的后继 splay 到根的右儿子,则根的右儿子的左子树恰好是区间,打懒标记即可。

复杂度

时间:单次操作均摊 O(log n)(splay 的双旋保证摊还分析)。
空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
struct Node {
int ch[2], fa; // 左右儿子、父节点
int val, sz, cnt; // 值、子树大小、值出现次数
bool rev; // 区间翻转懒标记
} tr[MAXN];
int tot, root;

void pushup(int x) { tr[x].sz = tr[tr[x].ch[0]].sz + tr[tr[x].ch[1]].sz + tr[x].cnt; }
void pushdown(int x) { // 下传翻转标记
if (tr[x].rev) {
swap(tr[x].ch[0], tr[x].ch[1]);
tr[tr[x].ch[0]].rev ^= 1;
tr[tr[x].ch[1]].rev ^= 1;
tr[x].rev = false;
}
}
int which(int x) { return tr[tr[x].fa].ch[1] == x; } // x 是父亲的右儿子?
void rotate(int x) { // 单旋:x 向上转一层
int y = tr[x].fa, z = tr[y].fa;
int k = which(x); // x 是 y 的哪个儿子
tr[y].ch[k] = tr[x].ch[k ^ 1]; // x 的另一侧儿子过继给 y
if (tr[x].ch[k ^ 1]) tr[tr[x].ch[k ^ 1]].fa = y;
tr[x].ch[k ^ 1] = y; // y 变成 x 的儿子
tr[y].fa = x;
tr[x].fa = z;
if (z) tr[z].ch[tr[z].ch[1] == y] = x; // x 顶替 y 在 z 中的位置
pushup(y); pushup(x); // 先更新下面的 y 再更新 x
}
void splay(int x, int goal = 0) { // 把 x 旋转到 goal 的儿子位置
while (tr[x].fa != goal) {
int y = tr[x].fa, z = tr[y].fa;
if (z != goal) { // 双旋
if (which(x) == which(y)) rotate(y); // zig-zig:先转父
else rotate(x); // zig-zag:先转 x
}
rotate(x);
}
if (!goal) root = x;
}
void insert(int v) { // 插入(BST 定位 + splay 到根)
int x = root, fa = 0;
while (x && tr[x].val != v) { fa = x; x = tr[x].ch[v > tr[x].val]; }
if (x) tr[x].cnt++; // 已存在:计数 +1
else {
x = ++tot;
tr[x] = {0, 0, fa, v, 1, 1, false};
if (fa) tr[fa].ch[v > tr[fa].val] = x;
}
splay(x); // 新节点旋到根(保持均摊)
}
void erase(int v) { // 删除一个值为 v 的节点
int x = root;
while (x && tr[x].val != v) x = tr[x].ch[v > tr[x].val];
if (!x) return;
splay(x); // 先旋到根
if (tr[x].cnt > 1) { tr[x].cnt--; pushup(x); return; }
if (!tr[x].ch[0]) { // 无左子树:右子树直接做根
root = tr[x].ch[1];
tr[root].fa = 0;
} else {
int p = tr[x].ch[0];
while (tr[p].ch[1]) p = tr[p].ch[1]; // 找左子树最大节点
splay(p); // 旋到根(此时 p 无右儿子)
tr[p].ch[1] = tr[x].ch[1]; // 挂上原右子树
if (tr[x].ch[1]) tr[tr[x].ch[1]].fa = p;
pushup(p);
}
}
int kth(int k) { // 第 k 小(k 从 1 开始)
int x = root;
while (true) {
pushdown(x);
if (k <= tr[tr[x].ch[0]].sz) x = tr[x].ch[0];
else if (k <= tr[tr[x].ch[0]].sz + tr[x].cnt) { splay(x); return tr[x].val; }
else { k -= tr[tr[x].ch[0]].sz + tr[x].cnt; x = tr[x].ch[1]; }
}
}
// ========== 区间操作要点(文艺平衡树)==========
// 1) 建树时插入两个哨兵(极小值、极大值),序列元素按中序遍历排在中间;
// 2) 处理区间 [l, r]:splay(第 l 个节点, 0) 把它旋到根;
// splay(第 r+2 个节点, root) 把它旋到根的右儿子;
// 此时 root->ch[1]->ch[0] 恰好是区间 [l, r],打懒标记或求和即可;
// 3) 输出中序遍历时先 pushdown;哨兵节点的值要避开真实数据范围。
// 注意 up/down 顺序:rotate 里先 pushup(y) 再 pushup(x);
// 访问儿子前先 pushdown(x),否则懒标记没下传就改了树形会出错。

注意点 / 坑点

  1. rotate 中指针更新顺序固定:先过继 x 的另一侧儿子给 y,再让 y 认 x 为父,再让 x 顶替 y 的位置(改 z 的儿子);漏掉任一 fa 更新都会悬空。
  2. splay 双旋:zig-zig(x 与 y 同为左 / 右儿子)先 rotate(y) 再 rotate(x);zig-zag 先 rotate(x) 两次。只做单旋会退化成 O(n)。
  3. pushup / pushdown 顺序:rotate 里先 pushup(y) 再 pushup(x)(x 在上面);任何访问儿子前先 pushdown,up/down 次序写反会得到错树。
  4. 哨兵节点:区间操作必须有两个哨兵(第 0 个与第 n+1 个位置),splay 目标不能是 0;哨兵值取 ±INF 且不参与输出。
  5. 删除”不存在”的值要提前判断,别对空指针操作;cnt 表示重复次数,erase 计数减一而非删节点。
  6. 递归建树 / 输出注意栈深度(n 大时中序遍历用栈或迭代);多组数据重置 tot 与 root。

可拓展的地方

  1. LCT(Link-Cut Tree):Splay 的 fa 指针变成”虚边 / 实边”,实现树的动态连边、断边、链上操作——Splay 是 LCT 的基石。
  2. 区间翻转 + 区间加 / 赋值 / 求和 + 区间插入删除(按位置 splay 的思想),Splay 可做”序列之王”;可持久化 Splay 也能实现但常数大,通常用 FHQ Treap 替代。

典型例题类型

  1. 平衡树基本操作:洛谷 P3369【模板】普通平衡树(Splay 版)。
  2. 区间翻转(文艺平衡树):洛谷 P3391【模板】文艺平衡树(Splay 版)。
  3. LCT 基础:洛谷 P3690【模板】Link Cut Tree。

三、图论

本部分速查表

算法 复杂度 适用场景
3.1 图的存储(vector 邻接表 / 链式前向星) 建图 O(m),遍历 O(n+m) 一切图论算法的基础;链式前向星适合卡常、网络流成对反向边
3.2 拓扑排序 O(n+m) DAG 排序、判环、任务依赖 / 课程表类问题
3.3 Dijkstra(堆优化) O((n+m) log n) 单源非负权最短路;扩展:分层图、次短路
3.4 Bellman-Ford / SPFA O(nm) / 平均 O(km) 最坏 O(nm) 负权图最短路、判负环
3.5 Floyd O(n³),空间 O(n²) 多源最短路、传递闭包、n ≤ 500 的稠密图
3.6 次短路 O((n+m) log n) 求严格 / 非严格次短路
3.7 最小生成树(Kruskal / Prim) O(m log m) / O((n+m) log n) 连通最小代价;次小生成树
3.8 最近公共祖先 LCA(倍增 / Tarjan) 预处理 O(n log n),查询 O(log n) / 离线 O(n+m) 树上路径查询、距离、差分
3.9 树的直径与重心 O(n) 最远点对、最小化最大距离、树形 DP 优化
3.10 树上差分 修改 O(1)(LCA O(log n)),还原 O(n) 路径 / 子树批量加,一次还原查询
3.11 重链剖分 路径 O(log²n),子树 O(log n) 在线路径加 / 路径和 / 子树操作
3.12 强连通分量与缩点 O(n+m) 可达性、缩点成 DAG 后 DP、2-SAT 判定
3.13 割点与桥 O(n+m) 无向图连通性分析、双连通分量
3.14 欧拉回路与欧拉路径 O(n+m) 一笔画问题、字典序最小欧拉路径
3.15 二分图匹配(匈牙利) 染色 O(n+m),匹配 O(nm) 二分图最大匹配、染色判定
3.16 二分图相关定理 同匹配算法(O(nm)) 点覆盖 / 独立集 / 路径覆盖转化
3.17 最大流 Dinic O(n²m),实际远快 网络流建模、最小割
3.18 最小费用最大流 O(F·nm)(SPFA 版) 带费用的流、指派问题
3.19 差分约束系统 建图 O(m),求解 / 判环 O(nm) 不等式组可行性、变量最值
3.20 2-SAT O(n+m)(Tarjan) 布尔约束可满足性判定与方案输出

3.1 图的存储

一句话用途:图的存储是所有图论算法的基础,负责把「点与边」组织成程序可以快速遍历的数据结构。本小节给出 vector 邻接表与链式前向星两种最常用的模板,前者写法简单直观,后者常数小、内存紧凑,适合在不同场景下按需选用。

算法思想:vector 邻接表为每个点开一个动态数组,存放该点出发的所有边(终点、边权、边编号),遍历时直接 for 循环扫描。链式前向星用 head[u] 记录点 u 的第一条边在数组中的下标,nxt[i] 指向同一起点的下一条边,to[i] 为终点,w[i] 为边权;加边采用头插法,遍历时沿 nxt 链走。其本质是把「邻接表」扁平化为若干定长数组,避免动态分配,并且编号从 1 开始、正反边成对(i 与 i^1)的写法是网络流反向边机制的基础(见 3.17)。

复杂度:时间:建图 O(m),遍历所有边 O(n+m);空间:O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5, M = 2e5 + 5; // 无向图边数要开 2 倍!

// ========== 1. vector 邻接表(写法简单,适合绝大多数题目) ==========
struct Edge {
int to; // 边的终点
ll w; // 边权(无权图可省略)
int id = 0; // 边的编号(判重边、输出方案时用,可选)
};
vector<Edge> g[N]; // g[u] 存放从 u 出发的所有边

// 加无向边:正反各一次
void addUndirected(int u, int v, ll w) {
g[u].push_back({v, w});
g[v].push_back({u, w});
}

// 遍历 u 的所有出边(示例函数)
void traverse(int u) {
for (auto &e : g[u]) {
// e.to 为终点,e.w 为边权
}
}

// ========== 2. 链式前向星(常数小、支持网络流成对反向边) ==========
int head[N], nxt[M], to[M], w[M], tot = 1;
// head[u]: u 的第一条边编号;tot: 已用边总数(从 1 开始)

void addEdge(int u, int v, int c) { // 加一条有向边 u -> v
to[++tot] = v; // 新边终点
w[tot] = c; // 边权
nxt[tot] = head[u]; // 头插:新边的 next 指向原第一条边
head[u] = tot; // 更新头指针
}

// 加无向边:两条有向边;tot 从 1 开始使 i^1 成对
void addUndirected2(int u, int v, int c) {
addEdge(u, v, c);
addEdge(v, u, c);
}

// 遍历 u 的所有出边(示例函数)
void traverse2(int u) {
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i]; // 终点
int c = w[i]; // 边权
}
}

注意点 / 坑点

  1. 链式前向星的数组大小按「边数」开而不是「点数」,无向图每条边存两次,边数组必须开 2 倍(M = 2 × 最大边数),否则越界产生玄学错误。
  2. 头插法导致遍历顺序与建图顺序相反;若题目对遍历顺序有要求(如字典序最小的欧拉路径,见 3.14),要先排序再倒序插入,或改用 vector 邻接表排序。
  3. vector 邻接表在边数很大(如 1e6)时反复 push_back 有扩容开销与内存碎片,OJ 卡常数时可换链式前向星;反之链式前向星下标多、易写错,小数据无脑用 vector。
  4. N 很大时邻接表要开全局数组(局部大数组可能爆栈);给 Edge 写构造函数时注意 push_back 的隐式转换陷阱,建议直接写聚合初始化。

可拓展的地方

  1. 链式前向星「tot 从 1 开始、i^1 成对」的写法是 Dinic / 费用流(3.17、3.18)反向边更新的基础,学完网络流后回头对比同一数组的复用。
  2. 可把存储封装成 struct Graph(addEdge / 遍历接口),配合模板复用;邻接表里存边编号(id)可支持删边、判重边等扩展需求。

典型例题类型

  • 图的遍历与存储入门:洛谷 P3916 图的遍历(反向建图 + DFS)。
  • 存储 + 最短路组合:洛谷 P4779 【模板】单源最短路径(标准版)(Dijkstra 需要邻接表或前向星支撑)。

3.2 拓扑排序

一句话用途:对「有向无环图(DAG)」的顶点排序,使对每条边 u→v 都有 u 排在 v 之前;用于任务依赖、课程先后等「先后关系」问题,同时可以判断有向图是否存在环。

算法思想:Kahn 算法维护所有「入度为 0」的顶点入队,每次取出队首放入拓扑序,并把它的所有出边终点的入度减 1,减到 0 时入队;若最终出队顶点数小于 n,说明存在环。DFS 判环写法:DFS 时给顶点打「访问中 / 访问完」两种状态,若递归中再次遇到「访问中」的顶点,说明它能回到自己,即存在环;DFS 后序压栈的结果反转后就是一组拓扑序。

复杂度:时间 O(n+m);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5;
vector<int> g[N]; // 邻接表
int indeg[N]; // 入度

// ========== 1. Kahn 算法(队列版):返回拓扑序;有环返回空 vector ==========
vector<int> topoSort(int n) {
vector<int> res;
queue<int> q;
for (int i = 1; i <= n; i++)
if (indeg[i] == 0) q.push(i); // 所有入度为 0 的点先入队
while (!q.empty()) {
int u = q.front(); q.pop();
res.push_back(u);
for (int v : g[u]) {
if (--indeg[v] == 0) // 删掉 u 后 v 的入度减一
q.push(v);
}
}
if ((int)res.size() != n) return {}; // 出队数 != n => 有环
return res;
}

// ========== 2. DFS 判环 + 输出拓扑序 ==========
int vis[N]; // 0=未访问 1=访问中(在递归栈上) 2=已访问完
vector<int> topo; // 后序压栈,最后反转
bool hasCycle = false;

void dfs(int u) {
vis[u] = 1; // 标记访问中
for (int v : g[u]) {
if (vis[v] == 0) dfs(v); // 未访问则递归
else if (vis[v] == 1) hasCycle = true; // 遇到访问中的点 => 环
}
vis[u] = 2; // u 及其子树处理完毕
topo.push_back(u); // 后序入栈
}

// 主流程:对每个未访问点跑 DFS,最后反转得到拓扑序
void getTopo(int n) {
for (int i = 1; i <= n; i++) if (!vis[i]) dfs(i);
reverse(topo.begin(), topo.end());
// 若 hasCycle 为 true,说明图有环,topo 不是合法拓扑序
}

注意点 / 坑点

  1. Kahn 判环必须用「出队顶点数 == n」判断,不能用「队列是否为空」:有环时环上顶点永远入不了队,但队列最终也会自然变空,会误判成无环。
  2. 多组测试数据必须清空 indeg 与邻接表;Kahn 用 queue 而非递归,避免在链状数据上爆栈。
  3. DFS 判环必须区分「访问中」与「访问完」两种状态:只用「已访问」一个布尔标记,会把「从另一分支再次到达已完成节点」误判为环。
  4. 拓扑序不唯一:若题目要求字典序最小,把 queue 换成 priority_queue(小根堆)即可,代价是多一个 log n。

可拓展的地方

  1. 拓扑序是 DAG 上 DP(最长路、方案数、概率)的天然转移顺序,保证无后效性;「缩点后 DAG 上 DP」(3.12)就依赖它。
  2. 可推广到「关键路径(AOE 网)」「课程表」「编译依赖」等实际问题;Kahn 的入度思想也可用于分层最短路等场景。

典型例题类型

  • 拓扑排序模板:洛谷 B3644 【模板】拓扑排序。
  • 判环 / 全序判断:洛谷 P1347 排序;「LeetCode 207 课程表」(拓扑判环)。

3.3 Dijkstra

一句话用途:单源最短路径(SSSP)算法,求从源点 s 到其余所有点的最短距离,只适用于边权非负的图;堆优化版是竞赛中最常用、最稳的最短路写法。

算法思想:贪心。维护一个小根堆,每次取出「dist 最小且尚未确定」的顶点 u 标记为已确定,然后松弛 u 的所有出边:若 dist[u] + w < dist[v],更新 dist[v] 并入堆。因为边权非负,堆顶取出的顶点其 dist 一定已是全局最短(反证:若存在更短路径,其沿途顶点 dist 必然更小,会先被取出),所以每个点只需「确定」一次。堆中可能残留过期记录,取出时用 visited 标记跳过。

复杂度:时间 O((n+m) log n);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5;
struct Edge { int to; ll w; };
vector<Edge> g[N];
ll dist[N]; // 源点到各点的最短路
bool vis[N]; // 是否已确定最短路

void dijkstra(int s, int n) {
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
fill(dist, dist + n + 1, INF);
memset(vis, 0, sizeof(vis));
// 小根堆:pair<距离, 顶点>;优先队列默认大根堆,用 greater 转小根堆
priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<>> pq;
dist[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (vis[u]) continue; // 过期记录:u 之前已被确定
vis[u] = true; // 第一次出堆即最短路确定
for (auto &e : g[u]) {
if (vis[e.to]) continue; // 已确定的点无需再松弛
if (d + e.w < dist[e.to]) {
dist[e.to] = d + e.w;
pq.push({dist[e.to], e.to});
}
}
}
}

注意点 / 坑点

  1. Dijkstra 只适用于非负权边:有负权边时「先确定者最短路最短」的贪心性质失效,必须改用 SPFA / Bellman-Ford(3.4)或差分约束的处理。
  2. 优先队列默认大根堆,存 pair 必须用 greater<> 或存负数;pair 先比距离再比编号,距离相同时不影响正确性。
  3. 堆中会残留旧纪录:取出后必须 if (vis[u]) continue;(或 if (d != dist[u]) continue;)跳过过期数据,否则可能死循环或 TLE。
  4. 自环松弛恒不成立、重边都会正常参与松弛,无需特判;INF 用 0x3f3f3f3f3f3f3f3f 级别的极大值,防止 INF + 边权溢出 int / ll。

可拓展的地方

  1. 分层图最短路:把状态扩展为 (顶点, 已用优惠次数),建 k+1 层图、层间连 0 权边,一次 Dijkstra 解决「最多 k 次免费」类问题。
  2. 次短路(3.6)在 Dijkstra 基础上同时维护最短路与次短路两个 dist;还可统计「最短路条数」、判定「必经边」、A* 求第 k 短路等。

典型例题类型

  • 模板题:洛谷 P4779 【模板】单源最短路径(标准版)。
  • 分层图:洛谷 P4568 [JLOI2011] 飞行路线(k 次免费机票);次短路:POJ 3255 Roadblocks。

3.4 Bellman-Ford 与 SPFA

一句话用途:Bellman-Ford(BF)与 SPFA 是支持「负权边」的单源最短路算法,并可检测负环(能无限缩短路径的环);SPFA 是 BF 的队列优化,竞赛中用于负权最短路与判负环,但最坏复杂度很高,须谨慎使用。

算法思想:BF 对全部 m 条边做 n-1 轮松弛(dist[v] = min(dist[v], dist[u]+w)):一条最短路至多经过 n-1 条边,故 n-1 轮必然收敛;若第 n 轮仍能松弛,说明存在负环。SPFA 只把「被成功松弛」的顶点入队,用队列代替全量扫描,减少无效松弛;若某个点入队次数超过 n,说明存在负环(该点被无限优化)。

复杂度:BF 时间 O(nm);SPFA 平均 O(km)(k 为小常数),最坏 O(nm),可被构造数据卡死。空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5;
struct Edge { int to; ll w; };
vector<Edge> g[N];
ll dist[N];
int cnt[N]; // 入队次数(判负环)
bool inq[N]; // 是否在队列中

// ========== 1. Bellman-Ford(O(nm),用边表实现) ==========
bool bellmanFord(int n, const vector<tuple<int,int,ll>> &edges, int s) {
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
fill(dist, dist + n + 1, INF);
dist[s] = 0;
for (int round = 1; round <= n - 1; round++) { // 最多 n-1 轮
bool updated = false;
for (auto &[u, v, w] : edges) { // 每轮扫全部边
if (dist[u] != INF && dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
updated = true;
}
}
if (!updated) break; // 提前收敛
}
for (auto &[u, v, w] : edges) // 第 n 轮仍可松弛 => 负环
if (dist[u] != INF && dist[u] + w < dist[v]) return true;
return false; // false 表示无负环
}

// ========== 2. SPFA(队列优化版) ==========
bool spfa(int n, int s) {
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
fill(dist, dist + n + 1, INF);
memset(cnt, 0, sizeof(cnt));
memset(inq, 0, sizeof(inq));
queue<int> q;
dist[s] = 0;
q.push(s); inq[s] = true; cnt[s] = 1;
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = false;
for (auto &e : g[u]) {
int v = e.to;
if (dist[u] != INF && dist[u] + e.w < dist[v]) { // 松弛成功才更新
dist[v] = dist[u] + e.w;
if (!inq[v]) {
if (++cnt[v] > n) return true; // 入队超过 n 次 => 负环
q.push(v); inq[v] = true;
}
}
}
}
return false;
}

注意点 / 坑点

  1. SPFA 最坏 O(nm):网格图、菊花图等构造数据能稳定卡死 SPFA;洛谷 P4779 标准版就明确卡 SPFA,只有「有负权边」或「需要判负环」时才用 SPFA,其余用 Dijkstra(3.3)。
  2. 判负环计数的是「入队次数」:入队超过 n 次即判定(含源点本身第 1 次);写成「被松弛次数」或用 n-1 作阈值都可能漏判或误判。
  3. 负环必须与源点连通才会被发现:只从 s 跑 SPFA 时,若负环在别的连通分量里检测不到;差分约束(3.19)用虚拟源点向所有点连 0 边正是为解决这一点。
  4. 松弛前要判断 dist[u] != INF,否则 INF + 负权可能溢出成很小的数,产生错误的「更短路」。

可拓展的地方

  1. 把松弛条件反向(dist[v] < dist[u] + w)即得最长路 SPFA,用于「求最大值」的差分约束(3.19);存在正环时同样无限松弛,与负环对称。
  2. BF 的「第 n 轮判环」思想可推广为任意「Bellman-Ford 式迭代」的可行性判定;SPFA 也可结合 SLF / LLL 小优化,但无法根治最坏复杂度。

典型例题类型

  • 负环模板:洛谷 P3385 【模板】负环。
  • 负权最短路:POJ 3259 Wormholes(判负环);「POJ 1860 Currency Exchange」(正环判无限增值)。

3.5 Floyd

一句话用途:多源最短路径算法,一次求出任意两点间的最短距离;同时可求传递闭包(可达性矩阵),适用于点数较少(一般 n ≤ 500)的稠密图或需要全源答案的题目。

算法思想:动态规划。设 f[k][i][j] 表示「只允许经过前 k 个点作为中转」时 i 到 j 的最短路,转移 f[k][i][j] = min(f[k-1][i][j], f[k-1][i][k] + f[k-1][k][j])。滚动掉第一维后只剩二维 d[i][j],且 k 必须放在最外层循环,保证中转点逐个加入。初始 d[i][i] = 0,有边则 d[i][j] = 边权(重边取 min),其余为 INF。

复杂度:时间 O(n³);空间 O(n²)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 505;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
ll d[N][N]; // 距离矩阵
int n, m;

void floyd() {
for (int k = 1; k <= n; k++) // 中转点 k 必须在最外层
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (d[i][k] != INF && d[k][j] != INF) // 防溢出
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}

// ---------- 传递闭包(可达性),bitset 优化 ----------
bitset<N> reach[N]; // reach[i][j] = 1 表示 i 可达 j
void closure() {
for (int k = 1; k <= n; k++) // k 仍在外层
for (int i = 1; i <= n; i++)
if (reach[i][k]) reach[i] |= reach[k]; // 一行位运算代替内层循环
// bitset 版复杂度约 O(n³ / 64)
}

int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
d[i][j] = (i == j ? 0 : INF); // 对角线必须为 0
for (int i = 0; i < m; i++) {
int u, v; ll w;
cin >> u >> v >> w;
d[u][v] = min(d[u][v], w); // 重边取最小
}
floyd();
return 0;
}

注意点 / 坑点

  1. k 必须最外层:写成 i、j 在外的顺序是错的,会导致中转点未完全加入而漏算路径——这是 Floyd 最高频的写错点。
  2. INF 防溢出:更新前判断 d[i][k] != INF && d[k][j] != INF,或用足够大的 INF;注意 0x3f3f3f3f 的 int 两倍不溢出、三倍会溢出,ll 同理,谨慎选值。
  3. 初始 d[i][i] = 0 不能省,否则每个点到自己的距离恒为 INF;若跑完后出现 d[i][i] < 0,说明存在负环。
  4. 无向图要双向建边;重边取 min;n 超过约 1000 时 O(n³) 大概率超时,稠密大图改 Dijkstra 全源(n 次)或 Johnson 算法。

可拓展的地方

  1. 最小环:枚举 k 作为「环上编号最大点」,先用 d[i][j] + w(i,k) + w(k,j) 更新答案,再执行常规松弛(此时 d[i][j] 不允许经过 k);传递闭包 bitset 版可处理 n = 2000 左右的比较 / 排名问题。
  2. Floyd 可记录中转点 path[i][j] 以输出具体路径;也可与 DP 结合处理「经过指定点集的最短路」(状态压缩)等扩展。

典型例题类型

  • Floyd 模板:洛谷 B3647 【模板】Floyd。
  • 最小环:洛谷 P6175 无向图的最小环问题;传递闭包 / 排名确定:POJ 3660 Cow Contest。

3.6 次短路

一句话用途:求源点到各点(或两点间)的「第二短」路径,分严格次短路(必须比最短路长)与非严格次短路(允许相等)两种,用于次短路线路规划、枚举前两优方案类题目。

算法思想:在 Dijkstra 基础上为每个点维护两个距离 dist1(最短路)与 dist2(次短路)。松弛时:若新值 nd < dist1[v],则把旧最短路「降级」为次短路,再更新最短路;否则若 nd > dist1[v] 且 nd < dist2[v],则直接更新次短路。两种状态都要入堆(堆元素 (距离, 顶点)),因为次短路同样可能继续松弛出下游点的次短路,一遍 Dijkstra 即可同时求出两者。

复杂度:时间 O((n+m) log n);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5;
struct Edge { int to; ll w; };
vector<Edge> g[N];
ll dist1[N], dist2[N]; // 最短路与次短路
const ll INF = 0x3f3f3f3f3f3f3f3fLL;

void secondShortest(int s, int n) {
fill(dist1, dist1 + n + 1, INF);
fill(dist2, dist2 + n + 1, INF);
priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;
dist1[s] = 0;
pq.push({0, s});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist2[u]) continue; // 比次短路还大:过期状态,跳过
for (auto &e : g[u]) {
ll nd = d + e.w;
if (nd < dist1[e.to]) {
// 打破最短路:旧最短路降级为次短路,再更新最短路
dist2[e.to] = dist1[e.to];
dist1[e.to] = nd;
pq.push({nd, e.to});
} else if (nd > dist1[e.to] && nd < dist2[e.to]) {
// 严格次短路要求 nd 严格大于最短路
dist2[e.to] = nd;
pq.push({nd, e.to});
}
}
}
// dist2[t] 即答案;仍为 INF 说明不存在次短路
}

注意点 / 坑点

  1. 严格 / 非严格之分:nd > dist1[e.to] 保证严格次短路;若题意允许与最短路等长,改为 nd >= dist1[e.to](同时注意 dist1 降级分支里 nd == dist1 的情况按题意处理)。
  2. 出堆判重必须写 if (d > dist2[u]) continue;:一个点有 dist1、dist2 两个合法状态都会入堆,不能像普通 Dijkstra 用 visited 布尔直接丢弃第二次出堆。
  3. 「降级」顺序不能反:先 dist2 = dist1dist1 = nd,否则旧最短路数据被覆盖丢失。
  4. 无次短路时 dist2[t] 保持 INF,输出前判断;重边、自环都会自然参与松弛,无需特判;n=1 或 s==t 时的边界(dist2 含义)按题意确认。

可拓展的地方

  1. 双 dist 数组思想可推广到「第 k 短路」(用 A*:估价函数取到终点最短路 + 优先队列,或可持久化堆);「严格 / 非严格第 k 优解」都可套用。
  2. 可扩展到「最短路计数」(并列方案数)、「次短路计数」、「严格次小生成树」(3.7 中用倍增维护路径最大 / 次大边)等衍生问题。

典型例题类型

  • POJ 3255 Roadblocks(无向图严格次短路,经典模板题)。
  • 进阶:第 k 短路洛谷 P4467 [SCOI2007] k短路;「洛谷 P2865 [USACO06NOV] Roadblocks」同源。

3.7 最小生成树

一句话用途:在带权无向连通图中选出 n-1 条边使图连通且总权值和最小(最小生成树 MST),用于布线、连通代价最小化;并衍生出次小生成树(枚举非树边替换)等问题。

算法思想:Kruskal 把所有边按权值升序排序,用并查集维护连通性,从小到大尝试加边:两端点不在同一集合就加入并合并(加满 n-1 条即停止),正确性由「切割性质 / 环性质」保证。Prim 从任意点出发,维护「已选点集」,每次取连接已选集与未选集的最小权边加入,堆优化找最小边,稠密图可用朴素 O(n²) 版。

复杂度:Kruskal O(m log m)(排序主导);Prim 堆优化 O((n+m) log n),朴素 O(n²)。空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 2e5 + 5;
struct Edge { int u, v; ll w; };
vector<Edge> edges; // Kruskal 用边表

// ---------- 并查集(路径压缩) ----------
int fa[N];
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }

// ========== 1. Kruskal ==========
ll kruskal(int n) {
for (int i = 1; i <= n; i++) fa[i] = i;
sort(edges.begin(), edges.end(),
[](const Edge &a, const Edge &b) { return a.w < b.w; });
ll ans = 0; int cnt = 0;
for (auto &e : edges) {
int ru = find(e.u), rv = find(e.v);
if (ru != rv) { // 不成环才加入
fa[ru] = rv;
ans += e.w;
if (++cnt == n - 1) break; // 已选够 n-1 条边
}
}
if (cnt < n - 1) return -1; // 图不连通,无生成树
return ans;
}

// ========== 2. Prim(堆优化) ==========
struct Node { int v; ll w; };
vector<Node> g[N];
ll prim(int n) {
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
vector<ll> low(n + 1, INF); // 各点到已选集合的最小边权
vector<bool> used(n + 1, false);
priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;
low[1] = 0;
pq.push({0, 1});
ll ans = 0; int cnt = 0;
while (!pq.empty()) {
auto [w, u] = pq.top(); pq.pop();
if (used[u]) continue; // 过期状态跳过
used[u] = true;
ans += w; cnt++;
for (auto &e : g[u])
if (!used[e.v] && e.w < low[e.v]) {
low[e.v] = e.w;
pq.push({e.w, e.v});
}
}
if (cnt < n) return -1; // 不连通
return ans;
}

注意点 / 坑点

  1. Kruskal 用「已加边数 == n-1」提前退出;结束时若 cnt < n-1 说明原图不连通,应输出 -1 而非 ans。
  2. 并查集必须写路径压缩(可加按秩合并),否则退化到 O(n²);Kruskal 对边排序,m 很大(1e6)时注意 sort 常数与内存。
  3. Prim 堆优化也要 if (used[u]) continue; 跳过过期记录;low 数组每次松弛都要更新并重新入堆。
  4. 权值相等的边可导致 MST 不唯一,但不影响最小总权值;求「严格次小生成树」时(见拓展)要维护路径「最大」与「次大」两条边信息以处理相等情况。

可拓展的地方

  1. 次小生成树:先求 MST,枚举每条非树边 (u,v,w) 加入,会形成环,删除环上除它之外的最大边,取所有方案的最小值;严格次小还要排除「最大边 == w」的情况,用倍增(3.8)维护路径最大 / 次大边即可。经典题:洛谷 P4180 严格次小生成树。
  2. Kruskal 思想可推广到「最小瓶颈生成树」(MST 即最小化最大边权)、「最大生成树」(权取负)、「Kruskal 重构树」(处理只经过权值 ≤ x 的边、查询连通块最值等离线问题)。

典型例题类型

  • 模板:洛谷 P3366 【模板】最小生成树。
  • 次小生成树:洛谷 P4180 [BJWC2010] 严格次小生成树;生成树计数:「CF 888G Xor-MST」(Boruvka + Trie)。

3.8 最近公共祖先 LCA

一句话用途:求树上两个节点的最近公共祖先,是树上路径查询、距离计算、树上差分(3.10)等问题的必备预处理;本小节给出倍增与 Tarjan 离线两种写法,并提及欧拉序 + RMQ 与树剖求 LCA(指向 3.11)。

算法思想:倍增法先 DFS 预处理每个点的深度 depth[u] 与 2^k 级祖先 fa[k][u];查询 u、v 时先把深的点跳到与浅的同一深度,再从大到小尝试跳 2^k,只要 fa[k][u] != fa[k][v] 就一起跳,最后 fa[0][u] 即 LCA。Tarjan 离线法在 DFS 中用并查集维护「已回溯子树」,遇到询问 (u,v) 时若 v 已访问,find(v) 的集合代表元即 LCA。欧拉序 + RMQ 把 LCA 化为区间最小值查询(预处理 O(n log n)、查询 O(1))。树剖求 LCA 见 3.11,同一套剖分可复用于路径操作。

复杂度:倍增预处理 O(n log n),单次查询 O(log n),空间 O(n log n);Tarjan 离线 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 5e5 + 5, LOG = 20; // LOG 满足 2^LOG > n(n=5e5 时 20 即可)
vector<int> g[N];
int depth[N], fa[LOG][N]; // fa[k][u]:u 向上 2^k 步的祖先

void dfs(int u, int f) {
depth[u] = depth[f] + 1;
fa[0][u] = f; // 1 级祖先
for (int k = 1; k < LOG; k++)
fa[k][u] = fa[k - 1][fa[k - 1][u]]; // 2^k = 2^(k-1) + 2^(k-1)
for (int v : g[u]) if (v != f) dfs(v, u);
}

int lca(int u, int v) {
if (depth[u] < depth[v]) swap(u, v);
int diff = depth[u] - depth[v]; // 第一步:跳到同深度
for (int k = 0; k < LOG; k++)
if (diff >> k & 1) u = fa[k][u];
if (u == v) return u;
for (int k = LOG - 1; k >= 0; k--) // 第二步:一起跳到 LCA 下方
if (fa[k][u] != fa[k][v]) {
u = fa[k][u];
v = fa[k][v];
}
return fa[0][u];
}

// ========== Tarjan 离线(并查集) ==========
int f[N]; bool vis[N];
vector<pair<int,int>> query[N]; // query[u] = {(v, 询问编号)}
int ans[N];
int find(int x) { return f[x] == x ? x : f[x] = find(f[x]); }

void tarjan(int u, int fa0) {
f[u] = u; vis[u] = true;
for (int v : g[u]) if (v != fa0) {
tarjan(v, u);
f[v] = u; // 子树处理完,并入父节点集合
}
for (auto &[v, id] : query[u])
if (vis[v]) ans[id] = find(v); // v 已访问 => 其集合代表元即 LCA
}

注意点 / 坑点

  1. LOG 必须满足 2^LOG > n:开小了跳不到根会出错;fa[k][u] 在根处指向 0,depth[0] 保持 0,防止越界。
  2. 查询第二步「跳到 LCA 正下方」完成后返回 fa[0][u],不要直接返回 u(此时 u、v 是 LCA 的两个孩子)。
  3. 递归 dfs 深度可达 n(链状树):n 大时用迭代写法或加大栈(如 Windows 下 #pragma comment(linker, "/STACK:...")、Linux 下加线程栈),否则爆栈。
  4. Tarjan 离线要求询问一次性读入(离线),不能边读边答;并查集合并方向是「子树并入父节点」,顺序颠倒会得到错误答案。

可拓展的地方

  1. 把 fa 扩展成结构体(同时存权值和、最大值、次大值),可 O(log n) 求树上任意路径的「和 / 最大 / 次大」,配合次小生成树(3.7)、树上路径查询使用。
  2. LCA 与树上差分(3.10)、树剖(3.11)结合完成路径批量操作;欧拉序 + RMQ 查询 O(1),适合询问极多(1e6 级)的场景;树上两点距离 = depth[u] + depth[v] - 2 * depth[lca]。

典型例题类型

  • 模板:洛谷 P3379 【模板】最近公共祖先(LCA)。
  • 应用:洛谷 P4281 [AHOI2008] 紧急集合(三点 LCA 与路径合并);距离类:「洛谷 P1967 货车运输」(最大生成树 + 倍增求路径最小边)。

3.9 树的直径与重心

一句话用途:树的直径是树上距离最远的两个点之间的距离(最长路径),树的重心是「删掉它后最大连通块最小」的点;二者是树形结构的两大核心性质,广泛用于最小化最大距离、树形 DP 优化与树的分治。

算法思想:直径用两遍 DFS/BFS:从任意点 u 出发找最远点 a,再从 a 出发找最远点 b,则 a 到 b 的路径就是一条直径(定理:树的最远点必为直径端点)。用 parent 数组记录 BFS 树即可回溯出直径路径。重心用一遍 DFS 维护每个点的子树大小 sz[u] 与「删掉它后的最大连通块」mx[u] = max(最大儿子子树, n - sz[u]),mx 最小的点即重心;一棵树的重心至多两个。

复杂度:时间 O(n);空间 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 2e5 + 5;
vector<int> g[N]; // 无向无权树
int parent[N]; // BFS 树,用于回溯直径路径
int far; // 距离起点最远的点

// BFS 返回从 s 出发的最远距离,并记录 parent
int bfs(int s, int n) {
vector<int> dist(n + 1, -1);
queue<int> q;
dist[s] = 0; q.push(s); parent[s] = 0;
int best = s;
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : g[u])
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
parent[v] = u;
q.push(v);
if (dist[v] > dist[best]) best = v;
}
}
far = best;
return dist[best]; // 返回最远距离(无权图即边数)
}

// 求直径端点 a、b 与路径(顶点序列)
void diameter(int n, int &a, int &b, vector<int> &path) {
bfs(1, n); a = far; // 第一遍:任意点 -> 端点 a
bfs(a, n); b = far; // 第二遍:a -> 端点 b
for (int x = b; x != 0; x = parent[x]) path.push_back(x);
reverse(path.begin(), path.end()); // path 为直径上的顶点序列
}

// ========== 树的重心 ==========
int sz[N], mx[N], centroid;

void dfsC(int u, int f, int n) {
sz[u] = 1; mx[u] = 0;
for (int v : g[u]) if (v != f) {
dfsC(v, u, n);
sz[u] += sz[v];
mx[u] = max(mx[u], sz[v]); // 孩子方向的最大子树
}
mx[u] = max(mx[u], n - sz[u]); // 父方向那一块(易漏!)
if (centroid == 0 || mx[u] < mx[centroid]) centroid = u;
}

注意点 / 坑点

  1. 直径定理要求边权非负(一般树题满足);带负权边时两遍 DFS 失效,需改用树形 DP 求直径。
  2. parent 数组来自「从 a 出发」的那次 BFS:回溯必须从 b 沿 parent 走到 0,不要从 a 反向走(parent 不是双向链表)。
  3. 重心的 mx[u] 必须包含父方向那一块 n - sz[u],只统计儿子子树是新手最常见的错误。
  4. 若题目数据不保证「树」(可能有重边 / 自环 / 不连通),先用并查集判边数 == n-1 且连通;两个重心时按题意取编号小 / 大的那个。

可拓展的地方

  1. 树的中心(直径中点):使「到最远点距离最小」的点,可由直径两端点距离公式直接求出,用于选址类「最小化最大距离」问题。
  2. 两棵树合并的直径结论(新直径端点在四个原端点中取最远对)、「删边求两棵子树直径」、树上 DP 结合直径做整体优化;重心常用于「点分治」的递归切分与「最小化最大子树」的平衡。

典型例题类型

  • 树的直径:洛谷 P3304 [SDOI2013] 直径(直径必经边性质);CF 1085D Minimum Diameter Tree。
  • 树的重心:POJ 3107 Godfather;「洛谷 P1364 医院设置」(带权重心 / 最小化距离和)。

3.10 树上差分

一句话用途:对树上的「路径」或「子树」做多次批量加操作,最后一次性 DFS 还原出每个点 / 每条边的真实值,把每次 O(路径长度) 的修改降到 O(1),是树上覆盖计数类题目的标准工具,常与 LCA(3.8)配合。

算法思想:利用「差分前缀和」思想在树上打标记。设 l = lca(u, v):

  • 点差分(路径 u→v 上每个点 +c):diff[u] += c, diff[v] += c, diff[l] -= c, diff[fa(l)] -= c;还原时 diff[u] += Σ diff[son],最终 diff[u] 即点 u 的真实值。
  • 边差分(路径 u→v 上每条边 +c):把「边权」下放到深度较大的端点(边挂在儿子点上),标记 diff[u] += c, diff[v] += c, diff[l] -= 2c;还原后 diff[u] 表示 u 与 fa[u] 之间的那条边。
  • 原理:自底向上累加时,路径两端各 +c 使路径上每个点被加一次,LCA 处减掉多算的部分。

复杂度:每次修改 O(1)(LCA 若用倍增则 O(log n)),最后还原 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 5e5 + 5, LOG = 20;
vector<int> g[N];
int depth[N], fa[LOG][N];
ll diff[N]; // 差分标记

void dfs(int u, int f) { // 预处理深度与倍增表(同 3.8)
depth[u] = depth[f] + 1;
fa[0][u] = f;
for (int k = 1; k < LOG; k++)
fa[k][u] = fa[k - 1][fa[k - 1][u]];
for (int v : g[u]) if (v != f) dfs(v, u);
}

int lca(int u, int v) {
if (depth[u] < depth[v]) swap(u, v);
int d = depth[u] - depth[v];
for (int k = 0; k < LOG; k++) if (d >> k & 1) u = fa[k][u];
if (u == v) return u;
for (int k = LOG - 1; k >= 0; k--)
if (fa[k][u] != fa[k][v]) { u = fa[k][u]; v = fa[k][v]; }
return fa[0][u];
}

// ========== 点差分:路径 u->v 上每个点 +c ==========
void pathAddPoint(int u, int v, ll c) {
int l = lca(u, v);
diff[u] += c;
diff[v] += c;
diff[l] -= c;
diff[fa[0][l]] -= c; // 点差分多减一次 l 的父节点
}

// ========== 边差分:路径 u->v 上每条边 +c(边权挂在深度大的端点) ==========
void pathAddEdge(int u, int v, ll c) {
int l = lca(u, v);
diff[u] += c;
diff[v] += c;
diff[l] -= 2 * c; // 边差分在 l 处减两次
}

// 最后一次 DFS 自底向上还原:diff[u] 累加所有子树的标记
void restore(int u, int f) {
for (int v : g[u]) if (v != f) {
restore(v, u);
diff[u] += diff[v];
}
// 此时:点差分 => diff[u] 是点 u 的真实值;
// 边差分 => diff[u] 是 u 与 fa[u] 之间那条边的真实值
}

注意点 / 坑点

  1. 点差分与边差分的标记公式不同:点差分是「u+, v+, l-, fa(l)-」,边差分是「u+, v+, l-2」,混用会导致整体偏差,务必按题目要求选择。
  2. 还原必须等「所有修改打完标记」之后,自底向上(后序 DFS)一次性进行;不能在修改中途还原,也不能用自顶向下累加。
  3. l 是根节点时 fa[0][l] = 0:差分数组下标要覆盖 0 且不越界(diff[0] 保持 0 即可),或特判跳过。
  4. 统计值可能很大(多次路径覆盖),diff 用 ll;若统计「是否被覆盖」(布尔),注意同一路径多次覆盖的累加语义。

可拓展的地方

  1. 差分标记的「加」可替换为异或 / 取 max 等操作,对应「路径异或」「路径最大覆盖」等变体;也可做「每条边被多少条路径覆盖」的统计。
  2. 与二分答案结合(如运输计划):二分最大路径长度,对超限路径打差分标记,检查是否存在一条边被所有超限路径覆盖;树剖(3.11)适合在线修改查询,差分适合离线「一批改、一次查」。

典型例题类型

  • 点差分:洛谷 P3128 [USACO15DEC] Max Flow P(统计路径覆盖次数最大值)。
  • 边差分 + 二分:洛谷 P2680 [NOIP2015 提高组] 运输计划。

3.11 重链剖分

一句话用途:重链剖分(树链剖分)把树上的「路径」与「子树」操作转化为序列上的「区间」操作,配合线段树实现 O(log²n) 的路径修改查询与 O(log n) 的子树修改查询,是「树上路径加 / 路径和 / 子树操作」在线问题的标准解法。

算法思想:第一次 DFS 求出每个点的子树大小 sz、重儿子 heavy(sz 最大的儿子)、深度 depth、父亲 fa;第二次 DFS 按「先重儿子」的顺序分配 dfs 序 dfn(保证每条重链上的 dfn 连续),并记录链顶 top。任意路径 u→v 可拆成 O(log n) 段「重链上的连续区间」:每次让 top 较深的点跳到 fa[top],把 [dfn[top], dfn[u]] 交给线段树,最终同链时处理剩余区间。子树区间 [dfn[u], dfn[u]+sz[u]-1] 因 dfn 的连续性天然是一段。路径拆分过程顺带可得 LCA。

复杂度:预处理 O(n);单次路径操作 O(log²n),单次子树操作 O(log n)。空间 O(n log n)(线段树)+ O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5;
vector<int> g[N];
int n, m, root, mod;
int fa[N], depth[N], sz[N], heavy[N]; // 第一次 DFS 结果
int dfn[N], top[N], rnk[N], timer; // rnk[dfn[u]] = u
ll w[N]; // 点权(原编号)

// ========== 第一次 DFS:sz / heavy / depth / fa ==========
void dfs1(int u, int f) {
fa[u] = f; depth[u] = depth[f] + 1;
sz[u] = 1; heavy[u] = 0;
int mx = 0;
for (int v : g[u]) if (v != f) {
dfs1(v, u);
sz[u] += sz[v];
if (sz[v] > mx) mx = sz[v], heavy[u] = v; // 记录重儿子
}
}

// ========== 第二次 DFS:dfn / top(先走重儿子保证重链连续) ==========
void dfs2(int u, int tp) {
dfn[u] = ++timer; rnk[timer] = u; top[u] = tp;
if (heavy[u]) dfs2(heavy[u], tp); // 重儿子继承链顶
for (int v : g[u])
if (v != fa[u] && v != heavy[u]) dfs2(v, v); // 轻儿子新开链
}

// ---------- 线段树:区间加 / 区间和(含 lazy 下传) ----------
ll tree[N * 4], lazy[N * 4];
void pushUp(int p) { tree[p] = (tree[p<<1] + tree[p<<1|1]) % mod; }
void pushDown(int p, int l, int r) {
if (lazy[p] == 0) return;
int m = (l + r) >> 1;
ll c = lazy[p];
(tree[p<<1] += c * (m - l + 1)) %= mod;
(lazy[p<<1] += c) %= mod;
(tree[p<<1|1] += c * (r - m)) %= mod;
(lazy[p<<1|1] += c) %= mod;
lazy[p] = 0;
}
void build(int p, int l, int r) {
if (l == r) { tree[p] = w[rnk[l]] % mod; return; } // 注意 rnk 映射
int m = (l + r) >> 1;
build(p<<1, l, m); build(p<<1|1, m+1, r);
pushUp(p);
}
void rangeAdd(int p, int l, int r, int ql, int qr, ll c) {
if (ql <= l && r <= qr) {
(tree[p] += c * (r - l + 1)) %= mod;
(lazy[p] += c) %= mod;
return;
}
pushDown(p, l, r);
int m = (l + r) >> 1;
if (ql <= m) rangeAdd(p<<1, l, m, ql, qr, c);
if (qr > m) rangeAdd(p<<1|1, m+1, r, ql, qr, c);
pushUp(p);
}
ll rangeSum(int p, int l, int r, int ql, int qr) {
if (ql <= l && r <= qr) return tree[p];
pushDown(p, l, r);
int m = (l + r) >> 1;
ll res = 0;
if (ql <= m) res += rangeSum(p<<1, l, m, ql, qr);
if (qr > m) res += rangeSum(p<<1|1, m+1, r, ql, qr);
return res % mod;
}

// ---------- 路径操作:拆成 O(log n) 段链区间 ----------
void pathAdd(int u, int v, ll c) {
while (top[u] != top[v]) {
if (depth[top[u]] < depth[top[v]]) swap(u, v);
rangeAdd(1, 1, n, dfn[top[u]], dfn[u], c); // 处理 u 所在整条链
u = fa[top[u]]; // u 跳到链顶的父亲
}
if (depth[u] > depth[v]) swap(u, v);
rangeAdd(1, 1, n, dfn[u], dfn[v], c); // 同链后处理剩余区间
}
ll pathSum(int u, int v) {
ll res = 0;
while (top[u] != top[v]) {
if (depth[top[u]] < depth[top[v]]) swap(u, v);
res = (res + rangeSum(1, 1, n, dfn[top[u]], dfn[u])) % mod;
u = fa[top[u]];
}
if (depth[u] > depth[v]) swap(u, v);
res = (res + rangeSum(1, 1, n, dfn[u], dfn[v])) % mod;
return res;
}

// ---------- 子树操作:dfn 连续,直接区间操作 ----------
void subAdd(int u, ll c) { rangeAdd(1, 1, n, dfn[u], dfn[u] + sz[u] - 1, c); }
ll subSum(int u) { return rangeSum(1, 1, n, dfn[u], dfn[u] + sz[u] - 1); }

注意点 / 坑点

  1. 路径拆分循环里比较的是 depth[top[u]]depth[top[v]],跳转是 u = fa[top[u]];写成比较 depth[u] 或跳错节点是树剖最常见的 bug。
  2. 线段树建树用 w[rnk[l]](dfn 序映射回原编号),不能直接用 w[l],否则权值挂错位置。
  3. 子树区间是 [dfn[u], dfn[u]+sz[u]-1]——只有重链剖分这种「先重儿子」的 dfn 才能保证子树连续;用错 dfs 序会出错。
  4. 要求取模时,加法、乘法每步都要取模(乘法可能溢出 int,用 ll);递归 dfs 深度可达 n(链状数据),必要时迭代化或扩栈。
  5. 若操作对象是「边权」而非「点权」,把每条边的权值下放到深度较大的端点,路径操作时注意别把 LCA 那一个点多算进去。

可拓展的地方

  1. 线段树可换成「线段树维护最大 / 最小」「可持久化线段树」「FHQ Treap(支持路径翻转)」等,扩展出路径 max / 路径翻转 / 历史版本等高级操作。
  2. 「长链剖分」是重链剖分的兄弟技术,用于 O(n) 优化「深度相关」的树上 DP;树剖 dfn 序还与「树状数组」「主席树」天然配合。

典型例题类型

  • 模板:洛谷 P3384 【模板】轻重链剖分 / 树链剖分。
  • 应用:洛谷 P3178 [HAOI2015] 树上操作;「洛谷 P2146 [NOI2015] 软件包管理器」(子树 + 路径操作)。

3.12 强连通分量与缩点

一句话用途:强连通分量(SCC)是有向图中「两两互相可达」的极大顶点集合;Tarjan 算法在线性时间内求出全部 SCC,缩点后得到 DAG,可处理可达性统计、2-SAT 可行性判定(3.20)以及缩点后 DAG 上的 DP 等问题。

算法思想:Tarjan 在 DFS 中维护 dfn[u](访问时间戳)与 low[u](u 的子树通过回边能到达的最早时间戳)。用栈保存「尚未判定归属」的顶点:当 low[u] == dfn[u] 时,从栈顶到 u 之间的所有顶点构成一个 SCC,一并弹出并编号。缩点即把每个 SCC 视为新点,跨 SCC 的原图边成为 DAG 上的边(计数类题目需去重)。Kosaraju 则是先对原图 DFS 记录出栈序,再在反向图上按出栈序逆序 DFS,每次得到的连通块即一个 SCC。

复杂度:时间 O(n+m);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5;
vector<int> g[N]; // 原图
vector<int> dag[N]; // 缩点后的 DAG
int dfn[N], low[N], timer;
int stk[N], top; // 手动栈(模拟系统栈,防爆栈)
bool instk[N]; // 是否在栈中
int sccId[N], sccCnt; // 每个点所属 SCC 编号
vector<int> sccList[N]; // 每个 SCC 包含的顶点(可选)

void tarjan(int u) {
dfn[u] = low[u] = ++timer;
stk[++top] = u; instk[u] = true;
for (int v : g[u]) {
if (!dfn[v]) { // 树边:递归后更新 low
tarjan(v);
low[u] = min(low[u], low[v]);
} else if (instk[v]) { // 回边 / 指向栈中顶点的横叉边
low[u] = min(low[u], dfn[v]);
}
// 指向已出栈顶点的边属于别的 SCC,无需处理
}
if (low[u] == dfn[u]) { // u 是所在 SCC 的根
sccCnt++;
while (true) {
int x = stk[top--];
instk[x] = false;
sccId[x] = sccCnt;
sccList[sccCnt].push_back(x);
if (x == u) break;
}
}
}

// 主流程 + 缩点建 DAG(用 set 去重)
void solve(int n) {
for (int i = 1; i <= n; i++) if (!dfn[i]) tarjan(i);
set<pair<int,int>> added;
for (int u = 1; u <= n; u++)
for (int v : g[u]) {
int a = sccId[u], b = sccId[v];
if (a != b && !added.count({a, b})) {
added.insert({a, b});
dag[a].push_back(b);
}
}
}

注意点 / 坑点

  1. 回边更新 low 时必须满足 instk[v](v 还在栈中),且用 dfn[v] 而非 low[v] 更新;漏掉 instk 判断会把已属于别的 SCC 的顶点算进来。
  2. 缩点后的 DAG 边要去重:直接用 vector push 会得到大量重边,计数 / DP 会多算;用 set 或排序后 unique。
  3. 图可能很大(n 达 1e6):递归 Tarjan 可能爆系统栈,可用手动栈(如上)或加大栈;多测要重置 dfn / low / timer / 栈指针。
  4. 无向图的「点双 / 边双」(3.13)与有向图 SCC 的 Tarjan 写法不同:无向图用 fa 跳过父边,有向图用 instk 判断,不要混用。

可拓展的地方

  1. 缩点后 DAG 上做拓扑排序 + DP(最长链、方案数、必经点)是经典套路;「可达性统计」可用 bitset 传递闭包(3.5)或拓扑序 DP(每点维护 bitset)。
  2. SCC 是 2-SAT(3.20)可行性的判定基础;Kosaraju 代码直观但需要反图、常数略大,两者任选其一熟练掌握即可。

典型例题类型

  • 模板:洛谷 P3387 【模板】缩点(缩点后 DAG 上 DP 求最长链)。
  • 应用:洛谷 P2341 [HAOI2006] 受欢迎的牛(出度为 0 的 SCC 判定);「洛谷 P2746 [USACO5.3] 校园网 Network of Schools」(入度 / 出度为 0 的 SCC 计数)。

3.13 割点与桥

一句话用途:无向图中,割点(关节点)是删除后使图不再连通的点,桥(割边)是删除后使图不再连通的边;Tarjan 在 O(n+m) 内求出所有割点与桥,并进一步划分点双连通分量与边双连通分量。

算法思想:与 SCC 的 Tarjan 同源但针对无向图:维护 dfn 与 low。对 DFS 树边 (u, v):

  • 若 low[v] >= dfn[u],则 u 是割点(v 的子树无法绕回 u 的上方);
  • 若 low[v] > dfn[u],则边 (u, v) 是桥(v 的子树连不回 u 及上方)。
    根节点特判:根是割点当且仅当它在 DFS 树中的孩子数 ≥ 2。点双连通分量(v-DCC)用栈维护、遇到割点时弹出;边双连通分量(e-DCC)删掉所有桥后,剩下的每个连通块就是一个 e-DCC。

复杂度:时间 O(n+m);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 2e4 + 5;
vector<int> g[N]; // 无向图
int dfn[N], low[N], timer;
bool isCut[N]; // 是否为割点
vector<pair<int,int>> bridges; // 所有桥

void tarjan(int u, int fa) {
dfn[u] = low[u] = ++timer;
int child = 0; // DFS 树中 u 的孩子数(根节点特判用)
for (int v : g[u]) {
if (v == fa) continue; // 跳过父边(若有重边需改为按边编号判)
if (!dfn[v]) {
tarjan(v, u);
low[u] = min(low[u], low[v]);
child++;
if (low[v] >= dfn[u]) isCut[u] = true; // 割点判定
if (low[v] > dfn[u]) bridges.push_back({u, v}); // 桥判定
} else {
low[u] = min(low[u], dfn[v]); // 回边:访问到祖先
}
}
if (fa == 0 && child < 2) isCut[u] = false; // 根节点特判
}

注意点 / 坑点

  1. 根节点特判必须在最后做:非根用 low[v] >= dfn[u] 判割点、用 low[v] > dfn[u] 判桥,两个不等号不能互换(割点取等号、桥不取等号)。
  2. v == fa 跳过父边会误伤重边:若 (u,v) 有两条边,第二条应视为「回边」而不是父边。处理方式是记录边编号,跳过「同一条边」而不是跳过顶点。
  3. 割点可以同时属于多个点双连通分量(出栈逻辑不同),桥不属于任何 e-DCC(删桥后剩余块才是 e-DCC);点双与边双的求法完全不同,勿混。
  4. 图可能不连通:主循环要对每个未访问点跑 tarjan,否则漏掉其他连通分量的割点 / 桥。

可拓展的地方

  1. 点双连通分量可建「圆方树」(每个 v-DCC 建方点、原图点建圆点),处理「必经点」「必经边」、仙人掌图等高级问题(如洛谷 P4320 道路相遇)。
  2. e-DCC 缩点后得到「桥树」(bridge tree),配合 LCA 处理「加几条边才能变成边双连通」类问题(如 POJ 3177 Redundant Paths)。

典型例题类型

  • 割点模板:洛谷 P3388 【模板】割点(割顶)。
  • 桥:POJ 1144 Network(割点);「洛谷 P1656 炸铁路」(输出所有桥)。

3.14 欧拉回路与欧拉路径

一句话用途:欧拉路径是一条经过图中每条边「恰好一次」的路径(一笔画),欧拉回路是起点终点相同的欧拉路径;本小节给出无向 / 有向图的判定条件与 Hierholzer 算法模板,可输出具体路径(含字典序最小方案)。

算法思想

  • 判定:无向图存在欧拉回路 ⇔ 图连通且所有点度数为偶数;存在欧拉路径 ⇔ 连通且恰好 0 或 2 个奇度点(奇度点即路径两端)。有向图存在欧拉回路 ⇔ 弱连通且所有点入度 == 出度;欧拉路径允许一个点出度 == 入度+1(起点)、一个点入度 == 出度+1(终点)。
  • Hierholzer:从起点 DFS,走一步删一条边(无向图反向边同步标记),走到「无路可走」时把当前点压栈;所有点处理完后,栈的逆序就是欧拉路径。关键在「边走过即删」,避免重复遍历。

复杂度:时间 O(n+m);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 5e2 + 5, M = 4096; // 点、边数组(无向图翻倍)
int head[N], to[M], nxt[M], tot = 1; // tot 从 1 开始,i^1 成对
bool used[M]; // 边是否已走过
int cur[N]; // 当前弧:每个点已扫到的边
vector<int> path; // 逆序存储,最后 reverse
int deg[N]; // 无向图度数 / 有向图出度

void addEdge(int u, int v) {
to[++tot] = v; nxt[tot] = head[u]; head[u] = tot;
to[++tot] = u; nxt[tot] = head[v]; head[v] = tot; // 无向图反向边
}

// Hierholzer:u 无路可走时压栈(后序)
void dfs(int u) {
for (int &i = cur[u]; i; ) {
if (used[i]) { i = nxt[i]; continue; } // 已走过,跳到下一条
used[i] = used[i ^ 1] = true; // 无向边成对标记
int v = to[i];
i = nxt[i]; // 先删掉这条边再递归
dfs(v);
}
path.push_back(u); // 后序入栈
}

int main() {
// 1. 判定(无向图):奇度点个数 must be 0(回路)或 2(路径)
// 2. 起点:回路任选一点;路径选某个奇度点
// 3. 若要求字典序最小:把每个点的邻接边按终点排序后建图(倒序插入)
int start = 1; // 依判定结果选择起点
dfs(start);
reverse(path.begin(), path.end()); // path 即欧拉路径
// 4. 检查 path 长度 == 边数 + 1,否则图不连通 / 判定错误
return 0;
}

注意点 / 坑点

  1. 判定先行:奇度点数量不为 0 或 2(无向)、入度出度差不对(有向)、图不连通(忽略孤立点后),直接判不存在;不要把判定写成「试跑 DFS」。
  2. 自环、重边都算独立边,必须每条都恰好走一次;邻接表用「当前弧指针 + used 标记」可正确处理重边,multiset 写法也能但常数大。
  3. 无向图删边必须成对(i 与 i^1 同时标记),否则会重复走同一条无向边;这是无向版与有向版唯一的实现差异。
  4. 递归深度可能等于边数 m(路径很长),m 大(>1e5)时改用迭代栈模拟,避免爆栈;输出前验证路径长度 == m+1。

可拓展的地方

  1. 字典序最小欧拉路径:按终点排序建图 + 从编号最小的合法起点出发即可(模板已给出思路)。
  2. 混合图欧拉路径(既有有向边又有无向边)可转化为「无向边定向」问题,用网络流(3.17)判定;「中国邮递员问题」(重复走边使总长最短)也建立在欧拉回路思想上。

典型例题类型

  • 模板:洛谷 P2731 [USACO3.3] 骑马修栅栏 Riding the Fences(无向图字典序最小欧拉路径)。
  • 判定 / 输出:洛谷 P1341 无序字母对(字母建图 + 欧拉路径输出)。

3.15 二分图匹配

一句话用途:判断一个图是否为二分图(能否把顶点分成两组、组内无边的图),并求二分图的最大匹配(最多能配成多少对),是「任务-工人」「课程-教室」「棋盘覆盖」等指派类问题的核心;匈牙利算法是竞赛中最常用的匹配算法。

算法思想:染色法判二分图:交替染 0/1 色,若相邻点出现同色则存在奇环,不是二分图。匈牙利算法为每个左侧顶点尝试找增广路:从 u 出发沿「未匹配边 → 匹配边 → 未匹配边…」交替走,走到一个未匹配的右侧顶点即找到增广路,翻转路径上的匹配状态使匹配数 +1;用 vis 标记右侧顶点避免死循环。Hopcroft-Karp 用 BFS 分层 + DFS 一次找多条不相交最短增广路,复杂度 O(E√V),适合大数据。

复杂度:染色 O(n+m);匈牙利 O(nm)(n 为左侧点数,m 为边数);HK O(E√V)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 505;
vector<int> g[N]; // 只存左侧 -> 右侧的边
int matchR[N]; // 右侧点匹配的左侧点编号(-1 未匹配)
bool visR[N]; // 本轮尝试中右侧点是否访问过

// 为左侧点 u 尝试找增广路,成功返回 true
bool dfs(int u) {
for (int v : g[u]) {
if (visR[v]) continue; // 本轮已试过,防死循环
visR[v] = true;
if (matchR[v] == -1 || dfs(matchR[v])) {
// v 空闲,或 v 当前的匹配者能另找增广路(让位)
matchR[v] = u;
return true;
}
}
return false;
}

int hungarian(int nL) {
memset(matchR, -1, sizeof(matchR));
int ans = 0;
for (int u = 1; u <= nL; u++) {
memset(visR, 0, sizeof(visR)); // 每个左侧点一轮,vis 必须清空
if (dfs(u)) ans++;
}
return ans;
}

// ========== 染色法判定二分图 ==========
int color[N]; // -1 未染色,0/1 两种颜色
bool isBipartite(int n) {
memset(color, -1, sizeof(color));
for (int i = 1; i <= n; i++) if (color[i] == -1) {
queue<int> q;
color[i] = 0; q.push(i);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : g[u]) {
if (color[v] == -1) { color[v] = color[u] ^ 1; q.push(v); }
else if (color[v] == color[u]) return false; // 冲突 => 非二分图
}
}
}
return true;
}

注意点 / 坑点

  1. visR 每个左侧点都要清空,且 dfs 内「先标记再递归」,否则会死循环或漏配;memset 清 visR 不要漏在循环外。
  2. 匈牙利要求输入是二分图,g[u] 只存「左侧 → 右侧」的单向边;无向二分图建图时只加一个方向,重复加两边会导致误配。
  3. 递归深度可达左侧点数,n 大时可能爆栈(可迭代化或扩栈);多测记得重置 matchR 与 g。
  4. 「最大匹配」「最小点覆盖」「最大独立集」的换算见 3.16,建模时明确左右部划分与顶点编号,防止重复计数。

可拓展的地方

  1. 带权最大匹配用 KM 算法(O(n³),相等子图 + 顶标)或费用流(3.18);右侧点远多于左侧点时可用 HK 提速(O(E√V))。
  2. 染色法推广到「判奇环」「2-着色」;匹配建模经典套路:棋盘黑白染色(如洛谷 P3355 骑士共存)、矩阵行列建模(POJ 3041)、时间区间配对等。

典型例题类型

  • 模板:洛谷 P3386 【模板】二分图最大匹配。
  • 建模应用:POJ 3041 Asteroids(行 / 列建点,障碍建边,最小点覆盖);「洛谷 P1640 [SCOI2010] 连续攻击游戏」(并查集或匹配)。

3.16 二分图相关定理

一句话用途:三条把「匹配」与「点覆盖 / 独立集 / 路径覆盖」互相转化的经典结论,用于把看似与匹配无关的问题(最少覆盖点、最多不冲突项、最少路径覆盖)转化为最大匹配求解。

算法思想

  • 最小点覆盖 = 最大匹配(König 定理):二分图中,覆盖所有边所需的最少点数等于最大匹配数。构造方法:从左侧所有未匹配点出发沿「未匹配边(左→右)、匹配边(右→左)」交替标记,答案取「左侧未标记点 ∪ 右侧标记点」。
  • 最大独立集 = 顶点总数 − 最小点覆盖 = n − 最大匹配:独立集与点覆盖互为补集(一个点集是点覆盖 ⇔ 其补集是独立集),因此最大化独立集等价于最小化点覆盖。
  • 最小路径覆盖(DAG)= n − 最大匹配:把 DAG 每个点拆成左(出点)、右(入点)两个点,原边 u→v 连「左 u → 右 v」,求最大匹配;每条匹配边把两个点串进同一条路径,未匹配点数即路径条数。

复杂度:转化后即匹配算法复杂度(匈牙利 O(nm) / HK O(E√V)),建图另需 O(m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e4 + 5;
vector<int> g[N]; // 左 -> 右
int matchR[N]; // 右侧点的匹配者(-1 为空)
int matchL[N]; // 左侧点的匹配对象(构造点覆盖用)
bool visR[N];

bool dfs(int u) { // 匈牙利核心(同 3.15)
for (int v : g[u]) {
if (visR[v]) continue;
visR[v] = true;
if (matchR[v] == -1 || dfs(matchR[v])) {
matchR[v] = u;
matchL[u] = v;
return true;
}
}
return false;
}

int maxMatch(int nL) {
memset(matchR, -1, sizeof(matchR));
memset(matchL, -1, sizeof(matchL));
int res = 0;
for (int u = 1; u <= nL; u++) {
memset(visR, 0, sizeof(visR));
if (dfs(u)) res++;
}
return res;
}

// ---------- 构造最小点覆盖:交替路标记 ----------
bool usedL[N], visR2[N];
void mark(int u) { // 从左侧未匹配点出发
usedL[u] = true;
for (int v : g[u]) { // 未匹配边:左 -> 右
if (!visR2[v]) {
visR2[v] = true;
if (matchR[v] != -1 && !usedL[matchR[v]])
mark(matchR[v]); // 匹配边:右 -> 左
}
}
}

// 主流程:
// int M = maxMatch(nL);
// cout << "最小点覆盖 = " << M << ",最大独立集 = " << nL + nR - M << "\n";
// for (int u = 1; u <= nL; u++) if (matchL[u] == -1) mark(u);
// 点覆盖 = (左侧中 !usedL 的点) ∪ (右侧中 visR2 的点)

注意点 / 坑点

  1. 三条结论只对二分图成立:一般图的最小点覆盖是 NP 完全问题,切勿套用;DAG 的路径覆盖也要先确认是 DAG(有环需先缩点,见 3.12)。
  2. 最大独立集公式中的 n 是「二分图总顶点数 = 左部 + 右部」,不是某一部的点数,容易算错。
  3. 最小路径覆盖要求路径「顶点不相交」;若允许点重复经过,需先用传递闭包(3.5)扩展边集再拆点。
  4. 构造点覆盖的标记必须从「左侧所有未匹配点」出发,只从一个点出发会漏标记导致构造错误。

可拓展的地方

  1. 「最大权闭合图」「二分图最小权点覆盖」等变体可用最大流(3.17)求解,König 定理与最小割定理一脉相承,是网络流建模的基石。
  2. 三定理与棋盘染色、矩阵行列、区间调度等经典建模配合,可解决大量「选最少 / 选最多」类组合优化问题。

典型例题类型

  • 最小点覆盖:POJ 3041 Asteroids(行、列为点,障碍为边)。
  • 最大独立集:洛谷 P3355 骑士共存问题(棋盘黑白染色 + 独立集);最小路径覆盖:洛谷 P2764 最小路径覆盖问题。

3.17 最大流 Dinic

一句话用途:求有向网络中从源点 s 到汇点 t 能通过的最大流量;Dinic 是竞赛最常用的最大流实现(BFS 分层 + DFS 多路增广 + 当前弧优化),也是最大流最小割定理、拆点建模等大量网络流题目的基础。

算法思想:建图时每条有向边同时建一条容量 0 的反向边(成对编号 i^1),增广时正向边减流量、反向边加流量,实现「反悔」(残量网络上的增广)。BFS 按残量>0 的边给每个点标 level(到源点最短距离),只沿 level[u]+1 == level[v] 的边增广,保证每轮增广都是最短路。DFS 在当前分层图上多路增广,并用当前弧 cur[u] 记录每个点已扫到的边,避免重复扫描已满的边。

复杂度:时间 O(n²m)(实际远快于上界,1e4 点 1e5 边规模可跑);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e4 + 5, M = 2e5 + 5;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
int head[N], to[M], nxt[M], tot = 1; // tot 从 1 开始,i^1 成对
ll cap[M]; // 残量容量(ll!)
int level[N], cur[N]; // 分层深度、当前弧
int s, t;

void addEdge(int u, int v, ll c) {
to[++tot] = v; cap[tot] = c; nxt[tot] = head[u]; head[u] = tot;
to[++tot] = u; cap[tot] = 0; nxt[tot] = head[v]; head[v] = tot; // 反向边
}

bool bfs() { // 分层,同时判断是否还有增广路
memset(level, -1, sizeof(level));
queue<int> q;
level[s] = 0; q.push(s);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (level[v] == -1 && cap[i] > 0) { // 只走有残量的边
level[v] = level[u] + 1;
q.push(v);
}
}
}
return level[t] != -1;
}

ll dfs(int u, ll flow) { // 多路增广,flow 为当前瓶颈
if (u == t) return flow;
ll used = 0;
for (int &i = cur[u]; i; i = nxt[i]) { // 当前弧:引用记住位置
int v = to[i];
if (level[v] == level[u] + 1 && cap[i] > 0) {
ll f = dfs(v, min(flow - used, cap[i]));
if (f > 0) {
cap[i] -= f;
cap[i ^ 1] += f; // 反向边加流量
used += f;
if (used == flow) break;
}
}
}
return used;
}

ll dinic() {
ll ans = 0;
while (bfs()) {
memcpy(cur, head, sizeof(head)); // 每轮 BFS 后重置当前弧
ans += dfs(s, INF);
}
return ans;
}

注意点 / 坑点

  1. 链式前向星 tot 必须从 1 开始,正向边与反向边才能成对(i^1);从 0 开始则 0^1 == 1 破坏配对,反向边机制全错。
  2. 容量与答案用 ll:边数多、容量大时 int 直接溢出;INF 取 0x3f3f3f3f3f3f3f3f。
  3. 当前弧优化必须写成 for (int &i = cur[u]; ...) 用引用,否则不生效;dfs 内 if (f > 0) 过滤无用递归,避免退化成指数级递归。
  4. 无向边要建「正反两条有向边 + 各自反向边」共 4 条(或两条容量 c 的正向边),不能只建 2 条;反向边的容量语义是「已用流量」,不是「原边权」。
  5. 递归 dfs 深度可达 n,n 大时注意栈空间;bfs 失败(level[t] == -1)即无增广路,结束循环。

可拓展的地方

  1. 最大流最小割定理:最大流 = 最小割容量;求「最小割」「最大权闭合图」(洛谷 P2762 太空飞行计划)时把权值为正的连源、负的连汇。
  2. 拆点建模:把「点容量」拆成入点 → 出点连容量边,处理「点不重复经过」「最少割点」等问题(洛谷 P1345 奶牛的电信);二分图最大匹配也可建「源→左、左→右、右→汇」容量 1 的网络流求解。

典型例题类型

  • 模板:洛谷 P3376 【模板】网络最大流。
  • 建模:洛谷 P1345 [USACO5.4] 奶牛的电信(拆点最小割);最大权闭合图:洛谷 P2762 太空飞行计划。

3.18 最小费用最大流

一句话用途:在每条边带「容量」与「单位流量费用」的网络上,求流量达到 F(或最大)时总费用最小的方案,用于指派、运输、带费用匹配等优化问题。

算法思想:在残量网络上反复找「费用最小的增广路」(即以费用为边权的最短路),沿该路增广并更新费用与流量,直到无法增广或达到目标流量 F。反向边费用取负(−cost),走反向边表示「退流」,费用被抵消,故图上存在负权边,最短路用 SPFA 求;也可用「势能 + Dijkstra」(Johnson 势能法把负权转非负)优化。沿增广路改容量时,用 pre 数组记录每个点的前驱顶点与前驱边编号。

复杂度:SPFA 版 O(F·nm)(F 为总流量),实际远小于上界;势能 Dijkstra 版 O(F·m log n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 5e3 + 5, M = 1e5 + 5;
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
int head[N], to[M], nxt[M], tot = 1;
ll cap[M], cost[M]; // 容量、单位费用(ll)
int pre[N], preEdge[N]; // 前驱顶点、前驱边编号(回溯增广路)
ll dist[N]; // 最小费用(= 最短路距离)
bool inq[N];
int n, m, s, t;

void addEdge(int u, int v, ll c, ll w) {
to[++tot] = v; cap[tot] = c; cost[tot] = w; nxt[tot] = head[u]; head[u] = tot;
to[++tot] = u; cap[tot] = 0; cost[tot] = -w; nxt[tot] = head[v]; head[v] = tot;
// 反向边费用取负:退流时费用抵消
}

bool spfa() { // 找费用最短路,记录前驱
fill(dist, dist + n + 1, INF);
memset(inq, 0, sizeof(inq));
queue<int> q;
dist[s] = 0; q.push(s); inq[s] = true;
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = false;
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (cap[i] > 0 && dist[u] + cost[i] < dist[v]) {
dist[v] = dist[u] + cost[i];
pre[v] = u; preEdge[v] = i; // 记录路径
if (!inq[v]) { q.push(v); inq[v] = true; }
}
}
}
return dist[t] != INF; // 还能找到增广路
}

pair<ll, ll> minCostMaxFlow() {
ll maxflow = 0, mincost = 0;
while (spfa()) {
// 沿 pre 链回溯求瓶颈流量
ll flow = INF;
for (int v = t; v != s; v = pre[v])
flow = min(flow, cap[preEdge[v]]);
// 沿增广路改容量
for (int v = t; v != s; v = pre[v]) {
cap[preEdge[v]] -= flow;
cap[preEdge[v] ^ 1] += flow;
}
maxflow += flow;
mincost += flow * dist[t]; // 费用 = 流量 × 单位费用(ll!)
}
return {maxflow, mincost};
}

注意点 / 坑点

  1. 反向边费用必须取负(−w),否则「退流」时费用算错;费用、dist、flow*dist[t] 全部用 ll,乘法会溢出 int。
  2. 每次增广都要重新 SPFA 一遍,流量大时很慢;优化方向是「势能 + Dijkstra」(维护势能 h,把边权替换为 cost + h[u] − h[v],使所有边权非负)。
  3. 回溯用 preEdge[v] 取「边编号」,更新时 cap[preEdge[v]] -= flow;循环终止条件是 v != s,不要写成 pre[v] != -1。
  4. 存在负费用边是正常且必需的(反向边一定为负),SPFA 天然支持;若要求「流量恰好为 F」,增广后 maxflow 达到 F 即停止。
  5. 无向边要建 4 条边(两个方向各带正费用);「点容量」限制用拆点(入点 → 出点,费用 0,容量为点容量)。

可拓展的地方

  1. 带权二分图最大权匹配可建模为费用流(源→左费用 0,左→右费用 = 权值,右→汇费用 0),取负后求最小费用即最大权匹配。
  2. 可扩展「上下界费用流」「最小费用可行流」「运输问题 / 指派问题」(洛谷 P4015 运输问题)、「修车」(拆时间点,洛谷 P2053)等建模。

典型例题类型

  • 模板:洛谷 P3381 【模板】最小费用最大流。
  • 建模应用:洛谷 P4015 运输问题(最小 / 最大费用流);「洛谷 P2053 [SCOI2007] 修车」(时间拆点建模)。

3.19 差分约束系统

一句话用途:把一组形如 x_j − x_i ≤ c 的线性不等式转化为图论最短路 / 最长路问题,判断不等式组是否有解,并求变量的最大 / 最小值;用于「至少 / 至多」型约束的调度、时限类问题。

算法思想:转化规则:x_j − x_i ≤ c ⇔ 连边 i → j 权为 c,则最短路性质 dist[j] ≤ dist[i] + c 恰好表达了该不等式。求最大值跑最短路(把所有约束统一成 ≤ 形式,dist 上界即最大值);求最小值跑最长路(统一成 ≥ 形式,或反向建边)。加虚拟源点 0 向所有点连权 0 的边,保证从 0 出发可达所有点,否则不连通的负环检测不到、孤立约束会丢失。有负环(最短路视角)说明不等式组无解。

复杂度:建图 O(m);判环 / 求值用 SPFA 或 Bellman-Ford,最坏 O(nm)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 1e5 + 5;
struct Edge { int to; ll w; };
vector<Edge> g[N];
ll dist[N];
int cnt[N]; // 入队次数(判环)
bool inq[N];

// 统一建图规则:x_v - x_u <= w => 连边 u -> v 权 w
void addInequality(int u, int v, ll w) {
g[u].push_back({v, w});
}

// 求最大值:所有点初始 dist = 0 并入队(等价于虚拟源点连 0 边);负环 => 无解
bool spfaMax(int n) {
const ll INF = 0x3f3f3f3f3f3f3f3fLL;
fill(dist, dist + n + 1, INF);
memset(inq, 0, sizeof(inq));
memset(cnt, 0, sizeof(cnt));
queue<int> q;
for (int i = 1; i <= n; i++) { // 虚拟源点效果:每个点都有 0 初始上界
dist[i] = 0;
q.push(i); inq[i] = true; cnt[i] = 1;
}
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = false;
for (auto &e : g[u]) {
if (dist[u] + e.w < dist[e.to]) {
dist[e.to] = dist[u] + e.w;
if (!inq[e.to]) {
if (++cnt[e.to] > n) return true; // 入队超 n 次 => 负环,无解
q.push(e.to); inq[e.to] = true;
}
}
}
}
return false; // false 表示有解
}

// 求最小值:把不等式写成 x_v >= x_u + w(连边 u -> v 权 w),跑最长路 SPFA:
// 松弛条件改为 if (dist[u] + w > dist[v]) dist[v] = dist[u] + w;
// 判环改为 cnt[v] > n(存在正环则无解),其余结构相同。

注意点 / 坑点

  1. 方向别搞反:x_j − x_i ≤ c 连的是 i → j;「≤」与「≥」建边方向相反,写之前先把所有约束统一成同一方向。
  2. 「求最大值跑最短路、求最小值跑最长路」是惯例而非强制:关键是所有约束统一成同一形式,否则最值语义错乱。
  3. 虚拟源点 0 必须连到每个点(权 0):否则①负环与源点不连通检测不到;②部分变量的约束被孤立,答案错误。模板用「所有点初始入队 + dist=0」实现同样效果。
  4. 判环的入队次数上限与总点数有关(含虚拟源点共 n+1 个点时阈值取 n+1;本模板等价于 n 个源点、阈值取 n),写错会漏判 / 误判。
  5. 有整数 / 非负约束(如 xi ≥ 1)要显式转成差分边(xi − x0 ≥ 1 即 x0 − xi ≤ −1),不能依赖默认值。

可拓展的地方

  1. 约束形式可推广:x_j − x_i ≥ c(反向建边取负)、x_j − x_i = c(拆成 ≤ 与 ≥ 两条边)、x_i ≤ c(0 → i 权 c)、x_i ≥ c(i → 0 权 −c)。
  2. 与「二分答案」结合做可行性判定;判负环也可用 Bellman-Ford 第 n 轮松弛法(3.4),两者等价;SPFA 判正环与负环对称,注意符号。

典型例题类型

  • 模板:洛谷 P5960 【模板】差分约束算法。
  • 应用:洛谷 P3275 [SCOI2011] 糖果(≥ 约束求最小值,0 权环特判);「POJ 1201 Intervals」(区间型约束,前缀和建模)。

3.20 2-SAT

一句话用途:判定一组布尔变量的「或 / 蕴含」约束是否可同时满足(可满足性),并在可行时输出一组赋值方案;n 个变量的 2-SAT 可在 O(n+m) 内求解,是逻辑约束建模的经典工具。

算法思想:每个变量 x 拆成两个顶点:「x 为真」(编号 x) 与「x 为假」(编号 x + n)。约束 a ∨ b 等价于两条蕴含边「¬a → b」与「¬b → a」(若 a 假则 b 必真,反之亦然);其它约束(a → b、a 必真、a 必假等)均可化为蕴含边。建图后用 Tarjan(3.12)求强连通分量:若存在变量 x 使得「x 真」与「x 假」在同一个 SCC,则自相矛盾,无解。输出方案:Tarjan 的 SCC 编号递增方向恰好是拓扑序逆序,取「编号较大」的一侧赋值即可(sccId[x] > sccId[x+n] 则 x 为真,否则为假)。

复杂度:建图 O(m),Tarjan O(n+m);空间 O(n+m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const int N = 2e5 + 5; // 2n 个顶点
vector<int> g[N]; // 蕴含图
int dfn[N], low[N], timer, stk[N], top;
bool instk[N];
int sccId[N], sccCnt;

// 编号约定:x 为真 = x,x 为假 = x + n(n 为变量个数)
int lit(int x, bool val) { return val ? x : x + n; }

void addImp(int a, int b) { g[a].push_back(b); } // a -> b

// 约束:x 取 xv 或 y 取 yv(即 a ∨ b)
void addOr(int x, bool xv, int y, bool yv) {
addImp(lit(x, !xv), lit(y, yv)); // ¬a -> b
addImp(lit(y, !yv), lit(x, xv)); // ¬b -> a
}

// 其他常用约束:
// x 必为真:addImp(lit(x,false), lit(x,true));
// x 必为假:addImp(lit(x,true), lit(x,false));
// x -> y :addImp(lit(x,true), lit(y,true)); addImp(lit(y,false), lit(x,false));

void tarjan(int u) {
dfn[u] = low[u] = ++timer;
stk[++top] = u; instk[u] = true;
for (int v : g[u]) {
if (!dfn[v]) { tarjan(v); low[u] = min(low[u], low[v]); }
else if (instk[v]) low[u] = min(low[u], dfn[v]);
}
if (low[u] == dfn[u]) {
sccCnt++;
while (true) {
int x = stk[top--];
instk[x] = false;
sccId[x] = sccCnt;
if (x == u) break;
}
}
}

bool twoSAT(int n) {
for (int i = 1; i <= 2 * n; i++) if (!dfn[i]) tarjan(i);
for (int i = 1; i <= n; i++)
if (sccId[i] == sccId[i + n]) return false; // 真与假同 SCC => 无解
return true;
}

// 输出方案(有解时):
// for (int i = 1; i <= n; i++)
// cout << (sccId[i] > sccId[i + n] ? "true" : "false") << ' ';
// 理由:Tarjan 编号是拓扑序的逆序,编号大 => 拓扑序靠前 => 取该侧为真

注意点 / 坑点

  1. 编号约定必须全局一致:1..n 为真、n+1..2n 为假;加边、判定、输出三处共用 lit() 映射,局部改约定会导致错乱。
  2. a ∨ b 必须生成两条蕴含边(¬a → b 与 ¬b → a),漏一条会漏约束,可能把无解判成有解。
  3. 输出方案依赖 Tarjan 的 SCC 编号性质(编号递增 = 拓扑序递减,取编号大者);若改用 Kosaraju 或自定义编号,取法要相应调整(一般取「拓扑序靠后」的一侧)。
  4. 变量下标从 1 开始,数组开 2n+5,循环到 2n;图可能不连通,主循环要对所有未访问顶点跑 Tarjan。
  5. 「x 必为真」这类单边约束必须显式加「假 → 真」蕴含边,否则该约束对图的约束力不足,可能误判。

可拓展的地方

  1. 与二分答案结合做可行性判定(把大小比较转化为布尔约束);「字典序最小方案」可对变量从小到大贪心赋值并每次重新判定可行性。
  2. 与强连通分量(3.12)、差分约束(3.19)同属「逻辑 / 不等式 → 图论」家族;注意 3-SAT 是 NP 完全问题,2-SAT 的 O(n+m) 算法不可推广。

典型例题类型

  • 模板:洛谷 P4782 【模板】2-SAT 问题。
  • 应用:洛谷 P4171 [JSOI2010] 满汉全席(美食偏好约束);「BZOJ 1997 [HNOI2010] 平面图判定」(哈密顿回路 + 2-SAT)。

四、数论

本部分速查表

算法 复杂度 适用场景
4.1 快速幂与快速乘 O(log b) 大指数取模、防 ll 溢出的乘法
4.2 最大公约数与扩展欧几里得 O(log min(a,b)) 求 gcd/lcm、解 ax+by=c、求逆元
4.3 素数判定 试除 O(√n);Miller-Rabin O(k·log³n) 单点判素、64 位大数判素
4.4 质因数分解 试除 O(√n);Pollard-Rho 期望 O(n^1/4) 大整数分解全部质因子
4.5 素数筛 埃氏 O(n log log n);线性筛 O(n) 素数表、最小质因子、积性函数预处理
4.6 模逆元 单点 O(log mod),线性递推 O(n) 除法取模、组合数预处理
4.7 欧拉函数与欧拉定理 单点 O(√n);线性筛 O(n) 降幂公式、指数为超大数的取模
4.8 中国剩余定理 O(n·log m) 同余方程组(互素 / 不互素)
4.9 Lucas 定理 O(p·log_p n) p 较小而 n,k 很大的组合数取模
4.10 莫比乌斯反演 线性筛 O(n) + 分块 O(√n) gcd 计数类题目、容斥
4.11 杜教筛 O(n^2/3)(配合 n^2/3 预处理) 积性函数前缀和(n 达 1e9~1e12)
4.12 数论分块 O(√n) ∑⌊n/i⌋ 类求和、配合反演加速
4.13 线性基 插入/查询 O(log V) 子集异或最值、异或第 k 小、计数
4.14 高斯消元 O(n³) 实数/模意义/异或线性方程组、行列式
4.15 BSGS O(√p)(含哈希) 求离散对数 a^x≡b (mod p)
4.16 原根与离散对数 O(√p + k·log p) 求最小原根、NTT、密码学
4.17 博弈论与 SG 函数 依状态数,一般 O(状态数×转移数) Nim、巴什、威佐夫、SG 组合游戏
4.18 组合数取模 预处理 O(n) + 单次查询 O(1) C(n,k) mod p 计算,n 很大时配合 Lucas

4.1 快速幂与快速乘

一句话用途:快速幂在 O(log b) 时间内计算 a^b mod p,是数论模板的地基(求逆元、判素、BSGS 等都要用到);快速乘用于解决 a·b 会溢出 long long(约 9.2e18)而题目又要求按大模数取模的问题。

算法思想:把指数 b 写成二进制 b = ∑ 2^i,则 a^b = ∏ a^(2^i)(只乘 b 的二进制位为 1 的项)。底数每轮自乘平方,指数右移一位,即”倍增”。快速乘同理:a·b = ∑ a·2^i(b 的二进制位为 1 时累加 a),把乘法拆成加法和移位,或直接用编译器内置的 __int128 类型兜住乘积再取模。

复杂度:时间 O(log b);空间 O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// ---------- 快速幂:a^b % mod ----------
ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod; // 注意:mod=1 时任何数取模都为 0,res 初始化为 1%mod 可避免返回 1 的错误
a %= mod; // 先取模,防止后续乘法溢出,也兼容 a 很大或为负(负值取模后转正)
while (b > 0) {
if (b & 1) res = res * a % mod; // 当前二进制位为 1,乘上对应的 a^(2^i)
a = a * a % mod; // 底数平方:a -> a^2 -> a^4 -> ...
b >>= 1; // 指数右移一位
}
return res;
}

// ---------- 快速乘(倍增/俄罗斯农民乘法):a*b % mod,不依赖 __int128 ----------
ll qmul(ll a, ll b, ll mod) {
ll res = 0;
a %= mod; b %= mod; // 负数取模后会变成非负,结果仍正确
while (b > 0) {
if (b & 1) res = (res + a) % mod; // 二进制位为 1 时累加
a = (a + a) % mod; // 翻倍:a -> 2a -> 4a -> ...
b >>= 1;
}
return res;
}

// ---------- 快速乘(__int128 版):写法最简洁,速度也最快 ----------
// 注意:__int128 是 GCC 扩展,在 OJ 的 g++/clang++ 环境下可用
ll qmul128(ll a, ll b, ll mod) {
return (ll)((__int128)a * b % mod);
}

// ---------- 配套:用快速乘实现的快速幂(mod 接近 1e18 时必须用这个) ----------
ll qpow_mul(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b > 0) {
if (b & 1) res = qmul128(res, a, mod);
a = qmul128(a, a, mod);
b >>= 1;
}
return res;
}

注意点 / 坑点

  1. res 初始化必须写 1 % mod:当 mod = 1 时答案是 0,初始化为 1 会返回错误结果。
  2. 乘法 res * a 可能溢出 ll:当 mod ≤ 1e9 时直接乘没问题;mod 超过约 4.6e9(√(9.2e18))时,res * a 就可能超过 9.2e18,必须改用快速乘或 __int128。
  3. 指数 b 为负数时本模板不适用(除非模数为素数且 a 可逆,先求逆元再对 -b 快速幂)。
  4. 快速乘的 res + a 在 mod 接近 1e18 时也可能溢出(两个接近 1e18 的数相加超过 ll),此时只能用 __int128。
  5. 底数 a 要 a %= mod 先行取模,否则 a 本身接近 1e18 时 a * a 必溢出。

可拓展的地方

  1. 矩阵快速幂:把”数”换成矩阵,乘法换成矩阵乘法,即可 O(log n) 求斐波那契等线性递推(模板见第 8.7 节,此处不重复)。
  2. 与欧拉定理结合实现降幂(见 4.7);与费马小定理结合实现逆元(见 4.6)。
  3. 快速乘可推广为”蒙哥马利乘法”,用于高精度库或密码学中对大模数的加速。

典型例题类型

  1. 直接求 a^b mod p(洛谷 P1226 【模板】快速幂)。
  2. 斐波那契数列第 n 项取模、矩阵递推加速(如 HDU 1005、洛谷 P1962)。
  3. 需要大模数乘法的题目(模数达到 1e18 量级,如某些省选题的毒瘤数据)。

4.2 最大公约数与扩展欧几里得

一句话用途:gcd/lcm 是所有数论题的基础工具;扩展欧几里得(exgcd)能求出 ax+by=gcd(a,b) 的一组特解,从而解一次不定方程 ax+by=c、求模逆元、合并同余式(配合 4.8 扩展 CRT 使用)。

算法思想:欧几里得算法用恒等式 gcd(a,b)=gcd(b,a mod b) 递归,直到 b=0。exgcd 在递归返回时通过 x、y 的引用交换与回代,构造出 ax+by=gcd 的解:当 b=0 时 x=1,y=0 是一组解;回溯时由 (x’,y’) 是 bx+(a mod b)y’=g 的解推出 ax+by=g 的解。若方程是 ax+by=c,则先判断 c 是否整除 g=gcd(a,b),是则把特解整体乘 c/g 即得一组解,通解为 x=x0+(b/g)·t, y=y0−(a/g)·t。

复杂度:时间 O(log min(a,b))(每步至少减半);空间 O(1)(迭代版)或 O(log)(递归栈)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// ---------- gcd / lcm ----------
ll gcd(ll a, ll b) { return b ? gcd(b, a % b) : a; }

ll lcm(ll a, ll b) {
// 先除后乘:a*b 可能溢出,而 a/gcd*b 不会(结果本身在 ll 范围内时)
return a / gcd(a, b) * b;
}

// ---------- 扩展欧几里得:求 ax+by = gcd(a,b) 的一组特解 (x,y),返回 gcd ----------
ll exgcd(ll a, ll b, ll &x, ll &y) {
if (b == 0) { // 递归边界:gcd(a,0)=a,此时 x=1, y=0 满足 a*1+0*0=a
x = 1; y = 0;
return a;
}
ll g = exgcd(b, a % b, y, x); // 注意参数顺序交换:递归解是 b*y + (a%b)*x = g
y -= a / b * x; // 回代:a*x + b*(y - (a/b)*x) = g,把系数修正到原方程
return g;
}

// ---------- 解不定方程 ax + by = c ----------
// 返回 0 表示无解;有解时返回 gcd,且 x 被调整为最小非负整数解,y 对应
int solveDiophantine(ll a, ll b, ll c, ll &x, ll &y) {
ll g = exgcd(a, b, x, y);
if (c % g != 0) return 0; // 无解判定:c 必须是 g 的倍数
x *= c / g; // 特解放大 c/g 倍
y *= c / g;
ll t = b / g; // x 的通解步长:x = x0 + (b/g)*t
if (t < 0) t = -t; // b 可能为负,步长取绝对值
x = (x % t + t) % t; // 取 x 的最小非负整数解
y = (c - a * x) / b; // 由方程反解出对应的 y
return 1;
}

// ---------- 用 exgcd 求逆元:a 在模 mod 下的逆元(要求 gcd(a,mod)=1) ----------
ll invExgcd(ll a, ll mod) {
ll x, y;
ll g = exgcd(a, mod, x, y);
if (g != 1) return -1; // 不互素则不存在逆元(返回 -1 表示无解)
return (x % mod + mod) % mod; // x 可能是负数,取模转正
}

注意点 / 坑点

  1. exgcd 返回的 x 可能是负数,凡是用它求逆元或通解都要 (x % t + t) % t 转成最小非负。
  2. 方程 ax+by=c 中 a、b、c 可能为负数:gcd 函数内取绝对值处理更稳(模板按绝对值处理时注意通解步长也要取正)。
  3. 求 lcm 一定要先除后乘(a / gcd * b),先乘后除在 a、b 都接近 1e18 时会溢出。
  4. c % g != 0 时无解要立即返回,不能继续乘 c/g(否则结果无意义且可能错)。
  5. 通解步长是 b/g 而不是 b:很多初学者写错导致求出的”最小解”其实不是最小。

可拓展的地方

  1. 与扩展 CRT 结合,迭代合并任意模数的同余方程组(见 4.8)。
  2. 求”线性同余方程 ax≡b (mod m)”:等价于解 ax+my=b,直接套用本模板。
  3. 可用于解二元一次不定方程并输出所有正整数解的数量(配合步长枚举)。

典型例题类型

  1. 直接求 gcd/lcm(洛谷 P1072 Hankson 的趣味题)。
  2. 解不定方程 / 青蛙的约会类(POJ 1061)。
  3. 求乘法逆元(洛谷 P1082 同余方程,本质是 exgcd)。

4.3 素数判定

一句话用途:判断一个数 n 是否为素数。小范围用试除法即可;n 达到 1e12~1e18 时必须用 Miller-Rabin(随机或确定底数),是 Pollard-Rho 分解(4.4)和很多数论题的前置。

算法思想:试除法检查 2 到 √n 的所有数是否能整除 n。Miller-Rabin 基于费马小定理的逆否:若 n 是素数,则对任意与 n 互素的 a 有 a^(n−1)≡1 (mod n)。把 n−1 写成 d·2^s,若 a^d≡1 或某个 a^(d·2^i)≡n−1 则通过该底数测试;合数会以很高概率被某个底数识破。对 64 位以内整数存在确定底数集合,无需随机。

复杂度:试除 O(√n);Miller-Rabin 每个底数 O(log n) 次快速幂(含 __int128 乘法),取 k 个底数总 O(k·log³n),实际极快。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// __int128 乘法取模:64 位判素必须用,否则 (x*x)%n 溢出
ll mul(ll a, ll b, ll mod) { return (ll)((__int128)a * b % mod); }

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) {
if (b & 1) res = mul(res, a, mod);
a = mul(a, a, mod);
b >>= 1;
}
return res;
}

// ---------- 试除法(n <= 1e7 量级时够用) ----------
bool isPrimeTrial(ll n) {
if (n < 2) return false;
for (ll i = 2; i * i <= n; i++)
if (n % i == 0) return false;
return true;
}

// ---------- Miller-Rabin 单底数测试 ----------
// 检查 a^(n-1) ≡ 1 (mod n),若 n 为素数必通过;合数以 ≥3/4 概率被识破
bool millerRabin(ll n, ll a) {
if (n % a == 0) return n == a; // 先处理 a 整除 n 的平凡情况
ll d = n - 1, s = 0;
while ((d & 1) == 0) { d >>= 1; s++; } // 分解 n-1 = d * 2^s,d 为奇数
ll x = qpow(a, d, n);
if (x == 1 || x == n - 1) return true; // 两种情况直接通过
for (int i = 1; i < s; i++) { // 连续平方 s-1 次
x = mul(x, x, n);
if (x == n - 1) return true; // 出现 n-1 说明二次探测定理通过
}
return false; // 都没有出现 n-1,必为合数
}

// ---------- 确定性 Miller-Rabin:ll 范围内 100% 正确 ----------
// 底数选择说明:
// n < 2^32 时用 {2, 7, 61} 即可(32 位确定集);
// n < 3.4e14 时用 {2,3,5,7,11,13,17};
// 有符号 64 位(|n| < 2^63)用下面 6 个底数;
// 再加 1795265022 可覆盖无符号 64 位(2^64 以内)
bool isPrime(ll n) {
if (n < 2) return false;
for (ll p : {2LL, 3LL, 5LL, 7LL, 11LL, 13LL, 17LL, 19LL, 23LL, 29LL, 31LL, 37LL})
if (n % p == 0) return n == p; // 小素数直接整除判断
ll bases[] = {2, 325, 9375, 28178, 450775, 9780504, 1795265022};
for (ll a : bases)
if (!millerRabin(n, a)) return false;
return true;
}

注意点 / 坑点

  1. x * x % n 在 n ≥ 4.6e9 时会溢出,判素模板里的乘法必须用 __int128 包装。
  2. 先处理小素数整除:否则底数 a 恰好整除 n 时 a^(n-1) mod n 可能算出 0 或错误结果(模板中 n % a == 0 的提前判断就是为此)。
  3. 底数集合选错会有伪素数:32 位用 {2,7,61},64 位用 7 底数集合,不能用仅 {2,3,5} 之类(存在对应强伪素数)。
  4. n 取 0、1、2、3 等小值要单独判断(n<2 直接 false,2、3 被小素数循环捕获返回 true)。
  5. 费马小定理要求底数与 n 互素,所以判素前先检查 gcd,或按模板先试除小素数。

可拓展的地方

  1. 随机化版本:不预先定底数,而是随机取 a∈[2,n−2] 测 k 次,出错概率 ≤4^(−k),配合 4.4 的 Pollard-Rho 使用。
  2. 与 Pollard-Rho 结合做 1e18 级整数分解(下一节)。
  3. 素性判定可推广为”强伪素数检测”,用于密码学中的大素数生成。

典型例题类型

  1. 大数判素(n 达 1e18,如 SPOJ PON - Prime or Not)。
  2. 配合 Pollard-Rho 分解后求约数个数/约数和(如 HDU 3864、洛谷 P4718)。
  3. 判断 n 是否为素数并统计区间内素数个数(小范围配合素数筛)。

4.4 质因数分解

一句话用途:把整数 n 分解为若干质因子的乘积(输出全部质因子,可带重数)。小范围试除法即可;n 达 1e12~1e18 时用 Pollard-Rho 随机算法(配合 Miller-Rabin 判素),是求约数个数、约数和、欧拉函数等的基础。

算法思想:Pollard-Rho 基于生日悖论:随机取两个数 x、y,若 x≠y 且 x≡y (mod p)(p 是 n 的某个质因子),则 gcd(|x−y|, n) 是 n 的一个非平凡因子。用伪随机函数 f(x)=(x²+c) mod n 迭代产生序列,该序列模 p 会进入循环(这就是”Rho”形状),用 Floyd 判环(快慢指针)找到碰撞即可得到一个因子;因子可能仍为合数,递归分解。递归前用 Miller-Rabin 判断当前数是否为素数。

复杂度:试除 O(√n);Pollard-Rho 期望 O(n^1/4) 次模乘(实际对 1e18 的合数很快,最坏情况无保证)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll mul(ll a, ll b, ll mod) { return (ll)((__int128)a * b % mod); }

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) {
if (b & 1) res = mul(res, a, mod);
a = mul(a, a, mod);
b >>= 1;
}
return res;
}

// Miller-Rabin 判素(确定性 64 位),详见 4.3 节
bool isPrime(ll n) {
if (n < 2) return false;
for (ll p : {2LL, 3LL, 5LL, 7LL, 11LL, 13LL, 17LL, 19LL, 23LL, 29LL, 31LL, 37LL})
if (n % p == 0) return n == p;
ll bases[] = {2, 325, 9375, 28178, 450775, 9780504, 1795265022};
for (ll a : bases) {
ll d = n - 1, s = 0;
while (!(d & 1)) { d >>= 1; s++; }
ll x = qpow(a, d, n);
if (x == 1 || x == n - 1) continue;
bool ok = false;
for (int i = 1; i < s; i++) {
x = mul(x, x, n);
if (x == n - 1) { ok = true; break; }
}
if (!ok) return false;
}
return true;
}

// ---------- Pollard-Rho:返回 n 的一个非平凡因子(1 < d < n) ----------
mt19937_64 rng(chrono::steady_clock::now().time_since_epoch().count());

ll pollardRho(ll n) {
if (n % 2 == 0) return 2; // 偶数直接返回
if (n % 3 == 0) return 3;
while (true) {
ll c = uniform_int_distribution<ll>(1, n - 1)(rng); // 随机常数
ll x = uniform_int_distribution<ll>(0, n - 1)(rng);
ll y = x, d = 1;
// f(v) = (v*v + c) mod n,乘法必须用 __int128
auto f = [&](ll v) { return (mul(v, v, n) + c) % n; };
for (int i = 1, k = 2; d == 1; i++) {
x = f(x); // 快指针每次走一步
d = gcd(x > y ? x - y : y - x, n); // 慢指针由 k 控制每隔一段追上
if (i == k) { y = x; k <<= 1; } // Floyd 判环:周期加倍
}
if (d != n) return d; // d==n 说明本次随机失败,重试
}
}

// ---------- 递归分解全部质因子(结果无序,含重数) ----------
void factor(ll n, vector<ll> &res) {
if (n == 1) return;
if (isPrime(n)) { res.push_back(n); return; } // 已经是素数,直接记录
ll d = pollardRho(n);
factor(d, res); // 递归分解两个部分
factor(n / d, res);
}

// ---------- 使用示例:质因数分解并去重(用于求欧拉函数等) ----------
vector<ll> uniqueFactors(ll n) {
vector<ll> f;
factor(n, f);
sort(f.begin(), f.end());
f.erase(unique(f.begin(), f.end()), f.end());
return f;
}

注意点 / 坑点

  1. 乘法必须用 __int128:(x*x) % n 在 n ≥ 4.6e9 时溢出,Pollard-Rho 会死循环或返回错误因子。
  2. Floyd 判环中”慢指针”的更新:不是每步都追,而是用倍增周期 k 每隔 k 步让 y 追到 x,写错会导致死循环。
  3. 出现 d == n 表示本次随机 c、x 选得不好(序列过早进入 n 的循环),必须换 c 重试,不能直接返回。
  4. 分解前先处理 2、3 等小因子,否则小因子会让随机算法退化甚至死循环。
  5. 递归分解要注意 n 可能是素数(pollardRho 对素数会死循环),所以递归前必须 isPrime 判断。

可拓展的地方

  1. 求出所有质因子后,用 DFS/DP 枚举全部约数,进而求约数个数、约数和、欧拉函数(4.7)。
  2. 加优化:小因子试除到 100 再进随机算法,可显著减少随机调用次数。
  3. 可配套”求第 k 大约数””统计 n 的因数个数”等经典题(分解后排列组合)。

典型例题类型

  1. 分解大整数并求约数个数/约数和(洛谷 P4718、HDU 3864)。
  2. 判断 n 是否有至少 4 个因子等条件(SPOJ DIVCNT 系列)。
  3. 配合欧拉函数求 phi(n)(n 达 1e18,如 POJ 1284 的变体)。

4.5 素数筛

一句话用途:一次性求出 [1,n] 内所有素数及每个数的最小质因子,用于批量的素数判定、积性函数(欧拉函数 φ、莫比乌斯函数 μ)预处理。欧拉线性筛是 O(n) 的,且每个合数只被它的最小质因子筛掉一次,是竞赛中最常用的筛法。

算法思想:埃氏筛从 2 开始,把每个素数的倍数全部标记为合数;从 i² 开始标记即可保证不重复工作。线性筛的核心是”每个合数只被最小质因子筛一次”:枚举已筛出的素数 p,标记 i·p,当 i % p == 0 时立即 break(因为再往后 p 就不是 i·p 的最小质因子了)。由于积性函数 f(a·b)=f(a)·f(b)(a、b 互素),在 break 前/后分两种情况更新 φ、μ 即可同时求出函数表。

复杂度:埃氏筛 O(n log log n);线性筛 O(n)。空间均为 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
using namespace std;
const int N = 10000000; // 按题目需求调整

// ---------- 埃氏筛:只求素数表 ----------
bool isCompEra[N + 1];
vector<int> primesEra;
void sieveEratosthenes(int n) {
for (int i = 2; i <= n; i++) {
if (!isCompEra[i]) {
primesEra.push_back(i);
// 从 i*i 开始标记:i 的更小倍数已被更小的素数标记过
if ((ll)i * i <= n)
for (ll j = (ll)i * i; j <= n; j += i) isCompEra[j] = true;
}
}
}

// ---------- 欧拉线性筛:同时求出素数表、最小质因子、欧拉函数、莫比乌斯函数 ----------
bool isComp[N + 1];
int primes[N + 1], cnt = 0; // primes 存素数表,cnt 为素数个数
int minp[N + 1]; // minp[i]:i 的最小质因子
int phi[N + 1]; // 欧拉函数
int mu[N + 1]; // 莫比乌斯函数

void linearSieve(int n) {
phi[1] = 1; mu[1] = 1; minp[1] = 1;
for (int i = 2; i <= n; i++) {
if (!isComp[i]) { // i 是素数
primes[++cnt] = i;
minp[i] = i;
phi[i] = i - 1; // 素数 p 的 φ(p) = p-1
mu[i] = -1; // 单个质因子的 μ = -1
}
for (int j = 1; j <= cnt && (ll)i * primes[j] <= n; j++) {
int p = primes[j];
isComp[i * p] = true;
minp[i * p] = p; // 记录最小质因子
if (i % p == 0) {
// p 已经是 i 的因子:i*p 中 p 出现至少两次(含平方因子)
// φ(i*p) = φ(i)*p;μ(i*p) = 0
phi[i * p] = phi[i] * p;
mu[i * p] = 0;
break; // 关键:p 是 i 的最小质因子时停,保证只被最小质因子筛
} else {
// i 与 p 互素,积性函数性质直接相乘
phi[i * p] = phi[i] * (p - 1);
mu[i * p] = -mu[i];
}
}
}
}

注意点 / 坑点

  1. 线性筛的 break 条件 i % p == 0 不能省或写错:少了 break,合数会被多个质因子重复筛到,复杂度退化且 minp 记录错误。
  2. 内层循环判断 i * primes[j] <= n 要写成 (ll)i * primes[j],i 和 p 都是 int,乘积可能溢出 int。
  3. 数组要开 n+1 大小且下标从 0 开始用,mu/phi 数组要记得初始化 mu[1]=1、phi[1]=1。
  4. 埃氏筛从 ii 开始标记:从 i2 开始也能正确但多出重复标记,且 i*i 要防 int 溢出。
  5. 若只需要判断素数,用 bitset/vector 可省内存;需要 minp/phi/mu 时用 int 数组。

可拓展的地方

  1. 线性筛同时求 μ、φ 已经体现”积性函数 + 线性筛”套路,任意积性函数(如约数个数 d(n)、约数和 σ(n))都能用同一框架求。
  2. 求最小质因子后,可在 O(log n) 内对任意数分解质因数(反复除以 minp)。
  3. 埃氏筛配合”区间筛”技巧可求 [L,R](R−L 小但 R 大)内的素数。

典型例题类型

  1. 求 [1,n] 内素数个数/质数判断(洛谷 P3383 【模板】线性筛素数)。
  2. 求欧拉函数表/莫比乌斯函数表做前缀和(配合 4.10、4.11)。
  3. 快速分解质因数求约数个数(配合 minp,如 HDU 2824)。

4.6 模逆元

一句话用途:在模意义下做除法:a/b mod p 需要先求 b 的逆元 inv(b),使 b·inv(b)≡1 (mod p)。四种方法覆盖所有场景:费马小定理(p 为素数)、exgcd(模数可为合数但要求互素)、线性递推(批量求 1..n 的逆元)、阶乘逆元(组合数预处理)。

算法思想:费马小定理:p 为素数且 p∤a 时 a^(p−1)≡1 (mod p),所以 a 的逆元是 a^(p−2) mod p。exgcd 把 ax≡1 (mod m) 化为 ax+my=1 用 4.2 求解。线性递推公式:inv[i] = (m − ⌊m/i⌋·inv[m mod i]) mod m,利用 m mod i < i 保证可从前面的值推出。阶乘逆元先算 fac[n] 的逆元,再倒推 ifac[i−1] = ifac[i]·i,从而 O(1) 求组合数。

复杂度:费马/exgcd 单点 O(log p);线性递推 O(n);阶乘预处理 O(n)、查询 O(1)。空间 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 1e9 + 7; // 常用素数模数

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) {
if (b & 1) res = res * a % mod;
a = a * a % mod;
b >>= 1;
}
return res;
}

ll exgcd(ll a, ll b, ll &x, ll &y) {
if (!b) { x = 1; y = 0; return a; }
ll g = exgcd(b, a % b, y, x);
y -= a / b * x;
return g;
}

// ---------- 方法 1:费马小定理(前提:mod 为素数) ----------
ll invFermat(ll a) { return qpow(a, MOD - 2, MOD); }

// ---------- 方法 2:exgcd(前提:gcd(a, mod) == 1,mod 可为合数) ----------
ll invExgcd(ll a, ll mod) {
ll x, y;
ll g = exgcd(a, mod, x, y);
if (g != 1) return -1; // 不互素无逆元
return (x % mod + mod) % mod;
}

// ---------- 方法 3:线性递推批量求 1..n 的逆元(前提:mod 为素数且 n < mod) ----------
vector<ll> inv;
void initInv(int n) {
inv.assign(n + 1, 0);
inv[1] = 1;
for (int i = 2; i <= n; i++)
inv[i] = (MOD - MOD / i * inv[MOD % i] % MOD) % MOD;
// 推导:MOD = i * (MOD/i) + MOD%i,两边除以 i*(MOD%i) 取模可得
}

// ---------- 方法 4:阶乘逆元 + 组合数预处理 ----------
vector<ll> fac, ifac;
void initFac(int n) {
fac.assign(n + 1, 1);
for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
ifac.assign(n + 1, 1);
ifac[n] = qpow(fac[n], MOD - 2, MOD); // 只求一次大逆元
for (int i = n; i >= 1; i--) ifac[i - 1] = ifac[i] * i % MOD; // 倒推
}
ll C(int n, int k) {
if (k < 0 || k > n) return 0; // 边界:越界组合数定义为 0
return fac[n] * ifac[k] % MOD * ifac[n - k] % MOD;
}

注意点 / 坑点

  1. 逆元存在当且仅当 gcd(a, mod) = 1:mod 为合数且 a 与 mod 不互素时无逆元,费马小定理也失效,只能换方法或换模数。
  2. 线性递推要求 n < mod,否则 MOD % i 可能为 0 或 i ≥ mod 导致公式失效。
  3. 阶乘预处理时 n 若 ≥ mod,则 fac[n] ≡ 0 (mod mod),逆元不存在,此时必须用 Lucas(见 4.9)。
  4. 费马小定理求逆元要求 a 不是 mod 的倍数:a % mod == 0 时”逆元”是 0 但语义上不存在,注意特判。
  5. exgcd 返回的 x 为负时取模转正;C 函数的边界判断 k<0 或 k>n 必须返回 0 而不是数组越界。

可拓展的地方

  1. 配合卢卡斯定理(4.9)处理 n,k ≥ mod 的组合数。
  2. 求逆元前缀积可实现”区间查询组合数乘积”,也可求 1..n 的任意 k 个组合数乘积。
  3. 逆元可用于模意义下的高斯消元(4.14)、求行列式、NTT 等一切需要”除法”的场景。

典型例题类型

  1. 线性求逆元(洛谷 P3811 【模板】乘法逆元)。
  2. 组合数取模(洛谷 P3807 的预处理部分,配合 Lucas)。
  3. 模意义下的分数运算/期望计算(很多概率题取模 1e9+7 时需要)。

4.7 欧拉函数与欧拉定理

一句话用途:欧拉函数 φ(n) 表示 [1,n] 中与 n 互素的整数个数,是积性函数,可用于欧拉定理降幂;当指数 b 大得无法直接读入(如 10^100000 的十进制大数)时,用扩展欧拉定理 a^b ≡ a^(b mod φ(m) + φ(m)) (mod m) 把指数降下来再快速幂。

算法思想:φ(n) = n·∏(1 − 1/p)(p 遍历 n 的不同质因子):分解质因数后逐个乘即可。欧拉定理:gcd(a,m)=1 时 a^φ(m)≡1 (mod m),说明 φ(m) 是指数的一个”周期”。扩展欧拉定理去掉互素限制:当 b ≥ φ(m) 时 a^b ≡ a^(b mod φ(m) + φ(m)) (mod m);b < φ(m) 时直接算。线性筛求 φ 表的方法见 4.5(φ(i·p) 的两条递推公式)。

复杂度:单点求 φ 需分解质因数 O(√n);线性筛求表 O(n)。扩展欧拉降幂再加 O(log b)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) {
if (b & 1) res = res * a % mod;
a = a * a % mod;
b >>= 1;
}
return res;
}

// ---------- 单个欧拉函数:phi(n) = n * prod(1 - 1/p) ----------
ll eulerPhi(ll n) {
ll res = n;
for (ll i = 2; i * i <= n; i++) { // 只需枚举到 sqrt(n)
if (n % i == 0) {
res = res / i * (i - 1); // 先除后乘,防溢出
while (n % i == 0) n /= i; // 把质因子 i 全部除掉,保证下次 i 是新的质因子
}
}
if (n > 1) res = res / n * (n - 1); // 剩余的大于 sqrt 的质因子
return res;
}

// ---------- 线性筛求欧拉函数表(与 4.5 相同,独立给出) ----------
const int N = 1000000;
int phi[N + 1], primes[N + 1], cnt;
bool isComp[N + 1];
void phiTable(int n) {
phi[1] = 1;
for (int i = 2; i <= n; i++) {
if (!isComp[i]) { primes[++cnt] = i; phi[i] = i - 1; }
for (int j = 1; j <= cnt && (ll)i * primes[j] <= n; j++) {
int p = primes[j];
isComp[i * p] = true;
if (i % p == 0) { phi[i * p] = phi[i] * p; break; } // p 是 i 的因子
phi[i * p] = phi[i] * (p - 1); // i 与 p 互素
}
}
}

// ---------- 扩展欧拉定理降幂:求 a^b mod m,其中 b 以十进制字符串给出 ----------
// 核心:读 b 的过程中边读边对 phi(m) 取模,同时标记 b 是否 >= phi(m)
ll superPow(string a, string b, ll m) {
if (m == 1) return 0; // 模 1 恒为 0
ll ph = eulerPhi(m);
ll e = 0;
bool big = false; // b 是否 >= phi(m)
for (char c : b) {
e = e * 10 + (c - '0');
if (e >= ph) { e %= ph; big = true; } // 边读边取模,防止 ll 溢出
}
if (big) e += ph; // 关键:b >= phi(m) 时指数要加上 phi(m)
// 把 a 也转成 ll 取模(若 a 也是大数,同样边读边取模)
ll aa = 0;
for (char c : a) aa = (aa * 10 + (c - '0')) % m;
return qpow(aa, e, m);
}

注意点 / 坑点

  1. 扩展欧拉定理两个分支别搞混:b < φ(m) 时指数就是 b;b ≥ φ(m) 时指数是 b mod φ(m) + φ(m)。只取余不加 φ(m) 在 gcd(a,m)≠1 时会错。
  2. 判断”b ≥ φ(m)”要边读边做:先乘 10 加 digit 再与 ph 比较,若先 % ph 再判断就无法知道是否达到过 ph。
  3. 单点求 φ 的循环里 while (n % i == 0) n /= i 会改变 n,若后面还要用原始 n 记得备份。
  4. 幂的底数也要取模:a 很大时 a^b 实际算的是 (a mod m) 的幂,先 % m
  5. φ 表数组、线性筛的 phi 递推在 i%p==0 分支必须是 φ(i)·p(不是 φ(i)·(p−1)),写错所有含平方因子的数都会错。

可拓展的地方

  1. 与 4.5 线性筛结合,同时求 φ 与 μ 的前缀和,供杜教筛(4.11)使用。
  2. 欧拉定理给出”模 m 乘法群”的周期结构,可配合原根(4.16)做离散对数。
  3. 降幂公式可处理”指数塔”(a1^(a2^(a3^…)))递归降幂的题目。

典型例题类型

  1. 大指数取模:指数是 10^100000 量级的大数(HDU 2837、洛谷 P4139 上帝与集合的正确用法)。
  2. 求 φ(n) 并验证欧拉定理(如 HDU 2824 求区间 φ 和)。
  3. 递归幂塔求值(指数塔自顶向下用扩展欧拉定理降幂)。

4.8 中国剩余定理

一句话用途:求解一元线性同余方程组 x ≡ aᵢ (mod mᵢ)。模数两两互素时用标准 CRT(O(n) 次 exgcd);模数不互素时用扩展 CRT(exCRT)——两两合并,迭代 n−1 次,能同时判断无解。

算法思想:CRT:令 M = ∏mᵢ,对每个 i 令 Mᵢ = M/mᵢ,求 Mᵢ 在模 mᵢ 下的逆元 tᵢ,则 x = ∑aᵢ·Mᵢ·tᵢ (mod M) 满足所有方程(因为对任意 j≠i,Mᵢ 是 mⱼ 的倍数,模 mⱼ 时该项消失)。exCRT:合并两个方程 x≡a₁ (mod m₁)、x≡a₂ (mod m₂) 时,设 x = a₁ + m₁·t,代入第二个方程得 m₁·t ≡ a₂−a₁ (mod m₂),用 exgcd 解出 t,新方程 x ≡ a₁+m₁·t (mod lcm(m₁,m₂))。每合并一次模数变为 lcm,n−1 次合并完成。

复杂度:CRT O(n·log m);exCRT 同样 O(n·log m)(每次 exgcd)。空间 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll exgcd(ll a, ll b, ll &x, ll &y) {
if (!b) { x = 1; y = 0; return a; }
ll g = exgcd(b, a % b, y, x);
y -= a / b * x;
return g;
}

// ---------- 标准 CRT:模数 m[] 两两互素 ----------
// 返回最小非负整数解;通解为 ans + k*M
ll crt(const vector<ll> &a, const vector<ll> &m) {
ll M = 1, ans = 0;
for (ll x : m) M *= x;
for (int i = 0; i < (int)a.size(); i++) {
ll Mi = M / m[i]; // 除第 i 个模数外的乘积
ll x, y;
exgcd(Mi % m[i], m[i], x, y); // 求 Mi 模 m[i] 的逆元
x = (x % m[i] + m[i]) % m[i];
// 防溢出:a[i] * Mi 可能超 ll,若 m 很大请改用 __int128
ans = (ans + (a[i] % M) * Mi % M * x) % M;
}
return ans;
}

// ---------- 扩展 CRT:合并两个同余式 x≡a1 (mod m1)、x≡a2 (mod m2) ----------
// 成功返回 true,并把合并结果写到 (a, m);无解返回 false
bool merge(ll a1, ll m1, ll a2, ll m2, ll &a, ll &m) {
ll x, y;
ll g = exgcd(m1, m2, x, y); // 先用 exgcd 求 gcd 及一组系数
ll c = a2 - a1;
if (c % g != 0) return false; // 无解:c 必须被 gcd 整除
ll m2g = m2 / g; // 约分后的模数
// 解 m1*t ≡ c (mod m2):两边同除 g 后 t ≡ (c/g) * x (mod m2/g)
// 其中 x 满足 (m1/g)*x ≡ 1 (mod m2/g)
ll t = (c / g) % m2g * x % m2g; // 注意先对 m2g 取模防溢出
t = (t % m2g + m2g) % m2g; // t 取最小非负
a = a1 + m1 * t; // 新同余式的余数(可能很大,必要时 __int128)
m = m1 / g * m2; // 新模数 = lcm(m1, m2)
a = (a % m + m) % m; // 归一化到 [0, m)
return true;
}

// exCRT 完整流程:从空开始逐个合并
ll excrt(const vector<ll> &a, const vector<ll> &m) {
ll curA = a[0], curM = m[0];
for (int i = 1; i < (int)a.size(); i++) {
if (!merge(curA, curM, a[i], m[i], curA, curM))
return -1; // 无解
}
return curA;
}

注意点 / 坑点

  1. exCRT 合并时 c / g 可能为负:先 % m2g 再乘 x 仍可能为负,最终 t 必须 (t % m2g + m2g) % m2g 转正。
  2. 合并时 exgcd 求得的是 (m1/g, m2/g) 意义下的系数还是 (m1, m2) 下的系数要分清:模板中直接在 m1、m2 上跑 exgcd,然后利用 gcd 的线性性质约分,t 的公式里 x 对应约分后方程的解——实现时按注释一步步来,别直接照抄公式。
  3. a1 + m1 * t 可能溢出 ll(模数乘积可达 1e36 量级):题目要求输出对某数取模的结果时先取模;否则用 __int128 或高精度。
  4. CRT 中 Mi % m[i] 先取模再 exgcd:Mi 可能本身超过 ll。
  5. 无解判断在 exCRT 中要逐次进行(第一次合并失败就无解),不要等到最后。

可拓展的地方

  1. 当模数不互素且合并结果无解时输出 -1,可处理”不保证有解”的题目。
  2. 与 Pollard-Rho 结合做”解同余方程组的特殊数据”(模数含大质数)。
  3. CRT 是扩展 Lucas(4.9)、扩展 BSGS(4.15)的收官一步:把各素因子幂下的答案合并成最终答案。

典型例题类型

  1. 标准 CRT:x 模两两互素的 mᵢ 等于 aᵢ(洛谷 P1495 曹冲养猪)。
  2. 扩展 CRT:模数不互素或无解判定(洛谷 P4777 【模板】扩展中国剩余定理)。
  3. 混合题:CRT + 高次同余/指数,答案对 M 取模(如 POJ 1006 生理周期)。

4.9 Lucas 定理

一句话用途:计算 C(n,k) mod p,其中 p 是较小的素数而 n、k 可以非常大(≥ 1e18)。当 n ≥ p 时阶乘预处理会因 fac 含 p 因子而失效(模 p 下为 0 无法求逆),Lucas 定理把 n、k 按 p 进制拆位递归求解。p 为合数时用扩展 Lucas(思路一句话见下)。

算法思想:Lucas 定理:C(n,k) ≡ C(⌊n/p⌋, ⌊k/p⌋) · C(n mod p, k mod p) (mod p)。递归直到 k 为 0;每层的 C 因为两个参数都 < p,可以直接用阶乘 + 逆元计算。证明方向:利用 (1+x)^p ≡ 1+x^p (mod p) 的二项式展开系数对比。扩展 Lucas:把 p 分解为 ∏pᵢ^eᵢ,对每个素因子幂单独计算 C(n,k) mod pᵢ^eᵢ(把 n! 中 pᵢ 因子全部提出并单独计数,剩余部分模 pᵢ^eᵢ 可逆,配合递推算”不含 pᵢ 因子的阶乘”,最后用 CRT 合并)。

复杂度:Lucas O(p + log_p n)(预处理阶乘 O(p),递归 log_p n 层每层 O(1))。扩展 Lucas O(∑pᵢ^eᵢ + 总因子数·log) 级别。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) {
if (b & 1) res = res * a % mod;
a = a * a % mod;
b >>= 1;
}
return res;
}

// ---------- 预处理阶乘(模 p,p 为素数,1 <= n < p) ----------
vector<ll> fac;
void initFac(ll p) {
fac.assign(p, 1); // 只开到 p:n mod p < p
for (ll i = 1; i < p; i++) fac[i] = fac[i - 1] * i % p;
}

// 小组合数:0 <= n,k < p
ll smallC(ll n, ll k, ll p) {
if (k < 0 || k > n) return 0;
return fac[n] * qpow(fac[k], p - 2, p) % p * qpow(fac[n - k], p - 2, p) % p;
}

// ---------- Lucas 定理 ----------
// 使用前必须先调用 initFac(p)(按当前模数 p 预处理阶乘),p 变了要重新初始化
ll lucas(ll n, ll k, ll p) {
if (k == 0) return 1; // 递归边界
return smallC(n % p, k % p, p) * lucas(n / p, k / p, p) % p;
}

扩展 Lucas 思路(一句话 + 关键代码):分解 p = ∏ pᵢ^eᵢ,对每个 pᵢ^eᵢ 用”提出 pᵢ 因子 + 处理 pᵢ 无关部分”的专用函数算出 C(n,k) mod pᵢ^eᵢ,最后 CRT 合并。核心专用函数如下(以单个素因子幂 pk 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 计算 n! 去掉所有 p 因子后模 pk 的值(n! 中 p 的个数单独统计)
ll facMod(ll n, ll p, ll pk) {
if (n == 0) return 1;
// 每 pk 个一组循环(1..pk 中与 p 互素的部分模 pk 有周期 pk),组数内快速幂
ll res = 1;
for (ll i = 1; i <= pk; i++)
if (i % p) res = res * (i % pk) % pk; // 一个完整周期的乘积
res = qpow(res, n / pk, pk); // 完整周期部分
for (ll i = 1; i <= n % pk; i++) // 余数部分
if (i % p) res = res * (i % pk) % pk;
return res * facMod(n / p, p, pk) % pk; // 递归:n/p! 中还含有 p 的倍数
}
// 计算 C(n,k) mod pk:f = facMod(n)*inv(facMod(k))*inv(facMod(n-k)) * p^(c)
// 其中 c = (n!中p的个数) - (k!中p的个数) - ((n-k)!中p的个数),用勒让德公式计算

注意点 / 坑点

  1. Lucas 前提是 p 为素数:p 为合数时阶乘逆元不存在(fac 里含 p 的因子),必须走扩展 Lucas。
  2. 阶乘数组只开到 p(fac.assign(p, 1) 放 0..p−1),别开到 n——n 可能是 1e18,直接爆内存。
  3. 递归边界是 k==0 返回 1:若写成 n==0 返回 1,当 k>n 时会算出非零错误值。
  4. 每层 smallC 里 qpow(fac[k], p-2, p) 的前提是 fac[k] 与 p 互素——因为 k < p 且 p 为素数所以成立,扩展 Lucas 里则不能这么干。
  5. p 很小(如 2)时 lucas 递归层数达 log₂n 层,注意用 ll 接收 n、k。

可拓展的地方

  1. 扩展 Lucas 的 facMod 可缓存 pk 的周期乘积,多组询问时更快。
  2. 与”卡特兰数取模””组合数奇偶性(p=2 的 Lucas 即按位比较)”结合出题。
  3. 与 4.18 的阶乘逆元方法衔接:n,k < p 时直接用预处理,n 很大时切到 Lucas。

典型例题类型

  1. 标准 Lucas(洛谷 P3807 【模板】卢卡斯定理)。
  2. 扩展 Lucas:p 为合数(洛谷 P4720 【模板】扩展卢卡斯定理)。
  3. 组合数奇偶性判断/大组合数取模(如 HDU 3037、CF 上的 Lucas 应用)。

4.10 莫比乌斯反演

一句话用途:处理”求 1≤i≤n, 1≤j≤m 中 gcd(i,j)=k 的对数””两两互素对数”等 gcd 计数问题。核心武器是恒等式 [gcd(i,j)=1] = ∑_{d|gcd(i,j)} μ(d),把计数转化为 μ 前缀和 + 整除分块(4.12),复杂度从 O(nm) 降到 O(√n)。

算法思想:莫比乌斯函数 μ(n):n=1 时 μ=1;n 为 k 个不同素数乘积时 μ=(−1)^k;n 含平方因子时 μ=0。它满足 ∑{d|n} μ(d) = [n=1](关键恒等式)。反演公式两种形式:①F(n)=∑{d|n}f(d) ⇔ f(n)=∑{d|n}μ(d)F(n/d);②F(n)=∑{n|d}f(d) ⇔ f(n)=∑{n|d}μ(d/n)F(d)。计数套路:要求 gcd(i,j)=1 的对数,把 [gcd=1] 换成 ∑{d|gcd(i,j)} μ(d),交换求和顺序得到 ∑_d μ(d)·⌊n/d⌋·⌊m/d⌋,用整除分块 O(√n) 求。

复杂度:线性筛求 μ 表及前缀和 O(n);每次查询整除分块 O(√min(n,m))。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 10000000;

// ---------- 线性筛求莫比乌斯函数并做前缀和 ----------
int mu[N + 1], primes[N + 1], cnt;
bool isComp[N + 1];
ll preMu[N + 1]; // μ 的前缀和,供整除分块使用

void mobiusSieve(int n) {
mu[1] = 1;
for (int i = 2; i <= n; i++) {
if (!isComp[i]) { primes[++cnt] = i; mu[i] = -1; }
for (int j = 1; j <= cnt && (ll)i * primes[j] <= n; j++) {
int p = primes[j];
isComp[i * p] = true;
if (i % p == 0) { mu[i * p] = 0; break; } // 含平方因子
mu[i * p] = -mu[i]; // 多一个质因子,符号翻转
}
}
for (int i = 1; i <= n; i++) preMu[i] = preMu[i - 1] + mu[i];
}

// ---------- 例 1:gcd(i,j)=1 的对数,1<=i<=n, 1<=j<=m ----------
ll countCoprime(ll n, ll m) {
ll ans = 0;
ll lim = min(n, m);
for (ll l = 1, r; l <= lim; l = r + 1) {
// 整除分块:在 [l, r] 内 floor(n/l) 与 floor(m/l) 均不变
r = min(n / (n / l), m / (m / l));
ans += (preMu[r] - preMu[l - 1]) * (n / l) * (m / l);
}
return ans;
}

// ---------- 例 2:gcd(i,j)=k 的对数(i<=n, j<=m) ----------
// 令 i=k*i', j=k*j',则 gcd(i',j')=1,范围缩到 n/k, m/k
ll countGcdK(ll n, ll m, ll k) {
return countCoprime(n / k, m / k);
}

// ---------- 例 3:n 以内与 x 互素的数的个数(莫比乌斯容斥形式) ----------
ll countCoprimeToX(ll n, ll x) {
// 对 x 的每个因子 d:贡献 mu[d] * floor(n/d)
ll ans = 0;
for (ll d = 1; d * d <= x; d++) {
if (x % d == 0) {
ans += mu[d] * (n / d);
if (d != x / d) ans += mu[x / d] * (n / (x / d));
}
}
return ans;
}

反演公式(两种形式,供推导使用)

  • 因子形式:若 F(n) = ∑{d|n} f(d),则 f(n) = ∑{d|n} μ(d)·F(n/d)。
  • 倍数形式:若 F(n) = ∑{n|d} f(d),则 f(n) = ∑{n|d} μ(d/n)·F(d)。
  • 常用技巧:f(n) = ∑_{d|n} μ(d) 即 [n=1],由此把 [gcd(i,j)=1] 展开成 μ 的求和。

注意点 / 坑点

  1. μ 线性筛的 break 分支(i%p==0 → mu=0)不能省,否则 μ 表全错。
  2. 整除分块区间右端点要用二维写法 r = min(n/(n/l), m/(m/l)),一维写法(只对 n)会导致重复/遗漏。
  3. (n/l) * (m/l) 可能溢出 ll(n、m 都达 1e9 时乘积达 1e18,可接受;再大用 __int128)。
  4. countGcdK 里 n/k、m/k 整除后可能为 0,函数要能处理 lim=0 的情况(返回 0)。
  5. 反演题推导时注意求和顺序交换后上下界:d 从 1 到 min(n,m)(除以 k 后)。

可拓展的地方

  1. 多组询问时 μ 前缀和 + 整除分块可做到 O(T·√n),配合”数论分块”(4.12)使用。
  2. 可推广到 gcd(i,j) 的 k 次方和、lcm 计数、约数个数函数 d(i·j) 等经典反演题。
  3. 与杜教筛(4.11)结合可把反演题的 n 上限推到 1e9~1e12(此时需要 μ 前缀和的大范围求法)。

典型例题类型

  1. gcd 计数:求 gcd(i,j)=k 的对数(洛谷 P2522 Problem b、P3455)。
  2. 求所有数对的 gcd 之和(洛谷 P1447、P3327)。
  3. 反演 + 容斥:区间内与 x 互素的个数(POJ 2773 等)。

4.11 杜教筛

一句话用途:当 n 大到 1e9~1e12 时(线性筛开不下数组),快速求积性函数前缀和 S_f(n) = ∑_{i=1}^n f(i),典型是求 φ 与 μ 的前缀和,配合莫比乌斯反演类题目使用。核心是 Dirichlet 卷积 + 整除分块 + 记忆化,复杂度 O(n^2/3)。

算法思想:若存在 g 使 f∗g = h(Dirichlet 卷积),则 S_f(n)·g(1) = S_h(n) − ∑{i=2}^n g(i)·S_f(⌊n/i⌋),即把 S_f(n) 用更小的 S_f(⌊n/i⌋) 表示,配合整除分块递归计算。已知:φ∗1 = id(恒等函数),μ∗1 = ε(单位函数),所以:S_φ(n) = n(n+1)/2 − ∑{i=2}^n S_φ(⌊n/i⌋);S_μ(n) = 1 − ∑_{i=2}^n S_μ(⌊n/i⌋)。先用线性筛预处理到 n^2/3 作为基底(小于该阈值的直接查表),大于阈值的用 unordered_map 记忆化递归。

复杂度:时间 O(n^2/3)(预处理 O(n^2/3),递归部分每次查询 O(n^2/3),多组询问共享记忆化);空间 O(n^2/3 + 询问数)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXN = 5000000; // 阈值取 n^(2/3),对 n=1e12 约为 5e6

// ---------- 线性筛预处理 phi、mu 及其前缀和(同 4.5/4.10) ----------
int phi[MAXN + 1], mu[MAXN + 1]; // φ、μ 单值很小,int 足够,省内存
ll prePhi[MAXN + 1], preMu[MAXN + 1]; // 前缀和才需要 ll(可到 ~1e12)
int primes[MAXN + 1], cnt;
bool isComp[MAXN + 1];
void init(int n) {
phi[1] = 1; mu[1] = 1;
for (int i = 2; i <= n; i++) {
if (!isComp[i]) { primes[++cnt] = i; phi[i] = i - 1; mu[i] = -1; }
for (int j = 1; j <= cnt && (ll)i * primes[j] <= n; j++) {
int p = primes[j];
isComp[i * p] = true;
if (i % p == 0) { phi[i * p] = phi[i] * p; mu[i * p] = 0; break; }
phi[i * p] = phi[i] * (p - 1);
mu[i * p] = -mu[i];
}
}
for (int i = 1; i <= n; i++) {
prePhi[i] = prePhi[i - 1] + phi[i];
preMu[i] = preMu[i - 1] + mu[i];
}
}

// ---------- 记忆化:大于阈值的结果存 unordered_map ----------
unordered_map<ll, ll> memPhi, memMu;

ll sumPhi(ll n) {
if (n <= MAXN) return prePhi[n]; // 查表
if (memPhi.count(n)) return memPhi[n]; // 记忆化
ll ans = n * (n + 1) / 2; // S_id(n) = n(n+1)/2
for (ll l = 2, r; l <= n; l = r + 1) { // 整除分块递归
r = n / (n / l);
ans -= (r - l + 1) * sumPhi(n / l); // g(i)=1,区间长度乘子问题
}
return memPhi[n] = ans;
}

ll sumMu(ll n) {
if (n <= MAXN) return preMu[n];
if (memMu.count(n)) return memMu[n];
ll ans = 1; // S_eps(n) = 1(仅 i=1 时非零)
for (ll l = 2, r; l <= n; l = r + 1) {
r = n / (n / l);
ans -= (r - l + 1) * sumMu(n / l);
}
return memMu[n] = ans;
}

注意点 / 坑点

  1. 阈值选择:预处理到 n^2/3 是最优的;阈值太小递归变多,太大预处理变慢。n=1e101e12 时 5e65e7 合适。
  2. ans = n * (n + 1) / 2 在 n 达 1e12 时约 5e23,超出 ll 范围:若题目要求精确值需用 __int128 累计,若取模则边加边取模。
  3. 整除分块里 n / l 用 ll:l、r 都要用 ll,int 会溢出。
  4. 记忆化必须用 unordered_map 存”大于阈值的 n”,不要存所有 n(否则又回到 O(n) 空间)。
  5. 递归深度:n 每次除以至少 2(分块),深度 O(log n),安全;但别忘递归函数要 const 引用或值传递 ll。

可拓展的地方

  1. 任意积性函数 f 若找到 g 使 f∗g = h 且 S_h 好求,都能用同一框架(如求 σ(n) 前缀和取 g=μ)。
  2. 配合莫比乌斯反演题把 n 上限推到 1e10+(如求 ∑gcd(i,j) 之和的加强版)。
  3. 可用”记忆化递归 + 预处理调和级数”处理更一般的积性函数前缀和。

典型例题类型

  1. 求 ∑φ(i) 前缀和(洛谷 P4213 【模板】杜教筛)。
  2. 求 ∑μ(i) 前缀和并配合反演(SPOJ DIVCNT1 系列)。
  3. n 达 1e10 的 gcd/lcm 求和题(如 51Nod 1239)。

4.12 数论分块

一句话用途:O(√n) 计算形如 ∑{i=1}^n ⌊n/i⌋、∑{i=1}^n f(i)·⌊n/i⌋ 的和。核心事实:⌊n/i⌋ 的值只有 O(√n) 种,相同的值连成一段 [l, r],其中 r = n/(n/l)。配合莫比乌斯反演可把 gcd 计数从 O(n) 优化到 O(√n)。

算法思想:对固定的 n,函数 g(i)=⌊n/i⌋ 在 i 较小时变化快(每步都变),在 i 较大时变化慢(很多 i 对应同一个值)。若已知左端点 l,则右端点为 r = ⌊n/⌊n/l⌋⌋,即 [l,r] 内 ⌊n/i⌋ 恒等于 ⌊n/l⌋。于是把区间 [1,n] 划分成 O(√n) 段,每段内值相同,用前缀和(如 μ 前缀和)乘以该值求和。二维情况(同时有 ⌊n/i⌋ 和 ⌊m/i⌋)右端点取 min 的两个分块端点。

复杂度:时间 O(√n)(二维 O(√min(n,m)));空间 O(1)(若需前缀和则 O(n) 预处理)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// ---------- 一维:求 sum_{i=1..n} floor(n/i) ----------
ll sumFloor(ll n) {
ll ans = 0;
for (ll l = 1, r; l <= n; l = r + 1) {
r = n / (n / l); // 关键公式:段 [l, r] 内 floor(n/i) 全等于 floor(n/l)
ans += (r - l + 1) * (n / l); // 段长 * 段值
}
return ans;
}

// ---------- 带权版:sum_{i=1..n} w[i] * floor(n/i)(w 为前缀和数组) ----------
// 用于配合莫比乌斯反演:w 取 μ 前缀和,见 4.10
ll sumWeighted(ll n, const vector<ll> &pre) {
ll ans = 0;
for (ll l = 1, r; l <= n; l = r + 1) {
r = n / (n / l);
ans += (pre[r] - pre[l - 1]) * (n / l);
}
return ans;
}

// ---------- 二维:sum_{i=1..n, j=1..m} floor(n/i)*floor(m/j) 的"同步分块" ----------
// 实际用于反演题:sum_{d=1..lim} mu[d]*floor(n/d)*floor(m/d)
ll sum2D(ll n, ll m) {
ll ans = 0;
ll lim = min(n, m);
for (ll l = 1, r; l <= lim; l = r + 1) {
// 两个函数同时不变的区间才是有效段
r = min(n / (n / l), m / (m / l));
ans += (r - l + 1) * (n / l) * (m / l);
}
return ans;
}

注意点 / 坑点

  1. 右端点公式必须用 r = n / (n / l) 而不是 r = n / l(后者是段值不是段端点),写错直接死循环或结果错。
  2. 一维模板中 l <= n:l 从 1 开始;如果从 0 开始,n/l 除零错误。
  3. 二维分块 r = min(n/(n/l), m/(m/l)) 且循环上限 l <= min(n,m):漏掉 min 会导致 n/l 或 m/l 为 0。
  4. 段长 r - l + 1 用 ll:n 达 1e12 时段长也可能很大。
  5. 乘法 (n/l) * (m/l) 溢出:结果超过 9e18 时用 __int128(见 4.1 快速乘)。

可拓展的地方

  1. 配合莫比乌斯反演(4.10)是 gcd 计数题的标准加速组合:反演把 gcd 条件展开成 μ 求和,分块把求和压到 O(√n)。
  2. 带权分块可处理任意”可前缀和化的权重”,如 φ 前缀和、id 前缀和,衍生出各种 gcd/lcm 和式。
  3. 三维分块 r = min(n/(n/l), m/(m/l), k/(k/l)) 处理多变量下取整乘积。

典型例题类型

  1. 求 ∑⌊n/i⌋(洛谷 P3935 的组成部分、POJ 的整除分块入门题)。
  2. 反演 + 分块求 gcd 计数(洛谷 P2522 Problem b)。
  3. 求和式涉及 ⌊n/i⌋·⌊m/i⌋ 的题目(如 HDU 1695 GCD)。

4.13 线性基

一句话用途:维护一个数的集合,支持 O(log V) 插入、求子集异或最大值/最小值、求子集异或第 k 小、判断某数能否由子集异或得到、统计子集异或的个数。本质是对向量空间在 GF(2) 上的高斯消元,是”子集异或”类题目的标准工具。

算法思想:线性基是一个数组 p[i](最高位为 i 的基向量),插入 x 时从高位到低位扫描:若 x 的第 i 位为 1 且 p[i] 为空则放入并结束;否则 x ^= p[i] 继续向低位。这样保证每个 p[i] 的最高位是 i,且集合中任意数的异或都能由基线性表出。最大值:从高位向低位贪心,若 x^p[i] > x 则异或。第 k 小:先”重建”把基化成每行只有最高位和更低位的”行阶梯形”(对每个 p[i],用低位 p[j] 消去其低位的 1),收集非零基向量 v,则第 k 小 = 按 k 的二进制位异或对应的 v。

复杂度:插入 O(log V)(V 为值域,ll 取 62 位);求最大/最小 O(log V);重建 + 第 k 小 O(log²V);空间 O(log V)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int MAXB = 62; // long long 的最高位

struct LinearBasis {
ll p[MAXB + 1]; // p[i]:最高位为 i 的基向量(没有则为 0)
LinearBasis() { memset(p, 0, sizeof p); }

// 插入 x;返回是否插入成功(false 说明 x 可由现有基表出,即出现线性相关)
bool insert(ll x) {
for (int i = MAXB; i >= 0; i--) {
if (!(x >> i & 1)) continue; // 第 i 位为 0 直接跳过
if (!p[i]) { p[i] = x; return true; }
x ^= p[i]; // 消去第 i 位,继续向低位
}
return false; // x 被消成 0:线性相关
}

// 判断 x 能否由集合子集异或得到
bool exist(ll x) {
for (int i = MAXB; i >= 0; i--) {
if (!(x >> i & 1)) continue;
if (!p[i]) return false;
x ^= p[i];
}
return x == 0;
}

// 求集合子集异或能得到的最大值(可指定初始值 x,如初始 0)
ll maxXor(ll x = 0) {
for (int i = MAXB; i >= 0; i--)
x = max(x, x ^ p[i]); // 贪心:异或后更大就异或
return x;
}

// 求第 k 小(k 从 0 开始计数,kth(0) 是最小值):
// 先把基重建为行阶梯形——对每个 p[i],用所有更低位的 p[j] 消掉它低位的 1,
// 使每个 p[i] 只保留最高位与若干更低位,且任意两行最高位不同
void rebuild() {
for (int i = MAXB; i >= 0; i--)
for (int j = i - 1; j >= 0; j--)
if (p[i] >> j & 1) p[i] ^= p[j];
}

// 重建后调用:返回第 k 小(0 <= k < 2^sz),k 超出可表示个数返回 -1
ll kth(ll k) {
rebuild();
vector<ll> v; // 收集非零基向量(下标小的对应低位)
for (int i = 0; i <= MAXB; i++)
if (p[i]) v.push_back(p[i]);
// sz 个基向量表出 2^sz 个值;sz=63 时 2^63 超出 ll 表示范围,等价于 k 恒合法
if (v.size() < 63 && k >= (1LL << v.size())) return -1;
ll res = 0;
for (int i = 0; i < (int)v.size(); i++)
if (k >> i & 1) res ^= v[i]; // 按 k 的二进制位选择基向量
return res;
}
};

// 应用示例:子集异或能得到的不同值个数
// 若插入时出现过线性相关(有 insert 返回 false),则 0 可由非空子集得到,
// 不同值个数 = 2^sz;否则 = 2^sz - 1(不含 0)
// 注意:sz=63 时 2^63 超出 ll,需改用 unsigned long long 或 __int128
ll distinctCount(const LinearBasis &b) {
int sz = 0;
for (int i = 0; i <= MAXB; i++) if (b.p[i]) sz++;
return (sz == 63) ? LLONG_MAX : (1LL << sz);
}

注意点 / 坑点

  1. 求第 k 小前必须先 rebuild:不重建直接按 k 的二进制选 p[i] 是错的(p[i] 可能含更高位的基的影子)。
  2. 插入时位循环要从高到低(MAXB 到 0),方向反了会破坏 p[i] 最高位为 i 的不变量。
  3. k 从 0 开始且 kth(0)=0;若集合本身不含 0(无线性相关)且题目要求”非空子集的第 k 小”,需要把 k 加 1 再查。
  4. 1LL << v.size() 当 sz=62 时是 4.6e18 正好在 ll 内,但若值域用到 63 位会溢出,注意移位类型。
  5. 异或第 k 小是”不同异或值”的第 k 小,与”子集个数”无关(相同值去重),读题时要确认。

可拓展的地方

  1. 求最小值:maxXor 对称地贪心 min;也可用 minXor 处理”子集异或最小值”(0 或最小基向量)。
  2. 统计方案数:子集异或得到某值 x 的方案数为 2^(n−sz)(每个自由元两选),常用于计数题。
  3. 可持久化线性基、带删除(时间轴线段树分治)、树上路径异或线性基(配合 LCA),拓展题很多。

典型例题类型

  1. 子集异或最大值(洛谷 P3812 【模板】线性基)。
  2. 子集异或第 k 小(HDU 3949 XOR)。
  3. 树上路径异或最值、异或计数类(如洛谷 P4151 最大异或和路径、CF 895C)。

4.14 高斯消元

一句话用途:解线性方程组(实数版选主元保证数值稳定;模意义版用逆元代除法;异或方程组用 bitset 加速),同时可求矩阵行列式(模版)、判断方程组无解/唯一解/无穷多解。是线性代数类题目(期望 DP、图上方程、生成树计数等)的基础工具。

算法思想:把增广矩阵通过行变换化成行阶梯形(主元所在列除主元行外全消为 0,即 Gauss-Jordan 全消元),每列对应一个未知数。无解的判定:某行系数全 0 但常数项非 0。自由元:某列没有主元(where[col] = -1),此时解不唯一。实数版选绝对值最大的行当主元减少精度损失;模意义版选第一个非零行即可,除法换成乘逆元;异或版把每行存成 bitset,行间操作是 O(n/64)。

复杂度:时间 O(n³)(异或版 bitset 优化为 O(n³/64));空间 O(n²)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const double EPS = 1e-9;

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; }
return res;
}

// ---------- 实数版(Gauss-Jordan)----------
// 返回:-1 无解;0 唯一解;1 无穷多解。有解时 ans 为解(自由元取 0)
int gauss(vector<vector<double>> a, vector<double> &ans) {
int n = a.size(); // a 是 n 行 n+1 列的增广矩阵
vector<int> where(n, -1); // where[col] = 该列主元所在的行
int row = 0;
for (int col = 0; col < n && row < n; col++) {
// 选主元:在 row..n-1 行中找第 col 列绝对值最大的
int piv = row;
for (int i = row + 1; i < n; i++)
if (fabs(a[i][col]) > fabs(a[piv][col])) piv = i;
if (fabs(a[piv][col]) < EPS) continue; // 第 col 列全 0:col 为自由元
swap(a[piv], a[row]);
where[col] = row;
// 消元:把第 col 列从其他所有行中消掉(Gauss-Jordan 全消元)
for (int i = 0; i < n; i++) {
if (i == row) continue;
double f = a[i][col] / a[row][col];
for (int j = col; j <= n; j++) a[i][j] -= f * a[row][j];
}
row++;
}
// 无解检查:系数全 0 而常数非 0 的行
for (int i = 0; i < n; i++) {
bool allZero = true;
for (int j = 0; j < n; j++)
if (fabs(a[i][j]) > EPS) { allZero = false; break; }
if (allZero && fabs(a[i][n]) > EPS) return -1;
}
// 回代主元
ans.assign(n, 0);
for (int col = 0; col < n; col++)
if (where[col] != -1) ans[col] = a[where[col]][n] / a[where[col]][col];
for (int col = 0; col < n; col++) if (where[col] == -1) return 1; // 有自由元
return 0;
}

// ---------- 模意义版(mod 为素数,除法换成逆元)----------
int gaussMod(vector<vector<ll>> a, vector<ll> &ans, ll mod) {
int n = a.size();
vector<int> where(n, -1);
int row = 0;
for (int col = 0; col < n && row < n; col++) {
int piv = -1;
for (int i = row; i < n; i++)
if (a[i][col]) { piv = i; break; } // 找非零主元
if (piv == -1) continue;
swap(a[piv], a[row]);
where[col] = row;
ll inv = qpow(a[row][col], mod - 2, mod); // 主元逆元
for (int i = 0; i < n; i++) {
if (i == row) continue;
ll f = a[i][col] * inv % mod;
for (int j = col; j <= n; j++)
a[i][j] = (a[i][j] - f * a[row][j] % mod + mod) % mod;
}
row++;
}
for (int i = 0; i < n; i++) {
bool allZero = true;
for (int j = 0; j < n; j++) if (a[i][j]) { allZero = false; break; }
if (allZero && a[i][n]) return -1;
}
ans.assign(n, 0);
for (int col = 0; col < n; col++)
if (where[col] != -1) ans[col] = a[where[col]][n] * qpow(a[where[col]][col], mod - 2, mod) % mod;
for (int col = 0; col < n; col++) if (where[col] == -1) return 1;
return 0;
}

// ---------- 异或线性方程组(bitset 优化一句话):每行是一个 bitset,行消元用
// 整字异或,复杂度从 O(n^3) 降到 O(n^3/64),n 可达 5000。实现与 gaussMod 完全同构,
// 把"乘逆元"换成"整行 ^= 主元行"即可 ----------

// ---------- 行列式(模版,mod 为素数)----------
ll detMod(vector<vector<ll>> a, ll mod) {
int n = a.size();
ll det = 1;
for (int i = 0; i < n; i++) {
int piv = -1;
for (int j = i; j < n; j++) if (a[j][i]) { piv = j; break; }
if (piv == -1) return 0; // 行列式奇异
if (piv != i) { swap(a[piv], a[i]); det = (mod - det) % mod; } // 换行翻转符号
det = det * a[i][i] % mod;
ll inv = qpow(a[i][i], mod - 2, mod);
for (int j = i + 1; j < n; j++) { // 只消下面的行(转成上三角)
ll f = a[j][i] * inv % mod;
for (int k = i; k < n; k++)
a[j][k] = (a[j][k] - f * a[i][k] % mod + mod) % mod;
}
}
return det;
}

注意点 / 坑点

  1. 实数版不选主元(直接取 a[row][col])在数据病态时会精度崩溃,选主元这步不能省。
  2. “第 col 列全 0 就 continue”导致 where[col] = -1,最后据此判断无穷多解;写漏 where 数组则无法区分唯一解与无穷多解。
  3. 模意义版要求 mod 为素数且主元非零;mod 为合数时主元可能不可逆,需交换行找”与 mod 互素”的主元。
  4. 行列式模版中换行必须翻转符号(det = (mod - det) % mod),漏掉则符号错。
  5. 异或版矩阵是”增广”的:判无解要看整行(含常数位)异或主元行后是否全 0 而常数位为 1。

可拓展的地方

  1. 期望 DP 列出的方程组(图上随机游走)直接套实数版。
  2. 矩阵树定理(生成树计数)需要模意义行列式,直接套 detMod。
  3. 异或方程组 + bitset 处理 n 达 5000 的题目(如开关灯问题的高斯消元版)。

典型例题类型

  1. 解线性方程组(洛谷 P3389 【模板】高斯消元法)。
  2. 模意义方程组(洛谷 P2447 外星人,bitset 异或版)。
  3. 行列式/矩阵树定理(洛谷 P4111 小 Z 的房间、SPOJ HIGH)。

4.15 BSGS

一句话用途:求解离散对数问题 a^x ≡ b (mod p) 的最小非负整数 x(p 为素数,或至少 gcd(a,p)=1)。BSGS 即”大步小步”,把 x 写成 i·m + j,预存小步 a^j 查表,大步枚举 i,O(√p) 求解。gcd(a,p)≠1 时用 exBSGS(思路一句话见下)。

算法思想:取 m = ⌈√p⌉,任意 x ∈ [0, p) 可唯一写成 x = i·m + j(0 ≤ i ≤ m,0 ≤ j < m)。等式 a^(i·m+j) ≡ b 等价于 a^j ≡ b·a^(−i·m)。于是先把所有 a^j (0 ≤ j < m) 存入哈希表(小步),再枚举 i 查表(大步):对每个 i 计算 t = b·a^(−i·m) mod p,若 t 在表中,则 x = i·m + j 即答案。a 与 p 互素保证 a^(−m) 存在(用快速幂求逆元)。

复杂度:时间 O(√p)(哈希表插入查询均摊 O(1));空间 O(√p)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; }
return res;
}

// 求最小非负整数 x 满足 a^x ≡ b (mod p),p 为素数(或 gcd(a,p)=1),无解返回 -1
ll bsgs(ll a, ll b, ll p) {
a %= p; b %= p;
if (b == 1) return 0; // x = 0 恒成立(含 a=0 的情形)
if (a == 0) return b == 0 ? 1 : -1; // 0^x ≡ 0 仅当 x>=1,最小为 1
ll m = (ll)ceil(sqrt((double)p)); // 大步步长:m = ceil(sqrt(p))
unordered_map<ll, ll> baby; // 小步表:a^j -> j
ll cur = 1;
for (ll j = 0; j < m; j++) {
if (!baby.count(cur)) baby[cur] = j; // 只记录第一次出现(保证最小 x)
cur = cur * a % p; // cur = a^j
}
// 大步:t_i = b * a^(-i*m),查表得 j,则 x = i*m + j
ll invAm = qpow(qpow(a, m, p), p - 2, p); // a^(-m) = (a^m)^(-1),费马求逆
ll t = b;
for (ll i = 0; i <= m; i++) {
if (baby.count(t)) return i * m + baby[t];
t = t * invAm % p; // t 递推:乘一次 a^(-m)
}
return -1; // 枚举完没找到:无解
}

exBSGS 思路(一句话):当 gcd(a,p) ≠ 1 时,把方程两边和模数同时除以 g = gcd(a,p):若 b % g ≠ 0 且 b ≠ 1 则无解,否则 a/g·a^(x−1) ≡ b/g (mod p/g),记下系数并递归处理 a^(x−1) ≡ b’ (mod p’),直到互素后用普通 BSGS,最后答案加上递归层数 k(同时特判小 x 的情况)。

注意点 / 坑点

  1. b == 1 时直接返回 0:x=0 是最小解,走通用流程会因 i*m+j 可能更大而错。
  2. m 取 ⌈√p⌉:取 ⌊√p⌋ 时可能漏解(x 的最大值接近 p,需要 i 枚举到 m)。
  3. 小步表只存”第一次出现”的 j:若后面覆盖,会丢掉更小的解。
  4. 大步的 t 要从 b 开始(i=0 时 t=b),循环 i 从 0 到 m,别从 1 开始。
  5. p 为合数且 gcd(a,p)≠1 时费马求逆失效:必须用 exBSGS 或先保证 a 与 p 互素。
  6. 用 unordered_map 时小心被卡哈希(可换 gp_hash_table 或自写哈希表)。

可拓展的地方

  1. exBSGS 处理模数不互素,模板见上思路,配合扩展欧几里得推导。
  2. BSGS 可以处理”指数型同余方程”及”求 a^x ≡ b 的所有解(x 有周期 φ(p))”。
  3. 配合原根(4.16)可把”模 p 意义下的乘法”转化为”指数加法”,用于 NTT 的阶分析。

典型例题类型

  1. 求离散对数(洛谷 P3846 【模板】BSGS、SPOJ MOD 等)。
  2. exBSGS:模数不互素(洛谷 P4195 【模板】扩展 BSGS)。
  3. 密码学背景 / 指数方程变形(如 poj 2417 Discrete Logging)。

4.16 原根与离散对数

一句话用途:求模素数 p 的最小原根 g(g 的 1..p−1 次幂恰好遍历 1..p−1 全部非零剩余类,即 g 是模 p 乘法群的生成元),并利用原根把乘法问题转化为指数加法(离散对数),是 NTT(数论变换)找单位根、密码学 Diffie-Hellman 密钥交换的理论基础。

算法思想:g 是模 p 的原根当且仅当 g 的阶为 φ(p)=p−1。检验方法:对 p−1 的每个质因子 q,若 g^((p−1)/q) ≠ 1 (mod p) 对所有 q 成立,则 g 是原根(因为 g 的阶若是 p−1 的真因子 d,则存在 q | (p−1)/d,使 g^((p−1)/q) ≡ 1)。最小原根数值通常很小(p 为素数时平均约 3),从 g=2 开始逐个试。离散对数:给定 g 和 a,求 x 使 g^x ≡ a (mod p),用 4.15 的 BSGS 求解;原根保证对任意 a≠0 解都存在且唯一(模 p−1 意义下)。

复杂度:分解 p−1 的质因子 O(√p);每个候选 g 检验 O(ω(p−1)·log p)(ω 为质因子个数);整体期望 O(√p + 常数·log p)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; }
return res;
}

// ---------- 求模素数 p 的最小原根 ----------
ll primitiveRoot(ll p) {
if (p == 2) return 1; // 特判:p=2 时原根为 1
// 1. 分解 p-1 的质因子(只需不同因子)
vector<ll> fac;
ll x = p - 1;
for (ll i = 2; i * i <= x; i++) {
if (x % i == 0) {
fac.push_back(i);
while (x % i == 0) x /= i;
}
}
if (x > 1) fac.push_back(x); // 剩余的 > sqrt 的质因子
// 2. 从 2 开始逐个检验
for (ll g = 2; g < p; g++) {
bool ok = true;
for (ll q : fac)
if (qpow(g, (p - 1) / q, p) == 1) { ok = false; break; }
if (ok) return g;
}
return -1; // 理论上不会走到(素数必有原根)
}

// ---------- 判断 g 是否为模 p 的原根(p 为素数) ----------
bool isPrimitiveRoot(ll g, ll p, const vector<ll> &facPm1) {
if (g <= 0 || g >= p) return false;
for (ll q : facPm1)
if (qpow(g, (p - 1) / q, p) == 1) return false;
return true;
}

离散对数应用简述

  • NTT:模 p = c·2^k + 1 的 NTT 需要 p 的原根(常用 p=998244353,原根 3),原根作为单位根 g^((p−1)/n) 使用,n 为 2 的幂。
  • 密码学:Diffie-Hellman 密钥交换的安全性建立在”已知 g^x mod p 反求 x 困难”(离散对数问题)之上;BSGS 给出了对小 p 的攻击方法。
  • 阶与循环节:a 的阶 = φ(p)/gcd(φ(p), 离散对数),可用于分析递推数列的循环节。

注意点 / 坑点

  1. 检验条件必须是”对所有质因子 q 都有 g^((p−1)/q) ≠ 1”,只查一个因子会误判。
  2. p=2 要特判(p−1=1 没有质因子,循环自然通过,但按定义 1 是原根)。
  3. 分解 p−1 时注意 x 在循环中被改变,最后 if (x > 1) 的剩余因子别漏。
  4. 原根的存在性:素数一定有原根,但合数(如 p=8)不一定有,模板只保证素数场景。
  5. 求原根时 g 从 2 开始枚举通常很快,但理论上最坏要试到 O(p),题目数据一般保证可行。

可拓展的地方

  1. 求出原根后可用 BSGS(4.15)实现”模 p 下的对数运算”,把乘法/幂运算降到指数域。
  2. 原根可推广到模 p^k 与模 2p^k(奇素数 p),用于扩展 NTT 或数论变换的合数模数。
  3. 求原根 + 离散对数是”循环群”结构题(阶、生成元、指标)的基础工具。

典型例题类型

  1. 求最小原根(洛谷 P6091 【模板】原根、POJ 1284 Primitive Roots 计数)。
  2. NTT 前置:确定模数的原根(998244353 等固定模数直接背原根 3)。
  3. 离散对数 + 原根的混合题(如 HDU 2815 的变形)。

4.17 博弈论与 SG 函数

一句话用途:判断公平组合游戏(两人轮流走、无随机、无平局、不能走的人输)的先手胜负,并给出必胜策略的取法。三大经典结论:Nim 游戏看异或和;巴什博弈看 n%(m+1);威佐夫博弈看黄金分割比。一般性的公平组合游戏用 SG 函数 + mex 求解,多堆组合时 SG 值异或。

算法思想

  • Nim:n 堆石子,每次取一堆的任意正整数个。结论:异或和 xor = a₁⊕a₂⊕…⊕aₙ,xor ≠ 0 先手必胜,且必胜走法是”找到一堆使异或和变 0”。
  • 巴什博弈:一堆 n 个石子,每次取 1~m 个。n % (m+1) ≠ 0 先手必胜(每次取走 n%(m+1) 个保持模数平衡)。
  • 威佐夫博弈:两堆 (a,b)(a≤b),每次可从一堆取任意个或两堆取同样多。若 ⌊(b−a)·φ⌋ = a(φ = (1+√5)/2 为黄金分割比)则先手必败,否则必胜。
  • SG 函数:状态 s 的 SG 值 sg[s] = mex{ sg[t] | s 可转移到 t },mex 是最小未出现的非负整数。终态(必败态)sg=0。多堆独立游戏组合(每次只动一堆)的总 SG = 各堆 SG 的异或,异或非 0 先手必胜。

复杂度:SG 递推 O(状态数 × 每状态转移数);Nim/巴什/威佐夫均为 O(n) 或 O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// ---------- Nim 博弈:异或和判胜负 ----------
bool nimWin(const vector<ll> &a) {
ll xr = 0;
for (ll v : a) xr ^= v;
return xr != 0; // 非 0 先手必胜;0 先手必败
}

// Nim 必胜走法:找一堆 a[i],令 a[i] ^= xr 后异或和为 0(即把 a[i] 变成 a[i]^xr)
// 要求 a[i]^xr < a[i],存在性由 xr != 0 保证

// ---------- 巴什博弈:n 个石子,每次取 1~m 个 ----------
bool bashWin(ll n, ll m) { return n % (m + 1) != 0; }
// 必胜策略:先手取 n%(m+1) 个,之后每次取 (m+1 - 对手取的数量) 个

// ---------- 威佐夫博弈:两堆 (a, b),a <= b ----------
bool wythoffLose(ll a, ll b) {
if (a > b) swap(a, b);
double phi = (1.0 + sqrt(5.0)) / 2.0; // 黄金分割比
ll k = (ll)((b - a) * phi); // 必败态的差对应黄金比例
return k == a; // 相等则先手必败
}

// ---------- SG 函数:一般公平组合游戏 ----------
const int MAXS = 100000;
int sg[MAXS + 1]; // sg 值
bool vis[MAXS + 1]; // 每轮标记出现过的后继 sg 值(mex 用)
vector<int> moveFrom[MAXS + 1]; // moveFrom[s]:从状态 s 能到的所有后继状态

// 递推计算 SG(要求状态图是无环的 DAG,按拓扑序从终态往前推)
void buildSG(int n) {
sg[0] = 0; // 默认 0 号是终态(没有后继),sg=0 必败
for (int s = 1; s <= n; s++) {
memset(vis, 0, sizeof vis); // 清空标记
for (int t : moveFrom[s]) // 枚举所有后继
if (t < s) vis[sg[t]] = true; // 只允许转移到编号更小的状态(DAG 保证)
int mex = 0;
while (vis[mex]) mex++; // mex:最小未出现的非负整数
sg[s] = mex;
}
}

// 多堆组合游戏:每堆是一个独立子游戏,总 SG = 各堆 SG 异或
bool multiGameWin(const vector<int> &sgOfEach) {
int xr = 0;
for (int v : sgOfEach) xr ^= v;
return xr != 0; // 非 0 先手必胜
}

注意点 / 坑点

  1. SG 递推只适用于 DAG(状态无环);若状态图有环(可取回石子),必须用 Sprague-Grundy 的博弈论变体(如”有向图博弈”的求解需更高级工具)。
  2. mex 是”最小未出现非负整数”,不是最大,写 while (vis[mex]) mex++ 别写反。
  3. 威佐夫博弈要求 a≤b 且用 double 算 φ 注意精度:b−a 很大时 (b−a)·φ 可能因浮点误差差 1,可用整数乘法 (b-a)*phi 后校验 k 与 k+1 两个候选。
  4. Nim 必胜走法里 a[i] ^ xr 必须小于原值才是合法走法(只能减少石子),存在性由异或非 0 保证,实现时逐个验证。
  5. 多堆游戏的”每堆独立”前提是每次只能动一堆;若一步能同时影响多堆(如威佐夫取两堆同量),不能简单异或。

可拓展的地方

  1. SG 函数可以打表找规律:小范围暴力算 SG,观察周期/模式后 O(1) 判断大范围(经典套路)。
  2. 有向图游戏、Anti-Nim(取走最后一颗石子者负)、阶梯 Nim(偶数堆石子挪到奇数层)都是 Nim 结论的推广。
  3. SG 与”删边游戏””树上的博弈”结合(如 Green Hackenbush),可用 SG 定理拆解。

典型例题类型

  1. Nim 裸题与变形(POJ 2234 Matches Game、洛谷 P2197)。
  2. SG 函数打表找规律(HDU 1847、洛谷 P4018 等取石子系列)。
  3. 巴什/威佐夫裸题(HDU 1846 Brave Game、POJ 1067 取石子游戏)。

4.18 组合数取模

一句话用途:计算 C(n,k) mod p。常规做法是阶乘 + 逆元预处理,O(1) 单次查询;前提是 p 为素数且 n < p。当 n 很大(≥ p 甚至 1e18)时,预处理因 fac 含 p 的因子而失效,改用 Lucas 定理(4.9);p 为合数用扩展 Lucas。本节给出可直接用的完整模板。

算法思想:C(n,k) = n! / (k!·(n−k)!)。预处理 fac[i]=i! mod p 与 ifac[i]=(i!)⁻¹ mod p(ifac 由 fac[n] 的逆元倒推,见 4.6),则 C(n,k) = fac[n]·ifac[k]·ifac[n−k] mod p。n、k 上限由预处理数组大小决定(n 多大数组开多大,一般 ≤ 1e7)。若 n ≥ p,fac[n] 含有因子 p,模 p 下为 0 无法求逆,此时必须用 Lucas(4.9)逐层拆 p 进制。

复杂度:预处理 O(n);单次查询 O(1)。空间 O(n)。(配合 Lucas 后查询 O(log_p n)。)

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 1e9 + 7; // 素数模数

ll qpow(ll a, ll b, ll mod) {
ll res = 1 % mod;
a %= mod;
while (b) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; }
return res;
}

// ---------- 阶乘 + 逆元预处理(n < MOD 且 MOD 为素数)----------
const int MAXN = 1000000;
ll fac[MAXN + 1], ifac[MAXN + 1];

void initComb(int n) {
fac[0] = 1;
for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
ifac[n] = qpow(fac[n], MOD - 2, MOD); // 费马小定理求 fac[n] 的逆元
for (int i = n; i >= 1; i--) ifac[i - 1] = ifac[i] * i % MOD; // 倒推
}

// 单次查询:0 <= k <= n <= MAXN
ll C(ll n, ll k) {
if (k < 0 || k > n) return 0; // 越界组合数记为 0
return fac[n] * ifac[k] % MOD * ifac[n - k] % MOD;
}

// ---------- 组合数版本二:n 很大(> MAXN 但 < MOD)时无法开数组 ----------
// 用公式 C(n,k) = C(n,k-1) * (n-k+1) / k 递推,O(k) 单次计算,k 小的时候适用
ll CLargeK(ll n, ll k, ll mod) {
if (k < 0 || k > n) return 0;
k = min(k, n - k); // C(n,k) = C(n,n-k),取小的一边
ll res = 1;
for (ll i = 1; i <= k; i++)
res = res * ((n - k + i) % mod) % mod * qpow(i, mod - 2, mod) % mod;
return res;
}

// ---------- Lucas 版:n, k 可达到 1e18,要求 p 为素数且 p 较小(详见 4.9)----------
// 注意:内部用"按模 p 预处理"的阶乘,与上方全局 MOD=1e9+7 的 fac 互不影响
ll lucas(ll n, ll k, ll p) {
if (k == 0) return 1;
// 惰性预处理:p 变化时重建 0..p-1 的阶乘与逆元(p 较小才可行)
static ll lastP = 0;
static vector<ll> facP, ifacP;
if (lastP != p) {
lastP = p;
facP.assign(p, 1);
for (ll i = 1; i < p; i++) facP[i] = facP[i - 1] * i % p;
ifacP.assign(p, 1);
ifacP[p - 1] = qpow(facP[p - 1], p - 2, p);
for (ll i = p - 1; i >= 1; i--) ifacP[i - 1] = ifacP[i] * i % p;
}
ll a = n % p, b = k % p;
if (b > a) return 0; // Lucas 拆位后小组合数越界 -> 整体为 0
ll small = facP[a] * ifacP[b] % p * ifacP[a - b] % p;
return small * lucas(n / p, k / p, p) % p; // 递归拆 p 进制
}

注意点 / 坑点

  1. 阶乘预处理的前提是 n < MOD(且 MOD 为素数):n ≥ MOD 时 fac[n] ≡ 0 (mod MOD),ifac 求逆会失败,必须切 Lucas。
  2. CLargeK 每次查询 O(k):k 接近 n/2 且 n 很大时退化为 O(n),谨慎使用;此时应改用 Lucas。
  3. (n - k + i) % mod 里 n−k+i 可能超过 ll(n 达 1e18 时),先取模再乘。
  4. Lucas 版里”小组合数”必须用”模 p 的阶乘预处理”(数组开到 p),不能复用模 1e9+7 的 fac。
  5. C 函数越界返回 0 是约定:题目若保证 k≤n 可省略,但保留更安全。

可拓展的地方

  1. 组合数前缀和 ∑C(n,i)(利用递推或配合 NTT 卷积加速)。
  2. 与 4.6 的阶乘逆元、4.9 的 Lucas 组合成”组合数三件套”,覆盖 n 任意大的取模。
  3. 配合线性基/容斥做”组合计数 + 异或”类题目(如计算恰好 k 个的方案数)。

典型例题类型

  1. 预处理 + O(1) 查询组合数(洛谷 P3414 SAC#1 组合数、CF 组合计数题)。
  2. n 很大 + 模素数,用 Lucas(洛谷 P3807 的 n,k 数据范围扩展版)。
  3. 组合数前缀和、组合恒等式验证类(常以结论题形式出现)。

五、字符串

本部分速查表

算法 复杂度 适用场景
5.1 字符串哈希 预处理 O(n),单次查询 O(1),空间 O(n) 快速判断子串相等、子串比较、最长回文二分等一切需要 O(1) 比较字符串的场景
5.2 KMP O(n + m),空间 O(m) 单模式串匹配、找所有出现位置与次数、最小循环节与周期
5.3 扩展 KMP 与 Z 算法 O(n + m),空间 O(n + m) 求一个串每个后缀与另一串的 LCP、字符串周期判定、前缀出现次数
5.4 Manacher O(n),空间 O(n) 最长回文子串、回文子串计数、最长双回文等回文类问题
5.5 AC 自动机 构建 O(Σ P
5.6 后缀数组 排序 O(n log n),LCP RMQ 预处理 O(n log n) 查询 O(1) 不同子串计数、最长重复子串、任意两后缀 LCP、多串最长公共子串
5.7 后缀自动机 构建 O(n),空间 O(n·26) 不同子串计数、子串出现次数、字典序第 k 小子串等后缀结构问题
5.8 最小表示法 O(n),空间 O(1) 循环同构串最小字典序、循环同构判断、旋转串去重

5.1 字符串哈希

一句话用途:字符串哈希把任意字符串映射为一个(或一对)整数,从而能在 O(1) 时间内判断任意两个子串是否相等,是字符串题中最常用的「快速比较」工具。适用于子串比较、最长回文二分、循环同构判断,以及一切需要频繁比较字符串的题目。

算法思想:把字符串看成 BASE 进制下的大整数(BASE 取大于字符集大小的素数,如 131、13331),预处理前缀哈希 h[i] = h[i-1] * BASE + s[i],则子串 s[l..r] 的哈希可用 h[r] - h[l-1] * BASE^(r-l+1) 在 O(1) 内求出(前缀和思想)。由于数值巨大,有两种取模方式:① 自然溢出,用 unsigned long long 自动对 2^64 取模,运算极快但理论上可被构造数据卡;② 双模哈希,取两个大素数(如 1e9+7、1e9+9)同时取模,把两个余数组成的 pair 作为哈希值,冲突概率极低。

复杂度

  • 时间:预处理 O(n),单次区间哈希查询 O(1)。
  • 空间:O(n)(前缀哈希数组 + 幂数组)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using ull = unsigned long long;

const int MAXN = 1000005;
const int BASE = 131; // 进制:取大于字符集大小的素数,如 131 / 13331
const ll MOD1 = 1000000007LL; // 双模哈希模数 1(素数)
const ll MOD2 = 1000000009LL; // 双模哈希模数 2(素数)

char s[MAXN];
int n;

// ---------- 模板一:自然溢出(unsigned long long 自动对 2^64 取模) ----------
ull h[MAXN], pw[MAXN]; // 前缀哈希、BASE 的幂(均自然溢出)

void build_ull() {
pw[0] = 1;
for (int i = 1; i <= n; i++) {
h[i] = h[i - 1] * BASE + (ull)(unsigned char)s[i - 1]; // s 下标从 0 开始
pw[i] = pw[i - 1] * BASE;
}
}

// 求子串 s[l..r] 的哈希,l、r 均为 1 下标(h[0] 表示空前缀)
ull get_ull(int l, int r) {
return h[r] - h[l - 1] * pw[r - l + 1]; // 乘法自然溢出等价于取模 2^64
}

// ---------- 模板二:双模哈希(冲突概率极低,可抗构造数据) ----------
ll h1[MAXN], h2[MAXN]; // 两个模数下的前缀哈希
ll p1[MAXN], p2[MAXN]; // 两个模数下 BASE 的幂

void build_mod() {
p1[0] = p2[0] = 1;
for (int i = 1; i <= n; i++) {
h1[i] = (h1[i - 1] * BASE + (unsigned char)s[i - 1]) % MOD1;
h2[i] = (h2[i - 1] * BASE + (unsigned char)s[i - 1]) % MOD2;
p1[i] = p1[i - 1] * BASE % MOD1;
p2[i] = p2[i - 1] * BASE % MOD2;
}
}

pair<ll, ll> get_mod(int l, int r) {
ll v1 = (h1[r] - h1[l - 1] * p1[r - l + 1] % MOD1 + MOD1) % MOD1;
ll v2 = (h2[r] - h2[l - 1] * p2[r - l + 1] % MOD2 + MOD2) % MOD2;
return {v1, v2}; // 减法先加 MOD 再取模,防止负数
}

int main() {
scanf("%s", s);
n = (int)strlen(s);
build_ull();
build_mod();
// 判断 s[1..3] 与 s[5..7] 是否相等:两套哈希同时相等才判相等
if (get_ull(1, 3) == get_ull(5, 7) && get_mod(1, 3) == get_mod(5, 7))
puts("两个子串相等");
else
puts("两个子串不相等");
return 0;
}

注意点 / 坑点

  1. 进制 BASE 必须大于字符集大小且最好取素数,否则不同字符可能映射到同一个「数位」造成大量冲突;字符要映射到正整数(如 (unsigned char)c 或 c - ‘a’ + 1),避免 ‘a’ 映射成 0 导致 “a” 与 “aa” 同哈希(前导零问题)。
  2. 区间哈希公式是 h[r] - h[l-1] * BASE^(r-l+1),指数是区间长度 r-l+1 而非别的值,写错幂次是最高频 bug;双模版中减法要先加 MOD 再取模。
  3. 自然溢出不是零冲突:ull 对 2^64 取模可被精心构造的数据卡掉(如洛谷 P3370 的毒瘤数据),正式比赛建议用双模或三模;自然溢出下也不要对 ull 额外取模。
  4. 不同长度的字符串即使哈希值碰巧相同也不能判等:比较前先比长度,或把长度编码进哈希(如 h = h * BASE + len)。
  5. 下标约定要统一:本模板用 1 下标(h[0] 为空),与 0 下标混用会取错区间甚至越界。

可拓展的地方

  1. 最长回文二分:预处理正串哈希与反串哈希,二分回文半径,用哈希 O(1) 判断以某点为中心、某半径的子串是否回文(奇、偶中心分别二分),复杂度 O(n log n),是哈希的重要应用(O(n) 正解见 5.4 Manacher)。
  2. 与二分/双指针/判重结合:哈希值可存入 unordered_map/set 做子串去重(如统计不同子串、洛谷 P1381 单词背诵),也可用于 Rabin–Karp 字符串匹配(滚动哈希)。
  3. 大字符集/二进制串:可按块做 Rolling Hash;多组数据时把模数、进制设为可配置参数,便于复用。

典型例题类型

  1. 纯模板:统计/判断子串相等 —— 洛谷 P3370【模板】字符串哈希。
  2. 结合双指针、二分:单词背诵类(洛谷 P1381)、最长回文二分(如 POJ 3974 可用哈希 + 二分实现)。
  3. 两串公共子串/公共前后缀类 —— 洛谷 P2957 [USACO09OCT] Barn Echoes G。

5.2 KMP

一句话用途:KMP 是单模式串匹配算法,在线性时间内找出模式串 p 在文本串 s 中的所有出现位置;其核心 next(前缀函数)数组还能求出字符串的最小循环节、周期与 border 结构,是字符串入门必学的算法。

算法思想:朴素匹配失配时文本指针只回退 1 位,KMP 利用模式串自身的重复结构加速:next[i] 表示 p 的前缀 p[0..i] 的最长相等真前缀与真后缀的长度(称最长 border)。匹配时文本指针 i 永不回退,失配时模式串指针 j 跳到 next[j-1] 继续比较,每个字符至多比较常数次,总复杂度 O(n + m)。

复杂度

  • 时间:构建 next 数组 O(m),匹配 O(n + m)。
  • 空间:O(m)(next 数组)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <bits/stdc++.h>
using namespace std;

// 构建前缀函数(next 数组)
// nxt[i]:模式串 p 的前缀 p[0..i] 的最长相等真前缀与真后缀的长度(最长 border 长度)
vector<int> get_next(const string& p) {
int m = (int)p.size();
vector<int> nxt(m, 0);
for (int i = 1; i < m; i++) { // i 从 1 开始,nxt[0] 恒为 0
int j = nxt[i - 1]; // 上一位置的最长 border 作为本轮起点
while (j > 0 && p[i] != p[j]) // 失配沿 border 链回退
j = nxt[j - 1];
if (p[i] == p[j]) j++; // 匹配成功则 border 长度 +1
nxt[i] = j;
}
return nxt;
}

// 在文本串 s 中找出模式串 p 的所有出现位置(返回起始下标,0 下标)
vector<int> kmp_match(const string& s, const string& p) {
vector<int> nxt = get_next(p);
vector<int> res;
int j = 0; // j:当前已匹配的 p 的前缀长度
for (int i = 0; i < (int)s.size(); i++) {
while (j > 0 && s[i] != p[j]) // 失配:文本指针 i 不回退,只跳模式串指针
j = nxt[j - 1];
if (s[i] == p[j]) j++; // 匹配一个字符
if (j == (int)p.size()) { // 完整匹配一次
res.push_back(i - j + 1); // 记录起点(0 下标)
j = nxt[j - 1]; // 允许重叠地继续寻找下一处出现
}
}
return res;
}

// 最小循环节:n - nxt[n-1] 一定是 p 的最短周期;
// 当且仅当 n % (n - nxt[n-1]) == 0 时,p 才能由若干个完整循环节拼成
int min_cycle(const string& p) {
vector<int> nxt = get_next(p);
int n = (int)p.size();
int cyc = n - nxt[n - 1];
return (n % cyc == 0) ? cyc : -1; // -1 表示不存在整循环节
}

int main() {
string s, p;
cin >> s >> p;
vector<int> pos = kmp_match(s, p);
for (int x : pos) printf("%d ", x + 1); // 题目常用 1 下标输出
return 0;
}

注意点 / 坑点

  1. next 数组的下标约定极易混淆:本模板 nxt[i] 表示 p[0..i] 的最长 border(nxt[0] = 0,失配跳 nxt[j-1]);另一类写法用 nxt[i] 表示 p[0..i-1] 的 border 且 nxt[0] = -1(失配跳 nxt[j]),两种约定混用必然错位,务必统一。
  2. 匹配成功记录位置后必须 j = nxt[j-1] 再继续,否则漏掉重叠出现(如 s = “aaaa”、p = “aa” 应有 3 处出现)。
  3. 最小循环节结论只在 n % (n - nxt[n-1]) == 0 时成立(POJ 2406 问的正是这个);不整除时 n - nxt[n-1] 仍是 p 的最短周期长度,但不能说 p 由整循环节构成。
  4. nxt[i] 定义的是「真」前缀/后缀(长度严格小于 i+1),不能把整个前缀自己算进去,否则循环节长度恒为 0。
  5. 边界:p 为空串时 get_next 返回空数组,匹配函数需单独处理(题目一般不会给空模式串)。

可拓展的地方

  1. 失配树(前缀函数树):所有 nxt 指针构成一棵树,DFS 或差分可求「每个前缀在串中出现多少次」(洛谷 P5829【模板】失配树、CF 432D)。
  2. KMP 自动机:把失配转移对每个字符预计算并补全成 DFA,匹配变成纯 O(n) 的自动机游走,这也是 5.5 AC 自动机在单串情形下的思想来源。
  3. border 与周期定理:next 数组蕴含串的所有周期与 border,可解循环节/周期类题目(如 POJ 1961 Period、洛谷 P4391)。

典型例题类型

  1. 纯模板:找模式串出现位置与次数 —— 洛谷 P3375【模板】KMP 字符串匹配。
  2. 最小循环节 / 周期类 —— POJ 2406 Power Strings、洛谷 P4391 [BOI2009] Radio Transmission 无线传输。
  3. border 应用类 —— CF 432D Prefixes and Suffixes(每个前缀作为后缀出现的次数)、洛谷 P5829【模板】失配树。

5.3 扩展 KMP 与 Z 算法

一句话用途:Z 算法在线性时间内求出 Z 数组——串 s 与它的每个后缀的最长公共前缀(LCP)长度;扩展 KMP 则用 Z 算法求一个串 b 与另一个串 a 的每个后缀的 LCP。常用于找字符串周期、判断「前缀 = 后缀」、统计每个前缀的出现次数等。

算法思想:Z 算法维护当前最靠右的匹配段 [l, r](即 s[l..r] == s[0..r-l])。计算 z[i] 时若 i 落在 [l, r] 内,可借用已算好的镜像位置值 z[i-l] 得到下界 min(r-i+1, z[i-l]),再做朴素扩展并更新 [l, r],均摊 O(n)。扩展 KMP 把 b 与 a 用分隔符拼接成 b + ‘#’ + a,做一次 Z 算法,则 a 的每个后缀与 b 的 LCP 直接读 z 数组对应位置。

复杂度

  • 时间:Z 算法 O(n);扩展 KMP(拼接后)O(n + m)。
  • 空间:O(n + m)(Z 数组 + 拼接串)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
using namespace std;

// Z 函数:z[i] = s 与后缀 s[i..] 的最长公共前缀长度(约定 z[0] = 0)
vector<int> z_function(const string& s) {
int n = (int)s.size();
vector<int> z(n, 0);
int l = 0, r = 0; // 当前最靠右的匹配段 [l, r](闭区间)
for (int i = 1; i < n; i++) {
if (i <= r) z[i] = min(r - i + 1, z[i - l]); // 借用镜像位置 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;
}

// 扩展 KMP:返回 ext,ext[i] = a 的后缀 a[i..] 与 b 的最长公共前缀长度
vector<int> ex_kmp(const string& a, const string& b) {
string t = b + "#" + a; // '#' 必须是不出现在 a、b 中的字符
vector<int> z = z_function(t);
int m = (int)b.size();
vector<int> ext(a.size());
for (int i = 0; i < (int)a.size(); i++)
ext[i] = z[m + 1 + i]; // b + '#' 共占 m+1 个位置
return ext;
}

// 应用:统计 b 在 a 中的出现次数(LCP == m 即匹配一次)
int count_occ(const string& a, const string& b) {
vector<int> ext = ex_kmp(a, b);
int cnt = 0;
for (int x : ext) if (x == (int)b.size()) cnt++;
return cnt;
}

int main() {
string a, b;
cin >> a >> b;
vector<int> ext = ex_kmp(a, b);
for (int i = 0; i < (int)ext.size(); i++) printf("%d ", ext[i]);
return 0;
}

注意点 / 坑点

  1. z[0] 的约定不统一:多数模板令 z[0] = 0,也有版本令 z[0] = n(「整个串与自身的 LCP」),公式与统计逻辑要按自己的约定调整,切勿混用两种定义。
  2. 拼接扩展 KMP 的分隔符必须保证不出现在 a、b 中,否则 LCP 会越过 ‘#’ 继续匹配;若输入可能包含 ‘#’,应改用 ‘\0’、’\1’ 等字符,或直接写不拼接的原生扩展 KMP。
  3. [l, r] 是闭区间:r = i + z[i] - 1,更新条件用 i + z[i] - 1 > r;若把 r 写成开区间,min 中要相应改用 r - i,两种约定混用会差 1 导致结果错误。
  4. 朴素扩展的越界检查:while 中要同时保证 i + z[i] < n;读取 z[i - l] 只有在 i <= r 时才安全(此时 i - l 一定已被算过)。

可拓展的地方

  1. 找周期:若 i 是 n 的约数且 i + z[i] == n,则前缀长度 i 是 s 的一个周期(可枚举 i 找所有周期);Z 数组也能像 next 数组一样统计每个前缀的出现次数(CF 432D)。
  2. 原生扩展 KMP(不拼接):同时维护两个串各自的匹配段,可直接处理含任意字符的两串 LCP,避免分隔符问题。
  3. 与哈希、后缀数组互补:Z 值刻画「每个后缀与前缀的 LCP」,在单串场景可 O(n) 完成许多后缀数组才能做的判断(如比较任意两个前缀的字典序)。

典型例题类型

  1. 纯模板:求 Z 数组 / 两串逐后缀 LCP —— 洛谷 P5410【模板】扩展 KMP。
  2. 前缀 = 后缀 / 出现次数统计类 —— CF 432D Prefixes and Suffixes。
  3. 周期判定类:O(n) 判断串是否由某前缀重复构成(z[i] == n - i),以及枚举所有周期。

5.4 Manacher

一句话用途:Manacher 算法在线性时间内求出以每个位置为中心的最长回文半径,从而 O(n) 求出最长回文子串、回文子串总数等,是回文类题目的最优通用解法。

算法思想:在字符串每个字符之间(以及首尾)插入未出现的字符 ‘#’,把所有回文统一成奇数长度,免去分别处理奇偶中心。维护当前最右回文段的右端点 mx 与中心 id,利用回文的镜像对称性:i 位于 [id, mx) 内时,p[i] 至少为 min(p[2*id - i], mx - i),再向两边朴素扩展并更新 mx、id。每个字符最多被扩展常数次,总复杂度 O(n)。

复杂度

  • 时间:O(n)。
  • 空间:O(n)(变换串 + p 数组)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

// 预处理:在首尾及每两个相邻字符之间插入 '#',统一奇偶回文
string expand(const string& s) {
string t = "#";
for (char c : s) { t += c; t += '#'; }
return t;
}

// Manacher:返回 p 数组
// p[i]:以 t[i] 为中心的最长回文半径(含中心),即回文覆盖 t[i-p[i]+1 .. i+p[i]-1]
// 原始串中对应回文长度为 p[i] - 1
vector<int> manacher(const string& s) {
string t = expand(s);
int n = (int)t.size();
vector<int> p(n, 1);
int id = 0, mx = 0; // mx:当前最右回文段的右端点(开区间,mx = id + p[id])
for (int i = 0; i < n; i++) {
if (i < mx) p[i] = min(p[2 * id - i], mx - i); // 镜像位置 2*id-i 的半径
while (i - p[i] >= 0 && i + p[i] < n && t[i - p[i]] == t[i + p[i]]) p[i]++;
if (i + p[i] > mx) { mx = i + p[i]; id = i; } // 更新最右回文段
}
return p;
}

int main() {
string s;
while (cin >> s) {
vector<int> p = manacher(s);
int m = (int)p.size();
int maxlen = 0;
long long tot = 0; // 回文子串总数(按「中心 + 半径」计,即按位置计)
for (int i = 0; i < m; i++) {
maxlen = max(maxlen, p[i] - 1); // 最长回文长度
tot += p[i] / 2; // 每个中心贡献 floor(p[i]/2) 个回文
}
printf("最长回文长度 = %d,回文子串个数 = %lld\n", maxlen, tot);
}
return 0;
}

注意点 / 坑点

  1. 半径定义要统一:本模板 p[i] 含中心(t 中回文长度 2*p[i]-1,原始回文长度 p[i]-1);另一类写法定义 p[i] 为不含中心的半径,两者差 1,最长回文长度与计数公式都要相应调整。
  2. mx 是开区间(mx = id + p[id]):初始化 p[i] 用 mx - i 而非 mx - i + 1,进入条件用 i < mx 而非 i <= mx,写错会导致半径偏大甚至越界。
  3. 回文计数公式 Σ floor(p[i]/2) 统计的是「中心 + 半径」对(同一回文串出现在不同位置算多次);若要求「本质不同回文子串」个数,需要去重(后缀自动机/回文自动机),不能直接套用。
  4. ‘#’ 中心对应偶数长度回文、字符中心对应奇数长度回文;扩展循环必须检查 i - p[i] >= 0 && i + p[i] < n 防止越界。
  5. 计数可能超过 int 范围,tot 用 long long(如全 ‘a’ 的长串)。

可拓展的地方

  1. 最长双回文串:枚举分割点,用 Manacher 预处理每个位置为终点/起点的最长回文,再拼接求最值(洛谷 P4555)。
  2. 「以 i 结尾的最长回文」预处理:在变换串上做差分区间赋值,可支持回文分割、添加字符变回文等组合题(如洛谷 P4287 [SHOI2011] 双倍回文)。
  3. 回文自动机(PAM)擅长 Manacher 不擅长的「本质不同回文计数、各回文出现次数」;在首尾加哨兵字符可省去边界判断(哨兵不能与串内字符冲突)。

典型例题类型

  1. 纯模板:最长回文子串 —— 洛谷 P3805【模板】manacher、POJ 3974 Palindrome。
  2. 回文计数类(k 大回文长度)—— 洛谷 P1659 [国家集训队] 拉拉队排练。
  3. 组合应用类:最长双回文串(洛谷 P4555)、回文分割/双倍回文(洛谷 P4287)。

5.5 AC 自动机

一句话用途:AC 自动机(Aho–Corasick)是在一棵 Trie 上建立 fail 指针构成的多模式串匹配自动机,能在线性时间内统计文本串中所有模式串的出现位置与次数,是敏感词过滤、多串匹配及「AC 自动机 + DP」计数类题目的基础。

算法思想:先把所有模式串插入 Trie;再用 BFS 给每个节点计算 fail 指针:fail[u] 指向 u 代表的字符串的最长真后缀所在节点(即沿 fail 链能匹配到的最长后缀)。BFS 同时把缺失的转移补成 fail 节点的转移(trie 图),使自动机成为完整 DFA。匹配时从根出发沿文本字符走转移,每到一个节点就沿 fail 链累计所有能匹配到的模式串。fail 指针构成一棵 fail 树(父节点是子节点的最长后缀),子树求和可统计每个模式串各自的出现次数。

复杂度

  • 时间:构建 O(Σ|P_i| × 26),匹配 O(|T|)(均摊,每个节点至多被统计一次)。
  • 空间:O(Σ|P_i| × 26)(trie 数组)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005; // 所有模式串总长度 + 1(trie 节点数上限)

struct ACAuto {
int trie[MAXN][26], fail[MAXN], cnt[MAXN], tot; // cnt[u]:以 u 结尾的模式串个数
int q[MAXN]; // BFS 队列(手写数组)

ACAuto() { memset(trie[0], 0, sizeof(trie[0])); tot = 0; } // 根节点清零

void insert(const string& s, int val = 1) { // 插入一个模式串
int u = 0;
for (char c : s) {
int v = c - 'a';
if (!trie[u][v]) {
trie[u][v] = ++tot;
memset(trie[tot], 0, sizeof(trie[tot])); // 新节点转移清零
}
u = trie[u][v];
}
cnt[u] += val; // 重复模式串:累加而不是覆盖
}

void build() { // BFS 构建 fail 指针,并补全转移成 trie 图
int head = 0, tail = 0;
for (int i = 0; i < 26; i++) // 根的第一层孩子入队,其 fail 指向根
if (trie[0][i]) q[tail++] = trie[0][i];
while (head < tail) {
int u = q[head++];
for (int i = 0; i < 26; i++) {
int v = trie[u][i];
if (v) {
fail[v] = trie[fail[u]][i]; // 父节点 fail 的同字符孩子
q[tail++] = v;
} else {
trie[u][i] = trie[fail[u]][i]; // 补全缺失转移(trie 图)
}
}
}
}

// 匹配文本串,返回所有模式串出现次数之和(每个模式串至多被统计一次)
long long query(const string& s) {
long long ans = 0;
int u = 0;
for (char c : s) {
u = trie[u][c - 'a']; // 自动机走一步(trie 图保证必有转移)
for (int v = u; v && cnt[v] != -1; v = fail[v]) { // 沿 fail 链统计
ans += cnt[v];
cnt[v] = -1; // 置 -1:本次运行不再重复统计
}
}
return ans;
}
};

int main() {
int n;
string s;
cin >> n;
ACAuto ac;
for (int i = 0; i < n; i++) { cin >> s; ac.insert(s); }
ac.build();
cin >> s;
printf("%lld\n", ac.query(s));
return 0;
}

注意点 / 坑点

  1. 根节点(0 号)的 cnt、fail 必须初始化为 0,trie[0] 整行清零;每个新建节点也要 memset 它的 26 个转移,漏清会产生「幽灵转移」导致错误。
  2. 重复模式串:同一模式串出现多次时要 cnt[u] += 1 累加(不覆盖),否则计数偏小;若题目要求去重,插入时只置 1,并在查询时用标记防止重复统计。
  3. 查询沿 fail 链统计时同一节点可能被多次走到,必须标记(cnt 置 -1 或独立 vis 数组),否则既重复计数,复杂度也会退化为 O(|T| × 深度)。
  4. fail 指向最长真后缀所在节点,根的孩子 fail 均为 0;BFS 的顺序保证处理 u 时 fail[u] 已经算好。
  5. 字符集不一定是 26:含大写字母/数字时要把 26 换成对应大小并统一映射函数,或用 map 实现转移。

可拓展的地方

  1. fail 树 + 树上差分/DFS:每个模式串的出现次数 = 其终止节点在 fail 树上的子树内「匹配终止点」个数之和,可统计每个模式串各自出现几次(洛谷 P3796【模板】AC 自动机(加强版))。
  2. AC 自动机 + DP:把「构造长度为 L 且不含任一模式串的串」转化为在自动机上走 L 步且不经过终止节点的路径计数(洛谷 P4052 [JSOI2007] 文本生成器、P3311 [SDOI2014] 数数),L 很大时配合矩阵快速幂。
  3. 大字符集优化:trie 转移数组换成 map/unordered_map 或离线离散化压缩,降低空间;trie 图补全只在定长小字符集下最方便。

典型例题类型

  1. 纯模板:统计模式串出现总次数 —— 洛谷 P3808【模板】AC 自动机(简单版)。
  2. 加强版:统计每个模式串各自出现次数(fail 树)—— 洛谷 P3796【模板】AC 自动机(加强版)。
  3. AC + DP:计数不含任一模式串的串 —— 洛谷 P4052 [JSOI2007] 文本生成器(容斥/DP)。

5.6 后缀数组

一句话用途:后缀数组把字符串的所有后缀按字典序排序(sa 数组),配合 height 数组可以 O(1) 查询任意两个后缀的 LCP,从而解决不同子串计数、最长重复子串、多串最长公共子串、子串出现次数等大量字符串问题。

算法思想:倍增 + 基数排序。每轮把「长度为 k 的前缀排名」作为第一关键字、紧随其后的 k 个字符排名作为第二关键字,用两遍计数排序把后缀按长度为 2k 的前缀排序,k 倍增直到所有后缀排名互不相同。height[i] = LCP(sa[i], sa[i-1]),利用性质 height[rk[i+1]] ≥ height[rk[i]] - 1 可 O(n) 求出。任意两后缀的 LCP 等于它们在 sa 中对应区间内相邻项 height 的最小值(RMQ,ST 表预处理后 O(1) 查询)。

复杂度

  • 时间:倍增 + 基数排序 O(n log n),height 数组 O(n),ST 表预处理 O(n log n)、单次查询 O(1)。
  • 空间:O(n log n)(ST 表;若不求任意 LCP 只需 O(n))。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005; // 字符串长度上限(ST 表按此大小开)

char s[MAXN];
int n;
int sa[MAXN], rk[MAXN], tmp[MAXN], cnt[MAXN], height[MAXN];

// 倍增 + 基数排序求后缀数组
void build_sa() {
int m = 256; // 计数数组大小:字符值域(ASCII 0..255,可先离散化缩小)
// 第一轮:按单个字符基数排序
for (int i = 0; i < n; i++) rk[i] = (unsigned char)s[i]; // 防 char 为负数
for (int i = 0; i < n; i++) cnt[rk[i]]++;
for (int i = 1; i < m; i++) cnt[i] += cnt[i - 1];
for (int i = n - 1; i >= 0; i--) sa[--cnt[rk[i]]] = i; // 倒序放置保证稳定
for (int k = 1; k < n; k <<= 1) {
int p = 0;
// 先按第二关键字排序:第二关键字越界(视为 -1)的后缀排最前
for (int i = n - k; i < n; i++) tmp[p++] = i;
for (int i = 0; i < n; i++) if (sa[i] >= k) tmp[p++] = sa[i] - k;
// 再按第一关键字(旧 rk)做一次稳定基数排序,得到新 sa
memset(cnt, 0, sizeof(int) * m);
for (int i = 0; i < n; i++) cnt[rk[tmp[i]]]++;
for (int i = 1; i < m; i++) cnt[i] += cnt[i - 1];
for (int i = n - 1; i >= 0; i--) sa[--cnt[rk[tmp[i]]]] = tmp[i];
// 由新 sa 重算 rk(tmp 暂存旧的 rk,swap 后再用)
swap(rk, tmp);
rk[sa[0]] = 0; p = 1;
for (int i = 1; i < n; i++) {
int a = sa[i], b = sa[i - 1];
bool same = (tmp[a] == tmp[b]) &&
((a + k < n ? tmp[a + k] : -1) == (b + k < n ? tmp[b + k] : -1));
rk[a] = same ? p - 1 : p++;
}
if (p == n) break; // 所有排名互不相同,提前结束
m = p; // 缩小计数数组范围(值域 = 当前排名数)
}
}

// height[i] = LCP(sa[i], sa[i-1]),height[0] 无意义(恒为 0)
void build_height() {
int k = 0;
for (int i = 0; i < n; i++) {
if (rk[i] == 0) { k = 0; continue; } // 排名第一的后缀没有前驱
int j = sa[rk[i] - 1];
while (i + k < n && j + k < n && s[i + k] == s[j + k]) k++;
height[rk[i]] = k;
if (k) k--; // 关键性质:height[rk[i+1]] >= height[rk[i]] - 1
}
}

// ST 表:任意两后缀的 LCP = 它们在 sa 中区间内 height 的最小值
int st[MAXN][18], lg2[MAXN];
void build_st() {
for (int i = 2; i <= n; i++) lg2[i] = lg2[i >> 1] + 1;
for (int i = 1; i < n; i++) st[i][0] = height[i];
for (int j = 1; (1 << j) < n; j++)
for (int i = 1; i + (1 << j) - 1 < n; i++)
st[i][j] = min(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
}

// 后缀 i 与后缀 j(0 下标)的 LCP,O(1)
int get_lcp(int i, int j) {
if (i == j) return n - i;
int l = rk[i], r = rk[j];
if (l > r) swap(l, r);
l++; // 区间为 (l, r],即 [l+1, r]
int len = r - l + 1, k = lg2[len];
return min(st[l][k], st[r - (1 << k) + 1][k]);
}

int main() {
scanf("%s", s);
n = (int)strlen(s);
build_sa();
build_height();
build_st();
for (int i = 0; i < n; i++) printf("%d ", sa[i] + 1); // 常用 1 下标输出
return 0;
}

注意点 / 坑点

  1. 计数数组 cnt 的大小必须覆盖字符值域:第一轮要 ≥ 字符集大小(ASCII 用 256),之后每轮 m 更新为当前排名数 p,memset 范围也要随 m 变化,写死或漏更新会越界或排序错误。
  2. 基数排序必须稳定:先排第二关键字再排第一关键字,第二遍倒序放置(i 从 n-1 到 0)保证同第一关键字时保持第二关键字的相对顺序,顺序写反结果全错。
  3. 第二关键字越界按 -1(最小)处理,(a + k < n ? tmp[a+k] : -1) 的边界判断不能省,k 倍增后尤其容易越界。
  4. 下标关系易混:height[rk[i]] 是后缀 i 与其前一名的 LCP;LCP(i, j) 查询区间是 (min(rk), max(rk)],左端点要先 +1,ST 查询右端点是 r - 2^k + 1。
  5. 多组数据时要手动清零 cnt、rk、sa 等全局数组;char 有符号时用 (unsigned char)s[i] 强制转换;字符集大(如整数数组)先离散化。

可拓展的地方

  1. 不同子串数 = n(n+1)/2 - Σ height[i](每个后缀贡献 n - sa[i] 个子串前缀,减去与前一名的重复部分);最长重复子串 = max height[i](可重叠),不可重叠版本可二分答案 + 按 height 分组判断(洛谷 P2852 Milk Patterns)。
  2. 区间 LCP 最小值除 ST 表外,也可用线段树或笛卡尔树;或把问题离线,用并查集从大到小合并处理(省空间)。
  3. 与后缀自动机(5.7)互补:SA + height 擅长相邻后缀关系与区间查询,SAM 擅长自动机结构与出现次数;height 数组与 SAM 的 link 树有对应关系。

典型例题类型

  1. 纯模板:求 sa、rk —— 洛谷 P3809【模板】后缀排序。
  2. 不同子串计数 / 最长重复子串 —— 洛谷 P2408 不同子串个数、P2852 [USACO06DEC] Milk Patterns G。
  3. 多串最长公共子串 / 区间 LCP 查询类 —— POJ 2774 Long Long Message(两串拼一起 + height 分组)。

5.7 后缀自动机

一句话用途:后缀自动机(SAM)是接受一个串所有子串的最小 DFA,O(n) 构建后能回答本质不同子串数、子串出现次数、字典序第 k 小子串等问题,是处理后缀结构的终极利器之一。

算法思想:SAM 的每个状态代表一组 endpos 集合相同的子串(其长度是一个连续区间 (len[link], len])。增量构建时每次 extend 一个字符 c:新建状态 cur 承接整个串的新后缀,沿 last 的 link 链补转移;遇到已存在的转移 q 时,按 len[p]+1 与 len[q] 的关系决定直接把 link 指向 q 或克隆 q(clone)。link(后缀链接)构成一棵树,父状态的 endpos 是子状态的并,因此按 len 基数排序后自底向上累加即可得到每个状态代表子串的出现次数。

复杂度

  • 时间:构建 O(n)(均摊),按 len 基数排序统计 O(n)。
  • 空间:O(n × 26)(next 数组方式;状态数 ≤ 2n-1,数组开 2 倍)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005; // 原串长度上限
const int MAXS = MAXN * 2; // 状态数 ≤ 2n-1,数组开 2 倍

struct SuffixAutomaton {
struct State { int len, link, next[26]; } st[MAXS];
int sz, last; // 状态总数、当前整个串对应的状态
int cnt[MAXS]; // cnt[u]:状态 u 直接代表的 endpos 个数(克隆节点为 0)
int num[MAXS]; // num[u]:状态 u 中任意子串的出现次数(endpos 大小)
int order[MAXS]; // 按 len 基数排序后的状态顺序

SuffixAutomaton() {
sz = 1; last = 0;
memset(st[0].next, 0, sizeof(st[0].next));
st[0].len = 0; st[0].link = -1; // 根的后缀链接指向 -1(哨兵)
}

void extend(int c) {
int cur = sz++;
st[cur].len = st[last].len + 1;
cnt[cur] = 1; // 只有非克隆节点初始 cnt 为 1
memset(st[cur].next, 0, sizeof(st[cur].next));
int p = last;
while (p != -1 && st[p].next[c] == 0) { // 沿 link 链补转移
st[p].next[c] = cur;
p = st[p].link;
}
if (p == -1) {
st[cur].link = 0; // 新串是全新前缀,link 直接连根
} else {
int q = st[p].next[c];
if (st[p].len + 1 == st[q].len) {
st[cur].link = q; // q 恰好代表以 c 结尾的最长后缀
} else {
int clone = sz++; // 分裂:克隆 q
st[clone] = st[q]; // 整体复制 q(len、link、全部 next)
st[clone].len = st[p].len + 1;
cnt[clone] = 0; // 克隆节点不新增 endpos
while (p != -1 && st[p].next[c] == q) { // 把指向 q 的转移改指 clone
st[p].next[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone; // q 与 cur 的 link 都指向 clone
}
}
last = cur;
}

// 按 len 基数排序后自底向上累加,得到每个状态的出现次数
void calc_cnt() {
static int bucket[MAXS];
for (int i = 0; i < sz; i++) bucket[st[i].len]++;
for (int i = 1; i < sz; i++) bucket[i] += bucket[i - 1];
for (int i = sz - 1; i >= 0; i--) order[--bucket[st[i].len]] = i;
for (int i = 0; i < sz; i++) num[i] = cnt[i];
for (int i = sz - 1; i >= 0; i--) { // len 从大到小处理
int v = order[i];
if (st[v].link != -1) num[st[v].link] += num[v]; // 并入 link 父状态
}
}

// 本质不同子串数 = Σ (len[u] - len[link[u]])
long long diff_substr() {
long long res = 0;
for (int i = 1; i < sz; i++) res += st[i].len - st[st[i].link].len;
return res;
}
};

int main() {
string s;
cin >> s;
SuffixAutomaton sam;
for (char c : s) sam.extend(c - 'a');
sam.calc_cnt();
printf("本质不同子串数 = %lld\n", sam.diff_substr());
// 例:子串 s[l..r] 的出现次数 —— 沿转移从根走到对应状态 u,次数 = num[u]
return 0;
}

注意点 / 坑点

  1. 状态数上界 2n-1、转移数上界 3n-4,数组必须开 2 倍(MAXS = 2*MAXN),开 1 倍必然越界;next[26] 方式单状态约 112 字节,n = 1e6 时约 224MB,超大串需改用 map/unordered_map 或离线压缩转移。
  2. 克隆节点 clone 必须整体复制 q(len、link、全部 next),且 cnt[clone] = 0;漏复制 next 会导致后续转移丢失、构建结果错误。
  3. 出现次数累加方向:按 len 从大到小(基数排序序)把 num[v] 并入 num[link[v]],顺序反了或漏掉根,计数会错。
  4. extend 中沿 link 补转移的 while 是均摊 O(1) 的,不要误以为是 O(深度);用 map 实现 next 时均摊性质仍成立(总操作 O(n log Σ))。
  5. last 必须始终指向当前整个串对应的状态;多组数据/重置时要重新初始化(sz = 1, last = 0)。

可拓展的地方

  1. 字典序第 k 小子串:在转移 DAG 上拓扑计数后贪心走转移(洛谷 P3975 [TJOI2015] 弦论);最大出现次数子串直接用 num 数组(P3804)。
  2. link 树(后缀树)上做 DP:求「出现次数 ≥ k 的最长子串」、两串最长公共子串(对另一串在 SAM 上跑匹配)、SAM + 线段树合并做区间本质不同子串(洛谷 P4070 [SDOI2016] 生成魔咒)。
  3. 与后缀数组等价换算:SAM 的 len 基数排序序对应 SA 的 height 结构,两者按需选择;SAM 也支持在线增量(每次加字符后 O(1) 更新答案)。

典型例题类型

  1. 纯模板:本质不同子串数、出现次数 —— 洛谷 P3804【模板】后缀自动机。
  2. 字典序第 k 小子串类 —— 洛谷 P3975 [TJOI2015] 弦论。
  3. 动态增量类(每次加字符问不同子串数)—— 洛谷 P4070 [SDOI2016] 生成魔咒。

5.8 最小表示法

一句话用途:最小表示法求一个字符串的所有循环同构串中字典序最小的那个的起始位置,O(n) 完成,常用于判断两个串是否循环同构、找循环串的最小排列、旋转串去重等。

算法思想:两个指针 i、j 从不同起点出发,k 记录已比较长度:比较 s[i+k] 与 s[j+k],相等则 k++;若 s[i+k] > s[j+k],说明以 i 开头的前 k+1 个字符组成的段不优于以 j 开头的对应段,i 直接跳到 i+k+1(这一段内的任何起点都可以排除),反之 j 跳;i、j 相同时错开一个。任一指针越界或 k 达到 n 即结束,答案取 min(i, j)。由于指针每轮至少前进 k+1,总复杂度 O(n)。

复杂度

  • 时间:O(n)。
  • 空间:O(1)(仅常数个指针变量)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;

// 返回 s 的最小表示(字典序最小的循环同构)的起始下标(0 下标)
int min_representation(const string& s) {
int n = (int)s.size();
int i = 0, j = 1, k = 0; // i、j:两个候选起点;k:已比较长度
while (i < n && j < n && k < n) {
char a = s[(i + k) % n]; // 用取模模拟循环
char b = s[(j + k) % n];
if (a == b) { k++; continue; }
if (a > b) { // 起点 i 更差:跳过 [i, i+k] 内所有起点
i += k + 1;
if (i == j) i++; // 避免两个指针相同
} else { // 起点 j 更差
j += k + 1;
if (i == j) j++;
}
k = 0;
}
return min(i, j);
}

// 构造 s 的最小表示串
string min_rotation(const string& s) {
int pos = min_representation(s);
int n = (int)s.size();
string res;
for (int i = 0; i < n; i++) res += s[(pos + i) % n];
return res;
}

// 应用:判断 a、b 是否循环同构(必须比较整个最小表示串,不能只比下标)
bool is_cyclic_shift(const string& a, const string& b) {
return a.size() == b.size() && min_rotation(a) == min_rotation(b);
}

int main() {
string s;
cin >> s;
cout << min_rotation(s) << endl; // 输出最小表示串
return 0;
}

注意点 / 坑点

  1. 指针跳跃量是 k+1 而不是 1:因为 [i, i+k] 内的任何起点作为循环串开头都不优于 j 段的对应比较结果,只有跳到 i+k+1 才能保证 O(n) 的均摊复杂度。
  2. i、j 相遇时必须错开(i == j 时 i++ 或 j++),否则二者永远同步比较导致死循环;循环结束条件 k ≥ n 对应整个串是同一字符重复的情况。
  3. 「循环同构」判断不能只比较最小表示的下标:如 “abab” 与 “baba” 循环同构但最小表示下标不同(0 vs 1),必须比较从各自起点开始的完整串(min_rotation 构造后逐字符比较)。
  4. 边界:n = 0 或 1 时循环不进入,min(i, j) 仍安全(i、j 中至少一个 < n);用取模访问循环串时注意 (i+k) % n 不会越界。
  5. 多次比较大串时每次构造 min_rotation 是 O(n),可用 5.1 字符串哈希对最小表示串取哈希再比较,避免拷贝。

可拓展的地方

  1. 循环同构判断的等价方法:a 与 b 循环同构 ⇔ b 是 a+a 的子串(用 KMP 或哈希判断),最小表示法是另一条 O(n) 路线。
  2. 与字符串哈希结合:把每个串先求最小表示再哈希,丢进 set 即可 O(n) 预处理、O(1) 判重「本质相同的旋转」(项链/密码串去重类题目)。
  3. 思想可推广:环上最小区间/最小旋转、两维(行与列同时旋转)的最小表示等变体问题。

典型例题类型

  1. 纯模板:求最小表示 —— 洛谷 P1368【模板】最小表示法、POJ 1509 Glass Beads。
  2. 循环同构判定类:判断两串是否互为旋转(破环为链 + KMP/哈希,或最小表示比较)。
  3. 结合哈希去重类:一堆旋转等价串求本质不同的个数(最小表示哈希后判重)。

六、动态规划

本部分速查表

算法 复杂度 适用场景
6.1 背包问题 01/完全背包 O(n·m);多重背包二进制拆分 O(n·m·log k);分组、二维费用 O(n·m) 级 有限/无限物品的资源分配、选择最优化
6.2 线性 DP(LIS/LCS) LIS:O(n²) 或 O(n log n);LCS:O(n·m) 序列上的最长上升/公共子序列问题
6.3 区间 DP O(n³),四边形不等式可优化到 O(n²) 区间合并、石子合并、括号序列
6.4 树形 DP 独立集 O(n);树形背包 O(n·m);换根 O(n) 树上选择、树上分组背包、全树最值
6.5 状压 DP O(2ⁿ·n),枚举子集 O(3ⁿ) 集合类问题、n ≤ 20 的 TSP
6.6 数位 DP O(位数 × 状态数) 统计 [l, r] 内满足数位性质的数
6.7 概率与期望 DP O(状态数 × 转移数) 期望步数、概率转移、博弈概率
6.8 单调队列优化 DP O(n) 转移取滑动窗口内最值
6.9 斜率优化 DP O(n) 转移含 i·j 交叉项的决策单调问题
6.10 四边形不等式优化 O(n²) 满足四边形不等式与单调性的区间 DP
6.11 记忆化搜索 O(状态数 × 转移数) 状态难定序、博弈类、带剪枝搜索
6.12 插头 DP O(n·m·3^m) 棋盘回路计数、连通性(慎用)

6.1 背包问题

一句话用途:在给定容量(总资源)限制下,从若干物品中做选择使价值最大化,是竞赛中最基础也最高频的一类 DP。01 背包(每件最多选一次)、完全背包(每件无限选)、多重背包(每件有数量上限)、分组背包(每组最多选一个)、二维费用背包(受两种资源限制)分别对应不同题目形态。

算法思想:以 01 背包为例,设 dp[j] 表示容量为 j 时能获得的最大价值,对每件物品做「选/不选」决策:dp[j] = max(dp[j], dp[j - w[i]] + v[i])。关键在一维滚动时容量 j 必须倒序枚举,保证每件物品最多被选一次;完全背包改为正序枚举即可让同一件物品被反复选取。多重背包用二进制拆分把数量 k 拆成 1, 2, 4, …, rest 几个 01 物品(任意 0~k 的个数都能用这些二进制块拼出),从而转化为 01 背包;它的单调队列优化(O(n·m))是按容量对 w[i] 取模分组、组内用单调队列维护 max(dp[j] - t·v[i]),实现较繁琐,竞赛中一般用二进制拆分即可。分组背包把「每组选一个」看成在组内做 01 背包(容量倒序在外层、组内物品在最里层)。二维费用背包只需多加一维容量。

复杂度:01 / 完全背包 O(n·m) 时间、O(m) 空间;多重背包二进制拆分 O(n·m·log k);分组背包 O(组数 × m × 组大小);二维费用 O(n·V1·V2)。

模板代码(混合背包,洛谷 P1833 樱花:01、完全、多重三种物品混在一起):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <bits/stdc++.h>
using namespace std;

struct Item { int w, v, type; }; // type: 1 = 01 背包物品, 2 = 完全背包物品

int main() {
int h1, m1, h2, m2, n;
scanf("%d:%d %d:%d %d", &h1, &m1, &h2, &m2, &n);
int V = (h2 * 60 + m2) - (h1 * 60 + m1); // 可用总时间 = 背包容量
vector<Item> it;
for (int i = 0; i < n; i++) {
int a, b, k;
scanf("%d%d%d", &a, &b, &k); // 花费、价值、数量(0 表示无限)
if (k == 0) { it.push_back({a, b, 2}); continue; } // 无限个 -> 完全背包
for (int p = 1; k > 0; p <<= 1) { // 二进制拆分数量 k
int cnt = min(p, k); // 拆出 1, 2, 4, ... 个为一组
it.push_back({a * cnt, b * cnt, 1});
k -= cnt; // 剩余数量继续拆
}
}
vector<int> dp(V + 1, 0);
for (auto &e : it) {
if (e.type == 1) // 01 背包:容量倒序
for (int j = V; j >= e.w; j--)
dp[j] = max(dp[j], dp[j - e.w] + e.v);
else // 完全背包:容量正序
for (int j = e.w; j <= V; j++)
dp[j] = max(dp[j], dp[j - e.w] + e.v);
}
printf("%d\n", dp[V]);
return 0;
}

分组背包核心循环(洛谷 P1757 通天之分组背包):

1
2
3
4
5
for (int g = 1; g <= G; g++)              // 1. 枚举每一组
for (int j = V; j >= 0; j--) // 2. 容量倒序(保证每组最多选一个)
for (auto &e : group[g]) // 3. 组内物品
if (j >= e.w)
dp[j] = max(dp[j], dp[j - e.w] + e.v);

二维费用背包核心循环(两维容量,01 型都倒序、完全型都正序):

1
2
3
4
for (int i = 1; i <= n; i++)
for (int j = V1; j >= w1[i]; j--) // 第一维容量
for (int k = V2; k >= w2[i]; k--) // 第二维容量
dp[j][k] = max(dp[j][k], dp[j - w1[i]][k - w2[i]] + v[i]);

注意点 / 坑点

  1. 01 背包一维滚动时容量必须倒序,否则同一件物品会被重复选取变成完全背包——这是最经典的错误。
  2. 二进制拆分时最后一个二进制块是剩下的余数 rest,不一定正好是 2 的幂;若直接拆成完整 1, 2, 4, …, 2^p 会超过原数量导致答案偏大。
  3. 初始化含义要分清:恰好装满(dp[0]=0,其余 -INF)与不超过容量(全部 0);求方案数时转移用加法。
  4. 多重背包若数量 k 与容量 m 都很大,二进制拆分可能超时,此时才考虑单调队列优化。
  5. 二维费用注意枚举顺序与数组维度大小,防止爆内存。

可拓展的地方

  1. 求「恰好装满」的最值或方案数时只需改初始化;输出具体方案时另开数组记录选择。
  2. 与滚动数组、bitset 优化(可行性背包压位)结合,超大容量时先按性价比贪心剪枝。
  3. 依赖背包(先选父物品才能选子物品)用树形背包处理,可参考 6.4 节。

典型例题类型

  1. 混合背包:洛谷 P1833 樱花(01 + 完全 + 多重)。
  2. 分组背包:洛谷 P1757 通天之分组背包。
  3. 二维费用:洛谷 P1855 榨取kkksc03。

6.2 线性 DP

一句话用途:线性 DP 是在一维/二维序列上按位置顺序转移的一类基础 DP,最经典的两个问题是 LIS(最长上升子序列)与 LCS(最长公共子序列),它们也是很多复杂 DP 的底层构件。

算法思想:LIS 的 O(n²) 做法是设 dp[i] 表示以 a[i] 结尾的最长上升子序列长度,转移 dp[i] = max(dp[j] + 1),要求 j < i 且 a[j] < a[i]。O(n log n) 做法维护 d 数组:d[len] 表示长度为 len 的上升子序列的最小末尾值,对每个 a[i] 用 lower_bound 二分找到第一个不小于它的位置并替换或追加,d 的长度即答案。LCS 设 dp[i][j] 表示 s 前 i 个与 t 前 j 个字符的最长公共子序列长度,按末尾字符是否相等分类转移。

复杂度:LIS:O(n²) 或 O(n log n),空间 O(n);LCS:O(n·m) 时间、O(n·m) 空间(可滚动到 O(m))。

模板代码(LIS 两种写法放一个程序里对拍验证):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <bits/stdc++.h>
using namespace std;

int main() {
int n; cin >> n;
vector<int> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];

// ---- LIS 方法一:O(n^2) ----
vector<int> f(n + 1, 1); // f[i]: 以 a[i] 结尾的 LIS 长度
for (int i = 1; i <= n; i++)
for (int j = 1; j < i; j++)
if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
cout << "O(n^2) : " << *max_element(f.begin() + 1, f.end()) << '\n';

// ---- LIS 方法二:O(n log n) 贪心 + 二分 ----
vector<int> d; // d[len]: 长度为 len 的上升子序列的最小末尾值
for (int i = 1; i <= n; i++) {
auto it = lower_bound(d.begin(), d.end(), a[i]); // 第一个 >= a[i] 的位置
if (it == d.end()) d.push_back(a[i]); // 找不到则长度 +1
else *it = a[i]; // 贪心:让末尾更小
}
cout << "O(nlogn): " << d.size() << '\n';
return 0;
}

模板代码(LCS,O(n·m)):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <bits/stdc++.h>
using namespace std;
const int N = 1005;
int dp[N][N]; // dp[i][j]: s 前 i 个字符与 t 前 j 个字符的 LCS 长度

int main() {
string s, t;
cin >> s >> t;
int n = s.size(), m = t.size();
s = " " + s; t = " " + t; // 下标从 1 开始,省去越界判断
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (s[i] == t[j]) dp[i][j] = dp[i - 1][j - 1] + 1; // 末尾相同
else dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]); // 舍去 s 或 t 的末尾
}
}
cout << dp[n][m] << '\n';
return 0;
}
// 空间优化一句话:dp[i][*] 只依赖 dp[i-1][*] 与 dp[i][*],
// 用两行滚动数组即可把空间从 O(n·m) 压到 O(m),时间不变。

注意点 / 坑点

  1. LIS 的 lower_bound 求的是严格上升;若题目要求非降(允许相等),应改用 upper_bound。
  2. O(n²) 的 LIS 必须初始化 dp[i] = 1(自己一个元素也算长度 1),漏掉初始化会全错。
  3. LCS 的空间优化只省空间不省时间;要输出具体公共子序列时需保留完整 dp 表再回溯。
  4. d 数组只记录长度与最小末尾,不能用来还原具体子序列(还原需额外记录转移路径)。

可拓展的地方

  1. 最长不下降子序列用 upper_bound;最长下降/不上升子序列把比较符号反过来,或对数组取负后套模板(如洛谷 P1020 导弹拦截)。
  2. 当 s、t 都是排列时,LCS 可转化为 LIS 用 O(n log n) 求解(洛谷 P1439)。
  3. 线性 DP 可与二分、线段树(维护前缀最值)结合处理更复杂的一维转移。

典型例题类型

  1. LIS:洛谷 P1020 导弹拦截(还需最长不上升子序列)。
  2. LCS:洛谷 P1439 最长公共子序列(排列版,转化为 LIS)。
  3. 入门线性 DP:洛谷 P1216 数字三角形。

6.3 区间 DP

一句话用途:区间 DP 把问题看成对一段连续区间做合并或划分,状态用 [i, j] 表示,先处理短区间再处理长区间。典型应用是石子合并(合并相邻两堆的最优代价)、环形石子合并(破环成链)以及括号匹配计数。

算法思想:设 dp[i][j] 表示把区间 [i, j] 合并成一份的最小/最大代价,枚举断点 k 把区间切成 [i, k] 与 [k+1, j] 两段,转移为 dp[i][j] = min(dp[i][k] + dp[k+1][j]) + cost(i, j),其中合并代价 cost 用前缀和 O(1) 求出(sum[j] - sum[i-1])。实现按区间长度 len 从小到大枚举,保证计算长区间时所有子区间已经算好。环形问题把数组复制一份接在后面(破环成链),答案取所有长度为 n 的窗口。括号匹配则按「两端匹配」或「拆成两段拼接」两种方式转移。

复杂度:O(n³) 时间、O(n²) 空间;满足四边形不等式时可优化到 O(n²)(见 6.10 节)。

模板代码(环形石子合并,洛谷 P1880):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <bits/stdc++.h>
using namespace std;
const int N = 205, INF = 0x3f3f3f3f;
int n, a[N], sum[N];
int mn[N][N], mx[N][N]; // 最小 / 最大合并代价

int main() {
cin >> n;
for (int i = 1; i <= n; i++) { cin >> a[i]; a[i + n] = a[i]; } // 破环成链:复制一倍
for (int i = 1; i <= 2 * n; i++) sum[i] = sum[i - 1] + a[i]; // 前缀和,O(1) 求区间和
for (int len = 2; len <= n; len++) { // 枚举区间长度(先短后长)
for (int i = 1; i + len - 1 <= 2 * n; i++) { // 枚举左端点
int j = i + len - 1; // 右端点
mn[i][j] = INF; mx[i][j] = 0;
for (int k = i; k < j; k++) { // 枚举断点
mn[i][j] = min(mn[i][j], mn[i][k] + mn[k + 1][j]);
mx[i][j] = max(mx[i][j], mx[i][k] + mx[k + 1][j]);
}
mn[i][j] += sum[j] - sum[i - 1]; // 本次合并的代价
mx[i][j] += sum[j] - sum[i - 1];
}
}
int ansMin = INF, ansMax = 0;
for (int i = 1; i <= n; i++) { // 环形:取 n 个长度为 n 的窗口
ansMin = min(ansMin, mn[i][i + n - 1]);
ansMax = max(ansMax, mx[i][i + n - 1]);
}
cout << ansMin << '\n' << ansMax << '\n';
return 0;
}

模板代码(括号匹配,POJ 2955 Brackets:最长合法括号子序列长度):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int dp[N][N]; // dp[i][j]: s[i..j] 中最长合法括号子序列长度

int main() {
string s;
while (cin >> s && s != "end") {
int n = s.size();
s = " " + s;
memset(dp, 0, sizeof dp);
for (int len = 2; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
if ((s[i] == '(' && s[j] == ')') || (s[i] == '[' && s[j] == ']'))
dp[i][j] = dp[i + 1][j - 1] + 2; // 两端匹配,包住中间
for (int k = i; k < j; k++) // 或者拆成两段拼接
dp[i][j] = max(dp[i][j], dp[i][k] + dp[k + 1][j]);
}
}
cout << dp[1][n] << '\n';
}
return 0;
}

注意点 / 坑点

  1. 区间长度必须从小到大枚举,顺序反了会用到尚未计算的长区间状态。
  2. 环形破环成链后数组要开 2 倍大小,答案只取长度为 n 的窗口,不能对整个 2n 区间求值。
  3. 括号匹配中「两端匹配」与「拆两段」两条转移都要执行并取 max,只做一条会漏解(如 ()() 形态)。
  4. 断点枚举是 k < j,k = j 时右区间为空没有意义;dp[i][i] 初始化为 0(单堆不需要合并)。

可拓展的地方

  1. 区间 DP 与四边形不等式结合可优化到 O(n²)(见 6.10 节)。
  2. 凸多边形最优三角剖分、矩阵链乘(POJ 1651)都是同一模型。
  3. 环形问题统一用「复制一倍 + 取窗口」套路,也可推广到环形线性 DP。

典型例题类型

  1. 石子合并(直线):洛谷 P1775;环形:洛谷 P1880。
  2. 括号匹配:POJ 2955 Brackets;补全括号最少添加数的计数版:POJ 1141 Brackets Sequence。
  3. 矩阵链乘:POJ 1651 Multiplication Puzzle。

6.4 树形 DP

一句话用途:树形 DP 把状态定义在树节点上,通过 DFS 自底向上(汇总子树信息)或自顶向下(下传父节点信息)完成转移。典型应用:树上最大独立集(选/不选)、树形背包(子树内做分组背包)、换根 DP(计算以任意点为根时的答案)。

算法思想:最大独立集设 dp[u][0/1] 表示 u 不选/选时子树的最大贡献,u 不选时子节点可任选,u 选时子节点必须不选。树形背包把每个子树看成一个分组(组内选 0..size 个),对每个儿子做一次类似 01 背包的合并。换根 DP 用两次 DFS:第一次任意定根自底向上算子树内答案,第二次自顶向下用「父答案 - 儿子子树贡献 + 兄弟子树贡献」的公式把根换到每个儿子,从而 O(n) 得到以所有点为根的答案。

复杂度:最大独立集 O(n);树形背包 O(n·m)(m 为容量);换根 DP O(n)。

模板代码(树上最大独立集,洛谷 P1352 没有上司的舞会):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <bits/stdc++.h>
using namespace std;
const int N = 6005;
int n, r[N], dp[N][2]; // dp[u][0]: u 不去; dp[u][1]: u 去
vector<int> g[N];
bool isRoot[N];

void dfs(int u) {
dp[u][1] = r[u]; // 选 u,先累加 u 的快乐值
for (int v : g[u]) {
dfs(v); // 先递归子树(自底向上)
dp[u][0] += max(dp[v][0], dp[v][1]); // u 不去:子节点可去可不去
dp[u][1] += dp[v][0]; // u 去:子节点必须不去
}
}

int main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> r[i];
fill(isRoot + 1, isRoot + n + 1, true);
for (int i = 1; i < n; i++) {
int u, v; cin >> u >> v; // 输入:v 是 u 的上司(父亲)
g[v].push_back(u);
isRoot[u] = false;
}
int root = 1;
while (!isRoot[root]) root++; // 找到根
dfs(root);
cout << max(dp[root][0], dp[root][1]) << '\n';
return 0;
}

模板代码(树形背包,洛谷 P2014 选课:有先修依赖,用虚拟根 0):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <bits/stdc++.h>
using namespace std;
const int N = 305;
int n, m, s[N], dp[N][N]; // dp[u][j]: u 的子树内选 j 门课(含 u)的最大学分
vector<int> g[N];

void dfs(int u) {
dp[u][1] = s[u]; // 选 u 本身(虚拟根 0 的学分为 0)
for (int v : g[u]) {
dfs(v);
for (int j = m; j >= 1; j--) // 总选择数倒序(01 型分组背包)
for (int k = 1; k < j; k++) // 从 v 的子树里再选 k 门
dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
}
}

int main() {
cin >> n >> m;
++m; // 虚拟根也占一个名额,容量 +1
for (int i = 1; i <= n; i++) {
int fa, sc; cin >> fa >> sc;
g[fa].push_back(i);
s[i] = sc;
}
dfs(0);
cout << dp[0][m] << '\n';
return 0;
}

模板代码(换根 DP,洛谷 P3478 STA-Station:找一个点使所有点到它的距离和最小):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1000005;
int n;
vector<int> g[N];
ll sz[N], f[N]; // sz[u]: 子树大小; f[u]: 以 u 为根子树内所有点到 u 的距离和

void dfs1(int u, int fa) { // 第一次 DFS:自底向上
sz[u] = 1;
for (int v : g[u]) if (v != fa) {
dfs1(v, u);
sz[u] += sz[v];
f[u] += f[v] + sz[v]; // v 子树内每个点都多走一步到达 u
}
}

void dfs2(int u, int fa) { // 第二次 DFS:自顶向下换根
for (int v : g[u]) if (v != fa) {
// 根从 u 换到 v:v 子树内所有点距离 -1,其余点距离 +1
f[v] = f[u] - sz[v] + (n - sz[v]);
dfs2(v, u);
}
}

int main() {
ios::sync_with_stdio(false); cin.tie(nullptr);
cin >> n;
for (int i = 1; i < n; i++) {
int u, v; cin >> u >> v;
g[u].push_back(v); g[v].push_back(u);
}
dfs1(1, 0); // 以 1 为根先算一次
dfs2(1, 0); // 再换根到所有点
int ans = 1;
for (int i = 2; i <= n; i++)
if (f[i] > f[ans]) ans = i; // 找距离和最小的点
cout << ans << '\n';
return 0;
}

注意点 / 坑点

  1. 树形 DP 必须先递归完子树再更新父节点,顺序反了会用到未初始化的子状态。
  2. 树形背包的容量枚举必须倒序,且 k 从 1 到 j-1(不能取 k = j,否则与 dp[u][j-k] 冲突);若整棵子树只能整体选/不选,则是另一种树形 01 背包。
  3. 树可能不连通(森林),需要多源 DFS;P2014 用虚拟根 0 统一处理。
  4. 递归深度:链状数据(如 1e6 个点连成一条链)会让 DFS 递归爆栈,可改用迭代 DFS 或手工栈。

可拓展的地方

  1. 最大独立集可推广到最大权独立集、树的最小点覆盖(与独立集互补)。
  2. 换根 DP 可用于「所有点到某点的距离和最小」「每个点子树内计数」等,把 O(n²) 的多源问题降到 O(n)。
  3. 树形背包可结合二分、容斥处理更复杂的依赖关系。

典型例题类型

  1. 最大独立集 / 最大权独立集:洛谷 P1352 没有上司的舞会。
  2. 树形背包:洛谷 P2014 选课、P1273 有线电视网。
  3. 换根 DP:洛谷 P3478 STA-Station、P2986 Great Cow Gathering。

6.5 状压 DP

一句话用途:当状态是「哪些元素已被选择」这类集合信息、且元素数 n ≤ 20 左右时,用二进制整数 mask 压缩集合做 DP,称为状压 DP。经典应用是 TSP(旅行商问题)以及各种子集划分、排列计数问题。

算法思想:dp[mask][i] 表示「已访问集合为 mask 且当前位于 i」的最优值,转移时从 i 走向未访问的 j:dp[mask | (1<<j)][j] = min(dp[mask][i] + w(i, j))。初始化 dp[1][0] = 0(只访问了起点 0),其余为 INF(求最小值时用 INF 表示不可达,避免用 0 造成错误转移)。枚举子集技巧:for (sub = S; sub; sub = (sub-1) & S) 可以 O(3ⁿ) 遍历所有集合的所有非空子集,常用于集合划分类 DP。

复杂度:TSP O(2ⁿ·n²) 时间、O(2ⁿ·n) 空间;枚举子集总复杂度 O(3ⁿ)。

模板代码(TSP,洛谷 P1171 售货员的难题):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <bits/stdc++.h>
using namespace std;
const int N = 20, INF = 0x3f3f3f3f;
int n, d[N][N];
int dp[1 << N][N]; // dp[mask][i]: 已访问集合为 mask、当前停在 i 的最短距离

int main() {
cin >> n;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++) cin >> d[i][j];
memset(dp, 0x3f, sizeof dp); // 全部初始化为 INF(不可达)
dp[1][0] = 0; // 起点是城市 0,只访问了 0
for (int S = 1; S < (1 << n); S++) { // 枚举集合状态
for (int i = 0; i < n; i++) if ((S >> i) & 1) { // i 必须在集合内
if (dp[S][i] == INF) continue; // 跳过不可达状态
for (int j = 0; j < n; j++) if (!((S >> j) & 1)) // j 未访问
dp[S | (1 << j)][j] = min(dp[S | (1 << j)][j], dp[S][i] + d[i][j]);
}
}
int ans = INF;
for (int i = 1; i < n; i++) // 最后回到起点 0
ans = min(ans, dp[(1 << n) - 1][i] + d[i][0]);
cout << ans << '\n';
return 0;
}

枚举子集技巧(O(3ⁿ) 遍历所有集合的所有非空子集):

1
2
3
for (int sub = S; sub; sub = (sub - 1) & S) {   // 每次去掉最低位的 1 并保持与 S 相交
// 处理子集 sub(含 S 本身)
}

注意点 / 坑点

  1. dp 初始化为 INF 而不是 0,否则不可达状态会以 0 参与转移产生错误答案;起点 dp[1][0] = 0 要单独设置。
  2. 数组大小 2ⁿ·n:n 超过 22 左右就会 MLE,需换用剪枝或 meet-in-the-middle 等算法。
  3. 枚举子集时 sub 从 S 递减到 1,注意 (sub-1) & S 保证 sub 永远是 S 的子集;需要空集时额外处理 sub = 0。
  4. 位运算优先级:S >> i & 1 应写成 (S >> i) & 1,避免因优先级出错。

可拓展的地方

  1. 哈密顿路径(不要求回到起点)、带权覆盖、集合划分(配合子集和)。
  2. 与数位 DP、轮廓线 DP 结合;小规模图的匹配与染色问题。
  3. n 较大时配合 meet-in-the-middle 或模拟退火(TSP 近似解)。

典型例题类型

  1. TSP:洛谷 P1171 售货员的难题、POJ 3311 Hie with the Pie(允许重复经过)。
  2. 集合划分 / 子集和:洛谷 P3052 摩天大楼里的奶牛。
  3. 状压 + 最短路思想:洛谷 P3959 宝藏。

6.6 数位 DP

一句话用途:统计区间 [l, r] 内满足某类数位性质(不含某数字、相邻位满足大小关系、数位和等于某值等)的整数个数,是「区间计数」类题目的标准解法,答案用前缀差分 solve(r) - solve(l-1) 得到。

算法思想:把数字按位从高位到低位枚举,DFS 状态为 (pos, limit, lead, 其他性质状态):pos 是当前位数,limit 表示之前是否一直贴着上界(贴着则当前位取值受限于上界数字),lead 表示是否仍处于前导零阶段(前导零不算数字、不参与性质判断)。核心优化是记忆化:只有当 !limit && !lead 时(该分支可以自由取值)才把结果记入 memo 并复用;limit 为真时每个分支只出现一次,缓存没有意义,必须重新计算。

复杂度:O(位数 × 状态数 × 10),long long 范围内位数不超过 19,实际可视为 O(位数²) 量级,远快于逐个数枚举。

模板代码(洛谷 P2657 windy 数:相邻两位数字差 ≥ 2,含前导零处理):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

int dig[15], len; // 从高位到低位存每一位
ll memo[15][10]; // memo[pos][last]:仅 !limit && !lead 时有效

// pos: 当前位(高位在前);last: 上一位数字;limit: 是否贴住上界;lead: 是否仍为前导零
ll dfs(int pos, int last, bool limit, bool lead) {
if (pos == len) return 1; // 成功构造出一个数字
if (!limit && !lead && memo[pos][last] != -1)
return memo[pos][last]; // 命中缓存(自由状态才缓存)
ll res = 0;
int up = limit ? dig[pos] : 9; // 本位可取的最大值
for (int x = 0; x <= up; x++) {
if (lead) {
// 仍是前导零:x 不参与相邻差限制(x==0 时 lead 保持为真)
res += dfs(pos + 1, x, limit && x == up, lead && x == 0);
} else {
if (abs(x - last) < 2) continue; // windy 数:相邻差必须 >= 2
res += dfs(pos + 1, x, limit && x == up, false);
}
}
if (!limit && !lead) memo[pos][last] = res; // 只有自由状态才写入缓存
return res;
}

ll solve(ll x) {
if (x < 0) return 0;
len = 0;
do { dig[len++] = x % 10; x /= 10; } while (x); // 拆出每一位
reverse(dig, dig + len); // 转为高位在前
memset(memo, -1, sizeof memo);
return dfs(0, 0, true, true); // 起点贴住上界且处于前导零
}

int main() {
ll a, b;
cin >> a >> b;
cout << solve(b) - solve(a - 1) << '\n'; // 区间转前缀差分
return 0;
}

注意点 / 坑点

  1. memo 只能在 !limit && !lead 时写入与复用;limit 为真的分支必须逐层实算,否则会统计出大于上界的数。
  2. 前导零处理:lead 阶段不能把 0 当作真实数字参与性质判断(如 windy 数中「0 与后续数字的差」不能触发限制),否则答案错误。
  3. solve(x) 对 x < 0 要特判返回 0;区间答案必须用 solve(b) - solve(a - 1),直接对 [a, b] 数位分解会错。
  4. 状态维度随题目增加(如数位和、是否出现某数字),memo 的维度必须与 DFS 状态一一对应,漏维度会导致结果错乱。

可拓展的地方

  1. 数位和、数位乘积、各位和取模等性质都能加进 DFS 状态(多一维取模即可)。
  2. 求「第 k 个满足性质的数」:配合二分答案,或在 DFS 里按字典序直接构造。
  3. 与矩阵快速幂结合可处理位数上万的大数计数(把转移写成矩阵)。

典型例题类型

  1. 不含 4 且不含连续 62:HDU 2089 不要 62(入门经典)。
  2. 相邻数字差 ≥ 2:洛谷 P2657 windy 数(含前导零处理)。
  3. 数位和取模 / 数字计数:洛谷 P4127 同类分布、P2602 数字计数。

6.7 概率与期望 DP

一句话用途:处理带随机性的最优化或计数问题,例如掷骰子走到终点的期望步数、随机游走、博弈中的胜率等。核心工具是期望的线性性与「倒推」思想。

算法思想:期望的线性性:E[X+Y] = E[X] + E[Y],可把复杂期望拆成若干简单随机变量的期望之和。求期望步数通常倒推:设 dp[i] 为「从状态 i 到目标态还需的期望步数」,目标态 dp[target] = 0,按转移图的反向拓扑序(或按下标递减)计算 dp[i] = 1 + Σ p_k · dp[后继]。而求「到达某态的概率」通常顺推:dp[后继] += dp[来源] · p。两者方向相反,不能混用。若转移图带环,dp[i] 会出现在等式两边,需要列方程用高斯消元求解。

复杂度:O(状态数 × 转移数),一般线性或 O(n·m)。

模板代码(掷骰子:从 0 出发每次掷 1~6 等概率前进,到达或超过 n 停止,求期望步数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <bits/stdc++.h>
using namespace std;

int main() {
int n; cin >> n; // 终点(超过 n 也算到达)
vector<double> dp(n + 1, 0.0);
// 倒推:dp[i] = 从 i 到终点还需的期望步数;dp[n] = 0
for (int i = n - 1; i >= 0; i--) {
double sum = 0;
for (int k = 1; k <= 6; k++)
sum += dp[min(i + k, n)]; // 超出终点按停在 n 处理(其期望为 0)
dp[i] = 1.0 + sum / 6.0; // 本次掷 1 步 + 六个后继的期望均值
}
printf("%.6f\n", dp[0]);
return 0;
}

概率顺推示例(DAG 上的到达概率):设 out[i] 为 i 的出度、f[i] 为到达 i 的概率,则按拓扑序对每条边 i -> j 执行 f[j] += f[i] / out[i]。

模板代码(DAG 期望倒推,洛谷 P4316 绿豆蛙的归宿:从 1 到 n 的期望路径长度):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
const int N = 100005;
vector<pair<int, int>> rg[N]; // 反向图:v -> (u, w)
int out[N], indeg[N]; // 原图出度、反向图入度
double dp[N]; // dp[u]: u 到终点 n 的期望路径长度

int main() {
int n, m; cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v, w; cin >> u >> v >> w;
rg[v].push_back({u, w}); // 建反向边,方便从终点倒推
out[u]++; indeg[u]++; // 反向图入度 = 原图出度
}
queue<int> q;
for (int i = 1; i <= n; i++) if (indeg[i] == 0) q.push(i);
while (!q.empty()) { // 沿反向图做拓扑排序
int u = q.front(); q.pop();
for (auto &e : rg[u]) {
int v = e.first, w = e.second;
dp[v] += (dp[u] + w) / out[v]; // 从 v 出发每条出边等概率
if (--indeg[v] == 0) q.push(v);
}
}
printf("%.2f\n", dp[1]);
return 0;
}

注意点 / 坑点

  1. 期望 DP 大多用倒推(目标态置 0),概率 DP 用顺推;方向反了会得到错误结果,务必先想清楚 dp 的定义。
  2. 浮点精度:期望值可能很大或很小,比较时用 EPS(如 1e-9);求和时可用 long double 提升精度。
  3. 出现自环/环时方程左右两边都含 dp[i],不能直接递推,需用高斯消元或迭代法解方程组(如洛谷 P3232 游走)。
  4. 转移概率必须归一化(Σp = 1),漏掉某个后继概率会让期望偏小。

可拓展的地方

  1. 与最短路结合(期望 + Dijkstra/SPFA)、与高斯消元结合处理带环随机游走。
  2. 博弈类期望(如「先手期望胜率」)可与 6.11 节记忆化搜索结合。
  3. 转移为线性递推时可用矩阵快速幂加速(对很大的 n 求期望)。

典型例题类型

  1. 掷骰子 / 随机游走期望步数:经典入门题,可拓展到洛谷 P3802 小魔女帕琪(期望线性性)。
  2. DAG 期望(倒推):洛谷 P4316 绿豆蛙的归宿。
  3. 带环期望 + 高斯消元:洛谷 P3232 游走。

6.8 单调队列优化 DP

一句话用途:当 DP 转移形如 dp[i] = max/min(dp[j] + w(j, i)),且可选决策点 j 落在随 i 单调右移的区间(滑动窗口)内时,可用单调队列把每次取最值从 O(窗口大小) 降到 O(1),整体 O(n)。

算法思想:维护一个下标递增、值(dp[j])单调(取 max 递减、取 min 递增)的双端队列:每来一个新决策点,先从队尾弹出所有不如它的旧点(新点更靠右且值更优,旧点永远不会再被选),再入队;每次取最优时弹出队头所有已滑出窗口的点。队列头部始终是当前窗口内的最优决策点。这样每个点至多入队、出队一次,总复杂度 O(n)。滑动窗口取最值(洛谷 P1886)就是这个思想的裸题,直接接入 DP 即可。

复杂度:O(n) 时间、O(n) 空间。

模板代码(洛谷 P1725 琪露诺:从 0 出发每次跳 [l, r] 步,求收集分数的最大值):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <bits/stdc++.h>
using namespace std;
const int N = 200005, NEG = -0x3f3f3f3f;
int n, l, r, a[N], dp[N];
int q[N], head, tail; // 手写单调队列存下标,dp 值单调递减

int main() {
cin >> n >> l >> r;
for (int i = 0; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) dp[i] = NEG; // 不可达标记
dp[0] = a[0];
head = tail = 0;
for (int i = 1; i <= n; i++) {
int add = i - l; // 新进入窗口的决策点
if (add >= 0 && dp[add] > NEG) {
while (head < tail && dp[q[tail - 1]] <= dp[add]) tail--; // 弹出不如它的
q[tail++] = add;
}
int out = i - r - 1; // 滑出窗口的决策点
if (out >= 0 && head < tail && q[head] == out) head++;
if (head < tail) dp[i] = dp[q[head]] + a[i]; // 队头是窗口内最优
}
int ans = NEG;
for (int i = max(0, n + 1 - l); i <= n; i++) // 能一步跳出 n 的格子才可结束
ans = max(ans, dp[i]);
cout << ans << '\n';
return 0;
}

滑动窗口取最值的完整模板(洛谷 P1886 滑动窗口):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <bits/stdc++.h>
using namespace std;
const int N = 1000005;
int n, k, a[N];
deque<int> dq; // 存下标,a 值单调递减

int main() {
cin >> n >> k;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) {
while (!dq.empty() && dq.front() < i - k + 1) dq.pop_front(); // 滑出窗口
while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back(); // 维护单调递减
dq.push_back(i);
if (i >= k) cout << a[dq.front()] << ' '; // 窗口 [i-k+1, i] 的最大值
}
return 0;
}

注意点 / 坑点

  1. 窗口边界要仔细:新决策点是 add = i - l,滑出窗口的是 out = i - r - 1,差 1 就会错。
  2. 出窗判断要放在取最值之前、入队之后,且必须用下标比较(队头存的是下标)。
  3. 手写数组队列注意 head <= tail,弹出队尾用 tail–,不要减过头。
  4. 只有「决策点区间随 i 单调右移」才能用单调队列;若窗口端点不单调,需改用线段树或平衡树维护。

可拓展的地方

  1. 多重背包的 O(n·m) 单调队列优化(按余数分组)本质就是滑动窗口取最值。
  2. 与斜率优化结合:斜率优化可看成「凸包上的单调队列」。
  3. 环形数组(复制一倍)后单调队列可处理环形滑动窗口问题。

典型例题类型

  1. 滑动窗口最值:洛谷 P1886 滑动窗口。
  2. 单调队列优化线性 DP:洛谷 P1725 琪露诺、P2034 选择数字、P3957 跳房子。
  3. 多重背包单调队列优化:洛谷 P1776 宝物筛选(数据强时二进制拆分会超时)。

6.9 斜率优化 DP

一句话用途:当转移可化为 dp[i] = min_j { dp[j] - K_i·X_j } + 常数(K_i、X_j 均单调)这种「一次函数取最值」形式时,把每个决策点 j 看成平面上的点 (X_j, Y_j),问题变成用斜率为 K_i 的直线切这些点求截距最值,用单调队列维护凸包即可 O(n) 完成。

算法思想:把转移整理成 Y_j - K_i·X_j 形式后,求最小值维护下凸包(斜率递增),求最大值维护上凸包(斜率递减)。用单调队列存凸包上的点:队头出队条件为 slope(队首, 次队首) < K_i(此时队首不再可能是切点);入队时用叉积比较斜率,若新点使倒数第二个点「凹」了(即 (Y_new - Y_b)/(X_new - X_b) ≤ (Y_b - Y_a)/(X_b - X_a)),就把它弹出。为避免浮点误差,比较全部用**叉积(交叉相乘)**写成整数形式,或用 long double 计算斜率。

复杂度:O(n) 时间、O(n) 空间(K 单调时才可用单调队列;K 不单调需在凸包上二分,O(n log n))。

模板代码(任务安排,洛谷 P2365:n 个任务分批做,每批启动耗时为 s):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n; ll s;
cin >> n >> s;
vector<ll> t(n + 1), c(n + 1);
for (int i = 1; i <= n; i++) {
cin >> t[i] >> c[i];
t[i] += t[i - 1]; // 时间前缀和
c[i] += c[i - 1]; // 费用系数前缀和
}
vector<ll> dp(n + 1, 0);
vector<int> q(n + 1); // 单调队列存候选决策点 j
int head = 0, tail = 0;
q[tail++] = 0; // 决策点 0:(X, Y) = (0, 0)
for (int i = 1; i <= n; i++) {
ll K = t[i] + s; // 本次切线的斜率
// 队头出队:slope(q0, q1) < K 时 q0 不再可能是最优
while (head + 1 < tail &&
dp[q[head + 1]] - dp[q[head]]
< K * (c[q[head + 1]] - c[q[head]]))
head++;
int j = q[head];
// 转移:任务 j+1..i 作为最后一批,启动时间 s 会拖慢 j 之后的所有任务
dp[i] = dp[j] + t[i] * (c[i] - c[j]) + s * (c[n] - c[j]);
// 新决策点 i 入队:叉积判断,删去使凸包不再下凸的倒数第二个点
while (head + 1 < tail &&
(dp[i] - dp[q[tail - 1]]) * (c[q[tail - 1]] - c[q[tail - 2]]) <=
(dp[q[tail - 1]] - dp[q[tail - 2]]) * (c[i] - c[q[tail - 1]]))
tail--;
q[tail++] = i;
}
cout << dp[n] << '\n';
return 0;
}

注意点 / 坑点

  1. 叉积比较必须保持「同向相乘」:两边都用 ΔY 与 ΔX 的交叉乘积,不要用除法,否则遇到两点 X 相等会除零或丢失精度。
  2. 该模板要求斜率 K 与横坐标 X 都单调(P2365 中 t[i]、c[i] ≥ 0);若 K 不单调(如洛谷 P5785 任务安排,T 可为负),需要在凸包上二分找切点,复杂度 O(n log n)。
  3. 中间乘积可能爆 long long(n 与数值都大时可达 1e18 量级),保险起见用 __int128 或 long double 参与比较。
  4. 决策点 0(什么都不做)必须初始入队,否则 j = 0 的转移会丢失,答案偏大。

可拓展的地方

  1. 斜率不单调 / X 不单调时分别用「凸包上二分」「李超线段树 / CDQ 分治」处理。
  2. 「玩具装箱」「特别行动队」等题都是同一套路,只需改 X、Y、K 的定义。
  3. 求最大值时维护上凸包,把出队条件的不等号反向即可。

典型例题类型

  1. 任务安排系列:洛谷 P2365(模板)、P5785(T 可为负,需二分)。
  2. 玩具装箱:洛谷 P3195(经典斜率优化入门)。
  3. 特别行动队:洛谷 P3628。

6.10 四边形不等式优化

一句话用途:对形如 dp[i][j] = min(dp[i][k] + dp[k+1][j]) + w(i, j) 的区间 DP,若代价 w 满足四边形不等式与单调性,则最优决策点 k 关于区间单调,可把断点枚举范围从 O(n) 缩到常数摊还,总复杂度从 O(n³) 降到 O(n²)。

算法思想:四边形不等式:对 a ≤ b ≤ c ≤ d 有 w(a, c) + w(b, d) ≤ w(a, d) + w(b, c);单调性:w(b, c) ≤ w(a, d)。证明套路是数学归纳:先证 dp 也满足四边形不等式,再据此推出决策单调性(若 dp 是四边形不等式函数,则其最优决策点 s[i][j] 满足 s[i][j-1] ≤ s[i][j] ≤ s[i+1][j])。于是枚举断点 k 时只查区间 [s[i][j-1], s[i+1][j]],这些区间的总长度摊还 O(n²),即得 O(n²) 总复杂度。

复杂度:O(n²) 时间、O(n²) 空间。

模板代码(石子合并的 O(n²) 版本,直线型):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
const int N = 305, INF = 0x3f3f3f3f;
int n, a[N], sum[N];
int dp[N][N], s[N][N]; // s[i][j]: dp[i][j] 取得最优时的断点 k

int main() {
cin >> n;
for (int i = 1; i <= n; i++) { cin >> a[i]; sum[i] = sum[i - 1] + a[i]; }
for (int i = 1; i <= n; i++) { dp[i][i] = 0; s[i][i] = i; } // 单堆区间
for (int len = 2; len <= n; len++) {
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
dp[i][j] = INF;
// 决策单调性:最优断点只在 [s[i][j-1], s[i+1][j]] 中
for (int k = s[i][j - 1]; k <= s[i + 1][j]; k++) {
if (dp[i][k] + dp[k + 1][j] < dp[i][j]) {
dp[i][j] = dp[i][k] + dp[k + 1][j];
s[i][j] = k; // 记录最优断点
}
}
dp[i][j] += sum[j] - sum[i - 1]; // 加上本次合并代价
}
}
cout << dp[1][n] << '\n';
return 0;
}

补充:如何判断 w 是否满足四边形不等式(小数据打表验证,用于决定能否套用本优化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

ll W(int a, int b) { /* 实际代价函数,如区间和 sum[b] - sum[a-1] */ return 0; }

// 验证 W 是否满足四边形不等式:W(a,c)+W(b,d) <= W(a,d)+W(b,c),对 a<=b<=c<=d
bool checkQuadrangle(int n) {
for (int a = 1; a <= n; a++)
for (int b = a; b <= n; b++)
for (int c = b; c <= n; c++)
for (int d = c; d <= n; d++)
if (W(a, c) + W(b, d) > W(a, d) + W(b, c)) return false;
return true; // 全成立则 w 满足四边形不等式,可放心使用 O(n^2) 优化
}

注意点 / 坑点

  1. 使用前提:w 必须同时满足四边形不等式与单调性(石子合并的区间和满足);不满足时强行套用会得到错误答案。
  2. s 数组的初始化:s[i][i] = i;循环里用到的 s[i+1][j] 必须比 s[i][j] 先算好(同一长度内 i 从小到大枚举保证这一点)。
  3. 决策单调性方向不要写反:是 s[i][j-1] ≤ k ≤ s[i+1][j],不是 s[i-1][j] 到 s[i][j+1]。
  4. 该优化只对「枚举断点」型的区间 DP 有效,对非决策单调的转移无效。

可拓展的地方

  1. 适用于一类区间划分 / 合并问题(石子合并、邮局问题、最优二叉搜索树等)。
  2. 与分治优化(决策单调栈)结合,可处理「每行只依赖上一行」的决策单调 DP。
  3. 不确定 w 是否满足条件时,可先用小数据打表验证四边形不等式再套模板。

典型例题类型

  1. 石子合并 O(n²) 版:POJ 1738(n 大需 O(n²)),环形版同套路。
  2. 邮局问题:POJ 1160 Post Office(决策单调性经典)。
  3. 最优二叉搜索树:经典区间 DP + 四边形不等式。

6.11 记忆化搜索

一句话用途:用「递归枚举状态 + 缓存已算状态」的方式实现 DP,与自底向上的递推在数学上完全等价,但书写更贴近状态定义,特别适合状态难以定序、博弈类(先手/后手胜负)、带剪枝的搜索型 DP。

算法思想:把 DP 状态写成递归函数 f(state),函数内部枚举所有转移并递归调用,返回值存入 memo(一般初始化为 -1 表示未算)。在无环依赖(或博弈树的必胜/必败归纳)下递归不会无限循环,memo 保证每个状态只算一次,总复杂度 = 状态数 × 转移数,与递推版一致。等价性:递推是「按拓扑序自底向上」,记忆化搜索是「自顶向下 + 缓存」,两者算出的 dp 值一一对应,可根据状态是否有天然拓扑序选择写法。

复杂度:O(状态数 × 转移数) 时间、O(状态数) 空间。

模板代码(洛谷 P1434 滑雪:网格最长下降路径,状态难定序 → 记忆化搜索):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <bits/stdc++.h>
using namespace std;
const int N = 105;
int n, m, h[N][N], dp[N][N];
int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1};

int dfs(int x, int y) {
if (dp[x][y] != -1) return dp[x][y]; // 已算过直接返回(记忆化)
int best = 1; // 至少包含自己这个格子
for (int k = 0; k < 4; k++) { // 四个方向
int nx = x + dx[k], ny = y + dy[k];
if (nx < 1 || nx > n || ny < 1 || ny > m) continue;
if (h[nx][ny] < h[x][y]) // 只能滑向更低处
best = max(best, dfs(nx, ny) + 1);
}
return dp[x][y] = best; // 记录并返回
}

int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) cin >> h[i][j];
memset(dp, -1, sizeof dp);
int ans = 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
ans = max(ans, dfs(i, j)); // 从任意格子出发取最大
cout << ans << '\n';
return 0;
}

博弈类 DP 的记忆化模板(局面 S 的先手是否必胜):

1
2
3
4
5
6
int dfs(int S) {                                 // 返回 1 先手必胜,0 先手必败
if (memo[S] != -1) return memo[S];
for (每个合法走法 S -> T)
if (!dfs(T)) return memo[S] = 1; // 存在一步走到必败态则必胜
return memo[S] = 0; // 所有走法都到必胜态则必败
}

注意点 / 坑点

  1. memo 必须初始化为「未计算」的哨兵值(如 -1),且真实答案不能与该值冲突(答案为 0 的 DP 尤其小心)。
  2. 递归层数:极端数据(链状图、深度 1e5+ 的搜索)会爆系统栈,C++ 默认栈约 8MB;可改用递推、迭代或手工栈,或在环境允许时加大栈空间。
  3. 带环的依赖不能用普通记忆化(会无限递归),需先判环或改用递推加拓扑序。
  4. 与剪枝结合时注意:剪枝改变了搜索树形态,memo 只能缓存「完整计算出」的状态,不能缓存被剪枝截断的中间结果。

可拓展的地方

  1. 与博弈论结合(必胜/必败态、SG 函数);SG 函数的计算天然就是记忆化搜索。
  2. 搜索树剪枝(α-β 剪枝、上下界剪枝)可叠加在记忆化之上处理更大的搜索空间。
  3. 记忆化搜索同样适用于 6.6 节数位 DP、6.12 节插头 DP(按格递归 + 哈希表缓存)。

典型例题类型

  1. 网格最长路:洛谷 P1434 滑雪。
  2. 博弈类(取石子、博弈图胜负):经典题如 POJ 2068 Nim、洛谷 P4018 Roy&October 之取石子。
  3. 需要剪枝的计数/最优解搜索:洛谷 P1074 靶形数独(记忆化 + 剪枝)。

6.12 插头 DP

一句话用途:在棋盘上按格子逐格推进、用「轮廓线」上的插头状态压缩记录连通性信息的一类 DP,最典型的应用是统计经过所有可走格子的哈密顿回路(回路计数)。属于进阶冷门考点,难度高、代码长,考试优先级最低,慎用

算法思想:轮廓线思想:处理到格子 (i, j) 时,只关心分界线上 m+1 个「插头」(每个插头表示某条路径在分界线上穿进穿出的口),用三进制编码压成整数状态(0 无插头,1 左括号,2 右括号,括号配对表示两条边属于同一条路径,即括号表示)。逐格做分类转移:无插头时新建一对、单插头时延伸或转弯、双插头时合并(括号需按规则改写,闭合回路只允许在最后一个格子发生)。用滚动数组 + 哈希表(unordered_map)只存出现过的状态,避免 3^(m+1) 的全量数组。

复杂度:O(n·m·3^m) 时间(实际远小于,因哈希表只存可达状态),O(可达状态数) 空间。

模板代码(回路计数最小模板,洛谷 P5056【模板】插头 DP):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 15;
int n, m, ex = -1, ey = -1; // 最后一个可走格子的坐标
char g[N][N];
int pw[N]; // pw[i] = 3^i

inline int get(int s, int p) { return (s / pw[p]) % 3; } // 取第 p 位的值
inline int setv(int s, int p, int v) { return s + (v - get(s, p)) * pw[p]; }

// 括号匹配:p 处为 '(' 则向右找配对的 ')';为 ')' 则向左找 '('
int match(int s, int p) {
if (get(s, p) == 1) { // 左括号:向右扫描
for (int q = p + 1, dep = 1; ; q++) {
int v = get(s, q);
if (v == 1) dep++;
else if (v == 2 && --dep == 0) return q;
}
} else { // 右括号:向左扫描
for (int q = p - 1, dep = 1; ; q--) {
int v = get(s, q);
if (v == 2) dep++;
else if (v == 1 && --dep == 0) return q;
}
}
return -1; // 合法状态不会走到这里
}

int main() {
cin >> n >> m;
for (int i = 0; i < n; i++) {
cin >> g[i]; // '.' 可走,'*' 障碍
for (int j = 0; j < m; j++)
if (g[i][j] == '.') { ex = i; ey = j; } // 记录最后一个可走格
}
if (ex == -1) { cout << 0 << '\n'; return 0; } // 没有可走格
pw[0] = 1;
for (int i = 1; i <= m; i++) pw[i] = pw[i - 1] * 3;

unordered_map<int, ll> mp[2]; // 滚动哈希表:状态 -> 方案数
int cur = 0;
mp[cur][0] = 1;
for (int i = 0; i < n; i++) {
unordered_map<int, ll> nxt; // 换行:整体左移一位(三进制下乘 3)
for (auto &pr : mp[cur]) nxt[pr.first * 3] = pr.second;
mp[cur].swap(nxt);
for (int j = 0; j < m; j++) {
int nxtid = cur ^ 1;
mp[nxtid].clear();
for (auto &pr : mp[cur]) {
int s = pr.first; ll val = pr.second;
int L = get(s, j), U = get(s, j + 1); // 左插头、上插头
if (g[i][j] == '*') { // 障碍:必须无插头
if (L == 0 && U == 0) mp[nxtid][s] += val;
continue;
}
if (L == 0 && U == 0) { // 新建右、下插头
if (i + 1 < n && j + 1 < m && g[i + 1][j] == '.' && g[i][j + 1] == '.')
mp[nxtid][setv(setv(s, j, 1), j + 1, 2)] += val;
} else if (L == 0 || U == 0) { // 单插头:转弯或直走
int v = L | U;
int s1 = setv(setv(s, j, 0), j + 1, 0);
if (i + 1 < n && g[i + 1][j] == '.') mp[nxtid][setv(s1, j, v)] += val;
if (j + 1 < m && g[i][j + 1] == '.') mp[nxtid][setv(s1, j + 1, v)] += val;
} else { // 双插头:合并
int s2 = setv(setv(s, j, 0), j + 1, 0);
if (L == 1 && U == 1) { // ( ( :把 U 的配对 ) 改成 (
int q = match(s, j + 1);
mp[nxtid][setv(s2, q, 1)] += val;
} else if (L == 2 && U == 2) { // ) ) :把 L 的配对 ( 改成 )
int q = match(s, j);
mp[nxtid][setv(s2, q, 2)] += val;
} else if (L == 2 && U == 1) { // ) ( :连接两个连通块
mp[nxtid][s2] += val;
} else { // ( ) :闭合回路,只能在最后格
if (i == ex && j == ey) mp[nxtid][s2] += val;
}
}
}
cur = nxtid;
}
}
cout << mp[cur][0] << '\n'; // 无插头状态 = 恰好一条回路
return 0;
}

注意点 / 坑点

  1. 状态编码务必一致:本模板三进制位 j 是左插头、位 j+1 是上插头,换行乘 3 左移一位;改错一位全盘皆错。
  2. 闭合回路(( 配 ) 的合并)只能允许在最后一个可走格子发生,否则会数出多条回路。
  3. 障碍格必须要求两个插头都为 0 才能继承状态,否则回路会穿过障碍。
  4. 3^m 可能巨大,必须用哈希表只存可达状态,不能用全量数组;注意滚动数组的清空时机。
  5. 该算法代码长、细节多、调试困难,赛场上性价比极低,除非题目明确是插头 DP,否则不要优先写

可拓展的地方

  1. 最小/最大回路权值、经过部分格子的回路计数:调整转移分支即可。
  2. 用括号表示法可处理「不闭合的路径计数」(一端在轮廓线上)与多回路问题。
  3. 对只需连通性、不需要括号配对的部分问题,状态可换成 2 进制压缩,进一步减少状态数。

典型例题类型

  1. 回路计数:洛谷 P5056【模板】插头 DP(即本模板)。
  2. 最大权回路 / 连通性计数:经典题如 Ural 1519 Formula 1、POJ 3133 Manhattan Wiring。
  3. 轮廓线思想也出现在骨牌覆盖类问题(状压 + 逐格推进,如 POJ 2411 Mondriaan’s Dream),这类简单版本优先掌握。

七、计算几何

本部分速查表

算法 复杂度 适用场景
7.1 基础运算 单次运算 O(1),极角排序 O(n log n) 点/向量结构体、叉积点积、向量旋转、极角排序,全章公共地基
7.2 点与线 各判定均 O(1) 点到直线距离、点在线段上、两线段相交判断
7.3 多边形 面积/重心/点在多边形内均 O(n) 多边形面积(鞋带公式)、点在多边形内(射线法)、重心
7.4 凸包 O(n log n) 点集凸包(Andrew 单调链)、凸包周长、围墙类问题
7.5 旋转卡壳 O(n)(输入为凸包) 凸包直径(最远点对)、最小面积外接矩形
7.6 半平面交 O(n log n) 多边形内核、线性不等式组可行域、约束可行性判定
7.7 最近点对 O(n log n) 平面点集最近点对、两类点之间最近距离
7.8 圆 最小圆覆盖期望 O(n) 最坏 O(n³),圆交点 O(1) 最小覆盖圆、圆与直线/两圆交点
7.9 精度与陷阱 辅助函数 O(1) 浮点比较规范、eps 选取、几何宏与 WA 自查清单

7.1 基础运算

一句话用途:提供计算几何最底层的公共工具——点/向量结构体与运算重载、点积与叉积、向量旋转、极角排序,以及贯穿全篇的 sgn/eps 精度约定;适用于任何需要处理平面坐标、判断方向或按角度扫描的题目。

算法思想:将点与向量统一为二维坐标结构体,把几何关系全部转化为代数运算。点积 a·b = |a||b|cosθ 的正负反映夹角为锐角/直角/钝角;叉积 a×b = |a||b|sinθ 带符号,其正负表示 b 在 a 的顺时针/逆时针方向,绝对值等于两向量张成的平行四边形面积,是判断”左转/右转”与共线的核心工具。极角排序把点按与 x 轴正方向的夹角排序:atan2 法直观但依赖浮点三角函数,象限排序法先按象限分组、组内用叉积比较,完全避开三角函数,精度与速度都更好。注意 double 只有约 15~17 位有效数字,一切比较都必须先经 sgn(配合 eps 容差)。

复杂度:时间:点积/叉积/旋转等单次运算 O(1),极角排序 O(n log n);空间:O(1)(排序的递归栈 O(log n))。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8; // 精度阈值:小于它的量视为 0(选取原则见 7.9)
const double PI = acos(-1.0);

/* 符号函数:浮点比较的唯一入口。负数返回 -1,接近 0 返回 0,正数返回 1 */
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

/* 点 / 向量:共用一个结构体,语义由使用场景决定 */
struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator + (const Point& p) const { return Point(x + p.x, y + p.y); }
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
Point operator * (double k) const { return Point(x * k, y * k); }
Point operator / (double k) const { return Point(x / k, y / k); }
/* 供 sort / unique 使用:先比 x 再比 y;必须经 sgn,
* 否则 -0.0 与 0.0、近似相等的点排序与去重行为会不稳定 */
bool operator < (const Point& p) const {
if (sgn(x - p.x) != 0) return x < p.x;
return y < p.y;
}
bool operator == (const Point& p) const {
return sgn(x - p.x) == 0 && sgn(y - p.y) == 0;
}
};
using Vector = Point;

/* 点积 a·b = |a||b|cosθ:>0 锐角,<0 钝角,=0 垂直 */
double dot(const Point& a, const Point& b) { return a.x * b.x + a.y * b.y; }
/* 叉积 a×b = |a||b|sinθ(带符号):>0 表示 b 在 a 的逆时针方向;
* 绝对值等于 a、b 张成的平行四边形面积,是判方向/判共线的核心 */
double cross(const Point& a, const Point& b) { return a.x * b.y - a.y * b.x; }
double length(const Point& a) { return sqrt(dot(a, a)); } // 向量模长
double dist(const Point& a, const Point& b) { return length(a - b); } // 两点距离

/* 向量逆时针旋转 rad 弧度(绕原点;旋转矩阵 [cosθ -sinθ; sinθ cosθ]) */
Point rotate(const Point& a, double rad) {
double c = cos(rad), s = sin(rad);
return Point(a.x * c - a.y * s, a.x * s + a.y * c);
}

/* ---- 极角排序方法一:atan2 ----
* atan2(y, x) ∈ [-π, π],直接按角度升序;实现简单但有浮点误差,
* 且 -π 与 +π 其实是同一个方向,环状扫描场景需要自行平移。 */
bool cmpAngle1(const Point& a, const Point& b) {
return atan2(a.y, a.x) < atan2(b.y, b.x);
}

/* ---- 极角排序方法二:象限 + 叉积(推荐:无三角函数,稳定且快) ---- */
int quad(const Point& p) { // 点 p 所在象限,坐标轴分给相邻象限,保证 0°~360° 严格递增
if (sgn(p.x) >= 0 && sgn(p.y) >= 0) return 1; // [0°, 90°):含正 x 轴、原点
if (sgn(p.x) <= 0 && sgn(p.y) > 0) return 2; // (90°, 180°]:含正 y 轴
if (sgn(p.x) < 0 && sgn(p.y) <= 0) return 3; // (180°, 270°):含负 x 轴
return 4; // (270°, 360°):含负 y 轴
}
bool cmpAngle2(const Point& a, const Point& b) {
int qa = quad(a), qb = quad(b);
if (qa != qb) return qa < qb; // 象限不同直接比象限
double c = cross(a, b); // 同象限内用叉积比相对角度
if (sgn(c) != 0) return c > 0; // cross(a,b) > 0 ⇒ b 在 a 逆时针方向 ⇒ a 角度更小
return length(a) < length(b); // 共线:按长度升序(视题目可改为降序)
}

注意点 / 坑点

  1. 浮点数绝不能直接用 ==< 比较,必须经 sgn(带 eps)判断;eps 一般取 1e-8 左右(详见 7.9),过小会放过累计误差,过大会把接近但不等的量判成相等。
  2. 叉积符号约定要统一:cross(a, b) > 0 表示 b 相对 a 逆时针;判断三点走向时注意书写顺序是 cross(p2 - p1, p3 - p1),写反会导致左右转判断颠倒。
  3. atan2 返回 [-π, π],排序后角度在 -π 附近的点与 +π 附近的点首尾相邻;若题目要求从某个指定起点按扫描顺序输出,需先对角度平移(小于基准的加 2π)再排序。
  4. 象限排序中,原点 (0,0) 的极角无定义,一般单独取出处理;同象限共线的点必须用长度做次级关键字,否则 sort 结果不稳定(不满足严格弱序)。
  5. 重载 operator<operator== 必须基于 sgn,保证 sort 与 unique 的比较基准一致,否则去重失效或排序结果与期望不符。

可拓展的地方

  1. 可继续扩展单位向量 unit()、法向量 perp()、向量投影与分解、绕任意点旋转(先平移再旋转再平移)等工具,作为公共头文件被全章复用。
  2. 坐标全为整数时可写 long long 版结构体并用 __int128 存叉积,彻底避免浮点误差;极角排序也可扩展为”以指定基准点(如凸包最左下点)为原点”的相对排序,用于 Graham 扫描。

典型例题类型

  1. 给定若干点与一条”贪吃蛇”路径,要求每一步选择极角最小的点前进——POJ 1696 Space Ant(极角排序,出自 POJ)。
  2. 判断点被线段划分后落在哪个区域内——POJ 2318 TOYS(用叉积判断点在线段的哪一侧,出自 POJ)。

7.2 点与线

一句话用途:解决点与直线/线段的三种基本判定与度量:点到直线的距离、点是否在线段上、两线段是否相交;是线段类题目(挡板划分、木棍叠放、区域可视等)的高频基础。

算法思想:距离用面积法:|叉积| 等于两向量张成的平行四边形面积,除以底边长即得高。点在线段上分解为两个条件——叉积为 0(与线段共线)且点积范围合适(dot(p-a, p-b) <= 0 等价于 p 落在以 a、b 为直径的圆内,即位于 a、b 之间,含端点)。两线段相交用”快速排斥 + 跨立实验”两步:先看两线段的包围盒(矩形)是否重叠,常数级排除大部分不相交情况;再检验两条线段的端点是否分别位于对方直线的两侧(叉积异号或为 0,即”跨立”),两者同时成立才相交。

复杂度:时间:三者均为 O(1);空间:O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8;
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

/* 前置:7.1 的 Point 结构体(此处给出简化版,完整版见 7.1) */
struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator + (const Point& p) const { return Point(x + p.x, y + p.y); }
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
};
using Vector = Point;
double dot(const Point& a, const Point& b) { return a.x * b.x + a.y * b.y; }
double cross(const Point& a, const Point& b) { return a.x * b.y - a.y * b.x; }
double length(const Point& a) { return sqrt(dot(a, a)); }

/* 点到直线的距离:直线由 a、b 两点确定(要求 a != b,否则除零) */
double distToLine(const Point& p, const Point& a, const Point& b) {
Vector v = b - a, w = p - a;
return fabs(cross(v, w)) / length(v); // |v × w| = 平行四边形面积,除以底 |v| 得高
}

/* 点是否在线段 ab 上(含端点) */
bool onSegment(const Point& p, const Point& a, const Point& b) {
if (sgn(cross(b - a, p - a)) != 0) return false; // 叉积非 0:三点不共线,直接排除
/* 共线时用点积判范围:dot(p-a, p-b) <= 0 ⇔ p 在以 a、b 为直径的圆内/上
* ⇔ p 位于 a、b 之间(含端点)。等价于区间 min/max 判断但更简洁。 */
return sgn(dot(p - a, p - b)) <= 0;
}

/* 两线段相交判断(含端点相交、共线重叠等退化情形):返回 true 表示相交 */
bool segIntersect(const Point& a1, const Point& a2, const Point& b1, const Point& b2) {
/* 第一步:快速排斥。两线段各自的包围盒(以端点为对角顶点的矩形)不相交
* ⇒ 线段必不相交。用 `< ... - eps` 判"确定不相交",常数级排除绝大多数情况。 */
if (max(a1.x, a2.x) < min(b1.x, b2.x) - eps || max(b1.x, b2.x) < min(a1.x, a2.x) - eps ||
max(a1.y, a2.y) < min(b1.y, b2.y) - eps || max(b1.y, b2.y) < min(a1.y, a2.y) - eps)
return false;
/* 第二步:跨立实验。d1、d2 为 b1、b2 相对直线 a1a2 的带符号叉积,
* 异号(或为 0)说明 b1、b2 分居直线 a1a2 两侧;对 a1、a2 相对直线 b1b2 同理。
* 两组条件同时成立才相交;用 <= 0 把"端点在对方直线上"也判为相交。 */
double d1 = cross(a2 - a1, b1 - a1);
double d2 = cross(a2 - a1, b2 - a1);
double d3 = cross(b2 - b1, a1 - b1);
double d4 = cross(b2 - b1, a2 - b1);
return sgn(d1) * sgn(d2) <= 0 && sgn(d3) * sgn(d4) <= 0;
}

/* 规范相交:只在两线段内部相交(不含端点接触)时返回 true */
bool segStrictIntersect(const Point& a1, const Point& a2, const Point& b1, const Point& b2) {
double d1 = cross(a2 - a1, b1 - a1);
double d2 = cross(a2 - a1, b2 - a1);
double d3 = cross(b2 - b1, a1 - b1);
double d4 = cross(b2 - b1, a2 - b1);
return sgn(d1) * sgn(d2) < 0 && sgn(d3) * sgn(d4) < 0; // 严格异号:端点接触、共线重叠都不算
}

注意点 / 坑点

  1. 快速排斥只是”必要条件”筛选,不能单独使用;跨立实验单独使用时,端点共线(叉积为 0)的情形必须用 <= 0 而不是 < 0,否则端点相接会被漏判。
  2. 线段共线重叠(如 [0,1] 与 [0.5,1.5])时四个叉积全为 0,本模板返回相交(true),符合”相交”的通常定义;若题目要求”规范相交”(只算内部相交、端点接触不算),必须用代码中的 segStrictIntersect
  3. distToLine 要求 a != b;若 a、b 重合,length(v) 为 0 会除零,需在调用前特判退化线段。
  4. onSegment<= 0 是包含端点的;题目若要求”不含端点”,请把判据改成 < 0,先与题意对齐再套模板。
  5. 叉积与点积的结果可能很大(坐标 1e9 时达 1e18),double 能表示但相对精度下降,必要时改用 long double 或整数运算(见 7.9)。

可拓展的地方

  1. 可继续实现”两直线交点”(参数法,见 7.6 的 lineIntersect)、判断两直线平行/重合(方向叉积为 0 且任一点到对方直线距离为 0)。
  2. 线段相交可扩展为返回具体交点坐标(用叉积比例插值),配合扫描线可做多线段相交检测、线段与圆/多边形相交等复合问题。

典型例题类型

  1. 判断两条直线/线段的位置关系(平行、相交、重合)并求交点——POJ 1269 Intersecting Lines(出自 POJ)。
  2. 依次叠放木棍,问最终哪些木棍没有被完全压住——POJ 2653 Pick-up Sticks(两两线段相交判定,出自 POJ)。

7.3 多边形

一句话用途:解决多边形的面积、点在多边形内判断与重心等基本度量问题;适用于任意简单多边形(可凹),是计算几何题目的高频基础操作。

算法思想:面积用鞋带公式:把多边形顶点绕原点做有向叉积累加,Σ cross(Pi, Pj) 等于两倍有向面积,取绝对值再除以 2 即面积(顶点顺时针或逆时针均可,只影响符号,顶点顺序不能乱)。点在多边形内用射线法:从点向右作水平射线,统计与多边形边的穿越次数,奇数在内部、偶数在外部;为正确处理射线穿过顶点与水平边的情况,采用”一端严格在上、另一端不在上”的半开计数约定。多边形重心一句话:把多边形三角剖分(以原点为公共顶点),每个三角形重心为 (Pi+Pj)/3,按有向面积加权平均,即 G = Σ cross(Pi,Pj)·(Pi+Pj) / (3·Σ cross(Pi,Pj))

复杂度:时间:面积/重心/点在多边形内均 O(n);空间:O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8;
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
};
double cross(const Point& a, const Point& b) { return a.x * b.y - a.y * b.x; }
double dot(const Point& a, const Point& b) { return a.x * b.x + a.y * b.y; }

/* 点在线段上(含端点),供 pointInPolygon 使用 */
bool onSegment(const Point& p, const Point& a, const Point& b) {
if (sgn(cross(b - a, p - a)) != 0) return false;
return sgn(dot(p - a, p - b)) <= 0;
}

/* 多边形面积:鞋带公式(Shoelace)。
* 顶点必须按顺序给出(逆时针或顺时针均可,只影响符号);结果取绝对值。 */
double polygonArea(const vector<Point>& poly) {
double area = 0;
int n = (int)poly.size();
for (int i = 0; i < n; i++) {
int j = (i + 1) % n; // 闭合:最后一个点与第一个点首尾相接
area += cross(poly[i], poly[j]); // 累加有向叉积(以原点为参考点)
}
return fabs(area) / 2.0; // Σ cross = 两倍有向面积,取绝对值并除以 2
}

/* 点在多边形内:射线法。返回 1 = 内部,0 = 外部,-1 = 在边上 */
int pointInPolygon(const Point& p, const vector<Point>& poly) {
int n = (int)poly.size(), cnt = 0;
for (int i = 0; i < n; i++) {
int j = (i + 1) % n;
if (onSegment(p, poly[i], poly[j])) return -1; // 点在边上
/* 从 p 向右水平发射射线,统计与多边形的穿越次数。
* 只统计"一端严格在上、另一端不在上"的边(半开约定):
* 避免射线恰好穿过顶点时被两条相邻边重复计数;
* 水平边两端都不在上,天然不计数。 */
bool up1 = poly[i].y > p.y + eps;
bool up2 = poly[j].y > p.y + eps;
if (up1 != up2) { // 该边跨越水平线 y = p.y
double t = (p.y - poly[i].y) / (poly[j].y - poly[i].y); // 参数 t ∈ (0,1)
double xc = poly[i].x + t * (poly[j].x - poly[i].x); // 交点横坐标
if (xc > p.x + eps) cnt++; // 交点在射线(右侧)上才计数
}
}
return (cnt & 1) ? 1 : 0; // 奇数个交点 ⇒ 在内部
}

/* 多边形重心:把多边形三角剖分(以原点为公共顶点),
* 每个三角形重心为 (Pi+Pj)/3,用其有向面积加权平均。 */
Point polygonCentroid(const vector<Point>& poly) {
int n = (int)poly.size();
double a2 = 0; // 两倍总的有向面积(权重总和)
Point g(0, 0);
for (int i = 0; i < n; i++) {
int j = (i + 1) % n;
double c = cross(poly[i], poly[j]); // 三角形 (O, Pi, Pj) 的两倍有向面积
a2 += c;
g.x += c * (poly[i].x + poly[j].x); // 三角形重心坐标 = (Pi + Pj) / 3
g.y += c * (poly[i].y + poly[j].y);
}
if (sgn(a2) == 0) return Point(0, 0); // 退化多边形(面积为 0),避免除零
g.x /= 3.0 * a2; // 公式:G = Σ c·(Pi+Pj) / (3·Σ c)
g.y /= 3.0 * a2;
return g;
}

注意点 / 坑点

  1. 鞋带公式要求顶点按顺序给出(顺/逆时针皆可),顺序打乱会得到错误面积;对自交多边形公式不成立,需先判断多边形是否简单(不自交)。
  2. 射线法的计数约定必须统一:边的一个端点严格在上方、另一个不在上方(含相等),否则射线恰好穿过顶点时会被重复计数;水平边不计数这一约定也要与”点在边上”的判断配套。
  3. 点在多边形内必须先处理”点在边上”(返回 -1),否则射线法可能给出错误的内外结果;p 恰在顶点上时半开约定保证不重不漏。
  4. 面积累加可能很大(坐标 1e9 时 Σcross 达 1e18 量级),double 精度足够但中间不要开方;需要精确整数结果时用 long long + __int128 存叉积累加。
  5. 重心公式对有向面积自动兼容顺/逆时针(符号抵消),但面积为 0 的退化多边形必须特判,否则除零;重心公式对自交多边形同样不成立。

可拓展的地方

  1. 配合 Pick 定理可求格点多边形的边界点数与内部点数(POJ 1265);对凸多边形可用 O(log n) 的叉积二分判断点在多边形内(极角二分)。
  2. 面积与重心公式可推广到带洞多边形(外圈与内圈分别计算相减)以及”多边形质心/转动惯量”类物理建模问题。

典型例题类型

  1. 给定多边形顶点坐标求面积,坐标可达大数——POJ 1654 Area(鞋带公式,整数累加,出自 POJ)。
  2. 判断点是否在三角形/多边形内(含边上与顶点)——洛谷 P1355 神秘大三角(出自洛谷)。
  3. 求多边形重心——HDU 1115 Lifting the Stone(出自 HDU);结合格点计数——POJ 1265 Area(Pick 定理,出自 POJ)。

7.4 凸包

一句话用途:给定平面点集,求出包含所有点的最小凸多边形(凸包);是计算几何最经典的算法之一,常用于求凸包周长、凸包上的最远点、点集最小包围等问题,也是旋转卡壳(7.5)的输入前置。

算法思想:Andrew 单调链法:先把点按 x 为第一关键字、y 为第二关键字排序并去重,然后分两次扫描构造凸包——从左到右构造下凸壳(只保留”左转”的顶点,遇到右转或共线就弹出栈顶),从右到左构造上凸壳,最后去掉重复的起点,得到按逆时针排列的凸包顶点。正确性依赖排序后的单调性:扫描过程中任何”凹陷”都会表现为右转而被弹出,最终保留下来的点全部左转,恰好围成凸多边形。

复杂度:时间:排序 O(n log n) + 线性扫描 O(n),总体 O(n log n);空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8;
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
/* 排序关键字:先 x 后 y,这是 Andrew 算法正确性的前提 */
bool operator < (const Point& p) const {
if (sgn(x - p.x) != 0) return x < p.x;
return y < p.y;
}
bool operator == (const Point& p) const {
return sgn(x - p.x) == 0 && sgn(y - p.y) == 0;
}
};
double cross(const Point& a, const Point& b) { return a.x * b.y - a.y * b.x; }
double length(const Point& a) { return sqrt(a.x * a.x + a.y * a.y); }
double dist(const Point& a, const Point& b) { return length(a - b); }

/* Andrew 单调链凸包:返回按逆时针排列的凸包顶点(不含边上的共线点) */
vector<Point> convexHull(vector<Point> p) {
sort(p.begin(), p.end()); // 先按 x 后按 y 排序
p.erase(unique(p.begin(), p.end()), p.end()); // 去重,依赖 == 运算符(基于 sgn)
int n = (int)p.size();
if (n <= 1) return p; // 退化:0 或 1 个点直接返回
vector<Point> h(2 * n); // 上下壳共最多 2n 个顶点
int k = 0;
/* 下凸壳:从左往右扫描 */
for (int i = 0; i < n; i++) {
/* 叉积 <= 0 则弹出栈顶:新点使前进方向"右转或直行",
* 说明栈顶点被包在内部,不是凸包顶点(<= 0 同时剔除共线点) */
while (k >= 2 && sgn(cross(h[k - 1] - h[k - 2], p[i] - h[k - 1])) <= 0) k--;
h[k++] = p[i];
}
/* 上凸壳:从右往左扫描(复用同一数组) */
int lower = k; // 记录下凸壳顶点数,防止误删
for (int i = n - 2; i >= 0; i--) {
while (k > lower && sgn(cross(h[k - 1] - h[k - 2], p[i] - h[k - 1])) <= 0) k--;
h[k++] = p[i];
}
h.resize(k - 1); // 去掉重复的最后一个点(即起点)
return h;
}

/* 凸包周长:相邻顶点距离之和 */
double hullPerimeter(const vector<Point>& h) {
double res = 0;
for (int i = 0; i < (int)h.size(); i++)
res += dist(h[i], h[(i + 1) % h.size()]);
return res;
}

注意点 / 坑点

  1. 排序必须”先 x 后 y”,这是 Andrew 算法的前提;去重依赖 operator==(基于 sgn),排序与去重的比较基准必须一致,否则 unique 删错点。
  2. 弹出条件用 cross <= 0 会剔除边上的共线点(得到严格凸包,顶点都是角点);若题目要求保留边界上所有点(如数格点、要求输出所有边界点),应改用 < 0
  3. 所有点共线时凸包退化为一条线段(返回 2 个端点);n <= 1 时直接返回原集合,调用处需对退化凸包单独处理(如 7.5 对 n < 3 的特判)。
  4. 返回的顶点按逆时针排列,某些题目对顺序有要求(如构造多边形的边);不要在原数组上做排序去重后继续使用原顺序。
  5. 叉积可能溢出:坐标 1e9 时 (dx)·(dy) 达 1e18,double 仍可表示但精度下降;整数坐标下用 long double 或 __int128 更稳。

可拓展的地方

  1. 凸包可作为后续算法的输入:与旋转卡壳结合求直径(7.5)、最小面积外接矩形;POJ 1113 Wall 是”凸包周长 + 圆环”的经典综合题。
  2. 可扩展”保留共线点的凸包”、动态凸包(std::set 维护,支持插入点并实时查询凸包)、三维凸包(增量法)等变体。

典型例题类型

  1. 求点集凸包并输出顶点/周长——洛谷 P2742【模板】二维凸包([USACO5.1] 圈奶牛 Fencing the Cows,出自洛谷)。
  2. 求包围所有点的最短围墙(凸包周长 + 2πr)——POJ 1113 Wall(出自 POJ)。

7.5 旋转卡壳

一句话用途:在凸包上快速求最远点对(凸包直径)、最小面积外接矩形等”凸多边形极值”问题;用双指针沿凸包旋转一圈,把朴素 O(n²) 降到 O(n)。

算法思想:对于凸包的每条边,最远点在该边所在直线的一侧,且随边的推进单调移动,因此用双指针:i 遍历边,j 从当前最远点继续向前推进,直到”下一个点到该边的距离不再增加”(距离与叉积成正比,用叉积绝对值比较免去开方与除法)。j 全程不回退,总移动次数 O(n)。最小面积外接矩形一句话思路:矩形的一条边必然与凸包的某条边共线,枚举每条边作为底边方向,用旋转卡壳同步维护左右两侧投影最远点与上方最高点,即可 O(n) 求出最小面积。

复杂度:时间:O(n)(n 为凸包顶点数,前提是输入为凸包);空间:O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8;
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
};
double cross(const Point& a, const Point& b) { return a.x * b.y - a.y * b.x; }
double dist(const Point& a, const Point& b) {
double dx = a.x - b.x, dy = a.y - b.y;
return sqrt(dx * dx + dy * dy);
}

/* 旋转卡壳求凸包直径(最远点对距离)。
* 输入必须是严格凸、按逆时针排列的凸包(用 7.4 的 convexHull 得到),n >= 3。 */
double rotatingCalipers(const vector<Point>& h) {
int n = (int)h.size();
if (n == 1) return 0; // 退化:单点
if (n == 2) return dist(h[0], h[1]); // 退化:线段
double ans = 0;
int j = 1; // 对踵点指针(逆时针单调推进)
for (int i = 0; i < n; i++) {
int ni = (i + 1) % n; // 当前边 (h[i], h[ni])
/* 推进 j:直到下一个点到边 (h[i], h[ni]) 的距离不再增大。
* 点到直线距离与 |叉积| 成正比(底边固定),直接比较叉积绝对值即可。 */
while (fabs(cross(h[ni] - h[i], h[(j + 1) % n] - h[i])) >
fabs(cross(h[ni] - h[i], h[j] - h[i]))) {
j = (j + 1) % n;
}
/* 最远点对要么是 (i, j) 要么是 (ni, j):
* 距离极大值可以出现在"顶点对"上,而不仅限于边对,两个都要试 */
ans = max(ans, dist(h[i], h[j]));
ans = max(ans, dist(h[ni], h[j]));
}
return ans;
}

注意点 / 坑点

  1. 输入必须是严格凸、按逆时针排列的凸包(用 7.4 的 convexHull 得到,不含共线边上的点),否则双指针的单调性不成立,结果错误。
  2. n < 3 的退化凸包要单独处理(本模板返回 0 或两点距离),不要直接进主循环。
  3. 最远点对可能出现在”顶点对”而非”边对”上,因此每个 i 必须同时检查 (i, j) 与 (ni, j) 两个候选,只检查一个会漏掉正确答案。
  4. 用叉积绝对值比较距离时,浮点误差可能让 j 停在相邻点;eps 不要取太大,必要时把坐标放大为整数再算。
  5. 外层 i 循环 n 次、内层 j 总推进次数也是 O(n)(j 单调绕凸包推进),总体才是 O(n);不要在内层做线性扫描或排序,否则退化为 O(n²)。

可拓展的地方

  1. 同一套双指针可求最小面积/最小周长外接矩形、凸包宽度(两条平行线夹住凸包的最小距离)、凸多边形内最大三角形面积(POJ 2079 Triangle)。
  2. 与二分结合可在凸多边形上 O(log n) 查询某方向上的最远点;旋转卡壳思想也可推广到三维凸包的直径问题。

典型例题类型

  1. 求点集最远点对距离——洛谷 P1452 / POJ 2187 Beauty Contest(凸包 + 旋转卡壳,出自洛谷/POJ)。
  2. 求凸多边形内最大三角形面积——POJ 2079 Triangle(出自 POJ);最小面积外接矩形类题目(枚举底边 + 三个方向指针)。

7.6 半平面交

一句话用途:求若干半平面(有向直线的左侧区域)的交集,结果是凸多边形或空集;常用于求多边形内核、线性不等式组可行域、”找一点满足若干线性约束”类问题。

算法思想:S&I(Sort & Incremental)增量法:每条有向直线代表一个半平面,先按极角排序(同向平行线只保留最靠左、限制最强的一条),再用双端队列增量加入直线——每加入一条直线,检查队尾、队首的交点是否还在新半平面内,不在则弹出对应直线;最后首尾两条直线互相裁剪并补上首尾交点,队列中相邻直线的交点围成交集多边形。正确性基于:极角有序后,交集区域边界上的边也按极角顺序出现,任何被新直线”切掉”的旧边必然位于队尾或队首。

复杂度:时间:排序 O(n log n) + 增量过程 O(n)(每条直线至多进出队一次);空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8;
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator + (const Point& p) const { return Point(x + p.x, y + p.y); }
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
Point operator * (double k) const { return Point(x * k, y * k); }
};
double cross(const Point& a, const Point& b) { return a.x * b.y - a.y * b.x; }

/* 有向直线:从 s 到 e,保留其左侧半平面(含直线上) */
struct Line {
Point s, e;
double ang; // 方向极角,用于排序
Line() {}
Line(Point _s, Point _e) : s(_s), e(_e) {
ang = atan2(e.y - s.y, e.x - s.x);
}
};

/* 点 p 是否在有向直线 l 的左侧(含在直线上) */
bool onLeft(const Line& l, const Point& p) {
return sgn(cross(l.e - l.s, p - l.s)) >= 0;
}

/* 求两直线交点(前提:两直线不平行,由极角不同保证) */
Point lineIntersect(const Line& a, const Line& b) {
Point u = a.e - a.s, v = b.e - b.s, w = a.s - b.s;
double t = cross(w, v) / cross(u, v); // 参数方程 a.s + t·u 与直线 b 的交点参数
return a.s + u * t;
}

/* 半平面交(S&I 增量法):输入若干有向直线(各保留其左侧),
* 返回交集区域顶点(逆时针);若交为空或退化为点/线段,返回空 vector。 */
vector<Point> halfPlaneIntersect(vector<Line> L) {
/* 1. 按极角排序;极角相同的平行线,把"更靠左"(限制更强)的排在后面 */
sort(L.begin(), L.end(), [](const Line& a, const Line& b) {
if (sgn(a.ang - b.ang) != 0) return a.ang < b.ang;
return cross(a.e - a.s, b.e - a.s) > 0; // 同向平行:b 在 a 左侧 ⇒ b 排后面
});
/* 2. 去重:同极角只保留最后一条(最靠左、最限制性的) */
vector<Line> ls;
for (const Line& l : L) {
if (ls.empty() || sgn(l.ang - ls.back().ang) != 0) ls.push_back(l);
else ls.back() = l;
}
/* 3. 双端队列增量维护:q 存直线,p 存相邻两条直线的交点 */
deque<Line> q;
deque<Point> p;
for (const Line& l : ls) {
/* 新直线若使队尾交点不满足(交点落到新直线的右侧),队尾直线被淘汰 */
while (!p.empty() && !onLeft(l, p.back())) { q.pop_back(); p.pop_back(); }
/* 同理淘汰队首直线 */
while (!p.empty() && !onLeft(l, p.front())) { q.pop_front(); p.pop_front(); }
if (!q.empty()) p.push_back(lineIntersect(q.back(), l)); // 新交点(q 非空时)
q.push_back(l);
}
/* 4. 收尾:队首直线裁剪队尾交点 */
while (!p.empty() && !onLeft(q.front(), p.back())) { q.pop_back(); p.pop_back(); }
if (q.size() < 3) return {}; // 少于 3 条有效边 ⇒ 交为空或退化
p.push_back(lineIntersect(q.front(), q.back())); // 补上首尾直线的交点
return vector<Point>(p.begin(), p.end());
}

注意点 / 坑点

  1. 半平面用”有向直线 + 保留左侧”表示,方向取反等价于保留另一侧;构造直线时方向写反,交集就变成了补集,务必对照题意。
  2. 同极角的平行线必须去重(只保留最靠左、限制最强的一条),否则平行的两条直线在求交点时会除零(cross(u,v) == 0)或产生错误的边。
  3. 交可能为空(返回空 vector)、退化为点或线段(q.size() < 3),也可能无界;若题目保证有界或要求输出多边形,可先加入一个足够大的包围盒(±1e9 的四条边界线)再求交。
  4. lineIntersect 要求两直线不平行,调用时传入的是极角相邻(或首尾)的直线,去重后它们的极角必然不同,保证安全。
  5. 浮点误差可能把”空交”误判为”极窄的多边形”,判断空交时可再加一道面积阈值检查(如 polygonArea < eps 视为空,见 7.3)。

可拓展的地方

  1. 求多边形内核:把每条边取”指向多边形内部”的半平面求交,交非空则多边形存在内核(POJ 3335 / 1279,可继续求内核面积)。
  2. 二分答案 + 半平面交可解”在若干线性约束下某目标函数最大值”类问题(POJ 1755 Triathlon);二维线性规划是半平面交的直接应用。

典型例题类型

  1. 求多个凸多边形的交集并输出——洛谷 P4196 [CQOI2006]凸多边形(半平面交模板题,出自洛谷)。
  2. 判断多边形是否存在内核 / 求内核面积——POJ 3335 Rotating Scoreboard、POJ 1279 Art Gallery(出自 POJ)。
  3. 判断是否存在一点使所有选手的胜负约束同时成立——POJ 1755 Triathlon(出自 POJ)。

7.7 最近点对

一句话用途:在平面点集中找距离最近的两点,是经典分治算法;也可改造为求两类点之间的最近距离(如雷达站与士兵,POJ 3714 Raid)。

算法思想:分治:按 x 坐标排序后从中间切开,分别递归求两半内部的最近距离 d,再检查”中间带”——横坐标距分割线小于 sqrt(d) 的点,因为任何跨越分割线的更优点对必然落在这个窄带内。带内点按 y 排序后,只需检查 y 差小于 sqrt(d) 的紧邻点对(几何上每点最多与常数个候选比较),即可在 O(n) 内完成合并。本模板在递归过程中用归并把区间按 y 排好序,保证每一层合并 O(n),总体严格 O(n log n)。

复杂度:时间:O(n log n)(本模板为归并版);空间:O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8;
const double INF = 1e100;

struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
};

bool cmpX(const Point& a, const Point& b) { return a.x < b.x; }

double dist2(const Point& a, const Point& b) {
double dx = a.x - b.x, dy = a.y - b.y;
return dx * dx + dy * dy; // 全程用距离平方,避免开方误差与开销
}

/* 分治求最近点对:p 需已按 x 排序;递归会把 p[l..r) 重排成按 y 有序,
* 返回该区间内的最小距离平方。 */
double solve(vector<Point>& p, int l, int r) {
if (r - l <= 3) { // 点数很少,直接暴力
double d = INF;
for (int i = l; i < r; i++)
for (int j = i + 1; j < r; j++)
d = min(d, dist2(p[i], p[j]));
return d;
}
int mid = (l + r) >> 1;
double midx = p[mid].x; // 分割线 x 坐标(必须在递归前记录!)
double d = min(solve(p, l, mid), solve(p, mid, r));
/* 归并:两半此时各自按 y 有序,合并成整体按 y 有序并写回 p */
vector<Point> t;
int i = l, j = mid;
while (i < mid && j < r) {
if (p[i].y < p[j].y) t.push_back(p[i++]);
else t.push_back(p[j++]);
}
while (i < mid) t.push_back(p[i++]);
while (j < r) t.push_back(p[j++]);
for (int k = l; k < r; k++) p[k] = t[k - l];
/* 中间带:收集中横坐标距分割线 < sqrt(d) 的点 */
vector<Point> strip;
double sq = sqrt(d);
for (int k = l; k < r; k++)
if (fabs(p[k].x - midx) < sq) strip.push_back(p[k]);
/* 带内已按 y 有序:只需检查 y 差 < sqrt(d) 的紧邻点对(每点候选 ≤ 7 个) */
for (int a = 0; a < (int)strip.size(); a++)
for (int b = a + 1; b < (int)strip.size() &&
(strip[b].y - strip[a].y) * (strip[b].y - strip[a].y) < d; b++)
d = min(d, dist2(strip[a], strip[b]));
return d;
}

/* 最近点对入口:返回最近距离;n < 2 时返回 0(无点对,调用处自行处理) */
double closestPair(vector<Point> p) {
sort(p.begin(), p.end(), cmpX); // 先按 x 排序
if (p.size() < 2) return 0;
return sqrt(solve(p, 0, (int)p.size())); // 最后开方还原真实距离
}

注意点 / 坑点

  1. 全程用”距离平方”比较,只在最后开方,既省时间又避免开方误差;INF 取 1e100 防止溢出为无穷。
  2. midx 必须在递归调用前记录——递归会把 p 按 y 重排,之后再取 p[mid].x 就不是分割线坐标了。
  3. 递归结束后 p 的 x 有序性被破坏,调用方不要依赖原顺序;如需保留原始数组请先复制一份。
  4. 存在重复点(距离 0)时返回 0 是合法结果;n < 2 时无点对,注意调用处特判。
  5. 带内检查的内层循环上界是”y 差平方 < d”,一旦不满足立即 break;若写成固定窗口(如 i+7)要小心数组越界,且窗口大小与 eps 无关、与几何常数有关。

可拓展的地方

  1. 给点加集合标记可求”两类点之间的最近距离”(同集合的点对距离直接置 INF 跳过),对应 POJ 3714 Raid。
  2. 分治思想可推广到三维最近点对(中间层变成平面带)、求 k 近邻;动态加点/查询可用 KD-Tree 维护。

典型例题类型

  1. 求平面点集最近点对距离——洛谷 P1429 平面最近点对(加强版,出自洛谷)。
  2. 求两个点集之间的最近距离——POJ 3714 Raid(出自 POJ)。

7.8 圆

一句话用途:解决圆类基础问题:最小圆覆盖(用最小半径的圆包住所有点)、圆与直线的交点、两圆交点等;是覆盖类与几何判定类题目的核心工具。

算法思想:最小圆覆盖用随机增量法:把点随机打乱后依次加入,维护当前最小覆盖圆;若新点已在圆内则跳过,否则新圆必经过该点——先以它为圆心(半径 0),再检查第二个点(此时圆以两点为直径),再检查第三个点(此时三点定圆)。随机顺序使得”需要重建”的概率极低,期望 O(n)。三点定圆:圆心是两条中垂线的交点,用克莱姆法则直接解出。圆与直线交点:把直线的参数方程代入圆方程,得到一个关于参数 t 的一元二次方程,判别式决定交点个数。两圆交点公式简述:令 d 为圆心距,a = (r1² - r2² + d²)/(2d) 为交线到第一个圆心的距离,h = sqrt(r1² - a²) 为半弦长,连心线方向上取投影点再沿垂直方向偏移 ±h 即得两交点。

复杂度:时间:最小圆覆盖期望 O(n)、最坏 O(n³);圆与直线/两圆交点 O(1);空间:O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8;
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator + (const Point& p) const { return Point(x + p.x, y + p.y); }
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
Point operator * (double k) const { return Point(x * k, y * k); }
Point operator / (double k) const { return Point(x / k, y / k); }
};
double cross(const Point& a, const Point& b) { return a.x * b.y - a.y * b.x; }
double dot(const Point& a, const Point& b) { return a.x * b.x + a.y * b.y; }
double length(const Point& a) { return sqrt(dot(a, a)); }
double dist(const Point& a, const Point& b) { return length(a - b); }

struct Circle { Point c; double r; };

/* 三点定圆(外接圆):圆心为两条中垂线的交点,克莱姆法则直接解 */
Circle circleFrom3(const Point& a, const Point& b, const Point& c) {
double bx = b.x - a.x, by = b.y - a.y;
double cx = c.x - a.x, cy = c.y - a.y;
double d = 2 * (bx * cy - by * cx); // 叉积的两倍;为 0 说明三点共线
if (sgn(d) == 0) { // 共线退化:取三点中最远两点的直径圆
Circle best;
double bestR = -1;
for (auto pr : {make_pair(a, b), make_pair(b, c), make_pair(c, a)}) {
double dd = dist(pr.first, pr.second);
if (dd > bestR) {
bestR = dd;
best = Circle{(pr.first + pr.second) / 2, dd / 2};
}
}
return best;
}
double ux = (cy * (bx * bx + by * by) - by * (cx * cx + cy * cy)) / d;
double uy = (bx * (cx * cx + cy * cy) - cx * (bx * bx + by * by)) / d;
Point o = a + Point(ux, uy);
return Circle{o, dist(o, a)};
}

/* 最小圆覆盖:随机增量法,期望 O(n),最坏 O(n³)(实际几乎不会达到) */
Circle minEnclosingCircle(vector<Point> p) {
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
shuffle(p.begin(), p.end(), rng); // 必须随机化,否则构造数据退化为 O(n³)
int n = (int)p.size();
Circle c{Point(0, 0), 0};
for (int i = 0; i < n; i++) {
if (sgn(dist(p[i], c.c) - c.r) <= 0) continue; // p[i] 已在圆内(含边界),跳过
c = Circle{p[i], 0}; // 否则以 p[i] 为圆心、半径 0 重新开始
for (int j = 0; j < i; j++) {
if (sgn(dist(p[j], c.c) - c.r) <= 0) continue;
c = Circle{(p[i] + p[j]) / 2, dist(p[i], p[j]) / 2}; // 以 p[i]、p[j] 为直径的圆
for (int k = 0; k < j; k++) {
if (sgn(dist(p[k], c.c) - c.r) <= 0) continue;
c = circleFrom3(p[i], p[j], p[k]); // 三点定圆:此时圆必过这三个点
}
}
}
return c;
}

/* 两圆交点:返回 0/1/2(-1 表示两圆重合),交点写入 out */
int circleIntersect(const Circle& A, const Circle& B, vector<Point>& out) {
double d = dist(A.c, B.c);
if (sgn(d - (A.r + B.r)) > 0 || sgn(d - fabs(A.r - B.r)) < 0) return 0; // 相离或内含
if (sgn(d) == 0 && sgn(A.r - B.r) == 0) return -1; // 圆心重合且半径相等:无穷多交点
double a = (A.r * A.r - B.r * B.r + d * d) / (2 * d); // 交点连线到 A 圆心的距离
double h = sqrt(max(0.0, A.r * A.r - a * a)); // 半弦长(max 防浮点负值)
Point dir = (B.c - A.c) / d; // 连心线单位方向向量
Point base = A.c + dir * a; // 连心线上交点连线的垂足
Point n(-dir.y, dir.x); // 垂直方向的单位向量
out.clear();
out.push_back(base + n * h);
if (sgn(h) > 0) out.push_back(base - n * h); // 相切时只有一个交点
return (int)out.size();
}

注意点 / 坑点

  1. 随机增量法必须先随机打乱(shuffle),否则精心构造的数据会让每轮都重建,退化到 O(n³);随机种子用 chrono 或 random_device 均可。
  2. 三点定圆要求三点不共线:d = 2 × 叉积 为 0 时必现退化,必须特判(本模板回退为最远两点直径圆),否则除零或产生错误圆。
  3. 两圆交点要先判相离(d > r1+r2)、内含(d < |r1-r2|)、重合(d = 0 且 r1 = r2)三种退化情况,再求 a 与 h;h 开方前用 max(0.0, …) 防止浮点误差产生负值。
  4. 圆与直线交点中,直线退化(两点重合)或圆心到直线距离恰等于半径(相切,只有一个交点)都要单独处理。
  5. 判断点是否在圆内用 dist - r <= 0(经 sgn),不要混用平方比较与开方比较导致误差不一致。

可拓展的地方

  1. 随机增量法可推广到三维最小覆盖球;”单位圆最多覆盖多少点”可用枚举两点定圆 + O(n) 验证(POJ 1981 Circle and Points)。
  2. 可扩展圆的切线/公切线、圆与多边形相交面积(扇形 + 三角形分解)、圆与圆交点在高精度题中的整数化处理等进阶操作。

典型例题类型

  1. 求覆盖所有点的最小圆并输出圆心与半径——洛谷 P1742 最小圆覆盖(出自洛谷)、BZOJ 1337 最小圆覆盖(出自 BZOJ)。
  2. 求单位圆最多能覆盖多少点——POJ 1981 Circle and Points(枚举两点确定候选圆,出自 POJ)。

7.9 精度与常见陷阱

一句话用途:汇总计算几何的浮点精度规范与高频 WA 原因,提供 sgn、eps、常用比较函数与几何宏,作为全章代码的前置公共头;任何浮点几何题提交前都应先过一遍本节的检查清单。

算法思想:double 只有约 15~17 位有效十进制数字,几何运算(叉积、开方、除法)会不断放大相对误差,因此所有比较必须带容差 eps,通过 sgn(x) 把”接近 0”判为 0。核心原则:能不浮点就不浮点(整数坐标用 __int128),能不开方就不开方(用平方比较),除法前先判分母;eps 的选取要与坐标量级匹配,默认 1e-8。这些规则不改变算法复杂度,只决定正确性。

复杂度:时间:sgn 与各比较函数均为 O(1);空间:O(1)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const double eps = 1e-8; // 常用精度阈值(选取讨论见注意点 1)
const double PI = acos(-1.0);
const double INF = 1e100;

/* 符号函数:一切 double 比较的入口 */
int sgn(double x) {
if (fabs(x) < eps) return 0;
return x > 0 ? 1 : -1;
}

/* 常用几何宏:注意参数必须整体加括号,防止优先级错误 */
#define sqr(x) ((x) * (x)) // 平方,用来避免不必要的开方
#define dis(a, b) (sqrt(sqr((a).x - (b).x) + sqr((a).y - (b).y))) // 两点距离

struct Point {
double x, y;
Point(double _x = 0, double _y = 0) : x(_x), y(_y) {}
Point operator - (const Point& p) const { return Point(x - p.x, y - p.y); }
/* 排序必须用 sgn,否则 -0.0 与 0.0、近似相等的点顺序不确定 */
bool operator < (const Point& p) const {
if (sgn(x - p.x) != 0) return x < p.x;
return y < p.y;
}
bool operator == (const Point& p) const {
return sgn(x - p.x) == 0 && sgn(y - p.y) == 0;
}
};

/* 比较的正确姿势:一律经 sgn 包装 */
bool eq(double a, double b) { return sgn(a - b) == 0; } // a == b
bool ne(double a, double b) { return sgn(a - b) != 0; } // a != b
bool lt(double a, double b) { return sgn(a - b) < 0; } // a < b
bool gt(double a, double b) { return sgn(a - b) > 0; } // a > b
bool le(double a, double b) { return sgn(a - b) <= 0; } // a <= b
bool ge(double a, double b) { return sgn(a - b) >= 0; } // a >= b

注意点 / 坑点

  1. eps 的选取:坐标在 1e41e9 时取 1e-81e-9;若只比较 O(1) 量级的量可放宽到 1e-6~1e-8;不要取 1e-15 以下(接近机器精度 2.2e-16,失去容差意义),也不要取 1e-4 以上(会把真实不同的量判成相等)。
  2. 常见 WA 原因清单:直接用 ==/< 比较 double;叉积符号写反(左右转判断颠倒);求面积忘记取绝对值;线段相交漏掉共线重叠与端点相接;极角排序用 atan2 后首尾相接处(-π/π)出错;sort 与 unique 的比较基准不一致;-0.00.0 导致排序/哈希不稳定。
  3. 避免大数开方:比较距离用平方;叉积结果可达 1e18,double 能表示但精度下降,整数坐标优先用 long long + __int128 存叉积。
  4. 避免除零:任何除法(参数 t、斜率、中垂线交点、lineIntersect)之前先 sgn 判断分母,退化输入(重合点、平行线、共线三点)一律特判。
  5. 输出与对拍:允许误差的题 printf("%.10f") 起步;对拍时用随机小数据 + 暴力解互验,是排查浮点题最有效的手段。
  6. 极角排序的坑:atan2 的 -π/π 分界、同角共线点的次级排序关键字、原点作为基准点时的平移处理,详见 7.1。

可拓展的地方

  1. 坐标极大或精度不够时换 long double(80 位,约 18~19 位有效数字);整数几何(__int128)可完全规避浮点误差。
  2. 把 sgn/Point/比较宏整理成公共头文件 geometry.h 供全章复用,并配合随机数据对拍脚本自动验证每个模板的正确性。

典型例题类型

  1. 任何浮点几何题都适用本节的 sgn/eps 规范;典型易错题:面积大数题(POJ 1654)、直线相交判定(POJ 1269)、圆覆盖题(POJ 1981,均出自 POJ)。
  2. 自查方向:结果与标准答案相差 1e-6 以内却 WA——检查输出精度与 eps 大小;TLE——检查是否在循环内重复开方、排序或用了 O(n²) 的浮点比较。

八、数学与多项式

本部分速查表

算法 复杂度 适用场景
组合数学(容斥、卡特兰、斯特林数) 预处理 O(N),单次查询 O(1);容斥枚举 O(2^t);斯特林 DP O(nk) 排列组合计数、互斥/不交条件计数、括号序列与栈序列、集合划分
生成函数 构造 O(1),朴素展开 O(n²),卷积加速 O(n log n) 拆分数、多重集排列、背包类计数、递推数列封闭形式
FFT O(n log n) 多项式乘法、大数乘法、卷积型计数(允许浮点误差)
NTT O(n log n),n ≤ 2^23(998244353) 需要精确取模的卷积、多项式运算的基础工具
多项式运算(求逆/ln/exp/开方/幂) 单次运算 O(n log n) 生成函数计数、递推加速、多项式代数运算
拉格朗日插值 朴素 O(n²);重心单点查询 O(n);连续点 O(n) 已知点求多项式任意点值、自然数幂和
矩阵快速幂与常系数线性递推 矩阵乘 O(k³ log n);递推第 n 项 O(k² log n) 线性递推第 n 项、图上走 k 步计数、DP 转移加速
Pólya 计数 直接枚举 O(n log MOD);欧拉函数分组 O(√n log MOD) 项链/手镯着色、本质不同构型计数

8.1 组合数学

一句话用途:组合数学是计数类题目的基础工具:求排列数、组合数,用容斥原理求「与某集合互斥」的条件计数,以及卡特兰数、斯特林数、贝尔数等经典计数序列。适用于排列组合计数、互斥条件计数、括号/出栈序列、集合划分等问题。

算法思想:组合数 C(n,k) 用「阶乘 + 阶乘逆元」预处理,单次 O(1) 查询;容斥原理把「并集大小」转化为对交集子集的交替加减,求「与 m 互质的个数」等价于对 m 的质因子集合做容斥;卡特兰数满足递推 h[n]=Σh[i]h[n-1-i] 与通项 C(2n,n)/(n+1);第一/二类斯特林数与贝尔数都有清晰的双变量/单变量递推,用动态规划填表即可。

复杂度:时间:组合数预处理 O(N)、查询 O(1);容斥枚举 O(2^t)(t 为不同质因子个数);斯特林/贝尔 DP O(nk)。空间:O(N) 或 O(nk)。

模板代码:第一部分是组合数、容斥与卡特兰数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll MOD = 998244353; // 需要是质数,且 N < MOD 才可用阶乘逆元
const int MAXN = 200000 + 5;
ll fac[MAXN], ifac[MAXN];

ll qpow(ll a, ll b) { // 快速幂(费马小定理求逆元用)
ll r = 1;
for (; b; b >>= 1, a = a * a % MOD)
if (b & 1) r = r * a % MOD;
return r;
}

void initComb(int n = MAXN - 1) { // 预处理阶乘与阶乘逆元
fac[0] = 1;
for (int i = 1; i <= n; i++) fac[i] = fac[i - 1] * i % MOD;
ifac[n] = qpow(fac[n], MOD - 2); // 先求最大的逆元,再倒推
for (int i = n; i >= 1; i--) ifac[i - 1] = ifac[i] * i % MOD;
}

ll C(int n, int k) { // 组合数 C(n,k),越界返回 0
if (k < 0 || k > n) return 0;
return fac[n] * ifac[k] % MOD * ifac[n - k] % MOD;
}

ll P(int n, int k) { // 排列数 P(n,k) = n!/(n-k)!
if (k < 0 || k > n) return 0;
return fac[n] * ifac[n - k] % MOD;
}

// 容斥原理模板:求 [1, n] 中与 m 互质的数的个数
// 思路:与 m 互质 = 不含有 m 的任何质因子,用容斥对质因子集合取并的补
ll coprimeCount(ll n, ll m) {
vector<ll> p;
for (ll x = 2; x * x <= m; x++) // 分解 m 的不同质因子
if (m % x == 0) {
p.push_back(x);
while (m % x == 0) m /= x;
}
if (m > 1) p.push_back(m); // 剩下一个大于 sqrt 的质因子
int t = p.size();
ll ans = 0;
for (int mask = 0; mask < (1 << t); mask++) { // 枚举质因子子集
ll prod = 1; int bits = 0;
for (int i = 0; i < t; i++)
if (mask >> i & 1) { prod *= p[i]; bits++; }
// mask=0 时加 n(全集);奇数个因子减,偶数个因子加
if (bits & 1) ans -= n / prod;
else ans += n / prod;
}
return ans;
}

// 卡特兰数:h[0]=1,h[n] = C(2n,n) - C(2n,n+1) = C(2n,n)/(n+1)
// 递推式:h[n] = Σ_{i=0}^{n-1} h[i]*h[n-1-i];也满足 h[n] = h[n-1]*(4n-2)/(n+1)
ll catalan(int n) {
return C(2 * n, n) * qpow(n + 1, MOD - 2) % MOD;
}

第二部分是第一/二类斯特林数与贝尔数(DP 填表):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 第二类斯特林数 S[n][k]:把 n 个不同球放入 k 个相同的非空盒子
// 递推:S[n][k] = S[n-1][k-1] + k * S[n-1][k],边界 S[0][0]=1
// 第一类斯特林数 s[n][k]:n 个不同元素组成 k 个圆排列(环)的方案数
// 递推:s[n][k] = s[n-1][k-1] + (n-1) * s[n-1][k],边界 s[0][0]=1
// 贝尔数 Bell[n]:把 n 个不同元素划分成任意个非空集合的方案数,Bell[n] = Σ_k S[n][k]
const int SMN = 1000; // 需要更大的 n 时调整并注意内存
ll S2[SMN][SMN], S1[SMN][SMN], Bell[SMN];

void initStirling() {
S1[0][0] = S2[0][0] = Bell[0] = 1;
for (int n = 1; n < SMN; n++) {
for (int k = 1; k <= n; k++) {
S2[n][k] = (S2[n - 1][k - 1] + k * S2[n - 1][k]) % MOD;
S1[n][k] = (S1[n - 1][k - 1] + (n - 1) * S1[n - 1][k]) % MOD;
}
for (int k = 0; k <= n; k++) // 贝尔数 = 第二类斯特林数按 k 求和
Bell[n] = (Bell[n] + S2[n][k]) % MOD;
}
}

注意点 / 坑点

  1. 阶乘逆元法要求模数为质数且 n < MOD;当 n ≥ MOD 时必须改用 Lucas 定理(见可拓展),否则逆元不存在导致结果全错。
  2. 容斥求互质个数时符号规则是「全集加、奇减偶加」,mask=0 对应的全集必须计入;m=1(无质因子)时直接返回 n。
  3. 容斥中 prod 是若干质因子的乘积,若 m 极大且质因子很多,注意用 ll 并保证 prod 不溢出(子集乘积不超过 m)。
  4. 卡特兰数分母 (n+1) 要用快速幂求逆元;n 大时建议用递推 h[n]=h[n-1]*(4n-2)/(n+1) 以 O(1) 递推并省去 2n 的阶乘预处理。
  5. 斯特林数 DP 中 S[n][0]=s[n][0]=0(n>0)由数组初始值自动满足,但 S[0][0]=s[0][0]=1 必须单独初始化。

可拓展的地方

  1. 模数非质数或 n 很大时,组合数用杨辉三角 O(n²) 递推,或用 Lucas 定理 / 扩展 Lucas 处理大 n 与合数模数。
  2. 第二类斯特林数有通项 S(n,k) = 1/k! · Σ_{i=0}^{k} (-1)^i C(k,i) (k-i)^n,可用 NTT(8.4)把求整行优化到 O(n log n)。
  3. 卡特兰数与二叉树形态、凸多边形三角剖分、Dyck 路径、括号匹配等大量组合对象一一对应,可互相转化建模。
  4. 贝尔数还可由 Bell[n+1] = Σ_{k=0}^{n} C(n,k)·Bell[k] 递推,两种递推可按需选用。

典型例题类型

  1. 组合数预处理 + 前缀和:洛谷 P2822 组合数问题。
  2. 容斥计数:HDU 1695 GCD、「求 [1,n] 中与 m 互质的个数」类题目。
  3. 卡特兰数:洛谷 P1044 栈(出栈序列数)、合法括号序列计数。

8.2 生成函数

一句话用途:生成函数把数列映射为形式幂级数,用代数运算(尤其乘法对应卷积)统一解决计数问题:拆分数、多重集排列、背包类计数、递推数列的封闭形式等,是「系数即答案」型计数题的核心建模工具。

算法思想:普通型生成函数 G(x)=Σ aₙxⁿ 中,两个数列卷积后系数恰为两个 OGF 的乘积系数,适合「无标号」组合(选或不选、拆分数);指数型生成函数 E(x)=Σ aₙxⁿ/n! 的乘积对应「有标号」结构的有序拼接(排列、集合划分)。拆分数 p(n) 是 Π_{k≥1}(1-x^k)⁻¹ 中 xⁿ 的系数;多重集排列数 n!/Π cᵢ! 来自对应 EGF 的系数乘 n!。生成函数乘法展开本质上就是多项式卷积,可用 8.3 FFT / 8.4 NTT 加速。

复杂度:构造 O(1);朴素展开 O(n²)(每种物品/每个因子一层循环);用 FFT/NTT 做卷积可优化到 O(n log n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353;

// ---------- 普通型生成函数(OGF)的应用:拆分数 ----------
// 拆分数 p(n):把 n 拆成若干正整数之和(顺序无关)的方案数
// OGF:Π_{k>=1} 1/(1-x^k) = Σ p(n)*x^n,取 x^n 的系数即答案
vector<ll> partition(int n) {
vector<ll> f(n + 1, 0);
f[0] = 1; // p(0) = 1(空拆分)
for (int k = 1; k <= n; k++) // 外层枚举拆出的部分大小 k(“物品”)
for (int s = k; s <= n; s++) // 内层是容量,完全背包顺序(可重复取)
f[s] = (f[s] + f[s - k]) % MOD;// 等价于把 f 乘上 1/(1-x^k)
return f; // f[i] = p(i)
}

// ---------- 指数型生成函数(EGF)的应用:多重集排列 ----------
// 多重集:共 n 个元素,第 i 种有 cnt[i] 个(同种不可区分)
// 本质不同排列数 = n! / Π cnt[i]!,即 EGF Π x^{cnt[i]}/cnt[i]! 的 n 次系数乘 n!
// 需要先按 8.1 的 initComb 预处理 fac / ifac
ll multisetPerm(const vector<ll>& cnt, const vector<ll>& fac, const vector<ll>& ifac) {
ll n = 0, res = 1;
for (ll c : cnt) {
n += c;
res = res * ifac[c] % MOD; // 先乘 Π 1/cnt[i]!
}
return res * fac[n] % MOD; // 再乘 n!
}

注意点 / 坑点

  1. 普通型与指数型不要混用:无标号结构(拆分数、组合、背包)用 OGF,有标号结构(排列、集合划分、图计数)用 EGF,用错会差阶乘因子。
  2. 拆分数 DP 中「物品在外、容量在内」是关键,改成容量在外会退化成有序拆分(把不同顺序也算进去)。
  3. 生成函数相乘只需保留前 n 项,展开后务必截断,否则多余项会污染后续计算并拖慢常数。
  4. 指数型生成函数里 1/k! 这类分母要预处理逆元,避免重复求逆或除法取模出错。
  5. 多重集排列中元素总数 n 很大时,阶乘表预处理上限必须覆盖 n,否则 fac[n] 越界;注意 0!=1、空多重集的排列数为 1 的边界。

可拓展的地方

  1. 与 8.3 FFT / 8.4 NTT 结合:生成函数相乘即多项式卷积,可把 O(n²) 的拆分数/背包计数优化到 O(n log n)。
  2. 用生成函数求递推数列封闭形式:如斐波那契 G(x)=x/(1-x-x²),系数可直接展开得到通项。
  3. 概率生成函数(PGF):对离散分布求期望 E[X]=G’(1)、方差等,可解决随机过程类计数。
  4. 多元生成函数处理多维约束(如限定每种颜色的个数),展开为多维卷积。

典型例题类型

  1. 整数拆分计数:洛谷 P1025 数的划分(把 n 拆成 k 个正整数之和)。
  2. 多重集本质不同排列计数(字符串重排、字母频次统计类题目)。
  3. 生成函数 + 卷积加速的背包计数(结合 8.3/8.4,如「若干种面值的硬币凑出总额的方案数」)。

8.3 FFT

一句话用途:快速傅里叶变换在 O(n log n) 时间内计算两个多项式的卷积,用于多项式乘法、大数乘法、卷积型计数(如两个序列的所有「和/积」配对统计)。代价是浮点运算,适合结果精度要求不极端的题目。

算法思想:卷积 (a*b)[k]=Σa[i]b[k-i] 直接算是 O(n²),FFT 通过「点值表示」加速:先把两个多项式在单位根处求值(蝴蝶变换 + 分治迭代),点值逐点相乘后再用逆变换回到系数表示,最后除以 n 并四舍五入。蝶形运算即把 DFT 按奇偶下标分治,位逆序重排保证迭代合并时数据就位。

复杂度:时间 O(n log n);空间 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
using cd = complex<double>;
const double PI = acos(-1.0);

// 迭代版 FFT:invert=false 正变换,invert=true 逆变换
void fft(vector<cd>& a, bool invert) {
int n = (int)a.size();
// 蝴蝶变换:把下标按二进制位逆序重排,使迭代合并时数据成对就位
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit; // 模拟二进制加一求逆序
j ^= bit;
if (i < j) swap(a[i], a[j]); // 只交换一次,避免重复
}
for (int len = 2; len <= n; len <<= 1) { // len:当前合并的区间长度
double ang = 2 * PI / len * (invert ? -1 : 1); // 逆变换用负角度
cd wlen(cos(ang), sin(ang)); // len 次单位根
for (int i = 0; i < n; i += len) {
cd w(1);
for (int j = 0; j < len / 2; j++) {
cd u = a[i + j]; // 上半部分
cd v = a[i + j + len / 2] * w; // 下半部分乘旋转因子
a[i + j] = u + v;
a[i + j + len / 2] = u - v;
w *= wlen;
}
}
}
if (invert) // 逆变换最后除以 n 完成缩放
for (cd& x : a) x /= n;
}

// 多项式乘法:返回 a*b 的系数(长度 a.size()+b.size()-1)
vector<ll> multiply(const vector<ll>& a, const vector<ll>& b) {
vector<cd> fa(a.begin(), a.end()), fb(b.begin(), b.end());
int n = 1;
while (n < (int)(a.size() + b.size() - 1)) n <<= 1; // 补成 2 的幂
fa.resize(n); fb.resize(n);
fft(fa, false); fft(fb, false); // 求值:两个点值序列
for (int i = 0; i < n; i++) fa[i] *= fb[i];// 点值逐点相乘
fft(fa, true); // 插值:逆变换回系数
vector<ll> res(a.size() + b.size() - 1);
for (int i = 0; i < (int)res.size(); i++)
res[i] = (ll)round(fa[i].real()); // 四舍五入消除浮点误差
return res;
}

// 大数乘法:两个十进制字符串相乘(先翻转成低位在前,卷积后统一进位)
string bigMul(string s1, string s2) {
if (s1 == "0" || s2 == "0") return "0";
vector<ll> a, b;
for (int i = (int)s1.size() - 1; i >= 0; i--) a.push_back(s1[i] - '0');
for (int i = (int)s2.size() - 1; i >= 0; i--) b.push_back(s2[i] - '0');
vector<ll> c = multiply(a, b);
for (int i = 0; i + 1 < (int)c.size(); i++) { // 从低位向高位进位
c[i + 1] += c[i] / 10;
c[i] %= 10;
}
while (c.size() > 1 && c.back() == 0) c.pop_back(); // 去掉前导 0
string res;
for (int i = (int)c.size() - 1; i >= 0; i--) res += char('0' + c[i]);
return res;
}

注意点 / 坑点

  1. 长度必须补到不小于 a.size()+b.size()-1 的 2 的幂,否则循环错位、结果错误;两个数组都要 resize 到同一长度。
  2. double 精度有限:系数值很大(约 1e14 以上)时误差累积,round() 可能取错,需要拆系数 FFT 或改用 NTT(8.4)。
  3. 逆变换后每个系数要除以 n 并用 round 四舍五入,直接取整或 (int) 强转可能因 -1e-9 之类误差差 1。
  4. 大数乘法必须先反转成低位在前,卷积完再统一进位、去前导 0;注意 s1 或 s2 为 “0” 的特判。
  5. complex 的乘除较慢,卡常时可手写复数结构体或减少中间拷贝。

可拓展的地方

  1. 拆系数 FFT:把系数拆成 a0+a1·B(B≈√值域)做 4 次(或 7 次)卷积组合,可处理大系数与任意模数。
  2. 用 NTT(8.4)替代 FFT,彻底消除浮点误差,且天然支持取模计数。
  3. 卷积型应用:字符串模糊匹配(通配符问题转卷积)、生成函数计数(8.2)、所有数对 (a[i],b[j]) 的和的分布统计等。

典型例题类型

  1. 多项式乘法模板:洛谷 P3803。
  2. 大数乘法:洛谷 P1919 A×B Problem。
  3. 卷积型计数 / 匹配问题:如「统计所有数对和等于 k 的个数」「带通配符的字符串匹配」。

8.4 NTT

一句话用途:数论变换是 FFT 的模意义版本,在质数模数下做精确(无浮点误差)的卷积,天然支持取模,是模意义下多项式乘法与 8.5 多项式运算的基础。常用模数 998244353 = 119·2²³+1,原根 g=3。

算法思想:与 FFT 完全同构,只是把复数单位根换成模意义下的「原根」:长度为 len 的变换用 w = g^((MOD-1)/len)。正变换用原根 g,逆变换用 g 的逆元,最后乘以 n 的逆元。蝶形(位逆序重排 + 分治合并)与 FFT 相同。当模数不是 NTT 友好模数时,用三个友好模数分别做 NTT 再 CRT 合并(三模数),或把系数拆成高低位组合(拆系数)。

复杂度:时间 O(n log n),n ≤ 2²³(对 998244353);空间 O(n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll MOD = 998244353; // = 119 * 2^23 + 1,支持长度不超过 2^23 的变换
const ll G = 3; // 998244353 的原根

ll qpow(ll a, ll b) {
ll r = 1;
for (; b; b >>= 1, a = a * a % MOD)
if (b & 1) r = r * a % MOD;
return r;
}

void ntt(vector<ll>& a, bool invert) {
int n = (int)a.size();
// 蝴蝶变换:位逆序重排(与 FFT 相同)
for (int i = 1, j = 0; i < n; i++) {
int bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) swap(a[i], a[j]);
}
for (int len = 2; len <= n; len <<= 1) {
ll wlen = qpow(G, (MOD - 1) / len); // len 次单位原根
if (invert) wlen = qpow(wlen, MOD - 2); // 逆变换用原根的逆
for (int i = 0; i < n; i += len) {
ll w = 1;
for (int j = 0; j < len / 2; j++) {
ll u = a[i + j];
ll v = a[i + j + len / 2] * w % MOD;
a[i + j] = (u + v) % MOD;
a[i + j + len / 2] = (u - v + MOD) % MOD; // 加 MOD 保证非负
w = w * wlen % MOD;
}
}
}
if (invert) {
ll invN = qpow(n, MOD - 2);
for (ll& x : a) x = x * invN % MOD; // 除以 n 的逆元
}
}

// 模意义下的多项式乘法
vector<ll> multiply(vector<ll> a, vector<ll> b) {
int need = (int)a.size() + (int)b.size() - 1;
int n = 1;
while (n < need) n <<= 1;
a.resize(n); b.resize(n);
ntt(a, false); ntt(b, false);
for (int i = 0; i < n; i++) a[i] = a[i] * b[i] % MOD;
ntt(a, true);
a.resize(need);
return a;
}

注意点 / 坑点

  1. 模数必须满足 MOD = c·2^k + 1 且 2^k ≥ 变换长度 n,否则 (MOD-1)/len 不是整数、原根不存在;998244353 只支持 n ≤ 2²³。
  2. 逆变换结束必须乘 n 的模逆元,漏掉会使结果整体放大 n 倍。
  3. (u - v + MOD) % MOD 保证非负;乘法中间量 (a[i+j+len/2] * w) 要用 long long 存,否则 int 溢出。
  4. 三模数合并时三个模数的乘积(约 4.7e26)远超 long long,CRT 合并需用 __int128 或「先合并两个、再与第三个合并」的写法。
  5. 若多项式长度超过 2²³,换更大的 NTT 模数(如 1004535809 = 479·2²¹+1,原根 3)或改用拆系数。

可拓展的地方

  1. 任意模数 NTT:三模数(998244353、1004535809、469762049)+ CRT 合并;或拆系数:a=a0+a1·B(B≈√MOD),展开成 4 次 NTT 组合后在目标模数下重组。
  2. 作为 8.5 多项式求逆/ln/exp、分治 FFT(洛谷 P4721)等所有多项式算法的底层卷积。
  3. 与矩阵快速幂、生成函数结合做带取模的卷积计数。

典型例题类型

  1. 多项式乘法(取模版):洛谷 P3803。
  2. 分治 FFT:洛谷 P4721(递推式含卷积,需要 NTT 加速)。
  3. 任意模数卷积、需要精确取模结果的计数题(如 Codeforces 上的模意义卷积模板题)。

8.5 多项式运算

一句话用途:多项式求逆、ln、exp、开方、幂次等运算是生成函数计数(8.2)、常系数线性递推高阶加速(8.7)的底层工具,凡是「卷积 + 倍增」能解决的问题都可以用这一套板子。适用题目包括:多项式求逆模板、多项式 ln/exp/开方/幂、用生成函数做组合计数。

算法思想:核心是牛顿迭代倍增:已知 B 是 A 的逆模 x^m,则 B’ = B·(2 − A·B) 模 x^{2m} 精度翻倍,每次迭代做常数次 NTT,总复杂度 O(n log n)。其余运算都归约到求逆:ln A = ∫ A’/A;exp A 用 B ← B·(1 − ln B + A) 迭代;开方 B ← (A + B²)/(2B);幂 A^k = exp(k·ln A)。

复杂度:求逆/ln/exp/开方/幂均为 O(n log n)(朴素 exp 每轮重算 ln 是 O(n log² n));空间 O(n)。

模板代码:以下 NTT 依赖 8.4 的 ntt 与 multiply。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353; // 使用 8.4 的 ntt / multiply / qpow

// ---------- 多项式求逆(倍增 + NTT) ----------
// 已知 A(A[0] != 0),求 B 使 A*B ≡ 1 (mod x^n)
vector<ll> polyInv(const vector<ll>& A, int n) {
vector<ll> B(1, qpow(A[0], MOD - 2)); // 常数项的逆,作为起点
for (int m = 1; m < n; m <<= 1) { // 当前精度 m,翻倍到 2m
int len = m << 1;
vector<ll> f(len, 0); // f = A 截断到 len 项
int take = min((int)A.size(), len);
copy(A.begin(), A.begin() + take, f.begin());
vector<ll> g(B.begin(), B.end()); // g = 当前的 B,补 0 到 len
g.resize(len, 0);
ntt(f, false); ntt(g, false);
for (int i = 0; i < len; i++)
// 牛顿迭代:B <- B*(2 - A*B),在频域点乘
g[i] = g[i] * ((2 - f[i] * g[i] % MOD + MOD) % MOD) % MOD;
ntt(g, true);
B.assign(g.begin(), g.begin() + len); // 取回前 len 个系数
}
B.resize(n);
return B;
}

// 多项式求导与积分(积分依赖预处理的逆元数组 inv[1..n])
vector<ll> polyDeriv(const vector<ll>& A) { // a_i -> (i+1)*a_{i+1}
int n = (int)A.size();
vector<ll> r(max(0, n - 1));
for (int i = 1; i < n; i++) r[i - 1] = A[i] * i % MOD;
return r;
}
vector<ll> polyInteg(const vector<ll>& A, const vector<ll>& inv) {
int n = (int)A.size();
vector<ll> r(n + 1);
for (int i = 0; i < n; i++) r[i + 1] = A[i] * inv[i + 1] % MOD;
return r;
}

// ---------- 多项式 ln ---------- 需 A[0] == 1
// ln A = ∫ (A'/A) dx:先求导、求逆,再卷积,最后积分(结果严格截断到 n 项)
vector<ll> polyLn(const vector<ll>& A, int n, const vector<ll>& inv) {
vector<ll> t = multiply(polyDeriv(A), polyInv(A, n));
t.resize(n);
vector<ll> r = polyInteg(t, inv);
r.resize(n); // ln A 的常数项为 0,去掉多余的第 n+1 项
return r;
}

// ---------- 多项式 exp(简要实现)---------- 需 A[0] == 0
// 牛顿迭代:B <- B*(1 - ln B + A) 模 x^{2m};朴素版每轮重算 ln
vector<ll> polyExp(const vector<ll>& A, int n, const vector<ll>& inv) {
vector<ll> B(1, 1); // exp(0) = 1
for (int m = 1; m < n; m <<= 1) {
int len = m << 1;
vector<ll> f = A; f.resize(len); // 截断到 len
vector<ll> lnB = polyLn(B, len, inv);
for (int i = 0; i < len; i++)
f[i] = (f[i] - lnB[i] + MOD) % MOD; // f = A - ln B
f[0] = (f[0] + 1) % MOD; // f = 1 - ln B + A
B = multiply(B, f); // B <- B * f
B.resize(len);
}
B.resize(n);
return B;
}

注意点 / 坑点

  1. 各运算对常数项有硬性要求:求逆需 A[0]≠0;ln 需 A[0]=1;exp 需 A[0]=0(否则先把 A[0] 提出来,exp(A[0]) 单独乘);开方需 A[0]=1 或为二次剩余。
  2. 倍增中每次都要把数组 resize 到当前长度 len 再 NTT,截断不到位或补 0 不全会导致精度错误、结果越界。
  3. 求逆迭代里 f、g 的长度必须都是 len(2 的幂),且 copy 时不能超过 A 的实际大小。
  4. 常数优化:减少 NTT 次数(合并乘法)、预处理逆元与单位根、中间量多用 int 减少取模次数;朴素 exp 每轮重算 ln 是 O(n log² n),大 n 卡常时需用 O(n log n) 的完整实现。
  5. 积分依赖逆元数组 inv[1..n],务必按需预处理,不能在函数里反复求逆。

可拓展的地方

  1. 多项式开方 B ← (A + B²)/(2B) 牛顿迭代,或经 exp 实现 A^(1/2);幂次 A^k = exp(k·ln A)。
  2. 多项式多点求值、快速插值、复合、复合逆等更高级运算。
  3. 结合生成函数(8.2)做组合计数,结合 8.7 用特征多项式把常系数线性递推做到 O(k log k log n)。

典型例题类型

  1. 多项式求逆模板:洛谷 P4238。
  2. 多项式 ln:洛谷 P4725;多项式 exp:洛谷 P4726;多项式开方:洛谷 P5205。
  3. 用多项式做生成函数计数(拆分数、有标号结构计数类题目)。

8.6 拉格朗日插值

一句话用途:已知 n+1 个点 (xᵢ, yᵢ) 确定唯一的 n 次多项式 f(x),拉格朗日插值可以在任意点 k 求 f(k),且不依赖系数。典型应用:自然数幂和(答案是关于 n 的低次多项式)、隐藏系数只给点值的题。

算法思想:公式 f(x) = Σ yᵢ·Π_{j≠i}(x−xⱼ)/(xᵢ−xⱼ):对每个 i 构造一个在 xᵢ 处取 1、其余已知点取 0 的基多项式,线性组合出 f。朴素写法每次查询 O(n²);把与 i 无关的公共项提出来即得重心形式,预处理分母后单点查询 O(n);若 xᵢ = i 连续,分母 Π_{j≠i}(i−j) = i!·(−1)^{n−i}·(n−i)!,可用前缀/后缀乘积做到 O(n) 查询。

复杂度:朴素插值 O(n²);重心插值预处理 O(n²)(或 O(n) 递推更新),单点查询 O(n);连续点写法 O(n) 预处理 + O(n) 查询。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353;

ll qpow(ll a, ll b) {
ll r = 1;
for (; b; b >>= 1, a = a * a % MOD)
if (b & 1) r = r * a % MOD;
return r;
}

// ---------- 朴素拉格朗日插值:O(n^2) 求 f(k) ----------
ll lagrange(const vector<ll>& x, const vector<ll>& y, ll k) {
int n = (int)x.size();
ll ans = 0;
for (int i = 0; i < n; i++) {
ll num = 1, den = 1;
for (int j = 0; j < n; j++) {
if (i == j) continue;
num = num * ((k - x[j] + MOD) % MOD) % MOD; // Π (k - x_j)
den = den * ((x[i] - x[j] + MOD) % MOD) % MOD;// Π (x_i - x_j)
}
ans = (ans + y[i] * num % MOD * qpow(den, MOD - 2)) % MOD;
}
return ans;
}

// ---------- 重心插值:预处理 O(n^2),单点查询 O(n) ----------
vector<ll> gx, gy, gw;
void baryInit(const vector<ll>& x, const vector<ll>& y) {
int n = (int)x.size();
gx = x; gy = y;
gw.assign(n, 1);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (i != j)
gw[i] = gw[i] * ((x[i] - x[j] + MOD) % MOD) % MOD;
for (int i = 0; i < n; i++)
gw[i] = gy[i] * qpow(gw[i], MOD - 2) % MOD; // w_i = y_i / Π(x_i-x_j)
}
ll baryQuery(ll k) {
int n = (int)gx.size();
for (int i = 0; i < n; i++)
if (k == gx[i]) return gy[i]; // 恰为已知点,直接返回(避免除 0)
ll prod = 1;
for (int i = 0; i < n; i++)
prod = prod * ((k - gx[i] + MOD) % MOD) % MOD; // Π(k - x_i)
ll ans = 0;
for (int i = 0; i < n; i++)
ans = (ans + gw[i] * qpow((k - gx[i] + MOD) % MOD, MOD - 2)) % MOD;
return ans * prod % MOD; // f(k) = prod * Σ w_i/(k-x_i)
}

// ---------- x_i = i(i = 0..n)连续点的 O(n) 插值 ----------
// 分母 Π_{j!=i}(i-j) = i! * (n-i)! * (-1)^{n-i};分子用前缀后缀乘积
// 需要先按 8.1 的 initComb 预处理 fac / ifac
ll lagrangeConsecutive(const vector<ll>& y, ll k, const vector<ll>& fac, const vector<ll>& ifac) {
int n = (int)y.size() - 1;
if (k <= n) return y[(int)k]; // k 在已知点范围内直接返回
vector<ll> pre(n + 2, 1), suf(n + 2, 1);
for (int i = 0; i <= n; i++) pre[i + 1] = pre[i] * ((k - i) % MOD + MOD) % MOD;
for (int i = n; i >= 0; i--) suf[i] = suf[i + 1] * ((k - i) % MOD + MOD) % MOD;
ll ans = 0;
for (int i = 0; i <= n; i++) {
ll num = pre[i] * suf[i + 1] % MOD; // Π_{j!=i}(k - j)
ll den = fac[i] * fac[n - i] % MOD; // i! * (n-i)!
if ((n - i) & 1) den = (MOD - den) % MOD; // 符号 (-1)^{n-i}
ans = (ans + y[i] * num % MOD * qpow(den, MOD - 2)) % MOD;
}
return ans;
}

注意点 / 坑点

  1. 查询点 k 恰好等于某个已知 xᵢ 时必须直接返回 yᵢ,否则分母出现 0(重心形式尤其容易踩)。
  2. 所有减法都要先加 MOD 再取模,避免负数;k 可能远大于 n,用 long long 存 k。
  3. 自然数幂和 Sₖ(n)=1^k+…+n^k 是 n 的 k+1 次多项式,必须取 k+2 个点再插值,点数不够结果错误。
  4. 连续点写法中符号 (-1)^{n−i} 与 i!、(n−i)! 的对应关系容易写反,建议用小样例(如 y=[0,1,4],f(2)=4? 实际 f(x)=x²,f(3)=9)自测。
  5. 朴素版每次查询 O(n²) 且要反复求逆元,多组查询务必改用重心形式。

可拓展的地方

  1. 重心插值支持动态加点:新加一个点只需把已有 wᵢ 全部乘 (xᵢ−xₙₑw) 的逆元再更新,O(n) 完成。
  2. 与多项式多点求值/快速插值结合,或与伯努利数闭式 Sₖ(n) = 1/(k+1)·Σ C(k+1,j)·Bⱼ·n^{k+1−j} 互相验证。
  3. 插值 + 前缀和:对答案是关于 n 的多项式的题(如「第 n 个前缀和的幂和」)先算少量点再插值。

典型例题类型

  1. 拉格朗日插值模板:洛谷 P4781。
  2. 自然数幂和:Codeforces 622F The Sum of the k-th Powers(先 O(k log k) 求出前 k+2 个点的值再插值)。
  3. 只给出多项式的若干个点值、要求另一个点值的题目(常见于交互题与生成函数题)。

8.7 矩阵快速幂与常系数线性递推

一句话用途:把「线性递推 / 线性变换迭代 n 次」写成矩阵形式,用快速幂把 O(n) 的逐项递推加速到 O(k³ log n)。适用于斐波那契类递推求第 n 项、图上恰好走 k 步的方案数、DP 转移矩阵加速、常系数线性递推求第 n 项等。

算法思想:若递推能写成向量转移 v_{t+1} = M·v_t,则 v_n = Mⁿ·v₀,矩阵快速幂二分求 Mⁿ。斐波那契取 v_t=[F_{t+1}, F_t],M=[[1,1],[1,0]]。常系数线性递推 f_n = Σ cᵢ·f_{n−i} 可以做得更快:把 xⁿ 对特征多项式 P(x)=x^k−Σcᵢx^{k−i} 取模降次(多项式乘法 + 快速幂,O(k² log n),配合 NTT 到 O(k log k log n));如果递推式未知,可用 Berlekamp–Massey(BM)算法从前 2k 项自动恢复最短递推式。

复杂度:矩阵乘法 O(k³),矩阵快速幂 O(k³ log n),空间 O(k²);常系数线性递推第 n 项 O(k² log n)(多项式取模法)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 1e9 + 7;

struct Mat { // 矩阵结构体
int n, m; // 行数、列数
vector<vector<ll>> a;
Mat(int n_ = 0, int m_ = 0) : n(n_), m(m_), a(n_, vector<ll>(m_, 0)) {}
static Mat identity(int sz) { // 单位阵
Mat I(sz, sz);
for (int i = 0; i < sz; i++) I.a[i][i] = 1;
return I;
}
};

Mat mul(const Mat& A, const Mat& B) { // 矩阵乘法,要求 A.m == B.n
Mat C(A.n, B.m);
for (int i = 0; i < A.n; i++)
for (int k = 0; k < A.m; k++) if (A.a[i][k]) // 跳过 0 项(稀疏加速)
for (int j = 0; j < B.m; j++)
C.a[i][j] = (C.a[i][j] + A.a[i][k] * B.a[k][j]) % MOD;
return C;
}

Mat qpow(Mat base, ll e) { // 矩阵快速幂(e >= 0)
Mat res = Mat::identity(base.n); // 结果初始为单位阵
while (e) {
if (e & 1) res = mul(res, base);
base = mul(base, base);
e >>= 1;
}
return res;
}

// 斐波那契第 n 项:F_0=0, F_1=1,转移矩阵 M = [[1,1],[1,0]]
// [F_{t+1}, F_t]^T = M * [F_t, F_{t-1}]^T,故 M^n 的 (1,0) 元素即 F_n
ll fib(ll n) {
if (n == 0) return 0;
Mat M(2, 2);
M.a[0][0] = M.a[0][1] = M.a[1][0] = 1;
M.a[1][1] = 0;
Mat P = qpow(M, n);
// P = M^n,[F_{n+1}, F_n]^T = P * [F_1, F_0]^T = P * [1, 0]^T
return P.a[1][0]; // F_n = (M^n)[1][0]
}

注意点 / 坑点

  1. 单位阵维度必须等于转移矩阵的阶;快速幂指数 n 与初始向量 v₀ 的对应关系(F₀/F₁ 从哪一项开始)极易写错,建议先手推 n=1、2 验证。
  2. 矩阵乘法三层循环用 i–k–j 顺序并跳过 0 元素,可显著加速稀疏转移矩阵;中间量 A.a[i][k]*B.a[k][j] 可能超 int,必须用 long long。
  3. n 很小时(如 n < k)直接递推更快,不要无脑上矩阵;n 为 0 或 1 的边界要特判。
  4. 常系数线性递推用特征多项式取模时要求递推式齐次(无常数项);BM 算法需要提供足够多的前项(一般 2k 项),项数不足会恢复出错。
  5. 取模的加法乘法都取模,别在矩阵里存负数或忘取模导致中间溢出。

可拓展的地方

  1. 图论计数:邻接矩阵 Mⁿ 的 (i,j) 元素 = 从 i 到 j 恰好走 n 步的方案数;带权图把权值编码进多项式矩阵。
  2. 转移矩阵随询问动态变化时,用线段树维护矩阵乘积(区间查询「经过某段路径的方案数」)。
  3. 常系数线性递推高级版:特征多项式 + NTT(8.4)做到 O(k log k log n),或先用 BM 恢复递推式再加速。
  4. 与 DP 结合:状态压缩 DP 的转移矩阵快速幂(如棋盘铺砖、自动机计数)。

典型例题类型

  1. 矩阵快速幂模板:洛谷 P3390;斐波那契:洛谷 P1962、POJ 3070 Fibonacci。
  2. 图上走 k 步计数(邻接矩阵快速幂类题目)。
  3. 常系数齐次线性递推模板:洛谷 P4723。

8.8 Pólya 计数

一句话用途:在置换群作用下把「本质不同」的着色/构型计数:项链、手镯(环上着色)、棋盘染色、正多面体染色等对称性计数题。Burnside 引理负责拆等价类,Pólya 定理给出每种置换下不动点的闭式计数。

算法思想:Burnside 引理:等价类数 = (1/|G|)·Σ_{g∈G} fix(g),其中 fix(g) 是 g 作用下的不动点个数。Pólya 定理:用 m 种颜色给 n 个位置着色时,fix(g) = m^{c(g)},c(g) 是置换 g 的循环个数(同一循环内颜色必须相同才不动)。项链只考虑旋转群 C_n(|G|=n,旋转 k 步有 gcd(n,k) 个循环);手镯加翻转构成二面体群 D_n(|G|=2n,翻转向量的循环数分奇偶讨论)。

复杂度:直接枚举置换 O(n log MOD)(每个置换一次快速幂);按循环数用欧拉函数分组 O(√n log MOD)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 998244353;

ll qpow(ll a, ll b) {
ll r = 1;
for (; b; b >>= 1, a = a * a % MOD)
if (b & 1) r = r * a % MOD;
return r;
}

// 欧拉函数:1~n 中与 n 互质的数的个数(试除法分解)
ll phi(ll n) {
ll res = n;
for (ll p = 2; p * p <= n; p++)
if (n % p == 0) {
res = res / p * (p - 1);
while (n % p == 0) n /= p;
}
if (n > 1) res = res / n * (n - 1);
return res;
}

// 项链(只考虑旋转):n 颗珠子,m 种颜色
// Burnside:答案 = (1/n) * Σ_{k=0}^{n-1} m^{gcd(n,k)},O(n log MOD)
ll necklace(int n, ll m) {
ll ans = 0;
for (int k = 0; k < n; k++)
ans = (ans + qpow(m, gcd(n, k))) % MOD; // 旋转 k 步有 gcd(n,k) 个循环
return ans * qpow(n, MOD - 2) % MOD; // 除以群大小 |G| = n
}

// 欧拉函数优化版:按循环数 d = gcd(n,k) 分组,个数为 φ(n/d),O(√n log MOD)
ll necklaceFast(int n, ll m) {
ll ans = 0;
for (int d = 1; d * d <= n; d++) {
if (n % d == 0) {
// 循环数为 d 的旋转有 φ(n/d) 个;循环数为 n/d 的有 φ(d) 个
ans = (ans + phi(n / d) % MOD * qpow(m, d)) % MOD;
if (d != n / d)
ans = (ans + phi(d) % MOD * qpow(m, n / d)) % MOD;
}
}
return ans * qpow(n, MOD - 2) % MOD; // |G| = n(仍是旋转群)
}

// 手镯(旋转 + 翻转):二面体群 D_n,|G| = 2n
ll bracelet(int n, ll m) {
ll ans = 0;
for (int k = 0; k < n; k++) // n 个旋转
ans = (ans + qpow(m, gcd(n, k))) % MOD;
if (n & 1) { // n 为奇数:n 个翻转,各 (n+1)/2 个循环
ans = (ans + n * qpow(m, (n + 1) / 2)) % MOD;
} else { // n 为偶数:两类翻转各 n/2 个
ans = (ans + (n / 2) * qpow(m, n / 2 + 1)) % MOD; // 过对珠:2 + (n-2)/2 个循环
ans = (ans + (n / 2) * qpow(m, n / 2)) % MOD; // 过对边:n/2 个循环
}
return ans * qpow(2 * n, MOD - 2) % MOD; // 除以 |G| = 2n
}

注意点 / 坑点

  1. 旋转 k 步的循环数 = gcd(n,k),从 k=0(恒等置换,n 个循环)开始枚举,别漏掉恒等置换。
  2. 项链(旋转群,|G|=n)与手镯(二面体群,|G|=2n)的群大小不同,翻转项忘加或除错会差 2 倍。
  3. 最终答案要乘 |G| 的模逆元(模数为质数时用费马小定理),|G| 与模数不互质时需用扩展欧几里得。
  4. 欧拉函数分组公式中「循环数为 d 的旋转个数 = φ(n/d)」,d 从 1 到 √n 枚举并成对处理 d 与 n/d,别重复。
  5. n 很大的时候颜色数 m 也可能很大,全程用快速幂取模,循环数相关计算用 long long 防溢出。

可拓展的地方

  1. 有限制着色:每个置换的 fix(g) 不能再用 m^{c(g)},改为对每个置换做一次 DP 统计不动点,再套 Burnside。
  2. 立体对称计数:正方体、正四面体等三维多面体顶点/面着色,枚举其旋转群的每个置换。
  3. Pólya 的生成函数版本:把颜色数 m 换成多项式(如 1+x+…),可统计「各颜色使用次数」的分布而不只是总数。
  4. 模数非质数时用扩展欧几里得求 |G| 的逆元,或先约分再取模。

典型例题类型

  1. 项链/手镯着色模板:POJ 1286 Necklace of Beads(三色)、POJ 2409 Let it Bead(多色 + 翻转)。
  2. Pólya 定理模板(大 n 取模):洛谷 P4980。
  3. 置换群 + Burnside 的推广题:棋盘/图形染色、图着色本质不同计数(常见于 ICPC 区域赛)。

九、搜索与其他技巧

本部分速查表

算法 复杂度 适用场景
DFS 与 BFS 基础 O(V + E) 图遍历、连通块、无权图最短路、状态搜索
双向 BFS 与 Meet in the Middle 双向 BFS O(b^(d/2)),MITM O(2^(n/2) · log n) 状态空间巨大的最短路、n ≤ 40 的折半枚举计数
迭代加深与 IDA* O(b^limit),空间 O(limit) 解深度未知的搜索(埃及分数、八数码)
A* 算法 O(b^d),实际取决于估价质量 有可采纳估价函数的状态搜索、第 K 短路
剪枝技巧 取决于剪枝强度,可把指数级压到可接受 组合优化搜索题(木棒、数独、生日蛋糕)
模拟退火与爬山 O(轮数 × 邻域大小) 几何最优化、排列近似最优
位运算技巧 单次 O(1) 集合表示、状压 DP、快速统计
经典贪心模型 O(n log n) 区间选点/覆盖/调度、哈夫曼、任务调度
离线算法 CDQ O(n log² n),整体二分 O((n+m) log V log n) 三维偏序、静态区间第 k 小
卡常与调试技巧 —(工程技巧) 大数据量读入、对拍验证正确性
常见坑点汇总 —(经验清单) 提交前的溢出/清空/边界排查

9.1 DFS 与 BFS 基础

一句话用途:DFS 与 BFS 是最基础的遍历与搜索工具:DFS 适合枚举所有方案(回溯)与树/图的递归处理,BFS 适合求无权图(含网格)最短路、连通块以及按层扩展的状态搜索;几乎所有搜索类题目都建立在二者之上。

算法思想:DFS(深度优先)沿一条分支走到底再回溯,用「标记 → 递归 → 撤销」三步完成状态枚举,vis 数组兼作去重;BFS(广度优先)借助队列按层扩展,第一次到达某状态的步数一定是最短步数(无权图),因此 dist 数组可兼作访问标记。网格题常用 id = x * m + y 把二维坐标压成一维,既省内存又便于开 dist 数组;连通块统计即对每个未访问点做一次遍历并计数。

复杂度:时间:DFS/BFS 均为 O(V + E)(状态数 × 转移数;网格为 O(行 × 列 × 4));空间:O(V)(vis/dist 数组 + 递归栈或队列)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ============ 模板一:回溯 DFS 递归框架(全排列) ============
// 通用框架 = 边界判断 + 枚举选择 + 标记 + 递归 + 撤销标记
#include <bits/stdc++.h>
using namespace std;
int n, a[20]; bool used[20]; // a 保存当前排列;used[x] 标记数字 x 是否用过
void dfs(int pos) { // 正在填第 pos 个位置
if (pos == n) { // 边界:填满则输出一个排列
for (int i = 0; i < n; i++) cout << a[i] << ' ';
cout << '\n';
return;
}
for (int x = 1; x <= n; x++) if (!used[x]) { // 枚举未用过的数
used[x] = true; a[pos] = x; dfs(pos + 1); // 选择 + 递归
used[x] = false; // 撤销:恢复现场
}
}

int main() {
cin >> n;
dfs(0);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// ============ 模板二:BFS 最短路 + 路径记录 ============
const int N = 100005;
vector<int> g[N];
int dist[N], pre[N]; // dist: 起点到各点最短距离;pre[v]: v 的前驱结点

void bfs(int s) {
memset(dist, -1, sizeof dist); memset(pre, -1, sizeof pre); // -1 未访问
queue<int> q; dist[s] = 0; q.push(s);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : g[u]) {
if (dist[v] != -1) continue; // 已访问过(第一次到达即最短路)
dist[v] = dist[u] + 1; // 无权图:层数即距离
pre[v] = u; // 记录前驱,用于还原路径
q.push(v);
}
}
}
// 输出 s → t 的最短路(pre 是逆序链,先收集再反转)
void printPath(int t) {
vector<int> path;
for (int x = t; x != -1; x = pre[x]) path.push_back(x);
reverse(path.begin(), path.end());
for (int x : path) cout << x << ' ';
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// ============ 模板三:网格 BFS(二维坐标转一维 + 连通块) ============
const int MAXN = 1005;
char mp[MAXN][MAXN]; // 地图,'#' 障碍,'.' 可走
int n, m;
inline int id(int x, int y) { return x * m + y; } // 二维坐标压成一维
const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};

// 求 (sx,sy) 到 (tx,ty) 最少步数;不可达返回 -1
int bfsGrid(int sx, int sy, int tx, int ty) {
vector<int> dist(n * m, -1); queue<int> q; // 一维 dist 省内存
dist[id(sx, sy)] = 0;
q.push(id(sx, sy));
while (!q.empty()) {
int cur = q.front(); q.pop();
int x = cur / m, y = cur % m; // 一维还原成二维
if (x == tx && y == ty) return dist[cur];
for (int k = 0; k < 4; k++) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= m || mp[nx][ny] == '#')
continue; // 越界或障碍
int nid = id(nx, ny);
if (dist[nid] != -1) continue; // 去重
dist[nid] = dist[cur] + 1;
q.push(nid);
}
}
return -1;
}
// 连通块个数:对每个未访问的 '.' 做一次 BFS 染色,次数即连通块数
int cntComp() {
bool vis[MAXN][MAXN] = {};
int comp = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < m; j++)
if (mp[i][j] == '.' && !vis[i][j]) {
comp++; queue<int> q; q.push(id(i, j)); vis[i][j] = true;
while (!q.empty()) { // 四方向扩散染色
int cur = q.front(); q.pop();
int x = cur / m, y = cur % m;
for (int k = 0; k < 4; k++) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (mp[nx][ny] != '.' || vis[nx][ny]) continue;
vis[nx][ny] = true;
q.push(id(nx, ny));
}
}
}
return comp;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ============ 模板四:无权图最短路条数计数 ============
long long cnt[N]; // cnt[v]:起点到 v 的最短路条数
void bfsCnt(int s) {
memset(dist, -1, sizeof dist); fill(cnt, cnt + N, 0);
queue<int> q;
dist[s] = 0; cnt[s] = 1; q.push(s);
while (!q.empty()) {
int u = q.front(); q.pop();
for (int v : g[u]) {
if (dist[v] == -1) { // 第一次到达:确定最短路
dist[v] = dist[u] + 1;
cnt[v] = cnt[u]; q.push(v);
} else if (dist[v] == dist[u] + 1) {
cnt[v] += cnt[u]; // 等长最短路,累加条数
}
}
}
}

注意点 / 坑点

  1. 「第一次到达即最短路」只对无权图成立;带权图必须用 Dijkstra,否则 dist 可能是非最优的。
  2. 去重标记必须在入队时设置(如 dist[nid] != -1 判断),而不是出队时——否则同一状态被重复入队,导致 MLE 甚至死循环。
  3. 二维转一维 id = x * m + y 时,x、y 的 0/1 基必须与 dist 数组下标一致;越界检查要在取 id 之前做,否则下标越界。
  4. 回溯 DFS 一定要「撤销」(恢复现场),漏掉撤销会得到错误计数;全局 vis 数组多测时记得清空。

可拓展的地方

  1. DFS 框架可推广为记忆化搜索(见 9.5)与迭代加深(见 9.3);BFS 可扩展为 0-1 BFS(边权 0/1 用双端队列)与多源 BFS(多个起点同时入队)。
  2. 路径记录除了 pre 前驱数组,还可存「走到当前状态的那一步操作」,输出操作序列(如打印最短路径的方向串)。

典型例题类型

  • 回溯 DFS:洛谷 P1605 迷宫、P1036 选数(组合枚举)。
  • BFS:洛谷 P1443 马的遍历(网格 BFS)、UVA 11624 Fire!(多源 BFS)。

9.2 双向 BFS 与 Meet in the Middle

一句话用途:当状态空间巨大(如八数码、单词变换)时,从起点和终点同时向中间扩展,可把搜索深度减半;Meet in the Middle(中途相遇)则把「折半枚举 + 排序合并」用于计数与判定类问题(如 n ≤ 40 的子集和),是暴力算法的经典提速手段。

算法思想:双向 BFS 用两个队列分别从起点和终点按层扩展,每次扩展规模较小的一侧,当两边状态相遇时步数相加即答案,状态总量由 b^d 降为约 2·b^(d/2)。Meet in the Middle 把 n 个数折成两半,各自枚举 2^(n/2) 种组合,一半排序后用二分与另一半配对,把 2^n 压到 2^(n/2) 级别。

复杂度:双向 BFS:时间 O(b^(d/2))(b 为分支因子、d 为最优步数),空间 O(b^(d/2))(两边各存状态);Meet in the Middle:时间 O(2^(n/2) · log 2^(n/2)),空间 O(2^(n/2))。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// ============ 模板一:双向 BFS(求最少步数,起点终点均已知) ============
#include <bits/stdc++.h>
using namespace std;

using State = string; // 状态类型(示例为字符串,可按题替换)

// 扩展函数:返回从 u 一步可达的所有状态(按题目规则实现)
vector<State> expand(const State& u) {
vector<State> res;
// 例:交换相邻两个字符产生新状态
for (int i = 0; i + 1 < (int)u.size(); i++) {
State v = u;
swap(v[i], v[i + 1]);
res.push_back(v);
}
return res;
}

// 双向 BFS:s 起点、t 终点,返回最少步数;不连通返回 -1
int biBfs(State s, State t) {
if (s == t) return 0;
unordered_map<State, int> d1, d2; // 两侧各自的步数表(哈希记录,兼作去重)
queue<State> q1, q2;
d1[s] = 0; q1.push(s);
d2[t] = 0; q2.push(t);

// 扩展一侧的一整层;cur 是本侧表,other 是对侧表
auto step = [&](queue<State>& q, unordered_map<State, int>& cur,
unordered_map<State, int>& other) -> int {
int sz = (int)q.size(); // 只扩展这一层,避免一次把整棵树全展开
while (sz--) {
State u = q.front(); q.pop();
for (State v : expand(u)) {
if (cur.count(v)) continue; // 本侧已访问,去重
if (other.count(v)) // 相遇:两段步数相加
return cur[u] + 1 + other[v];
cur[v] = cur[u] + 1;
q.push(v);
}
}
return -1; // 这一层没相遇
};

while (!q1.empty() && !q2.empty()) {
// 每次扩展规模较小的一侧,让两边状态量尽量均衡
if (q1.size() <= q2.size()) {
int ans = step(q1, d1, d2);
if (ans != -1) return ans;
} else {
int ans = step(q2, d2, d1);
if (ans != -1) return ans;
}
}
return -1; // 某一侧队列先空说明不连通,无解
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ============ 模板二:Meet in the Middle(折半枚举 + 排序二分) ============
// 例题:n 个数中选任意个,使总和 ≤ W,求方案数(n ≤ 40 时 2^40 不可行)
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

int n; ll W, a[45];

// 枚举 a[l..r) 的所有子集和(每数选或不选),结果存入 out
void gen(int l, int r, vector<ll>& out) {
int m = r - l;
for (int mask = 0; mask < (1 << m); mask++) { // mask 的每个二进制位表示选/不选
ll sum = 0;
for (int i = 0; i < m; i++)
if (mask >> i & 1) sum += a[l + i];
out.push_back(sum);
}
}

int main() {
cin >> n >> W;
for (int i = 0; i < n; i++) cin >> a[i];

int mid = n / 2; // 折半点:左右各约 n/2 个数
vector<ll> L, R;
gen(0, mid, L); // 左半:2^(n/2) 种和
gen(mid, n, R); // 右半:2^(n-n/2) 种和
sort(R.begin(), R.end()); // 右半排序,供二分查找

ll ans = 0;
for (ll x : L) { // 对每个左半和,二分右半能配几个
ll lim = W - x;
if (lim < 0) continue; // 左半和已超预算,跳过
ans += upper_bound(R.begin(), R.end(), lim) - R.begin(); // 统计 ≤ lim 的个数
}
cout << ans << '\n';
return 0;
}

注意点 / 坑点

  1. 双向 BFS 相遇时步数 = cur[u] + 1 + other[v],容易漏掉中间的 +1;并且每次必须扩展一整层(记录层数 sz),否则两个队列深度不同步,会错判或漏解。
  2. 两边状态表用 unordered_map 时,若状态是 pair/自定义结构必须提供哈希函数,否则退化为线性查找;string/int 可用默认哈希但注意常数。
  3. MITM 折半后一半排序、一半遍历,遍历侧的子集和可能超 int,用 long long;n 为奇数时右半多一个数即可(n/2 与 n-n/2)。
  4. 双向 BFS 两个方向的状态编码必须完全一致(同一套状态表示),否则无法判相遇。

可拓展的地方

  1. 双向 BFS 可推广到「起点集合 → 终点集合」的批量最短路(多源汇),以及双向 A*(两边各带估价)。
  2. MITM 的合并方式可换成双指针 / 哈希 / 位集(bitset);还能用于 4SUM、最大子集异或、图论中的中途相遇最短路(如 Codeforces 1006F)。

典型例题类型

  • 双向 BFS:UVA 10422 Knights in FEN(骑士、经典双向 BFS)、HDU 1195 Open the Lock(数字锁)。
  • MITM:洛谷 P4799 [CEOI2015 Day2] 世界冰球锦标赛(折半枚举模板题)、Codeforces 1006F Xor-Paths。

9.3 迭代加深与 IDA*

一句话用途:迭代加深(IDDFS)用于「解深度未知但最优深度不会太大、且要求最少步数」的搜索:逐层加深深度上限,避免 DFS 一头扎进无限深的分支;IDA* 在迭代加深基础上用乐观估价函数剪枝,是八数码、埃及分数等经典题的标配算法。

算法思想:IDDFS 从深度上限 0 开始,每次只允许 DFS 走 limit 层,搜不到就把 limit+1 重来。由于按深度递增搜索,第一次成功时 limit 就是最优解深度;深度上限的存在使搜索树规模主要受 limit 控制而非总深度。IDA* 额外维护乐观估价 h(state)(剩余所需步数的下界,如八数码中「每个数字到目标位置的曼哈顿距离之和」),当 depth + h > limit 时该分支必不可能在 limit 内出解,直接剪枝。相比 BFS 的优势:空间只有 O(limit)(一条递归栈),状态不需要全部入队,适合分支因子大、深度较浅、或状态无法全部存下的场景;缺点是每加深一层都要重搜,若最优深度很大则不如 BFS/双向 BFS。

复杂度:时间:最坏 O(b^limit)(b 为分支因子、limit 为最优深度),空间 O(limit)(仅递归栈,远小于 BFS 的队列)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// ============ 模板一:IDDFS(迭代加深 DFS 框架) ============
#include <bits/stdc++.h>
using namespace std;

int limit; // 当前深度上限
bool found; // 是否已找到解

// 当前深度 depth,返回该分支是否在 limit 内出解
bool dfs(int depth) {
if (depth == limit) { // 达到限制深度
// 判断是否为目标状态,是则记录解并返回 true
// if (isGoal()) { record(); return true; }
return false;
}
// for (每个后继状态 v) {
// if (可行性剪枝) continue;
// if (dfs(depth + 1)) return true; // 找到解立即返回,不再回溯
// }
return false;
}

void iddfs() {
for (limit = 0; ; limit++) { // 从 0 开始逐层加深
found = false;
if (dfs(0)) break; // 该深度出解,limit 即最少步数
// 若能事先证明无解(如已达到理论上限层数),在这里 break 防止死循环
}
}
1
2
3
4
5
6
7
8
9
10
11
12
// ============ 模板二:IDA*(在 IDDFS 上加乐观估价剪枝) ============
int h(const State& s); // 乐观估价:从 s 到目标所需步数的下界
// 要求 h ≤ 真实剩余步数(如曼哈顿距离和)
bool dfs(int depth, State s) {
if (depth + h(s) > limit) return false; // 核心剪枝:估计总步数已超限
if (isGoal(s)) { record(s); return true; }
for (State v : expand(s)) {
if (v == 上一个状态) continue; // 防回头,减少对称重复搜索
if (dfs(depth + 1, v)) return true;
}
return false;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// ============ 模板三:埃及分数(迭代加深 + 剪枝的完整实现) ============
// 把分数 a/b 拆成若干分子为 1 的分数的和:要求项数最少;项数相同时最大分母最小。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

int maxd; // 当前深度上限(最多拆分项数)
ll ans[100], tmp[100]; // 最优解、当前解(各存每项的分母)

ll gcd(ll a, ll b) { return b ? gcd(b, a % b) : a; }

// 已选 deep 项,剩余分数 a/b,下一项分母必须 ≥ from(保证分母严格递增)
bool dfs(int deep, ll a, ll b, ll from) {
if (a == 0) return false; // 分数拆完却还有剩余层数:无法再加正项
if (deep == maxd) { // 层数用完
if (a == 0) { // 恰好拆完,更新最优解
// 更新规则:最大分母更小者更优(分母严格递增,最后一项即最大分母)
if (ans[0] == -1 || tmp[maxd - 1] < ans[maxd - 1])
for (int i = 0; i < maxd; i++) ans[i] = tmp[i];
return true;
}
return false;
}
// 剪枝 1(可行性):剩余 (maxd-deep) 项即使全取最大的 1/from 也凑不够 a/b
if ((maxd - deep) * b <= a * from) return false;
bool ok = false;
// 分母下界:1/x ≤ a/b ⇒ x ≥ b/a(向上取整),同时保证 ≥ from
ll x = max(from, (b + a - 1) / a);
for (; ; x++) {
// 剪枝 2:从这项起即使剩余项全取 1/x 都不够,分母更大只会更小,直接停止
if ((maxd - deep + 1) * b <= a * x) break;
tmp[deep] = x;
// 减去 1/x:a/b - 1/x = (a*x - b) / (b*x),再约分
ll na = a * x - b, nb = b * x;
ll g = gcd(na, nb);
if (dfs(deep + 1, na / g, nb / g, x + 1)) ok = true; // 下一项分母 > x
}
return ok;
}

int main() {
ll a, b; cin >> a >> b;
ll g = gcd(a, b); a /= g; b /= g; // 先约分
memset(ans, -1, sizeof ans);
for (maxd = 1; ; maxd++) // 迭代加深:从 1 项开始逐项加深
if (dfs(0, a, b, 1)) break;
for (int i = 0; i < maxd; i++) printf("%lld%c", ans[i], " \n"[i == maxd - 1]);
return 0;
}

注意点 / 坑点

  1. h 必须乐观(下界):h ≤ 真实剩余步数,否则会剪掉最优解;h 越贴近真实值,剪枝越强、越快。
  2. 埃及分数的分母增长极快,a * xb * x 可能超过 2^63,必要时用 __int128 或加上限约束。
  3. 迭代加深每层都重新搜索,最优深度很大时开销巨大;若分支因子小、深度大,BFS/双向 BFS 反而更快——选算法前先估最优深度。
  4. 一定加「防回头」(不走回上一状态)与「分母严格递增」这类对称性剪枝,否则搜索树爆炸;a == 0 时提前返回,避免 (b+a-1)/a 除零。

可拓展的地方

  1. 估价函数可升级为模式数据库(pattern database)或线性冲突等更强下界,显著提升 IDA* 效率。
  2. 迭代加深思想可用于「深度优先 + 逐步放宽约束」的优化问题,也可与双向搜索结合成「双向迭代加深」。

典型例题类型

  • 埃及分数:UVA 12558、POJ 3134(迭代加深与剪枝的教科书题)。
  • 八数码:POJ 1077 Eight(IDA* 与 A* 均可)。
  • 华容道 / 五子棋判定类搜索也可用 IDA*。

9.4 A* 算法

一句话用途:A* 在 Dijkstra 基础上引入启发式估价,优先扩展「已走代价 g + 乐观估计 h」最小的状态,在有权/无权图上都能显著减少扩展量,是八数码、15 数码、第 K 短路等状态搜索的经典解法。

算法思想:维护优先队列,键为 f = g + h:g 是起点到当前状态的确定代价,h 是对「当前状态到目标」剩余代价的乐观估计。只要 h 满足可采纳性(h ≤ 真实剩余代价),第一次从队列弹出目标状态时其 g 就是最优解。h 越接近真实代价,扩展的状态越少。与 Dijkstra/BFS 的关系:h ≡ 0 时 A* 退化为 Dijkstra(按 g 排序,带权图);无权图边权为 1 时 Dijkstra 再退化为 BFS。若 h 还满足一致性(h(u) ≤ w(u,v) + h(v)),每个状态只出队一次;否则同一状态可能多次入队,必须用 g 表判优。

复杂度:时间:最坏 O(b^d)(b 分支因子、d 最优深度),实际取决于 h 的质量,h 很好时可接近线性;空间:O(入队状态数),比 IDA* 大(需同时维护 open 表与 g 表)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// ============ 模板:A* 求八数码最少步数 ============
#include <bits/stdc++.h>
using namespace std;

const string GOAL = "123804765"; // 目标状态:0 表示空格

// 乐观估价 h:每个数字到目标位置的曼哈顿距离之和。
// 每步移动最多让某个数字靠近目标 1 格,故 h ≤ 真实剩余步数(可采纳)。
int hval(const string& s) {
int sum = 0;
for (int i = 0; i < 9; i++) {
if (s[i] == '0') continue;
int j = (int)GOAL.find(s[i]); // 该数字在目标态的位置
sum += abs(i / 3 - j / 3) + abs(i % 3 - j % 3);
}
return sum;
}

struct Node {
int g; // 起点到该状态的实际步数
string s; // 棋盘状态(9 个字符)
// 优先队列默认大根堆,重载 < 使 "f = g + h 最小" 的先出队
bool operator<(const Node& o) const { return g + hval(s) > o.g + hval(o.s); }
};

int astar(string start) {
if (start == GOAL) return 0;
unordered_map<string, int> g; // 各状态的最短步数(去重 + 判优)
priority_queue<Node> pq;
g[start] = 0;
pq.push({0, start});

const int dx[4] = {1, -1, 0, 0}, dy[4] = {0, 0, 1, -1};
while (!pq.empty()) {
Node u = pq.top(); pq.pop();
if (u.g != g[u.s]) continue; // 过期节点(g 已被更新得更优),跳过
if (u.s == GOAL) return u.g; // 第一次弹出目标即最优解
int p = (int)u.s.find('0'); // 空格位置
int x = p / 3, y = p % 3;
for (int k = 0; k < 4; k++) {
int nx = x + dx[k], ny = y + dy[k];
if (nx < 0 || nx >= 3 || ny < 0 || ny >= 3) continue; // 越界
string v = u.s;
swap(v[p], v[nx * 3 + ny]); // 空格与邻居交换
int ng = u.g + 1;
if (!g.count(v) || ng < g[v]) { // 更优则更新并入队(允许重复入队)
g[v] = ng;
pq.push({ng, v});
}
}
}
return -1; // 不可达
}

注意点 / 坑点

  1. 可采纳性是 A* 最优性的前提:h 必须 ≤ 真实代价,写错方向(估成上界)会丢最优解。
  2. 同一状态可能多次入队,必须用 g 表判优(ng < g[v] 才更新),否则可能死循环或答案错误;出队时用 u.g != g[u.s] 跳过过期节点。
  3. 状态编码越紧凑越好:八数码可把 9 个数字压进一个 int(或用康托展开映射到 0~362879),string 作 unordered_map 键的哈希常数偏大,大数据量注意常数。
  4. 可先判无解再搜索:八数码可解性由逆序对数的奇偶性决定,能省掉大量白搜。

可拓展的地方

  1. h 可升级为「曼哈顿距离 + 线性冲突(linear conflict)」等更强估价;也可做双向 A*(两边各带估价)。
  2. 内存紧张时改用 IDA*(见 9.3)或跳点搜索(JPS,适合网格图);A* 与 Dijkstra 可互相替代的场景按图规模与启发质量选择。

典型例题类型

  • 八数码:POJ 1077 Eight、HDU 1043 Eight(逆序对判无解 + A*/IDA*)。
  • 第 K 短路:POJ 2449 Remmarguts’ Date(A* 求第 k 短路径,经典模板)。

9.5 剪枝技巧

一句话用途:搜索题的灵魂是剪枝——用可行性、最优性、排序、对称性等手段提前砍掉不可能出解的分支,把指数级搜索压到可接受规模;剪枝质量往往决定题目能否通过。

算法思想:可行性剪枝判断当前部分解是否还有可能完成(如木棒拼接中已拼长度是否超过目标);最优性剪枝用「当前代价 + 乐观估计 ≥ 已知最优解」砍掉不可能更优的分支;排序剪枝先尝试「更有希望」的选择(如从长到短),以便更早找到较优解、让最优性剪枝尽早生效;对称性剪枝跳过与已失败分支等价的重复选择(如相同长度的木棒);记忆化把重复出现的子问题结果缓存,避免重复搜索。

复杂度:无通用上界,取决于剪枝强度;好的剪枝能把指数级降到多项式级甚至近线性,差的剪枝与裸搜索无异。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// ============ 剪枝片段 1:可行性 + 排序 + 对称性剪枝(POJ 1011 木棒) ============
#include <bits/stdc++.h>
using namespace std;

int n, len, cnt; // 小木棒数、目标大棒长度、大棒根数
int a[70];
bool used[70];

// 拼第 k 根大棒、已拼 cur、从下标 start 开始试(保证组合不重复)
bool dfs(int k, int cur, int start) {
if (k == cnt) return true; // 全部拼完
if (cur == len) return dfs(k + 1, 0, 0); // 当前根拼满,拼下一根
int last = -1; // 记录本层刚失败的长度
for (int i = start; i < n; i++) {
if (used[i]) continue;
if (cur + a[i] > len) continue; // 可行性剪枝:放不下
if (a[i] == last) continue; // 对称性剪枝:同长度刚失败,跳过
used[i] = true;
if (dfs(k, cur + a[i], i + 1)) return true;
used[i] = false;
last = a[i];
// 两个强剪枝:拼「第一段」就失败,或「恰好填满」仍失败,
// 说明该长度在任何方案中都不可行,直接返回
if (cur == 0) return false;
if (cur + a[i] == len) return false;
}
return false;
}

int main() {
while (cin >> n && n) {
int sum = 0;
for (int i = 0; i < n; i++) { cin >> a[i]; sum += a[i]; }
sort(a, a + n, greater<int>()); // 排序剪枝:从长到短,尽早失败
for (len = a[0]; len <= sum; len++) { // 大棒长度 ≥ 最长段
if (sum % len) continue; // 总长必须能被 len 整除
cnt = sum / len;
memset(used, 0, sizeof used);
if (dfs(0, 0, 0)) { cout << len << '\n'; break; }
}
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
// ============ 剪枝片段 2:最优性剪枝(当前代价 + 乐观估计 ≥ best 则剪) ============
// 适用:求最小代价类搜索(最小步数、最小花费),先搜出较优解再不断收紧剪枝
int best = INT_MAX; // 全局已知最优解
void dfs(int step, int cost) {
if (cost + optimistic(step) >= best) return; // 乐观估计都追不上 best,必不可能更优
if (step == n) { best = min(best, cost); return; } // 到达叶节点更新答案
for (int x : choices[step]) {
if (cost + w[step][x] >= best) continue; // 单步就超,直接跳过
dfs(step + 1, cost + w[step][x]);
}
}
// 注意:optimistic(step) 是"剩余部分代价的下界",必须 ≤ 真实剩余代价
1
2
3
4
5
6
7
8
9
10
// ============ 剪枝片段 3:记忆化(缓存子问题,避免重复搜索) ============
int memo[1 << 15][16]; // 状态维度(如 mask 集合 + 当前位置)
int dfsMemo(int mask, int u) {
if (memo[mask][u] != -1) return memo[mask][u]; // 命中缓存直接返回
int res = INT_MAX / 2;
// ... 按题目做状态转移 ...
return memo[mask][u] = res; // 记录并返回
}
// 使用前 memset(memo, -1, sizeof memo);
// 记忆化前提:子问题只依赖状态本身(无后效性),且状态数不能太大

注意点 / 坑点

  1. 最优性剪枝依赖「先找到较优解」:搜索顺序至关重要,排序剪枝让大/优的选择先试,否则剪枝形同虚设。
  2. 记忆化必须保证无后效性,且注意状态空间大小与内存;用 -1 初始化时,若答案本身可能为 -1 要换标记值。
  3. 强剪枝(如 cur == 0 直接 return false)必须先证明正确性再写,剪错的剪枝会丢解。
  4. 剪枝宁保守勿错误:拿不准的条件宁可不剪,正确性永远优先于速度。

可拓展的地方

  1. 剪枝可与双向搜索、迭代加深结合(IDA* 本质就是「深度限制 + 乐观估价」两类剪枝的组合)。
  2. 用位运算优化剪枝判定:以位集表示可选集合,O(1) 检查可行性(数独类题常用),配合低内存的状态压缩。

典型例题类型

  • 木棒拼接:POJ 1011 Sticks(剪枝入门的经典题)。
  • 生日蛋糕:洛谷 P1731(可行性 + 最优性双重剪枝的教科书题)。
  • 数独:POJ 3074(位运算 + 可行性剪枝)。

9.6 模拟退火与爬山

一句话用途:用于难以精确求解的最优化问题(几何最优化、排列近似最优、函数极值):爬山法沿邻域贪心下降,模拟退火以一定概率接受更差解以跳出局部最优,是「能出近似解就行」类题目的实用随机化手段。

算法思想:爬山法维护当前解,不断在邻域中找更优者移动,直到没有改进;缺点是一旦进入局部最优就停滞。模拟退火引入温度 T:每步随机扰动得到新解,若更优必接受,否则以概率 exp(-Δ/T) 接受(Δ 为变差),T 按几何级数衰减到接近 0——高温时接受差解概率大(全局探索),低温时趋于爬山(局部收敛),从而有概率跳出局部最优。配合随机起点多次运行,进一步提高命中全局最优的概率。

复杂度:时间:O(迭代轮数 × 邻域大小),轮数由初温、末温、衰减率决定(可调);空间:O(解的大小)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ============ 模板一:爬山法(邻域贪心 + 随机扰动) ============
#include <bits/stdc++.h>
using namespace std;

// 目标函数:返回解 x 的代价(越小越好),按题目实现
double calc(const vector<double>& x) { /* ... */ return 0.0; }

vector<double> hillClimb(vector<double> x) {
double step = 1.0; // 初始步长
while (step > 1e-9) { // 步长小于精度即视为收敛
bool improved = false;
for (int d = 0; d < (int)x.size(); d++) { // 依次尝试每个维度
for (int dir = -1; dir <= 1; dir += 2) { // 正负两个方向
vector<double> nx = x;
nx[d] += dir * step;
if (calc(nx) < calc(x)) { x = nx; improved = true; }
}
}
if (!improved) step *= 0.5; // 无改进则缩小步长,进入更细的搜索
}
return x;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ============ 模板二:模拟退火(温度衰减 + Metropolis 接受准则) ============
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
uniform_real_distribution<double> uni(0.0, 1.0); // [0,1) 均匀随机数

// 邻域生成:随机选一维加一个幅度与温度成正比的随机偏移(高温扰动大)
vector<double> neighbor(const vector<double>& x, double T) {
vector<double> nx = x;
int d = (int)(rng() % nx.size());
nx[d] += (uni(rng) * 2 - 1) * T; // 扰动幅度随 T 收缩
return nx;
}

double anneal(vector<double> x) {
double T = 100.0, T_min = 1e-8, alpha = 0.999; // 初温 / 末温 / 衰减率
double cur = calc(x);
while (T > T_min) {
vector<double> nx = neighbor(x, T);
double nv = calc(nx);
double delta = nv - cur;
// Metropolis 准则:更优必接受;更差以 exp(-delta/T) 概率接受
if (delta < 0 || exp(-delta / T) > uni(rng)) {
x = nx; cur = nv;
}
T *= alpha; // 指数降温
}
return x;
}
1
2
3
4
5
6
7
8
// ============ 模板三:多次运行 + 随机起点(取最优) ============
vector<double> best; double bestVal = 1e18; // 全局最优解与最优值
for (int run = 0; run < 30; run++) { // 独立运行若干次
vector<double> x0(dim); // dim 为解的维度
for (auto& v : x0) v = (uni(rng) * 2 - 1) * 1000; // 随机起点
vector<double> x = anneal(x0); // 或 hillClimb(x0)
if (calc(x) < bestVal) { bestVal = calc(x); best = x; }
}

注意点 / 坑点

  1. 参数敏感:初温太高收敛慢、太低跳不出局部最优;衰减率 α 一般取 0.98 ~ 0.9999,需按题调试。
  2. 随机算法结果不稳定:提交前本地多次运行验证正确率,比赛时多做几次取最优(时间换质量)。
  3. 几何最优化(最小圆覆盖、费马点)注意退化情况(共线、重合点、n=1)与浮点精度(用 eps 比较)。
  4. 排列类问题(如旅行商近似解)的邻域应定义为「交换 / 反转」操作,不能直接做数值扰动,需自定义 neighbor。

可拓展的地方

  1. 模拟退火与爬山混合:先用退火粗搜跳出局部最优,再把结果作为爬山起点精细收敛(两阶段优化)。
  2. 遗传算法、粒子群等种群算法是模拟退火的推广,适合更复杂的组合优化;也可以对退火结果做局部 2-opt/3-opt 微调。

典型例题类型

  • 几何最优化:POJ 2069 Super Star(最小球覆盖)、洛谷 P1337 [JSOI2004] 平衡点(模拟退火经典)。
  • 排列近似最优:TSP 类(POJ 1699)、带随机 2-opt 的路线规划近似解。

9.7 位运算技巧

一句话用途:位运算以 O(1) 完成集合运算与统计,是状压 DP、枚举子集、快速计数题的基石;掌握 lowbit、枚举子集、popcount 与异或性质可以大幅简化代码并提速。

算法思想:用整数的二进制位表示集合(第 k 位表示元素 k 是否在集合中),则并 / 交 / 差 / 对称差分别对应 |&~^;lowbit = x & (-x) 取出最低位的 1;枚举子集用 (sub - 1) & mask 依次剥掉最低位;1 的个数用 __builtin_popcount 直接得到。异或满足自反性 x ^ x = 0,配合前缀异或可 O(1) 求区间异或;a + b = (a ^ b) + 2 * (a & b) 揭示了异或与加法的关系。这些技巧与状压 DP 天然配合:dp[mask] 以集合为状态,转移时枚举元素或子集。

复杂度:单次位运算 O(1)(popcount 为硬件指令);枚举单个 mask 的子集 O(2^|mask|);枚举全部 mask 的所有子集总代价 O(3^n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ============ 位运算工具箱 ============
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 1) lowbit:取出 x 二进制中最低位的 1(结果是一个 2 的幂)
int lowbit(int x) { return x & (-x); }

// 2) 判断 x 是否为 2 的幂
bool isPow2(int x) { return x > 0 && (x & (x - 1)) == 0; }

// 3) 统计二进制中 1 的个数(编译器内建,硬件指令 O(1))
int popcount(int x) { return __builtin_popcount(x); }
int popcountll(ll x) { return __builtin_popcountll(x); }

// 4) 最低位 1 的位置(从 0 开始计);__builtin_ctz(0) 未定义,调用前特判
int lowbitIndex(int x) { return __builtin_ctz(x); }

// 5) 枚举集合 mask 的所有非空子集(从 mask 本身开始,每次去掉最低位)
void enumSubsets(int mask) {
for (int sub = mask; sub; sub = (sub - 1) & mask) {
// ... 处理子集 sub(sub 按数值从大到小遍历)
}
}

// 6) 第 k 位操作:置 1 / 清 0 / 翻转 / 查询(k 从 0 开始)
int setBit(int x, int k) { return x | (1 << k); }
int clearBit(int x, int k) { return x & ~(1 << k); }
int flipBit(int x, int k) { return x ^ (1 << k); }
bool testBit(int x, int k) { return (x >> k) & 1; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ============ 异或性质 ============
// 1) 自反:x ^ x = 0,x ^ 0 = x;满足交换律、结合律
// → 出现偶数次的数两两抵消,可用于"找唯一出现奇数次的数"
int findOdd(const vector<int>& a) {
int res = 0;
for (int x : a) res ^= x;
return res;
}

// 2) 前缀异或:区间 [l, r] 的异或和 = pre[r] ^ pre[l-1](类比前缀和)
// pre[i] = a[0] ^ a[1] ^ ... ^ a[i]

// 3) 与加减法的关系:a + b = (a ^ b) + 2 * (a & b)
// (不进位加法 + 进位左移一位),可递归实现加法:
int add(int a, int b) { return b ? add(a ^ b, (a & b) << 1) : a; }
1
2
3
4
5
6
7
8
9
10
11
12
// ============ 与状压 DP 的配合 ============
// dp[mask] 以"已选集合 mask"为状态;转移时枚举不在集合中的元素
// 例:TSP 状压——dp[mask][u] 表示已访问集合 mask、当前在 u 的最短距离
int dp[1 << 16][16], w[16][16];
for (int mask = 0; mask < (1 << n); mask++)
for (int u = 0; u < n; u++)
if (mask >> u & 1) // u 已在集合中
for (int v = 0; v < n; v++)
if (!(mask >> v & 1)) // v 未访问
dp[mask | (1 << v)][v] = min(dp[mask | (1 << v)][v],
dp[mask][u] + w[u][v]);
// 转移顺序:mask 按数值从小到大枚举,保证拓扑序(新 mask 更大)

注意点 / 坑点

  1. 1 << k 在 k ≥ 31 时溢出 int,对 long long 位集用 1LL << k
  2. __builtin_ctz(0)__builtin_popcount 对 0 的行为:popcount(0)=0 安全,ctz(0) 未定义必须特判。
  3. 枚举子集必须从 sub = mask 开始、以 sub > 0 结束(写法 for (int sub = mask; sub; ...)),否则漏掉非空子集或死循环。
  4. 状压 DP 的 n 一般不超过 20~22(内存 2^n × n),超出考虑折半枚举(见 9.2)或换算法。

可拓展的地方

  1. bitset 可做高维集合运算(传递闭包、按位卷积的近似优化),配合 lowbit 实现树状数组「求第 k 大」等。
  2. 位运算还可用于 Nim 博弈 SG 值计算、二分图匹配的优化(匈牙利算法的 bitset 加速)等进阶场景。

典型例题类型

  • 状压 DP 入门:洛谷 P1433 吃奶酪(TSP 状压)、POJ 3311 Hie with the Pie。
  • 子集枚举:洛谷 P3959 宝藏(状压 + 子集枚举)、POJ 1170(状态压缩 + 背包)。

9.8 经典贪心模型

一句话用途:贪心在每步做局部最优选择,常用于区间类问题(选点、覆盖、调度)、哈夫曼编码、任务调度等;配套「交换论证」证明正确性;「反悔贪心」用堆撤销上一步选择,处理带截止时间约束的最优值问题。

算法思想:区间模型统一套路是「排序 + 扫描」:区间调度按右端点升序排序,能接上就选;区间选点与调度同构(点在右端点最划算);区间覆盖按左端点排序后,每次取能接上且右端点最远的区间。哈夫曼编码每次用优先队列合并权值最小的两棵树。反悔贪心先按截止时间排序贪心选择,当已选数量超过截止时间时,用小根堆踢掉收益最小的已选项(等价于撤销上一步)。正确性一般用交换论证证明:设最优解与贪心解在某一步不同,把最优解中的选择换成贪心的选择后仍不劣,从而贪心解就是最优解。

复杂度:区间模型与调度 O(n log n)(排序主导);哈夫曼 O(n log n);反悔贪心 O(n log n)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ============ 模型 1:区间调度(选最多互不相交的区间) ============
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

struct Seg { int l, r; };

// 按右端点升序排序;能接上(左端点 > 上一个右端点)就选
int maxDisjoint(vector<Seg>& a) {
sort(a.begin(), a.end(), [](const Seg& x, const Seg& y) {
return x.r < y.r; // 关键:按右端点排序
});
int cnt = 0, last = -1e9;
for (auto& s : a)
if (s.l > last) { cnt++; last = s.r; } // 选它,更新覆盖到的右端点
return cnt;
}
1
2
3
4
5
6
7
8
9
// ============ 模型 2:区间选点(用最少的点覆盖所有区间) ============
// 与区间调度同构:按右端点排序,未被覆盖的区间在右端点处放一个点
int minPoints(vector<Seg>& a) {
sort(a.begin(), a.end(), [](const Seg& x, const Seg& y) { return x.r < y.r; });
int cnt = 0, last = -1e9;
for (auto& s : a)
if (s.l > last) { cnt++; last = s.r; } // 点放在右端点最优(贪心性)
return cnt;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// ============ 模型 3:区间覆盖(用最少的区间覆盖 [L, R]) ============
// 按左端点排序,每次选"能接上且右端点最远"的区间
int minCover(vector<Seg>& a, int L, int R) {
sort(a.begin(), a.end(), [](const Seg& x, const Seg& y) { return x.l < y.l; });
int cnt = 0, cur = L, i = 0, n = (int)a.size();
while (cur < R) {
int far = cur; // 当前覆盖范围内能到的最远右端点
while (i < n && a[i].l <= cur) far = max(far, a[i++].r);
if (far == cur) return -1; // 出现断层:无法继续覆盖
cur = far; cnt++;
}
return cnt;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
// ============ 模型 4:哈夫曼编码(优先队列合并) ============
// 每次取权值最小的两棵树合并,总代价 = Σ(叶子权值 × 深度)
ll huffman(vector<ll> w) {
priority_queue<ll, vector<ll>, greater<ll>> pq(w.begin(), w.end()); // 小根堆
ll cost = 0;
while (pq.size() > 1) {
ll a = pq.top(); pq.pop();
ll b = pq.top(); pq.pop();
cost += a + b; // 合并代价累加(树上带权路径长)
pq.push(a + b);
}
return cost;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ============ 模型 5:反悔贪心(堆维护,撤销上一步选择) ============
// 例:n 个工作,每个有截止时间 d[i] 与收益 p[i],每单位时间做一个,求最大收益
struct Job { int d, p; };
ll maxProfit(vector<Job> a) {
sort(a.begin(), a.end(), [](const Job& x, const Job& y) { return x.d < y.d; });
priority_queue<ll, vector<ll>, greater<ll>> pq; // 已选收益的小根堆
for (auto& j : a) {
pq.push(j.p); // 先贪心选上
if ((int)pq.size() > j.d) pq.pop(); // 超截止时间:反悔,踢掉收益最小的
}
ll sum = 0;
while (!pq.empty()) { sum += pq.top(); pq.pop(); }
return sum;
}

注意点 / 坑点

  1. 贪心正确性必须证明(交换论证 / 归纳),不能凭直觉;复杂贪心先写暴力对拍验证(见 9.10)。
  2. 区间端点「开 / 闭」要看题目定义:不相交用 s.l > last 还是 s.l >= last,读题时确认。
  3. 反悔贪心堆的键方向必须与目标一致(求最大收益就踢最小收益),写反直接错解。
  4. 哈夫曼合并的累加和用 long long,避免大量合并时溢出;堆里元素个数可能到 2n-1。

可拓展的地方

  1. 区间模型可扩展为带权区间调度(排序 + DP + 二分)、最小化最大空闲的区间安排等变体。
  2. 反悔贪心可与可撤销并查集、线段树结合处理更复杂约束(带依赖调度、K 大反悔);交换论证也能迁移到其他排序类贪心(如按比值排序的背包变体)。

典型例题类型

  • 区间调度 / 选点:洛谷 P1803 凌乱的yyy / 线段覆盖(区间调度)、POJ 1328 Radar Installation(区间选点)。
  • 哈夫曼:洛谷 P1090 合并果子(优先队列模板题)。
  • 反悔贪心:洛谷 P4053 [JSOI2007] 建筑抢修(经典反悔贪心)。

9.9 离线算法

一句话用途:当询问很多且允许离线处理时,用 CDQ 分治解决多维偏序(统计满足若干不等式约束的点对),用整体二分批量回答「第 k 小」类询问;二者都要求问题可离线、答案可二分、信息可归并。

算法思想:CDQ 分治先按第一维排序,再递归分治:递归时统计「左半区间对右半区间」的贡献,把三维偏序降成二维(第二维用双指针扫描、第三维用树状数组),分治层数 O(log n),每层 O(n log k)。整体二分把「值域二分」和「询问划分」同时进行:每次把值 ≤ mid 的位置加入树状数组,用区间和判定每个询问的答案落在左半还是右半,递归处理,所有询问共享同一套二分过程,避免每个询问单独二分。

复杂度:CDQ 分治:时间 O(n log² n)(每层 O(n log k),共 log n 层),空间 O(n);整体二分:时间 O((n + m) log V log n)(V 为值域大小),空间 O(n + m)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// ============ 模板一:CDQ 分治求三维偏序(洛谷 P3810 思路) ============
// 对每个元素 j,求满足 a_i <= a_j、b_i <= b_j、c_i <= c_j 且 i != j 的元素个数。
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;

struct Node {
int a, b, c; // 三维坐标
int cnt; // 完全相同的点合并后的个数
int ans; // 满足偏序关系的点数(不含自身)
} p[N];
int n, m, k, bit[N]; // n 原始点数、m 去重后点数、k 为 c 值域;bit 树状数组

void add(int i, int v) { for (; i <= k; i += i & -i) bit[i] += v; }
int sum(int i) { int s = 0; for (; i > 0; i -= i & -i) s += bit[i]; return s; }

// CDQ 分治处理区间 [l, r]:统计"左半对右半"的贡献
void cdq(int l, int r) {
if (l == r) return;
int mid = (l + r) >> 1;
cdq(l, mid); cdq(mid + 1, r); // 先递归,保证两半内部已按 b 有序
// 双指针统计:左半按 b 递增、右半按 b 递增
int i = l;
for (int j = mid + 1; j <= r; j++) {
while (i <= mid && p[i].b <= p[j].b) { // b 值不超过右半当前点
add(p[i].c, p[i].cnt); // 按 c 维度加入树状数组
i++;
}
p[j].ans += sum(p[j].c); // 统计 c 值 ≤ 当前点的左点贡献
}
for (int t = l; t < i; t++) add(p[t].c, -p[t].cnt); // 回滚本层修改
// 归并排序合并 [l, r](按 b 升序),供上层递归继续使用
inplace_merge(p + l, p + mid + 1, p + r + 1, [](const Node& x, const Node& y) { return x.b < y.b; });
}
int main() {
scanf("%d%d", &n, &k);
for (int i = 1; i <= n; i++)
scanf("%d%d%d", &p[i].a, &p[i].b, &p[i].c);
// 先按 (a, b, c) 字典序排序:左半元素的 a 一定 ≤ 右半元素的 a
sort(p + 1, p + n + 1, [](const Node& x, const Node& y) {
return x.a != y.a ? x.a < y.a : (x.b != y.b ? x.b < y.b : x.c < y.c);
});
// 去重:完全相同的点合并,cnt 记录个数(它们两两互相偏序)
m = 0;
for (int i = 1; i <= n; i++) {
if (m > 0 && p[i].a == p[m].a && p[i].b == p[m].b && p[i].c == p[m].c)
p[m].cnt++;
else { p[++m] = p[i]; p[m].cnt = 1; }
}
cdq(1, m);
// 与自己相同的 (cnt-1) 个点也满足偏序,补上
for (int i = 1; i <= m; i++) p[i].ans += p[i].cnt - 1;
// 统计每个等级的出现次数并输出(等级 0..n-1)
static int res[N];
for (int i = 1; i <= m; i++) res[p[i].ans] += p[i].cnt;
for (int i = 0; i < n; i++) printf("%d\n", res[i]);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// ============ 模板二:整体二分(静态区间第 k 小,P3834 思路) ============
// 适用:多组询问、答案可二分、判定可离线批量处理。
#include <bits/stdc++.h>
using namespace std;
const int N = 200005;

struct Op { // 统一事件:修改(数组元素)或查询
int type; // 1 = 修改(位置 x 上的值 y 参与计数);2 = 查询
int x, y; // 修改: x=位置, y=值;查询: x=l, y=r
int k, id; // 查询专用:第 k 小、询问编号
} op[N];
int n, m, ans[N], bit[N];

void add(int i, int v) { for (; i <= n; i += i & -i) bit[i] += v; }
int sum(int i) { int s = 0; for (; i > 0; i -= i & -i) s += bit[i]; return s; }

// 答案值域 [l, r],事件区间 [ql, qr)
void solve(int l, int r, int ql, int qr) {
if (ql >= qr) return;
if (l == r) { // 值域缩成一点:答案确定
for (int i = ql; i < qr; i++) if (op[i].type == 2) ans[op[i].id] = l;
return;
}
int mid = (l + r) >> 1, L = ql, R = qr, i = ql; // 划分:左半 [ql, L)、右半 [R, qr)
while (i < R) {
if (op[i].type == 1) {
if (op[i].y <= mid) { // 值在左半:加入树状数组并放左边
add(op[i].x, 1);
swap(op[i], op[L++]); i++;
} else { // 值在右半:放右边,继续处理换来的元素
swap(op[i], op[--R]);
}
} else {
int cnt = sum(op[i].y) - sum(op[i].x - 1); // 区间内值 ≤ mid 的个数
if (cnt >= op[i].k) { // 第 k 小落在左半
swap(op[i], op[L++]); i++;
} else { // 落在右半:k 减去左半贡献
op[i].k -= cnt;
swap(op[i], op[--R]);
}
}
}
for (int t = ql; t < L; t++) if (op[t].type == 1) add(op[t].x, -1); // 回滚
solve(l, mid, ql, L);
solve(mid + 1, r, R, qr);
}
int main() {
scanf("%d%d", &n, &m);
int tot = 0, mn = INT_MAX, mx = 0;
for (int i = 1; i <= n; i++) {
int v; scanf("%d", &v); op[tot++] = {1, i, v, 0, 0}; // 元素视为"修改"事件
mn = min(mn, v); mx = max(mx, v);
}
for (int i = 0; i < m; i++) {
int l, r, k; scanf("%d%d%d", &l, &r, &k);
op[tot++] = {2, l, r, k, i}; // 询问事件
}
solve(mn, mx, 0, tot); // 值域从最小值到最大值
for (int i = 0; i < m; i++) printf("%d\n", ans[i]);
return 0;
}

注意点 / 坑点

  1. CDQ 只适用于离线 + 可归并的问题;必须先按第一维排序,且相同元素要合并(cnt)并在最后补 cnt - 1
  2. 树状数组用完必须回滚(只撤销本层加入的点),否则污染兄弟子树;撤销范围要与加入范围严格一致。
  3. 整体二分中,询问划分到右半时必须 k -= cnt(扣除左半贡献),漏掉则答案整体偏移。
  4. 整体二分初始值域要覆盖真实最小/最大值(可为负数或 0),不能想当然从 1 开始。

可拓展的地方

  1. CDQ 可推广到四维偏序(套一层 CDQ 或树套树)、动态修改的三维偏序(加时间维)以及离线优化的斜率 DP。
  2. 整体二分可处理「带修改的第 k 小」(把修改拆成删除 + 插入事件)、树上路径第 k 小、以及可持久化线段树的替代方案。

典型例题类型

  • 三维偏序:洛谷 P3810 【模板】三维偏序(陌上花开,CDQ 模板题)、BZOJ 3262。
  • 静态区间第 k 小:洛谷 P3834 【模板】可持久化线段树 2(整体二分可过)、POJ 2104 K-th Number。

9.10 卡常与调试技巧

一句话用途:比赛中最怕「思路对却 TLE/RE」——本节给出快读、开 O2、避免拷贝、数组替代 map 等卡常手段,以及「暴力 + 数据生成器 + 对拍循环」的调试流程,帮助快速定位错误。

算法思想:卡常的本质是减小常数因子:读入用 getchar 手写快读(或关同步的 cin);循环中避免 vector 频繁拷贝(传引用、提前 reserve);小范围查询用数组替代 map/unordered_map(哈希常数大);编译加 -O2。对拍思想:写一个数据规模小的暴力程序和一个随机数据生成器,循环对比两者输出,一旦不一致就保存该样例,再单步调试定位错误。

复杂度:—(工程技巧,无算法复杂度)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ============ 快读 ============
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 快读:比 cin 快数倍,数据量达 1e6 级别时必备
template <typename T>
void read(T& x) {
x = 0; int f = 1; char c = getchar();
while (c < '0' || c > '9') { if (c == '-') f = -1; c = getchar(); }
while (c >= '0' && c <= '9') { x = x * 10 + (c - '0'); c = getchar(); }
x *= f;
}
// 用法:read(n); read(a[i]);
// 简单场景也可直接关同步:
// ios::sync_with_stdio(false);
// cin.tie(nullptr);
// 注意:cin 与 scanf/printf 混用会乱序,全程只用一种
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@echo off
REM ============ 对拍脚本(Windows 批处理) ============
REM 目录下准备三个程序:gen.exe(数据生成器)、std.exe(标准/暴力)、my.exe(待测)
for /l %%i in (1,1,1000) do (
gen.exe > in.txt
std.exe < in.txt > ans.txt
my.exe < in.txt > out.txt
fc /b ans.txt out.txt >nul
if errorlevel 1 (
echo WA at test %%i
copy in.txt wa_in_%%i.txt >nul
goto :end
) else (
echo OK test %%i
)
)
:end
REM 提示:输出行末多余空格/换行会造成误报,必要时先 strip 再比较
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ============ 随机数据生成器(C++) ============
#include <bits/stdc++.h>
using namespace std;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());

int main(int argc, char* argv[]) {
int seed = argc > 1 ? atoi(argv[1]) : 0; // 传入编号做种子,便于复现同一组数据
mt19937 rng(seed);
int n = rng() % 100 + 1; // 随机规模(对拍时用小数据)
cout << n << '\n';
for (int i = 0; i < n; i++)
cout << (int)(rng() % 1000) << " \n"[i == n - 1]; // 随机值域
return 0;
}
// 注意:还要单独手写特殊数据(全 0、最大规模、重复元素、负数),别只靠随机

常见 TLE / RE 排查思路:

1
2
3
4
5
6
7
8
9
10
11
TLE 排查思路:
1. 复杂度:n = 1e5 用 O(n²) 必然超时,先重算复杂度再优化;
2. 常数:vector 频繁拷贝 / clear、map 查找、递归过深、iostream 未关同步;
3. 死循环:while 边界写错、二分 mid 不收敛、BFS 未标记导致无限入队;
4. 剪枝无效:搜索题剪枝条件写错,退化为全量搜索。

RE 排查思路:
1. 数组越界:0 基 / 1 基混乱、vector 访问空、邻接表越界;
2. 栈溢出:深递归(如 1e6 深度)在默认栈上爆栈,改迭代或开全局大数组;
3. 除零 / 取模零:模数为 0、分母为 0(gcd 前先判非零);
4. 未初始化:局部数组未清零、指针悬空、迭代器失效。

注意点 / 坑点

  1. cinscanf/printf 混用(尤其关同步后)会导致读取顺序错乱,全程只选一种 IO 方式。
  2. 对拍只覆盖小数据的正确性,大数据还要单独检查复杂度与内存;生成器要覆盖边界(最小规模、最大规模、重复、负数)。
  3. 调试输出用 cerr(走 stderr)不会污染对拍的 stdout 对比;提交前务必删掉调试打印。
  4. 开 O2 后未定义行为(有符号溢出、越界读写)的表现可能改变,本地 AC 不代表评测机 AC。

可拓展的地方

  1. 快读可扩展为支持 long long / 字符串的模板;输出量大时用 putchar 手写快输。
  2. 对拍可升级为多生成器 + 随机化打乱,本地用 AddressSanitizer / UBSan 检测越界与 UB(比赛环境不可用时靠对拍兜底)。

典型例题类型

  • 无具体题目——这是所有题的通用调试手段;常用于验证「拿不准的贪心 / 搜索」正确性(配合 9.8、9.5)。

9.11 常见坑点汇总

一句话用途:集中列出竞赛中最常犯的错误:int 溢出、负数取模、多测清空、数组大小、浮点误差、初始化与 IO 同步,配合提交前自查清单,最大限度避免「样例过了却 WA/RE」。

算法思想:多数坑点源于 C++ 底层行为与题目数据的边界:int 只有 31 位有效;% 对负数向零取整;全局数组默认清零而局部数组不保证;vector/queue 需要手动清空;浮点比较不能用 ==。把这些规则固化成条件反射,提交前逐条核对清单。

复杂度:—(经验清单,无算法复杂度)。

模板代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ============ 坑点示范代码 ============
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 1) int 溢出:1e9 级别相乘必须用 long long(先转再乘,避免先按 int 溢出)
ll p = 1LL * 1000000000 * 1000000000; // 1e18,正确

// 2) 负数取模:C++ 的 % 向零取整(-7 % 3 == -1)
int normMod(int x, int m) { // 规范化为 [0, m)
return ((x % m) + m) % m; // 对负数也安全
}

// 3) 数组大小经验:线段树 4 倍、SAM/后缀自动机 2 倍、链式前向星 2 倍边数
// const int N = 100005; int seg[N << 2];

// 4) 浮点比较:用 eps 而非 ==(-0.0 与 0.0 也视为相等)
bool eq(double a, double b) { return fabs(a - b) < 1e-9; }

// 5) 多测清空:vector 用 clear(),queue 重新定义,数组用 memset 或时间戳
// memset(vis, 0, sizeof vis); // 前缀和/并查集 fa/h[] 同理

// 6) 输入输出同步:大数据量开场就关(见 9.10);编译用
// g++ -O2 -std=c++17 main.cpp -o main

提交前自查清单:

1
2
3
4
5
6
7
8
9
10
11
□ 溢出:所有乘法/加法是否可能超过 2^31?计数类答案是否要 long long?
□ 数组大小:线段树 4 倍?SAM 2 倍?图 2 倍边?字符串开 +1?
□ 多测清空:vector / queue / vis / 前缀和 / 并查集 fa / 链式前向星 h[] 是否全部重置?
□ 负数取模:((x % m) + m) % m;负数下标转正偏移。
□ 下标越界:0 基 / 1 基统一;循环边界是 < 还是 <=;vector 判空再访问。
□ 浮点:比较用 eps;输出用 fixed << setprecision(k);注意 -0.0。
□ 初始化:局部大数组会爆栈,开全局;全局数组默认清零但多测要重置。
□ IO:大数据量关同步;cin 与 scanf 不混用;输出用 '\n' 而非 endl(endl 会 flush,慢)。
□ 边界数据:n = 1、全相同、最大/最小规模、负数、0、空输入。
□ 递归深度:深递归改迭代 / 手动栈;BFS 入队即标记,防止重复入队。
□ 特判:无解输出、起点 = 终点、图不连通等题目要求的边界输出。

注意点 / 坑点

  1. 样例过了不等于正确:一定要对拍 + 构造边界数据(见 9.10),尤其是 n=1 与全相同这类退化输入。
  2. 不同 OJ 的栈空间、优化与时限不同:本地 AC 不代表评测机 AC,注意平台差异与数据规模上限。
  3. 提交前删掉调试输出:cerr 大量输出会拖慢程序,printf 调试会污染标准输出导致 WA。
  4. 修改模板类代码时,记得同步改相关常量(如 N 增大后忘记改线段树数组大小,越界直接 RE)。

可拓展的地方

  1. 建立个人「错题清单」:每次 WA/RE 的原因归类记录(溢出 / 清空 / 边界……),赛前快速翻阅。
  2. 把易错检查自动化:本地保留 assert 调试断言,提交前统一注释;对拍脚本里自动跑一遍边界数据。

典型例题类型

  • 无具体题目——这是通用工程规范;与 9.10 的对拍手段配合,用于所有题目的提交前检查。

十、附录与 STL 速查

本部分速查表

算法/主题 复杂度或要点 适用场景
STL 容器 vector 尾部 O(1) 均摊、set/map 增删查 O(logn)、unordered_map 均摊 O(1) 数据组织、栈/队列/堆模拟、去重与字典
常用算法函数 sort O(nlogn)、lower_bound/upper_bound O(logn)、unique O(n)、next_permutation 均摊 O(n) 排序查找、去重、排列枚举、前缀和
常用数学公式 等差/等比求和、Pascal/Vandermonde 恒等式、模运算性质、2 的幂表 计数与数论题的公式化推导
时间复杂度参考 1s 约 1e7~1e8 次简单运算;n≤1e5 可承受 O(nlogn) 赛前根据数据规模预估算法可行性
比赛经验与调试技巧 对拍脚本 + 随机生成器 + 自查清单 查错、验证正确性、提交前检查
学习路线建议 语法→枚举→二分→数据结构→图论→数论→DP→字符串→几何→多项式 规划训练顺序、平台与刷题节奏

10.1 STL 容器速查

一句话用途 / 内容:STL 容器是竞赛中组织数据的标准工具,本节约定各容器的常用操作、复杂度与典型坑点。选对容器(vector/set/map/unordered_map)能显著降低编码量与出错率。

容器复杂度总表

容器 随机访问 插入/删除 查找 底层结构
vector O(1) 尾部 O(1) 均摊;中间 O(n) O(n) 动态数组
list 任意位置 O(1)(已知迭代器) O(n) 双向链表
deque O(1) 两端 O(1);中间 O(n) O(n) 分段数组
stack push/pop O(1) 默认 deque
queue push/pop O(1) 默认 deque
priority_queue push/pop O(logn) top O(1) vector 堆
set / multiset O(logn) O(logn) 红黑树
map O(logn) O(logn) 红黑树
unordered_map / unordered_set O(1) 均摊(最坏 O(n)) O(1) 均摊 哈希表
string O(1) 尾部 O(1) 均摊;中间 O(n) find 最坏 O(n·m) 类似 vector<char>

示例代码(带中文注释)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 自定义比较器:priority_queue 专用。注意其语义与 sort 相反——
// 返回 true 表示 a 的"优先级更低",写 a > b 得到堆顶最小的"小根堆"
struct Cmp {
bool operator()(const int& a, const int& b) const { return a > b; }
};

// 自定义哈希:防止 unordered_map 被构造数据卡成 O(n)(splitmix64 + 随机种子)
struct Hash {
static uint64_t splitmix64(uint64_t x) {
x += 0x9e3779b97f4a7c15ULL;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
return x ^ (x >> 31);
}
size_t operator()(uint64_t x) const {
static const uint64_t FIXED_RANDOM =
chrono::steady_clock::now().time_since_epoch().count();
return splitmix64(x + FIXED_RANDOM);
}
};

int main() {
// ---------- vector ----------
vector<int> v;
v.reserve(100000); // 预留容量,避免反复扩容(扩容要 O(n) 拷贝)
for (int i = 0; i < 10; ++i) v.push_back(i); // 尾部插入,均摊 O(1)
v.insert(v.begin(), -1); // 头部插入 O(n),元素整体后移
v.erase(v.begin()); // 删除头部 O(n)
vector<int>().swap(v); // 释放 v 的内存(clear() 只清元素不还内存)

// ---------- stack / queue / priority_queue ----------
stack<int> st; st.push(1); st.pop(); int t = st.top();
queue<int> q; q.push(2); q.pop(); int f = q.front();
priority_queue<int> mx; // 默认大根堆
priority_queue<int, vector<int>, Cmp> mn; // 小根堆(自定义比较器)
mn.push(5); mn.push(1); // mn.top() == 1

// ---------- set / multiset ----------
set<int> s = {3, 1, 2, 3}; // 自动去重升序 {1,2,3}
s.insert(0); s.erase(1); // erase(值) O(logn)
auto it = s.lower_bound(2); // 成员函数 O(logn):第一个 >=2 的迭代器
multiset<int> ms = {1, 1, 2};
ms.erase(1); // 坑:删除"所有"值为 1 的元素!
ms.erase(ms.find(1)); // 想删一个:先 find 再按迭代器删

// ---------- map ----------
map<string, int> cnt;
cnt["apple"]++; // operator[]:不存在时自动插入默认值 0
cnt.insert_or_assign("apple", 3); // C++17:存在则赋值,不存在则插入
cnt.try_emplace("banana", 1); // C++17:不存在才插入,效率更高
for (auto& [k, val] : cnt) { /* 按 key 升序遍历 */ }

// ---------- unordered_map(均摊 O(1),可能被卡) ----------
unordered_map<ll, int> um;
um.reserve(1 << 16); // 预开桶,减少 rehash
unordered_map<ll, int, Hash> safe; // 带自定义哈希,防被卡

// ---------- pair / tuple ----------
pair<int, int> p = {1, 2};
auto [a, b] = p; // C++17 结构化绑定
tuple<int, string, double> tp = make_tuple(1, "s", 2.5);
int x; string y; double z;
tie(x, y, z) = tp; // 或 get<0>(tp) 逐个取
// 注意:pair 自带字典序比较,可直接当 set/map 的 key

// ---------- string ----------
string str = "hello";
str += " world";
size_t pos = str.find("lo"); // 找不到返回 string::npos(极大值)
string sub = str.substr(0, 5); // [0,5),substr 复杂度 O(len)
return 0;
}

注意点 / 坑点

  1. 迭代器失效:vector/deque 的 insert/erase 会使失效位置之后的迭代器全部失效(vector 扩容时全部失效);map/set/multiset 删除只使被删迭代器失效,其余保持有效。循环删除时用 it = s.erase(it) 或先取后继再删,避免迭代器悬挂。
  2. priority_queue 比较器方向相反:自定义比较器写 a > b 才是小根堆(top 最小),与 sort 的升序直觉相反;且比较器必须满足严格弱序(相等元素必须返回 false)。
  3. unordered_map 可能被卡:可构造大量哈希碰撞使复杂度退化到 O(n)(CF/牛客常见套路)。应对:用自定义哈希(splitmix64 + 随机种子,见上)或改用红黑树版 map(稳定 O(logn))。
  4. reserve 与 clear 的内存问题clear() 只析构元素、不释放容量,多测数据大会持续占内存;用 vector<int>().swap(v)shrink_to_fit() 释放。reserve 只预分配不改 size,resize 才改变 size 并默认构造元素。
  5. multiset::erase(值) 删光同值元素ms.erase(x) 删除所有等于 x 的元素,只想删一个必须 ms.erase(ms.find(x))
  6. set/map 成员 lower_bound vs 全局 lower_boundstd::lower_bound(s.begin(), s.end(), x) 在 set 上是 O(n)(无随机访问),必须用成员函数 s.lower_bound(x) 才是 O(logn),upper_bound 同理。

可拓展的地方

  1. 容器适配:deque 可同时当栈和队列;priority_queue 配合”懒删除堆”(用另一堆记录待删元素)可实现可删堆。
  2. 与算法结合:vector 存邻接表(图论标配);map 做离散化(坐标压缩);string + 哈希实现字符串 O(1) 判等。

10.2 常用算法函数速查

一句话用途 / 内容<algorithm><numeric> 提供排序、二分、排列枚举、前缀和等高频函数,本节约定用法与复杂度,避免重复造轮子。

函数速查表

函数 复杂度 要点
sort(begin, end[, cmp]) O(nlogn) 不稳定排序;cmp 须满足严格弱序
stable_sort O(nlogn) 稳定排序,相等元素保持相对顺序,可能多用内存
lower_bound / upper_bound O(logn) 要求区间有序;返回迭代器
binary_search O(logn) 只返回 bool,是否存在
unique + erase O(n) 先排序再去重(unique 只去”相邻重复”)
next_permutation / prev_permutation 均摊 O(n) 下一个/上一个字典序排列
accumulate O(n) 求和,注意初值类型与溢出
partial_sum O(n) 前缀和
max_element / min_element O(n) 返回迭代器
nth_element 均摊 O(n) 第 k 小就位,其余无序
reverse / rotate / fill O(n) 反转 / 旋转 / 填充
gcd / lcm O(log min(a,b)) C++17 在 <numeric>;g++ 另有 __gcd
__builtin_popcountll / __builtin_ctz 等 O(1) GCC 内置位运算(二进制 1 个数、末尾 0 个数等)

示例代码(带中文注释)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

int main() {
vector<int> a = {5, 2, 8, 2, 1, 8};

// ---------- 排序 ----------
sort(a.begin(), a.end()); // 升序 {1,2,2,5,8,8}
sort(a.begin(), a.end(), greater<int>()); // 降序
sort(a.begin(), a.end(), [](int x, int y) { // lambda 自定义:按绝对值
return abs(x) < abs(y);
});

// ---------- 去重(必须"先排序"!) ----------
sort(a.begin(), a.end());
a.erase(unique(a.begin(), a.end()), a.end()); // 经典三件套:排序→unique→erase

// ---------- 二分(要求区间有序) ----------
sort(a.begin(), a.end());
auto it1 = lower_bound(a.begin(), a.end(), 5); // 第一个 >=5 的位置
auto it2 = upper_bound(a.begin(), a.end(), 5); // 第一个 >5 的位置
bool has = binary_search(a.begin(), a.end(), 5);
int cnt = int(upper_bound(a.begin(), a.end(), 5)
- lower_bound(a.begin(), a.end(), 5)); // 5 出现的次数

// ---------- 排列枚举(先升序排序以枚举全部排列) ----------
sort(a.begin(), a.end());
do {
// 对当前排列处理
} while (next_permutation(a.begin(), a.end())); // 无下一排列时返回 false 并重置为升序

// ---------- 求和与前缀和 ----------
ll sum = accumulate(a.begin(), a.end(), 0LL); // 初值写 0LL,防止 int 中途溢出
vector<int> pre(a.size());
partial_sum(a.begin(), a.end(), pre.begin()); // pre[i] = a[0]+...+a[i]

// ---------- 极值与第 k 小 ----------
auto mx = max_element(a.begin(), a.end()); // 返回迭代器,*mx 为最大值
nth_element(a.begin(), a.begin() + 2, a.end()); // 第 3 小元素就位,其余无序

// ---------- 数学与位运算 ----------
int g = gcd(12, 18); // 6,<numeric>,C++17
ll l = lcm(4LL, 6LL); // 12
int pop = __builtin_popcount(7); // 3(int 版)
int popll = __builtin_popcountll(1LL << 40); // 1(long long 版)
int ctz = __builtin_ctz(8); // 3,即 8 = 2^3 的 v2
return 0;
}

注意点 / 坑点

  1. unique 只去相邻重复:不先排序,unique 无法全局去重;unique 返回”新逻辑末尾”的迭代器,必须配合 erase 才能真正缩短容器。
  2. lower_bound 要求有序:在无序区间调用二分函数是未定义行为;对 set/multiset 必须用成员函数版(见 10.1 坑点 6),否则退化为 O(n)。
  3. 比较器必须严格弱序cmp(x,y)cmp(y,x) 不能同时为真,相等元素必须返回 false;写 return x <= y; 会破坏 sort 正确性(乱序甚至崩溃)。
  4. accumulate 溢出与类型accumulate(a.begin(), a.end(), 0) 的 0 是 int,中途可能溢出,初值应写 0LL;同理 1 << 40 溢出 int,应写 1LL << 40
  5. next_permutation 的起始状态:要枚举全部排列需先升序排序;当前排列已是字典序最大时函数返回 false 并把区间重置为升序。
  6. nth_element 只保证第 k 位就位:左右两侧无序,不能当全排序用;复杂度均摊 O(n)。

可拓展的地方

  1. lower_bound 配合 check 函数做”二分答案”,可解最大值最小/最小值最大类问题;nth_element 可快速求中位数、第 k 大。
  2. prev_permutation + next_permutation 可做排列生成器;partial_sumadjacent_difference 构成差分/前缀和的互逆操作,用于区间修改与查询。

10.3 常用数学公式

一句话用途 / 内容:竞赛推导公式速查:求和公式、组合恒等式、常用数列、模运算性质与 2 的幂表,用于把计数/数论问题快速化简为可直接编码的形式。

求和公式

公式 结果
等差数列 a1, a1+d, …, a1+(n-1)d S = n(a1+an)/2 = n(2a1+(n-1)d)/2
等比数列 a1, a1q, …, a1q^(n-1)(q≠1) S = a1(1-q^n)/(1-q);|q|<1 时无穷和为 a1/(1-q)
前 n 个正整数 1+2+…+n = n(n+1)/2
平方和 1²+2²+…+n² = n(n+1)(2n+1)/6
立方和 1³+2³+…+n³ = [n(n+1)/2]²
前 n 个奇数 1+3+5+…+(2n-1) = n²

组合恒等式(C(n,k) 表示 n 选 k,约定 C(n,0)=1,k>n 时为 0):

恒等式 形式
对称性 C(n,k) = C(n,n-k)
Pascal 递推 C(n,k) = C(n-1,k-1) + C(n-1,k)
Vandermonde 卷积 Σ_{i=0..k} C(a,i)·C(b,k-i) = C(a+b,k)
霍克斯坦(Hockey-stick) Σ_{i=k..n} C(i,k) = C(n+1,k+1)
二项式定理特例 Σ_{i=0..n} C(n,i) = 2^n
带系数求和 Σ_{i=0..n} i·C(n,i) = n·2^(n-1)
拆项递推 C(n,k) = n/k · C(n-1,k-1)(k≥1,模意义下配合逆元)

常用数列

  • 斐波那契:F0=0, F1=1, Fn = F(n-1)+F(n-2)。性质:Σ_{i=1..n} Fi = F(n+2)-1;Σ Fi² = Fn·F(n+1);F(m+n) = F(m-1)F(n) + F(m)F(n+1);大 n 用矩阵快速幂 O(logn)。
  • 卡特兰数:Cn = C(2n,n)/(n+1),递推 Cn = Σ_{i=0..n-1} Ci·C(n-1-i),且 Cn = Cn-1·(4n-2)/(n+1)。计数对象:n 对括号的合法序列、n 个节点的不同二叉树形态、凸 n+2 边形三角剖分、不越过对角线的格路。
  • 斯特林数:第二类 S(n,k) = S(n-1,k-1) + k·S(n-1,k),表示把 n 个不同元素分成 k 个非空子集的方案数(边界 S(n,0)=[n=0],S(n,1)=S(n,n)=1);第一类 s(n,k) = s(n-1,k-1) + (n-1)·s(n-1,k),表示 n 个元素排列成 k 个循环置换的方案数。

模运算性质(m 为模数):

  • 分配律:(a+b) mod m = ((a mod m)+(b mod m)) mod m,乘法同理;减法注意 C++ 负数取模:(a%m - b%m + m) % m
  • 除法需要逆元:a/b mod m = a·b⁻¹ mod m;b⁻¹ 存在当且仅当 gcd(b, m) = 1。m 为素数时由费马小定理 b⁻¹ = b^(m-2)(快速幂);一般情形用扩展欧几里得解 b·x ≡ 1 (mod m)。
  • 幂运算:a^b mod m 用快速幂 O(logb);指数 b 极大时(a、m 互质)可先对指数取模 φ(m)。
  • 同余方程:ax ≡ b (mod m) 有解当且仅当 gcd(a,m) | b。

2 的幂表

备注
2^10 1024 ≈ 1e3
2^20 1,048,576 ≈ 1e6 百万
2^30 1,073,741,824 ≈ 1.07e9 int 上限 2^31-1 ≈ 2.1e9
2^40 1,099,511,627,776 ≈ 1.1e12 long long 轻松容纳
2^60 ≈ 1.15e18 long long 上限 ≈ 9.2e18

示例代码(带中文注释)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 1e9 + 7;

ll qpow(ll a, ll b, ll mod = MOD) { // 快速幂:a^b % mod,O(logb)
ll r = 1 % mod; a %= mod;
for (; b; b >>= 1) {
if (b & 1) r = r * a % mod;
a = a * a % mod;
}
return r;
}

ll inv(ll x) { return qpow(x, MOD - 2); } // 费马小定理求逆元(要求 MOD 为素数)

int main() {
int n = 5; // 例:算 C(10,3) 与卡特兰数 C5
int MAX = 2 * n;
vector<ll> fac(MAX + 1), ifac(MAX + 1);
fac[0] = 1;
for (int i = 1; i <= MAX; ++i) fac[i] = fac[i - 1] * i % MOD;
ifac[MAX] = inv(fac[MAX]);
for (int i = MAX; i >= 1; --i) ifac[i - 1] = ifac[i] * i % MOD;

// 组合数函数:C(n,k) = n! / (k! (n-k)!),阶乘预处理后 O(1) 查询
auto C = [&](int nn, int kk) -> ll {
if (kk < 0 || kk > nn) return 0;
return fac[nn] * ifac[kk] % MOD * ifac[nn - kk] % MOD;
};

ll c = C(10, 3); // 120
ll cat = C(2 * n, n) * inv(n + 1) % MOD; // 卡特兰数 C5 = 42

// 斐波那契(n 小直接用递推)
ll f0 = 0, f1 = 1;
for (int i = 2; i <= n; ++i) { ll t = (f0 + f1) % MOD; f0 = f1; f1 = t; }

// C++ 负数取模规范化:-7 % MOD 为负数,需 +MOD 再取模
ll neg = (-7 % MOD + MOD) % MOD;
printf("%lld %lld %lld %lld\n", c, cat, f1, neg);
return 0;
}

注意点 / 坑点

  1. 模运算中除法不可直接取模(a/b) % m ≠ (a%m)/(b%m),必须先求逆元;逆元仅当 gcd(b,m)=1 时存在,m 非素数时不能用费马小定理(需扩展欧几里得)。
  2. C++ 负数取模-7 % 3 == -1(符号与被除数一致),不是数学上的 2,需 (x % m + m) % m 规范化。
  3. 组合数越界与超时:n 较大时直接算 C(2n,n) 会溢出也会超时,必须用阶乘 + 逆元且全程取模;n ≥ 1e7 需线性筛预处理或 Lucas 定理(大组合数 + 小素数模)。
  4. 2 的幂用错类型1 << 40 是 int 运算直接溢出,必须 1LL << 40__builtin_popcount__builtin_popcountll 的参数类型要匹配。
  5. 卡特兰数除法要求互质C(2n,n)/(n+1) 中除以 n+1 需要逆元,当模数不整除 n+1 时可用逆元;n 大到阶乘表放不下时改用不带除法的 DP 递推(如括号序列 DP)。

可拓展的地方

  1. 模运算可扩展为矩阵快速幂求斐波那契、线性递推与 CRT 解同余方程组;逆元可用线性递推 O(n) 预处理 1..n:inv[i] = (m - m/i)·inv[m%i] % m。
  2. 生成函数视角:Vandermonde、卡特兰数与二项式定理都是生成函数的基础,可衔接多项式与 FFT;斯特林数可与容斥、Bell 数结合处理集合划分计数。

10.4 时间复杂度参考

一句话用途 / 内容:赛前根据数据规模快速估算可承受的复杂度,并预估内存与运算量,避免写完才发现过不了。

数据规模与可承受复杂度对照表(按 1s 时限、常数适中的 C++ O2 估计):

数据规模 n 可承受复杂度 代表算法
n ≤ 10 O(n!) 全排列枚举
n ≤ 20 O(2^n) 状压 DP、子集枚举
n ≤ 50 O(n³) 常数小 一般图匹配等
n ≤ 100 ~ 500 O(n³) Floyd、区间 DP
n ≤ 1e3 O(n²) 朴素 DP、暴力
n ≤ 1e4 O(n√n) 分块、莫队
n ≤ 1e5 O(n log n) sort、线段树、Dijkstra、二分答案
n ≤ 1e6 O(n) 线性筛、单调队列、KMP
n ≤ 1e7 线性扫(常数小) 前缀和、差分、双指针
n ≤ 1e9 O(logn) / O(1) 二分、快速幂、数学公式
n ≤ 1e18 O(logn) 矩阵快速幂、欧几里得类

说明:时限 2~3s 或常数很小时可放宽一档;反过来,带大常数(unordered_map、递归、大量取模)时要收紧。

1s 时限的运算量参考(C++,O2 优化,简单运算如加减、数组访问):

  • 约 1e7 ~ 1e8 次简单运算可稳过;带较大常数时按 1e7 量级把握。
  • O(n log n) 在 n = 1e6 时约 2×10^7 次比较,通常可过;n = 1e7 时约 2.3×10^8 次,危险。
  • 取模、除法、浮点运算比加减慢数倍;深递归还有函数调用与栈开销。

内存估算:256MB ≈ 2.68×10^8 字节。

  • int(4B)一维数组理论上限约 6.7×10^7 个,实际留余量按 5×10^7 把握;long long(8B)理论上限约 3.3×10^7 个,实际按 2.5×10^7 把握。
  • 二维:int[3000][3000] = 36MB(安全);int[10000][10000] = 400MB(超限)。
  • 结构体按实际 sizeof 算(对齐可能补位);vector/map 有额外开销(如每个 map 节点约 48B)。

测时代码(带中文注释)

1
2
3
4
5
6
7
8
9
10
11
12
#include <bits/stdc++.h>
using namespace std;

int main() {
auto st = chrono::steady_clock::now();
long long s = 0;
for (int i = 0; i < 1e8; ++i) s += i; // 1e8 次加法实测约 0.1~0.3s(O2)
auto ed = chrono::steady_clock::now();
double ms = chrono::duration<double, milli>(ed - st).count();
printf("time: %.2f ms, s=%lld\n", ms, s);
return 0;
}

注意点 / 坑点

  1. 复杂度只是上界估计:常数差异很大——unordered_map 比数组慢 5~10 倍,% 运算在大循环里可能是瓶颈;大循环内尽量用加减替代取模(或延迟取模)。
  2. 内存单位换算:1MB = 2^20 B 而非 1e6 B;int a[1000000] 是 4MB;提交时注意题目内存限制是 64/128/256MB。
  3. 别只看大 O 忽略 IO:n = 1e5 的 O(n²) 是 1e10 次运算必 TLE;输出量巨大(1e6 行)时用 ios::sync_with_stdio(false) 或 printf 提速。
  4. 多测叠加:T 组数据每组 O(n),总复杂度要乘 T;读题注意 T 的上限(如 T = 1e5 且每组 n = 1e5 就是 1e10)。
  5. 递归深度与栈空间:默认栈约 8MB,1e6 层递归会爆栈,深搜需改迭代或显式栈;大数组要开全局(局部变量在栈上)。

可拓展的地方

  1. 用实际测时代替纯理论估计:本地用题目上限数据压测,记录耗时与内存(Linux time、Windows 任务管理器/性能计数器)。
  2. 复杂度分析与”卡常数”结合:快读快写、数组替代 STL、循环展开、减少取模,在边界数据上争取通过。

10.5 比赛经验与调试技巧

一句话用途 / 内容:对拍、随机造数据与自查清单是保证代码正确性的三板斧:先用对拍找错,再用自查清单快速定位错误类型,最后按提交前清单放行。

对拍方法:同时准备「数据生成器 gen」+「暴力程序 brute(保证正确,可慢)」+「被测程序 sol」,循环生成数据、跑两个程序、比较输出,不一致即输出该组数据供人工分析。

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
# ---- 对拍脚本(Linux / bash:对拍.sh)----
for ((i = 1; i <= 10000; i++)); do
./gen > in.txt # 生成器输出输入数据
./brute < in.txt > ans.txt
./sol < in.txt > out.txt
if ! diff -q ans.txt out.txt > /dev/null; then
echo "WA on case $i"; break
fi
done
echo "All tests passed"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
REM ---- 对拍脚本(Windows / bat:对拍.bat)----
@echo off
for /L %%i in (1,1,10000) do (
gen.exe > in.txt
brute.exe < in.txt > ans.txt
sol.exe < in.txt > out.txt
fc ans.txt out.txt >nul
if errorlevel 1 (
echo WA on case %%i
pause
exit /b
)
)
echo All tests passed

要点:三者输入输出格式必须统一;暴力程序用 O(n²) 甚至更慢都行(生成器把 n 取小),被测程序再单独用题目规模测性能;跑对拍前先手动验证 2~3 组人工数据确认暴力正确。

随机数据生成器示例(带中文注释)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
int rnd(int l, int r) { return uniform_int_distribution<int>(l, r)(rng); } // [l,r] 内随机

int main(int argc, char** argv) {
if (argc > 1) rng.seed(atoi(argv[1])); // 对拍时用参数作种子:出错后可用同一种子复现
int n = rnd(1, 10), m = rnd(0, 15); // 数据开小,方便暴力程序跑
printf("%d %d\n", n, m);

// 随机排列
vector<int> p(n); iota(p.begin(), p.end(), 1);
shuffle(p.begin(), p.end(), rng);

// 随机树:每个点 i 连向 [1, i-1] 中随机一个父亲,保证连通且无环
for (int i = 2; i <= n; ++i)
printf("%d %d\n", i, rnd(1, i - 1));

// 随机图:先连链保证连通,再按概率加随机边
vector<pair<int, int>> edges;
for (int i = 2; i <= n; ++i) edges.push_back({i, rnd(1, i - 1)});
for (int i = 1; i <= n; ++i)
for (int j = i + 1; j <= n; ++j)
if (rnd(1, 100) <= 30) edges.push_back({i, j}); // 30% 概率加边
// 按需 shuffle(edges.begin(), edges.end(), rng) 打乱输出顺序
return 0;
}

常见错误自查清单

  1. 数组越界:访问 a[i] 时 i 可能为负或 ≥ n;数组多开 n+5 余量;循环内用 i+1、i-1 时检查边界。
  2. 未初始化:局部数组/变量不会自动清零;vector<vector<int>> 记得 resize(n+1) 再用;结构体成员初始化。
  3. 多测未清空:每轮测试前 clear 所有全局容器、重置 vis 标记(可用时间戳技巧避免全量 memset)、重置并查集与计数器。
  4. 类型溢出:int 乘 int 先溢出再赋给 ll;1 << 40 溢出;n*(n+1)/2 在 n = 1e5 时约 5e9 需 ll;求和、距离、计数一律用 ll。
  5. 取模顺序:减法先加模 (a-b+m)%m;乘法每步 %;两个 ll 相乘取模时 (a%m)*(b%m)%m,m 很大时用 __int128 或快速乘。
  6. 二分死循环mid = (l+r)/2r = midmid = (l+r+1)/2l = mid,取整方向与更新式不匹配会死循环。
  7. 输入输出:关掉同步后不要混用 printf 与 cin;getline 前残留换行;%s 读字符串注意长度上限。
  8. 浮点误差:判等用 fabs(a-b) < eps;输出用 fixed << setprecision(6);几何题优先整数或 long double。
  9. 内存与栈:大数组开全局(局部栈约 8MB);递归过深改迭代或手写栈。

提交前检查清单

  • 删除本地的 freopen / 文件读写残留(或确认题目要求的文件名正确)
  • 数组大小 ≥ 题目上限 + 余量;所有可能溢出处用 long long
  • 多测数据完整读入再处理;每组数据状态清空
  • 去掉调试输出与注释掉的测试代码;确认无越界、野指针等未定义行为
  • 输出格式:大小写、空格、换行、Case #x: 前缀、末尾换行
  • 选择正确语言标准(C++17)与优化选项;确认无本地环境依赖
  • 边界数据自测:n=0/1、最大最小、全相同、降序、极限数据
  • 重读一遍题目:输入输出顺序、取模要求、多测组数读法

可拓展的地方

  1. 对拍可加”特殊生成器”:专门构造边界/极端数据(全 0、全相同、最大 n、随机大数)单独跑,比纯随机更能暴露问题。
  2. 沉淀造数据工具库:把常用生成器(树、图、排列、大整数、字符串)收集成自己的工具,配合 stress test(随机小 n 对比暴力)与 performance test(最大 n 测时限)双轨使用。

10.6 学习路线建议

一句话用途 / 内容:给出一份从零到进阶的训练路线、平台与节奏建议,帮助把刷题量转化为稳定的竞赛能力。

推荐学习顺序(按依赖关系排列,建议每个阶段配 30~80 道题):

  1. C++ 基础语法:输入输出(cin/scanf、快读)、循环、数组、函数、结构体、STL 入门(vector/string/map)。
  2. 枚举与模拟:暴力枚举、按题意模拟、打表找规律(训练把题意翻译成代码)。
  3. 二分与贪心:二分答案(最大值最小/最小值最大)、三分、经典贪心及其证明。
  4. 基础数据结构:栈、队列、链表、堆(priority_queue)、并查集、单调栈/单调队列、树状数组、线段树、ST 表。
  5. 图论入门:DFS/BFS、拓扑排序、最短路(Dijkstra、Floyd、Bellman-Ford/SPFA)、最小生成树(Kruskal、Prim)、LCA。
  6. 数论:素数筛(埃氏/线性)、gcd/exgcd、快速幂与逆元、同余与模运算、CRT、欧拉函数、莫比乌斯反演(进阶)。
  7. 动态规划:线性 DP、背包(01/完全/多重)、区间 DP、状压 DP、树形 DP、数位 DP、DP 优化(滚动数组、单调队列、斜率优化)。
  8. 字符串:KMP、字符串哈希、Trie、AC 自动机、Manacher、后缀数组(进阶)。
  9. 计算几何:点/向量/叉积、线段相交、凸包(Andrew/Graham)、旋转卡壳(进阶)。
  10. 进阶与多项式:网络流(最大流/费用流)、分治(CDQ、整体二分)、莫队、FFT/NTT、Splay/LCT、可持久化数据结构。

推荐练习平台

平台 特点 建议用法
洛谷 中文题库全、题解详细、有难度分级与题单 入门与专题训练首选,按题单刷
Codeforces 全球高质量比赛、题目新颖、有 Rating 体系 每周打 Div.2/Div.3,赛后补题
AtCoder ABC 难度友好、题目质量高、题面规范 入门~中级选手的日常练习
Vjudge 聚合多 OJ(POJ/HDU/UVA)、有现成专题 参加 ACM 专题训练
牛客 国内比赛多、有周赛与多校联合训练 模拟国内赛制、笔试准备

刷题节奏建议

  • 日常:保持每天 1~2 题,会做的题不写错、不会的题补会;同一知识点连刷 5 题以上形成肌肉记忆。
  • 专题周:每周固定一个专题(如本周树状数组),只刷该专题题,配合读模板与总结,循序渐进不跳级。
  • 比赛:每周至少一场正式比赛(CF Div.2 / AtCoder ABC / 洛谷月赛);赛后补题至少补出 2~3 题并记录错因。
  • 复盘:每周复盘错题,把”想错的原因”写进个人错误清单(对应 10.5 的自查清单),赛前扫一遍。

模板整理方法

  • 每学一个算法写一个可复用、带中文注释、可独立编译的模板(如 mytemplate/ds/segtree.cpp),头部写明适用条件、复杂度与边界;配 1~2 道例题记录用法。
  • 按主题建文件夹:graph/math/dp/string/data-structure/;每个模板文件头部写清”适用场景 + 坑点”。
  • 定期重构:赛后发现更简洁写法或踩了新坑就更新模板;模板要能 5 分钟内默写核心,考前只复习模板与自查清单。

注意点 / 坑点

  1. 不要过早深入高级算法:模拟、二分、DFS/BFS、背包、最短路不扎实时学 FFT/LCT 是浪费时间;按依赖顺序,前一阶段不熟不进入下一阶段。
  2. 刷题量 ≠ 能力:只看题解不自己写、只复制模板不默写,比赛照样卡壳;每道题至少独立写完并 AC 一次。
  3. 必会模板要熟到条件反射:快速幂、并查集、Dijkstra、线段树等高频模板赛前烂熟,考试时间只花在思考上。
  4. 注意平台差异:不同 OJ 的时限、栈大小、C++ 标准不同,练习时就按比赛标准提交(如 CF 用 GNU C++17),避免”本地能过、OJ 编译错”。

可拓展的地方

  1. 进入进阶后按”知识网络”组织学习:图论与 DP 结合(树形 DP、图上 DP)、数论与组合结合(容斥、莫比乌斯)、字符串与数据结构结合(AC 自动机 + DP),形成跨专题解题能力。
  2. 维护个人”比赛复盘文档”与”模板库”长期迭代:把每次比赛的失误、每个新算法的模板沉淀下来,赛季末就是一本个人算法笔记。