第 14 讲 · 实战

洛谷题单:例题与作业

看懂和写出来之间隔着一百道题。这一讲把整门课的知识点映射到可提交的编程题上, 每题都给出「考点 → 思路 → 容易踩的坑」,模板题附完整 C++ 代码。

建议:每讲配套 2~4 题 平台:洛谷 luogu.com.cn 关键词:模板题 · 分层作业 · 对拍调试
关于题号(请务必先读) 洛谷题库会持续更新,题号与难度标签可能变动。本讲的题号均为编写时的常见对应关系, 如果某道题号打开后不是你期望的题目,请直接在洛谷站内搜索题目名称 (本站每一题都给出了中文题目名,搜索即可命中)。 做题时请以站内的实际题面、数据范围与时空限制为准。

14.1 怎么用这份题单

三步做题法

  1. 先读题、定算法:看数据范围反推复杂度。 例如 n ≤ 1000 允许 O(n²),n ≤ 10⁵ 只允许 O(n log n),n ≤ 20 想状压 / 搜索剪枝。
  2. 手推小样例:自己造一组 3~5 个元素的数据,用纸笔跑一遍自己的算法,确认逻辑无误再写代码。
  3. 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)。注意两种约定的换算
P4391Radio Transmission 无线传输普及+/提高KMP 最小循环节 答案就是 n − π[n-1]。想清楚为什么「不完整」的循环节也成立。
P3538OKR-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 原因
  1. 没开 long long:n = 5×10⁵ 时逆序对可达 1.25×10¹¹,int 直接溢出。
  2. 合并时没写 <=:写成 < 会把相等元素也算成逆序对,答案偏大且排序不再稳定。
  3. 递归边界写成 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求先序排列普及/提高−中序 + 后序 → 前序 后序的最后一个字符是根;在中序里找到它来划分左右子树。递归处理,注意子串边界。
P1827American 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;」。
P2419Bessie 的体重问题普及/提高−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 的本质:每选一条边就减少一个连通块。
P1546Shortest 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;
}
最短路最常踩的五个坑
  1. 忘记 if (d > dist[u]) continue;:堆里可能存着同一个点的多个过期距离,不跳过会重复松弛,最坏退化成 O(nm)。
  2. 无向图数组没开两倍:边结点数是 2m,数组开成 m 会越界(这种错误常常只在大数据下才崩,非常难查)。
  3. int 存距离:多条边累加会溢出,一律用 long long
  4. INF 设成 0x7fffffff:两个 INF 相加直接溢出成负数。用 0x3f3f3f3f(约 10⁹,两倍仍在 int 范围内)或 1LL << 62
  5. 图不连通时没处理:题目通常要求输出一个特定值(如 2³¹−1 或 -1),别直接输出一个巨大的 INF。

14.6 第 10~12 讲 · 查找、排序与 DP 题单

题号题目难度考点思路提示
P2249【深基13.例1】查找普及−二分查找 要求「第一个等于 x 的位置」——就是 lower_bound。建议先用 lower_bound 过,再手写一遍边界。
P1102A−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 表普及+/提高稀疏表 RMQO(n log n) 预处理、O(1) 查询静态区间最值。思想与倍增一脉相承。
P3387【模板】缩点提高+/省选−Tarjan 强连通分量 + DAG DP缩点后在 DAG 上求最长路。是「有向无环图应用」的综合题。
P3388【模板】割点(割顶)提高+/省选−Tarjan 求割点依赖 dfnlow 两个数组,是 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 本章小结

做题时的三个自问

  1. 数据范围允许什么复杂度?我现在写的是这个量级吗?
  2. 这个算法在最优/最坏/特殊输入(全相同、已排序、完全图)下会退化吗?
  3. 如果 WA 了,我的程序状态和课件动画里的状态从第几步开始不一样?

推荐刷题节奏

  • 每天 1~2 题,比周末突击 10 题有效得多。
  • 一题最多卡 40 分钟。超时就去看题解,但看完必须合上题解重写一遍
  • 每题 AC 后在代码里写一行注释:这题的关键结论是什么。一个月后回看,这就是你自己的速查表。
  • 定期做「重做旧题」:只重做那些当时看了题解的题。