第 14 讲 · 实战
洛谷题单:例题与作业
看懂和写出来之间隔着一百道题。这一讲把整门课的知识点映射到可提交的编程题上, 每题都给出「考点 → 思路 → 容易踩的坑」,模板题附完整 C++ 代码。
关于题号(请务必先读)
洛谷题库会持续更新,题号与难度标签可能变动。本讲的题号均为编写时的常见对应关系,
如果某道题号打开后不是你期望的题目,请直接在洛谷站内搜索题目名称
(本站每一题都给出了中文题目名,搜索即可命中)。
做题时请以站内的实际题面、数据范围与时空限制为准。
14.1 怎么用这份题单
三步做题法
- 先读题、定算法:看数据范围反推复杂度。
例如
n ≤ 1000允许 O(n²),n ≤ 10⁵只允许 O(n log n),n ≤ 20想状压 / 搜索剪枝。 - 手推小样例:自己造一组 3~5 个元素的数据,用纸笔跑一遍自己的算法,确认逻辑无误再写代码。
- WA 了先自己调试:把中间变量打印出来,用课件里的动画对照「程序的状态变化」和「算法的正确状态变化」差在哪一步。
必备的自查习惯
- 数组开够了吗? 链式前向星要开
2 * m;线段树要开4 * n。 - 初始值对吗? 求最小值时
INF够大吗?求最大值时负无穷设了吗? - 会不会爆 int? 看到乘法、求和、权值累加,先想
long long。 - 边界处理了吗? n = 1、空串、只有一条边的图、完全图。
- 多组数据清空了吗? 全局数组、
vector、并查集都要重置。
难度标签对照
入门
普及−
普及/提高−
普及+/提高
提高+/省选−
省选/NOI−
—— 建议顺序:先把每章的 入门 与 普及− 清完,
再挑 普及/提高−,最后挑战 普及+/提高 以上。
14.2 分阶段作业计划
阶段一 · 线性结构(配合第 01~04 讲)
目标:把顺序表、链表、栈、队列的代码变成肌肉记忆。
- 用数组模拟栈 / 队列各写一遍(不用 STL)
- 完成「模拟链表」类题目 2 道
- 完成「括号匹配 / 表达式求值」类题目 2 道
阶段二 · 串与树(配合第 05~07 讲)
目标:KMP 能独立默写,树的遍历能随手写。
- KMP 模板题过掉,并自己造数据验证 π 数组
- 二叉树的三种遍历 + 层序遍历各写一遍
- 赫夫曼编码 / 堆的模板题 2 道
阶段三 · 图论(配合第 08~09 讲)
目标:五种图论算法的模板都能盲写。
- 并查集、Dijkstra、Floyd、Kruskal、拓扑排序各 1 道模板题
- DFS / BFS 求连通块 2 道
- 关键路径 / 最长路 1 道
阶段四 · 排序、查找与 DP(配合第 10~13 讲)
目标:能根据数据范围选择正确的算法与数据结构。
- 归并排序求逆序对、快速排序优化各 1 道
- 二分答案 2 道(体会「答案单调性」)
- 背包 3 道 + LIS / LCS 各 1 道
- 综合模拟赛 1 场(3 小时 4 题)
14.3 第 01~04 讲 · 线性结构题单
14.3.1 数组、模拟与栈
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P1046 | 陶陶摘苹果 | 入门 | 数组遍历 | 热身题。把 10 个高度读进数组,统计 h + 30 ≥ a[i] 的个数。 |
| P1047 | 校门外的树 | 入门 | 数组标记 / 差分 | 最直观是开一个布尔数组把区间内的树标记掉,最后数剩下的;进阶可以用差分数组把每个区间操作降到 O(1)。 |
| P2367 | 语文成绩 | 普及− | 差分数组 | 多次区间加、最后一次查询——差分数组的模板题。d[l] += v; d[r+1] -= v; 最后做一次前缀和。 |
| P1739 | 表达式括号匹配 | 入门 | 栈 / 计数器 | 第 03 讲的括号匹配。因为只有一种括号,其实用一个计数器就够了;如果扩展到三种括号就必须用栈。 |
| P1449 | 后缀表达式 | 普及− | 栈 + 表达式求值 | 题目用 . 分隔操作数、@ 结束。注意「先弹出的是右操作数」——减法和除法顺序别反。 |
| P1981 | 表达式求值 | 普及/提高− | 双栈 / 中缀求值 | 只有 + 和 ×,且要求结果 mod 10000。可以先把乘法算完(用栈存「待加的项」),再统一相加。 |
| P1177 | 【模板】快速排序 | 普及− | 快速排序 | 第 11 讲的快排模板。这题数据会卡「取端点做基准」的写法,务必用随机基准或三数取中。 |
| P1908 | 逆序对 | 普及/提高− | 归并排序 / 树状数组 | 归并排序在合并时统计「右边元素比左边先放」的次数,就是逆序对数。注意答案要开 long long。 |
配套练习:手写模拟链表
用数组模拟单链表(静态链表)完成「在指定位置插入 / 删除」,并自己写
main 对拍验证。
这题洛谷没有直接对应,但它是理解链式前向星的前提,务必动手写一遍。
14.3.2 队列与单调结构
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P1540 | 机器翻译 | 普及− | 队列 + 哈希/标记 | 典型 FIFO 场景:内存满时淘汰最早进入的单词。用 queue 配一个布尔数组记录「是否在内存中」。 |
| P1886 | 滑动窗口 /【模板】单调队列 | 普及/提高− | 单调队列 | 第 04 讲的单调队列。求最大值用单调递减队列,求最小值用单调递增;出队条件注意「队首是否已滑出窗口」。 |
| P1440 | 求 m 区间内的最小值 | 普及/提高− | 单调队列 | 同一套模板换个问法。注意输出格式与前 m−1 个位置的答案约定。 |
| P1160 | 队列安排 | 普及/提高− | 双向链表 / 数组模拟 | 频繁在某人的左边/右边插入、以及删除——典型的双向链表应用。用数组模拟双向链表(l[]、r[])比指针更快。 |
14.3.3 串与 KMP
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P3375 | 【模板】KMP | 普及/提高− | KMP / π 数组 | 第 05 讲的模板题。输出要求:先输出所有出现位置(1 基),再输出模式串的 π 数组(教材版 next)。注意两种约定的换算。 |
| P4391 | Radio Transmission 无线传输 | 普及+/提高 | KMP 最小循环节 | 答案就是 n − π[n-1]。想清楚为什么「不完整」的循环节也成立。 |
| P3538 | OKR-A Horrible Poem | 提高+/省选− | 循环节 + 字符串哈希 | 判断区间是否为周期串,用哈希 O(1) 比较;枚举循环节长度时只需枚举质因子。 |
| P2375 | 动物园 | 提高+/省选− | KMP 进阶(num 数组) | 在 π 的基础上再做一次「受限回退」,是理解 KMP 均摊分析的最佳练习。 |
| P3370 | 【模板】字符串哈希 | 普及− | 字符串哈希 | 把每个字符串映射成整数后丢进 set 统计不同个数。用第 10 讲的双模数哈希最稳。 |
| P3805 | 【模板】Manacher | 普及+/提高 | 回文串 | 与 KMP 属于同一类「利用已经算出的信息避免重复比较」的思想,学完 KMP 再看会很好懂。 |
14.3.4 例题精讲:KMP 模板题(P3375)
为什么要专门讲这道题
它要求同时输出「所有匹配位置」与「next 数组」,是检验你是否真正分清 KMP 两种下标约定的最好题目。
#include <bits/stdc++.h>
using namespace std;
/* 洛谷 P3375【模板】KMP
输入:两行,第一行主串 S,第二行模式串 P
输出:第一行所有匹配位置(1 基下标,空格分隔,行末也有空格)
第二行模式串的 next 数组(教材 1 基约定:next[1]=0, next[i]=最长相等真前后缀长度+1) */
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
string s, p;
cin >> s >> p;
int n = s.size(), m = p.size();
/* ---------- 1. 求 next 数组(1 基,教材约定) ----------
nxt[i] 表示:p[1..i-1] 的最长相等真前后缀长度 + 1
等价于 0 基前缀函数 pi 的 "pi[i-2] + 1" */
vector<int> nxt(m + 1, 0);
nxt[1] = 0;
for (int i = 2, j = 0; i <= m; ++i) {
while (j && p[i - 1] != p[j]) j = nxt[j]; // 注意:教材的写法是 j = nxt[j]
if (p[i - 1] == p[j]) ++j;
nxt[i] = j;
}
/* ---------- 2. KMP 匹配(1 基) ---------- */
for (int i = 1, j = 0; i <= n; ++i) {
while (j && s[i - 1] != p[j]) j = nxt[j];
if (s[i - 1] == p[j]) ++j;
if (j == m) { // 匹配成功,起点是 i - m + 1
cout << i - m + 1 << ' ';
j = nxt[j]; // 继续找下一次出现(若只要第一次则 break)
}
}
cout << '\n';
/* ---------- 3. 输出 next[1..m] ---------- */
for (int i = 1; i <= m; ++i) cout << nxt[i] << ' ';
cout << '\n';
return 0;
}
14.3.5 例题精讲:归并排序求逆序对(P1908)
逆序对是「排序 + 分治」的经典结合。归并排序在合并两个有序段时,如果右边段的元素 先被放进结果数组,就说明它比左边段剩下的所有元素都小——这一批「左边剩余元素」恰好构成 「以该元素为右端点的逆序对」。于是统计工作可以完全融进归并过程,不增加任何复杂度。
#include <bits/stdc++.h>
using namespace std;
/* 洛谷 P1908 逆序对
思路:归并排序的同时统计逆序对,O(n log n)
关键:合并时若右段元素先出,则它与"左段剩余的所有元素"都构成逆序对,
一次累加 mid - i + 1 个(这里用 1 基的 [l, mid] 与 [mid+1, r]) */
long long ans = 0; // 逆序对数最多 n(n-1)/2,n = 5e5 时约 1.25e11,必须开 long long
vector<int> a, tmp;
void msort(int l, int r) {
if (l >= r) return;
int mid = l + (r - l) / 2; // 防止 (l + r) 溢出
msort(l, mid);
msort(mid + 1, r);
int i = l, j = mid + 1, k = l;
while (i <= mid && j <= r) {
if (a[i] <= a[j]) { // 取等号放左边,保证稳定性
tmp[k++] = a[i++];
} else {
tmp[k++] = a[j++];
ans += mid - i + 1; // 左段从 i 到 mid 的元素都比 a[j] 大
}
}
while (i <= mid) tmp[k++] = a[i++];
while (j <= r) tmp[k++] = a[j++];
for (int t = l; t <= r; ++t) a[t] = tmp[t];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
a.assign(n + 1, 0);
tmp.assign(n + 1, 0);
for (int i = 1; i <= n; ++i) cin >> a[i];
msort(1, n);
cout << ans << '\n';
return 0;
}
/* 另一种同样常见的写法:树状数组 + 离散化
思路:把元素按值离散化成 1..k,从后往前扫,
每次查询"比当前元素小的已出现个数"即为其贡献的逆序对数。
复杂度同样是 O(n log n),但常数更小、代码更短(适合作为对拍的第二个实现)。 */
三个常见 WA 原因
- 没开
long long:n = 5×10⁵ 时逆序对可达 1.25×10¹¹,int直接溢出。 - 合并时没写
<=:写成<会把相等元素也算成逆序对,答案偏大且排序不再稳定。 - 递归边界写成
l == r却没有提前返回,导致无限递归或漏掉单元素区间。
14.3.6 例题精讲:单调队列求滑动窗口最大值(P1886)
单调队列的思想是:如果一个元素比队尾元素更大、而且位置更靠后,那么队尾元素永远不可能再成为答案, 于是可以直接把它从队尾弹掉。每个元素最多入队一次、出队一次,所以整个算法是 O(n)。
#include <bits/stdc++.h>
using namespace std;
/* 洛谷 P1886 滑动窗口 /【模板】单调队列
输入:n k,然后 n 个整数
输出:第一行每个窗口的最小值,第二行每个窗口的最大值 */
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
cin >> n >> k;
vector<int> a(n + 1);
for (int i = 1; i <= n; ++i) cin >> a[i];
deque<int> dq; // 存"下标",队列里的值保持单调
/* ---------- 最小值:维护单调递增队列 ---------- */
for (int i = 1; i <= n; ++i) {
while (!dq.empty() && a[dq.back()] >= a[i]) dq.pop_back(); // 队尾更大且更靠前 → 永远用不上
dq.push_back(i);
if (dq.front() <= i - k) dq.pop_front(); // 队首滑出窗口
if (i >= k) cout << a[dq.front()] << ' ';
}
cout << '\n';
/* ---------- 最大值:维护单调递减队列(只需把比较方向反过来) ---------- */
dq.clear();
for (int i = 1; i <= n; ++i) {
while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
dq.push_back(i);
if (dq.front() <= i - k) dq.pop_front();
if (i >= k) cout << a[dq.front()] << ' ';
}
cout << '\n';
return 0;
}
一句话记住单调队列
求最小值用递增队列、求最大值用递减队列;两个动作的顺序是「先弹队尾(淘汰没用的),再压入自己,最后弹队首(淘汰过期的)」。
这个「淘汰过期元素」的判据
dq.front() <= i - k 是最容易写错的地方:注意是 <= 而不是 <。
14.4 第 05~07 讲 · 串、树与堆题单
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P1305 | 新二叉树 | 普及− | 二叉树的遍历 | 输入是「根 左 右」的形式,直接按前序遍历输出即可。注意用字符数组存左右孩子。 |
| P1030 | 求先序排列 | 普及/提高− | 中序 + 后序 → 前序 | 后序的最后一个字符是根;在中序里找到它来划分左右子树。递归处理,注意子串边界。 |
| P1827 | American Heritage | 普及/提高− | 树的遍历还原 | 与上一题同理(中序 + 前序)。练兵的好题。 |
| P1364 | 医院设置 | 普及/提高− | 树的遍历 / 最短路 | n 很小(≤100),对每个点做一次 BFS 求带权和,取最小值即可。 |
| P3378 | 【模板】堆 | 普及− | 二叉堆 | 先手写一遍 push/pop/top 理解上浮下沉,再用 priority_queue 写一遍对比。 |
| P1090 | 合并果子 | 普及− | 赫夫曼树 / 贪心 + 堆 | 每次合并最小的两堆——这正是赫夫曼算法。用 priority_queue<int, vector<int>, greater<int>>(小根堆)。 |
| P2168 | 荷马史诗 | 提高+/省选− | k 叉赫夫曼树 | P1090 的进阶版:k 叉赫夫曼 + 要求树高最小。注意补 0 权值结点使 (n−1) % (k−1) == 0。 |
| P3374 | 【模板】树状数组 1 | 普及/提高− | 树状数组 | 单点修改 + 区间查询。lowbit(x) = x & -x 是核心,理解「每个结点管多长一段」。 |
| P3367 | 【模板】并查集 | 普及− | 并查集 | 路径压缩必写;再加上按秩/按大小合并可以更快。这题是第 09 讲 Kruskal 的前置。 |
| P1551 | 亲戚 | 普及− | 并查集 | 并查集最直观的应用。写完 P3367 后 5 分钟即可 AC。 |
14.5 第 08~09 讲 · 图论题单
14.5.1 图的遍历
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P5318 | 【深基18.例3】查找文献 | 普及/提高− | DFS / BFS 遍历 | 要求按编号从小到大访问邻接点——所以邻接表里的每个链表要先排序。这是最容易 WA 的点。 |
| P3916 | 图的遍历 | 普及/提高− | 反向建图 + DFS | 正向做是 O(n²)。把边反向,从大到小枚举起点做 DFS,每个点第一次被访问时的起点编号就是答案。 |
| P1330 | 封锁阳光大学 | 普及/提高− | 二分图染色 | 黑白染色,取两种颜色中较少的一边。注意图可能不连通,要对每个连通块分别处理。 |
| P1144 | 最短路计数 | 普及+/提高 | BFS / Dijkstra + 计数 | 无权图用 BFS:当 dist[v] == dist[u] + 1 时把方案数累加。是理解「最短路 DAG」的好题。 |
14.5.2 最短路
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P3371 | 【模板】单源最短路径(弱化版) | 普及/提高− | Dijkstra / SPFA | 数据较弱,朴素 Dijkstra O(n²) 可过。不求最优写法,先把算法写对。 |
| P4779 | 【模板】单源最短路径(标准版) | 普及+/提高 | 堆优化 Dijkstra | n、m 到 10⁵ 级别,必须用堆优化 + 链式前向星,且要写「过期元素 if (d > dist[u]) continue;」。 |
| P2419 | Bessie 的体重问题 | 普及/提高− | Floyd 传递闭包 | n ≤ 100,直接 Floyd 求可达性。若一个点能被确定的相对位置有 n−1 个,答案 +1。 |
| P1119 | 灾后重建 | 普及+/提高 | Floyd 的在线化 | 经典题:把 Floyd 的最外层 k 变成「按时间逐个解禁村庄」,深刻理解 k 的物理含义(中转点集合)。 |
| P3385 | 【模板】负环 | 普及+/提高 | Bellman-Ford / SPFA | 若某个点入队次数 ≥ n,则存在负环。也可以用「最短路径边数 ≥ n」来判定。 (想练手也可以站内搜索「负环」「SPFA 判负环」找更多同类题。) |
14.5.3 生成树、拓扑与关键路径
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P3366 | 【模板】最小生成树 | 普及/提高− | Kruskal / Prim | 建议两种都写一遍:Kruskal 排序 + 并查集;Prim 用 priority_queue。注意判「图不连通」时输出 orz。 |
| P1195 | 口袋的天空 | 普及/提高− | Kruskal 变体 | 「连成 k 个云朵」= 只选 n−k 条边。抓住 Kruskal 的本质:每选一条边就减少一个连通块。 |
| P1546 | Shortest network Agri-Net | 普及/提高− | 最小生成树 | 输入是邻接矩阵,注意读入格式(一行一个数)。Prim 的裸题。 |
| P1113 | 杂务 | 普及/提高− | 拓扑排序 + DP | 经典的 DAG 上求最长路:f[v] = max(f[v], f[u] + time[v])。是理解关键路径的前置题。 |
| P4017 | 最大食物链计数 | 普及/提高− | 拓扑排序 + 计数 DP | 拓扑序上做路径计数,注意入度为 0 的点初始化 f = 1,出度为 0 的点累加答案。 |
| P1807 | 最长路 | 普及/提高− | DAG 最长路 / SPFA | 有向无环图可以直接按拓扑序 DP;有环时要注意正环问题(本题保证无正环)。 |
| P1347 | 排序 | 普及+/提高 | 拓扑排序 + 判环/唯一性 | 边加边判断:出现环 → 矛盾;队列中同时存在多个入度 0 的点 → 还不能确定唯一顺序。非常考察对 Kahn 算法的理解。 |
建议手推的关键路径练习
洛谷上没有直接的关键路径模板题,但 P1113 杂务 就是它的简化版。
请自己画一张 AOE 网(8 个事件、10 条活动),完整算出 ve、vl、e、l 四个数组并找出关键活动,
再对照第 09 讲的动画检查每一步。这是 408 大题的常客。
14.5.4 例题精讲:堆优化 Dijkstra 完整模板(P4779 风格)
最短路题的难点往往不在算法本身,而在「建图 + 松弛 + 路径还原 + 边界」四件事都要写对。 下面这份模板把这些全都包含进去了,可以直接当比赛模板用。
#include <bits/stdc++.h>
using namespace std;
/* 洛谷 P4779【模板】单源最短路径(标准版)
要求:n, m 到 1e5 级别,边权非负,输出源点到每个点的最短路长度(不可达输出 2^31-1)
这份模板同时演示:链式前向星建图、堆优化 Dijkstra、路径还原、long long 防溢出 */
const int MAXN = 100005;
const long long LINF = (1LL << 62);
int head[MAXN], nxt[MAXN * 2], to[MAXN * 2], wt[MAXN * 2], ecnt = 0;
/* 注意:无向图每条边要加两次,所以数组要开 2*m;有向图开 m 即可 */
void addEdge(int u, int v, int w) {
to[ecnt] = v; wt[ecnt] = w; nxt[ecnt] = head[u]; head[u] = ecnt++;
}
int n, m, s;
vector<long long> dist;
vector<int> pre; // 路径还原:pre[v] = 最短路树上 v 的父结点
void dijkstra(int src) {
dist.assign(n + 1, LINF);
pre.assign(n + 1, 0);
/* 小根堆:pair 的 first 是距离,默认按 first 比较;greater<> 让它变成小根堆 */
priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<>> pq;
dist[src] = 0;
pq.push({0, src});
while (!pq.empty()) {
auto [d, u] = pq.top(); pq.pop();
if (d > dist[u]) continue; // 关键!堆里可能有过期状态,必须跳过
for (int e = head[u]; e != -1; e = nxt[e]) {
int v = to[e];
if (d + wt[e] < dist[v]) { // 松弛
dist[v] = d + wt[e];
pre[v] = u; // 记录是从 u 走过来的
pq.push({dist[v], v});
}
}
}
}
/* 还原 src → t 的路径(不含 src 时把 src 排除即可) */
vector<int> getPath(int src, int t) {
vector<int> path;
if (dist[t] == LINF) return path; // 不可达
for (int cur = t; cur != 0; cur = pre[cur]) {
path.push_back(cur);
if (cur == src) break;
}
reverse(path.begin(), path.end());
return path;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> s;
memset(head, -1, sizeof(int) * (n + 1)); // 只清零前 n+1 个,避免 O(MAXN) 的常数
for (int i = 0; i < m; ++i) {
int u, v, w;
cin >> u >> v >> w;
addEdge(u, v, w); // 有向图加一次;无向图这里要再加一条 addEdge(v, u, w)
}
dijkstra(s);
for (int i = 1; i <= n; ++i) {
if (dist[i] == LINF) cout << (1LL << 31) - 1 << ' '; // 按题目要求输出不可达的表示
else cout << dist[i] << ' ';
}
cout << '\n';
/* 示例:输出 s → n 的路径(很多最短路题的第二问都是这个) */
vector<int> path = getPath(s, n);
for (size_t i = 0; i < path.size(); ++i) {
cout << path[i];
if (i + 1 < path.size()) cout << " -> ";
}
if (!path.empty()) cout << '\n';
return 0;
}
最短路最常踩的五个坑
- 忘记
if (d > dist[u]) continue;:堆里可能存着同一个点的多个过期距离,不跳过会重复松弛,最坏退化成 O(nm)。 - 无向图数组没开两倍:边结点数是 2m,数组开成 m 会越界(这种错误常常只在大数据下才崩,非常难查)。
- 用
int存距离:多条边累加会溢出,一律用long long。 - 把
INF设成0x7fffffff:两个 INF 相加直接溢出成负数。用0x3f3f3f3f(约 10⁹,两倍仍在 int 范围内)或1LL << 62。 - 图不连通时没处理:题目通常要求输出一个特定值(如 2³¹−1 或 -1),别直接输出一个巨大的 INF。
14.6 第 10~12 讲 · 查找、排序与 DP 题单
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P2249 | 【深基13.例1】查找 | 普及− | 二分查找 | 要求「第一个等于 x 的位置」——就是 lower_bound。建议先用 lower_bound 过,再手写一遍边界。 |
| P1102 | A−B 数对 | 普及/提高− | 二分 / 双指针 / 哈希 | 排序后对每个 a[i] 二分查找 a[i]+C 的出现次数(upper_bound − lower_bound)。 |
| P1024 | 一元三次方程求解 | 普及− | 二分 / 枚举 | 根在 [−100, 100] 内,每 1 个单位区间若两端函数值异号则二分求根。练习「二分实数」。 |
| P1182 | 数列分段 Section II | 普及/提高− | 二分答案 | 「最大值最小」的经典二分答案:二分每段和的上界,check 函数贪心分段看是否 ≤ M 段。 |
| P2678 | 跳石头 | 普及/提高− | 二分答案 | 二分最短跳跃距离,check 函数统计需要搬走的石头数。二分答案的三要素:答案单调、check 可行、边界正确。 |
| P1177 | 【模板】快速排序 | 普及− | 快速排序 | 见 14.3.1。 |
| P1059 | 明明的随机数 | 入门 | 排序 + 去重 | sort + unique + erase 三连,或直接用 set。 |
| P1090 | 合并果子 | 普及− | 堆 | 见 14.4,同时也是「贪心 + 优先队列」的入门题。 |
| P1216 | 数字三角形 | 普及− | 线性 DP | DP 的第一步:f[i][j] = max(f[i-1][j-1], f[i-1][j]) + a[i][j]。可以用滚动数组压成一维。 |
| P1048 | 采药 | 普及− | 01 背包 | 背包入门。一维写法时容量必须倒序枚举,想清楚为什么。 |
| P1616 | 疯狂的采药 | 普及/提高− | 完全背包 | 与上一题唯一的区别是容量正序枚举。请自己推导这个区别的根源。 |
| P1833 | 樱花 | 普及+/提高 | 混合背包 / 多重背包 | 有的物品只能取一次、有的能取多次。用二进制拆分把多重背包转成 01 背包。 |
| P1060 | 开心的金明 | 普及− | 01 背包 | 价值变成「价格 × 重要度」,其余不变。用来巩固 01 背包模板。 |
| P1020 | 导弹拦截 | 普及/提高− | LIS + Dilworth 定理 | 第一问是最长不上升子序列;第二问是「最少不上升序列个数 = 最长上升子序列长度」。用 O(n log n) 的 LIS。 |
| P1439 | 【模板】最长公共子序列 | 普及+/提高 | LCS → LIS | 两个序列都是 1~n 的排列时,可以把第一个序列的值映射成下标,问题转化成求第二个序列的 LIS。 |
| P2196 | 挖地雷 | 普及/提高− | DAG 上 DP + 路径还原 | 用 pre[] 记录前驱,最后递归输出路径。练习「DP + 方案还原」。 |
| P1352 | 没有上司的舞会 | 普及+/提高 | 树形 DP | 入门树形 DP:f[u][0/1] 表示 u 参不参加。DFS 后序遍历自底向上转移。 |
14.7 进阶挑战(学有余力)
| 题号 | 题目 | 难度 | 考点 | 思路提示 |
|---|---|---|---|---|
| P3368 | 【模板】树状数组 2 | 普及/提高− | 树状数组 + 差分 | 区间修改 + 单点查询,用树状数组维护差分数组。 |
| P3372 | 【模板】线段树 1 | 普及+/提高 | 线段树 + lazy | 区间加 + 区间求和。理解 pushdown 的时机是线段树的核心。 |
| P3373 | 【模板】线段树 2 | 提高+/省选− | 线段树 + 双 lazy | 乘法与加法标记的优先级问题,务必先乘后加。 |
| P3379 | 【模板】最近公共祖先 LCA | 普及+/提高 | 倍增 / Tarjan | 倍增法:先跳到同深度,再一起往上跳。与树形 DP 结合可解很多题。 |
| P3865 | 【模板】ST 表 | 普及+/提高 | 稀疏表 RMQ | O(n log n) 预处理、O(1) 查询静态区间最值。思想与倍增一脉相承。 |
| P3387 | 【模板】缩点 | 提高+/省选− | Tarjan 强连通分量 + DAG DP | 缩点后在 DAG 上求最长路。是「有向无环图应用」的综合题。 |
| P3388 | 【模板】割点(割顶) | 提高+/省选− | Tarjan 求割点 | 依赖 dfn 与 low 两个数组,是 DFS 生成树性质的直接应用。 |
| P1939 | 【模板】矩阵加速(数列) | 普及+/提高 | 矩阵快速幂 | 把线性递推写成矩阵乘法,再用快速幂加速到 O(log n)。 |
| P3376 | 【模板】网络最大流 | 提高+/省选− | Dinic | 属于图论进阶,学完本章可以了解 BFS 分层 + DFS 增广的思想。 |
14.8 调试与对拍:自己造数据验证程序
做题最容易卡在「样例过了但提交 WA」。这时候不要干瞪眼,用对拍: 写一个慢但一定正确的暴力程序,再写一个随机数据生成器,循环比较两者输出。
/* ============ 对拍三件套之 ①:暴力程序 brute.cpp ============
以「求数组区间和」为例,暴力就是每次都老老实实累加 */
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, q;
cin >> n >> q;
vector<long long> a(n + 1);
for (int i = 1; i <= n; ++i) cin >> a[i];
while (q--) {
int l, r;
cin >> l >> r;
long long s = 0;
for (int i = l; i <= r; ++i) s += a[i]; // O(n) 暴力
cout << s << '\n';
}
return 0;
}
/* ============ 对拍三件套之 ②:数据生成器 gen.cpp ============
生成 n ≤ 10 的小数据,便于暴露边界问题 */
#include <bits/stdc++.h>
using namespace std;
int main() {
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
int n = rng() % 10 + 1; // 1 ~ 10
int q = rng() % 10 + 1; // 1 ~ 10
cout << n << ' ' << q << '\n';
for (int i = 1; i <= n; ++i) cout << (int)(rng() % 21 - 10) << " \n"[i == n];
for (int i = 0; i < q; ++i) {
int l = rng() % n + 1, r = rng() % n + 1;
if (l > r) swap(l, r);
cout << l << ' ' << r << '\n';
}
return 0;
}
:: ============ 对拍三件套之 ③:批处理脚本(Windows) ============
:: 把三个文件编译后放在同一目录,双击运行即可
@echo off
g++ brute.cpp -O2 -o brute.exe
g++ mine.cpp -O2 -o mine.exe
g++ gen.cpp -O2 -o gen.exe
for /l %%i in (1,1,1000) do (
gen.exe > in.txt
brute.exe < in.txt > brute.out
mine.exe < in.txt > mine.out
fc brute.out mine.out > nul
if errorlevel 1 (
echo **************** 第 %%i 组数据 WA 了!****************
type in.txt
echo ---------- 暴力输出 ----------
type brute.out
echo ---------- 你的输出 ----------
type mine.out
pause
exit /b
)
)
echo 全部通过!
pause
对拍的正确姿势
- 数据要小:先跑 n ≤ 10 的小数据,边界错误(数组越界、初始值、n=1)暴露得最快。
- 随机种子要变:用
chrono::steady_clock或读系统时间做种子,否则每次数据都一样。 - 小数据全过之后再放大:把 n 调到 100、1000,能抓出复杂度或溢出问题。
- 别用对拍验证算法思路:对拍只能验证「实现有没有写错」,算法本身选错了它对不出来。
14.9 本章小结
做题时的三个自问
- 数据范围允许什么复杂度?我现在写的是这个量级吗?
- 这个算法在最优/最坏/特殊输入(全相同、已排序、完全图)下会退化吗?
- 如果 WA 了,我的程序状态和课件动画里的状态从第几步开始不一样?
推荐刷题节奏
- 每天 1~2 题,比周末突击 10 题有效得多。
- 一题最多卡 40 分钟。超时就去看题解,但看完必须合上题解重写一遍。
- 每题 AC 后在代码里写一行注释:这题的关键结论是什么。一个月后回看,这就是你自己的速查表。
- 定期做「重做旧题」:只重做那些当时看了题解的题。