1 为什么需要练对拍
模拟赛 250 分左右的阶段,最常见的丢分原因不是「不会写」,而是:
我的贪心看起来是对的,但真的是对的吗?
P1803 凌乱的 yyy / 线段覆盖 是练这件事最合适的题:暴力、贪心、反例、随机数据,四样东西一次凑齐。
- 难度:普及−
- 建议练习时间:25~35 分钟
- 核心训练:随机数据生成 + 01 序列暴力 + 贪心 + 自动对拍
2 题目模型
给出
两个贪心候选,只差一个排序关键字:
| 策略 | 排序关键字 | 是否正确 |
|---|---|---|
| 早开始优先 | 按开始时间升序 | 错(本文要让学生亲手证伪) |
| 早结束优先 | 按结束时间升序 | 对 |
3 四个文件搭一套对拍
在题目的工作目录里建好这四个文件(本文配套代码都在 code/ 下):
p1803-stress/
├── gen.cpp # 随机数据生成器
├── wrong_greedy.cpp # 待验证的贪心(故意写错)
├── brute.cpp # 标准答案:01 序列
└── check.sh # 自动对拍脚本
1. gen.cpp:生成随机区间
// gen.cpp —— 随机数据生成器
// 生成 n 个短区间(允许重复), 规模控制在 n <= 8, 方便暴力 DFS 对拍
#include <bits/stdc++.h>
using namespace std;
random_device rd;
mt19937 rng(rd());
int rnd(int l, int r) {
return rng() % (r - l + 1) + l;
}
int main() {
int n = rnd(4, 8);
cout << n << '\n';
for (int i = 0; i < n; i++) {
int l = rnd(1, 15);
int r = rnd(l + 1, 20);
cout << l << ' ' << r << '\n';
}
return 0;
}
这里用 rng() % (r-l+1) 取随机数。它确实有理论上的取模偏差(模数不整除 mt19937 的输出空间是 rand() 在很多平台上只有 32767 个取值、低位周期短;srand(time(0)) 秒级种子会让两次运行拿到同一串数据。
追求严格均匀时再换 uniform_int_distribution 即可。
生成器不保证区间互不相同(实测约 8% 的数据里会出现重复区间)。这不是 bug:两道完全相同的比赛拿来对拍,正好考一下冲突判断有没有写对。
2. wrong_greedy.cpp:故意写错的贪心
// wrong_greedy.cpp —— 故意写错的贪心: 按开始时间排序
// 直觉: 开始得越早越应该参加. 这个直觉是错的, 用对拍找反例.
#include <bits/stdc++.h>
using namespace std;
int n;
int L[100], R[100]; // L[i] / R[i]: 第 i 场比赛的开始 / 结束时间
int id[100]; // 排序用的下标数组
// 按开始时间升序
bool cmp_start(int x, int y) {
if (L[x] != L[y]) return L[x] < L[y];
return R[x] < R[y];
}
int main() {
cin >> n;
for (int i = 0; i < n; i++) {
cin >> L[i] >> R[i];
id[i] = i;
}
// 错误策略: 开始时间最早的优先
sort(id, id + n, cmp_start);
int ans = 0;
int last = INT_MIN;
for (int i = 0; i < n; i++) {
int k = id[i];
if (L[k] >= last) {
ans++;
last = R[k];
}
}
cout << ans << '\n';
return 0;
}
先不要告诉学生这个程序是错的。 由老师抛出一个「我觉得没问题」的猜想,让学生的任务变成证伪它:
越早开始的比赛越值得参加,所以按开始时间排序就是最优的。请你用随机数据和暴力程序检查我这个想法。
3. brute.cpp:标准答案,01 序列
就是 [
Rbook: 01序列] 里那套模板:一个长度为 b[1..n],
b[i] = 1 表示参加第 dfs(dep) 在第 0 和 1,
到 dep > n 就得到一种完整方案,不改题意、不加剪枝。
// brute.cpp —— 标准答案: 01 序列
// b[i] = 0 表示不参加第 i 场, b[i] = 1 表示参加第 i 场
#include <bits/stdc++.h>
using namespace std;
const int maxn = 20; // 对拍时 n <= 8
int n;
int ans = 0;
int L[maxn], R[maxn]; // 第 i 场比赛的开始 / 结束时间
int b[maxn]; // 01 序列
// 检查当前的 01 序列是否合法: 任意两场被选中的比赛都不能冲突
bool check() {
for(int i = 1;i <= n ;++i ) {
if( b[i] == 0) continue;
for(int j = i+1;j <= n ;++j ) {
if( b[j] == 0) continue;
// 首尾相接不算冲突, 只有真正重叠才算
if( L[i] < R[j] && L[j] < R[i]) return false;
}
}
return true;
}
// dep: 现在决定第 dep 场比赛选不选
void dfs(int dep) {
if( dep > n) {
if( !check() ) return;
int cnt = 0;
for(int i = 1;i <= n ;++i ) // i: 数一数选了几场
cnt += b[i];
ans = max(ans,cnt);
return;
}
for(int i = 0;i <= 1 ;++i ) // i: 0->1
{
b[dep] = i;
dfs(dep+1);
}
}
int main () {
std::cin >> n;
for(int i = 1;i <= n ;++i ) // i: 1->n
std::cin >> L[i] >> R[i];
dfs(1);
std::cout << ans << "\n";
return 0;
}
枚举部分和 01 序列文章完全一致:
for(int i = 0;i <= 1 ;++i ) {
b[dep] = i;
dfs(dep+1);
}
区别只在 dep > n 那里:01 序列文章是直接输出,本题是先用 check() 判合法、再数一数选了几场。
对拍时
这份暴力必须和题意同语义:P1803 允许「上一场结束时间 = 下一场开始时间」首尾相接。
所以冲突判定要写成 L[i] < R[j] && L[j] < R[i](相等不算冲突)。
写成 <= 就会把合法方案误判成冲突,标准答案变小——对拍时先怀疑的应该是自己的暴力。
4. check.sh:对拍脚本
脚本就是 [
Rbook: 对拍脚本] 里那套最小模板,一个字符都不多:
#!/bin/bash
# check.sh —— 对拍脚本
# ./wrong_greedy 是被测程序, ./brute 是标准答案(01 序列暴力)
g++ -o gen gen.cpp || exit 1
g++ -o wrong_greedy wrong_greedy.cpp || exit 1
g++ -o brute brute.cpp || exit 1
for i in {1..100}; do
printf "\r$i"
./gen >in # 生成数据
./brute <in >out_std # 标准答案
./wrong_greedy <in >out_g # 被测程序
diff -b out_g out_std || exit 1
done
循环体只有四句:生成数据 → 跑标准答案 → 跑被测程序 → diff;不一样就 exit 1。
把四个文件放在同一目录,在 NOI Linux 2.0 终端里执行:
chmod +x check.sh
./check.sh
| 行 | 作用 |
|---|---|
g++ ... || exit 1 |
编译;失败就别拿旧程序去对拍 |
printf "\r$i" |
原地刷进度,不刷屏 |
diff -b out_g out_std || exit 1 |
答案不一致 → 找到反例,停下 |
diff -b 会忽略行尾空白和空行差异,避免因为多一个空格就假报错。
退出时 in / out_std / out_g 都留在目录里,供你直接 cat 看反例,不需要脚本帮你打印。
8 学生应该看到的结果
终止时,进度行后面接着就是 diff 给出的对比(< 是贪心,> 是标准答案):
7
1c1
< 1
---
> 2
接着自己看现场:
cat in # 反例数据
cat out_g # 贪心答案
cat out_std # 暴力答案
(上面那组真实反例是 7 / 11 13 / 3 18 / 3 9 / 3 12 / 12 13 / 12 18 / 1 20:
贪心先选了最早开始的 [1, 20],一选就把全场占满,后面全部作废,答案 1;
正确答案是 [3, 9] + [11, 13],答案 2。)
手工构造也能得到同款反例,课堂上建议先给这一组:
4
1 10
2 3
4 5
6 7
- 错误贪心输出
1:选中了[1, 10]这一场长比赛,之后全被挡住; - 正确暴力输出
3:选[2, 3]、[4, 5]、[6, 7]三场短比赛。
区间示意:橙色长条 [1, 10] 是错误贪心挑中的那场(一场占满全场);绿色三条 [2, 3]、[4, 5]、[6, 7] 才是最优解。这一组数据精确说明了「开始得早」和「结束得早」不是同一件事。
9 让学生自己修好贪心
要求只改 wrong_greedy.cpp 里的排序:把 cmp_start 换成 cmp_end,并换一下 sort 的第三个参数。
// 正确策略: 结束时间最早的优先
bool cmp_end(int x, int y) {
if (R[x] != R[y]) return R[x] < R[y];
return L[x] < L[y];
}
// 主函数里改成:
sort(id, id + n, cmp_end);
再跑一次 ./check.sh,1000 组应当全部通过。
为什么不变量是「结束时间」而不是「开始时间」?因为每选一场,唯一影响后续的是这场比赛什么时候结束:结束得越早,后面剩下的可选空间越大。这就是交换论证的核心——把最优解里的第一场换成当前结束最早的那场,不会让后面的选择变少。
10 对拍不能证明正确
1000 组通过,只说明「没找到反例」,不等于「算法正确」。随机测试负责发现错误,数学证明负责建立信心,两件事不能互相替代。
另外,标准答案本身也可能写错:如果把「首尾相接」误当成冲突,暴力算出来的答案会偏小,于是贪心反而显得对。所以对拍报错时,第一反应应该是回头核对自己的暴力和题意。
课堂上最后留三个问题:
- 为什么暴力程序可以当标准答案?它必须满足什么条件?
- 为什么生成器要故意生成小数据?如果
,还能用 的暴力对拍吗? - 对拍 1000 次没发现错误,能不能直接说算法一定正确?
参考答案要点:
- 暴力必须本身正确且足够慢但可跑;标准答案一旦写错,对拍就是在比两个错误程序。
- 小数据才能让暴力跑得起来,也才能让 1000 组覆盖更多结构;
很大时暴力跑不完,只能改成另一种(已证明正确的)算法互相对拍,或者放弃对拍、只做小数据验证。 - 不能。反例可能极稀疏,随机数据也可能一直抽不到;随机对拍是工程手段,不是证明。
11 课堂节奏建议
| 环节 | 时间 | 内容 |
|---|---|---|
| 讲模型 | 5 min | 线段覆盖 + 两个贪心候选 |
| 写代码 | 12 min | 抄写四个文件,先跑通 |
| 找反例 | 10 min | 学生亲手看 diff 报错 |
| 改贪心 | 5 min | 换排序关键字,重跑 |
| 收尾提问 | 5 min | 上面三个问题 |
怀疑自己的算法,用数据寻找反例,用证明建立信心。
12 相关文章
- [
Rbook: 01序列]:brute.cpp直接用的模板(b[dep] = 0 / 1递归枚举) - [
Rbook: 子集枚举]:同一件事的二进制写法(mask从0枚举到),可互相替代 - [
Rbook: 对拍脚本]:本文 check.sh的模板出处 - [
Rbook: 区间选点证明]:区间选点,同一套交换论证 - [
Rbook: 区间分组证明]:区间分组,同一套相交模型 - [
Rbook: ]:把 01 序列暴力改写成 DFS 的练习