对拍练习:线段覆盖问题

1 为什么需要练对拍

模拟赛 250 分左右的阶段,最常见的丢分原因不是「不会写」,而是:

我的贪心看起来是对的,但真的是对的吗?

P1803 凌乱的 yyy / 线段覆盖 是练这件事最合适的题:暴力、贪心、反例、随机数据,四样东西一次凑齐。

[ luogu P1803: 凌乱的yyy / 线段覆盖]

  • 难度:普及−
  • 建议练习时间:25~35 分钟
  • 核心训练:随机数据生成 + 01 序列暴力 + 贪心 + 自动对拍

2 题目模型

给出 nn 场比赛的开始时间与结束时间,选出尽量多的互不冲突的比赛。

两个贪心候选,只差一个排序关键字:

策略 排序关键字 是否正确
早开始优先 按开始时间升序 错(本文要让学生亲手证伪)
早结束优先 按结束时间升序 对

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) 取随机数。它确实有理论上的取模偏差(模数不整除 2322^{32} 时),但 mt19937 的输出空间是 2322^{32}, 区间长度又只有 10~20,偏差在 10−910^{-9} 量级,对拍完全无感知。 真正要避开的是两种老写法: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序列] 里那套模板:一个长度为 nn 的 01 序列 b[1..n], b[i] = 1 表示参加第 ii 场。枚举方式也照搬模板——dfs(dep) 在第 depdep 个位置试 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() 判合法、再数一数选了几场。

说明

对拍时 n≤8n \le 8,最多枚举 28=2562^8 = 256 种方案,判合法最多再乘个 828^2,跑 1000 组也就一瞬间。

注意

这份暴力必须和题意同语义: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 组通过,只说明「没找到反例」,不等于「算法正确」。随机测试负责发现错误,数学证明负责建立信心,两件事不能互相替代。

另外,标准答案本身也可能写错:如果把「首尾相接」误当成冲突,暴力算出来的答案会偏小,于是贪心反而显得对。所以对拍报错时,第一反应应该是回头核对自己的暴力和题意。

课堂上最后留三个问题:

  1. 为什么暴力程序可以当标准答案?它必须满足什么条件?
  2. 为什么生成器要故意生成小数据?如果 n=105n = 10^5,还能用 O(2n)O(2^n) 的暴力对拍吗?
  3. 对拍 1000 次没发现错误,能不能直接说算法一定正确?

参考答案要点:

  1. 暴力必须本身正确且足够慢但可跑;标准答案一旦写错,对拍就是在比两个错误程序。
  2. 小数据才能让暴力跑得起来,也才能让 1000 组覆盖更多结构;nn 很大时暴力跑不完,只能改成另一种(已证明正确的)算法互相对拍,或者放弃对拍、只做小数据验证。
  3. 不能。反例可能极稀疏,随机数据也可能一直抽不到;随机对拍是工程手段,不是证明。

11 课堂节奏建议

环节 时间 内容
讲模型 5 min 线段覆盖 + 两个贪心候选
写代码 12 min 抄写四个文件,先跑通
找反例 10 min 学生亲手看 diff 报错
改贪心 5 min 换排序关键字,重跑
收尾提问 5 min 上面三个问题
一句话算法

怀疑自己的算法,用数据寻找反例,用证明建立信心。

12 相关文章