课程 · C++ 速查 · STL 专题 · 第 11 块

对拍与随机数据:让 bug 自己跑出来

学会 mt19937 造可复现的随机数据、把边界塞进生成器、用 assert 自查,以及暴力与正解对拍的骨架。

第 11 块(共 12 块已写好)10 条动作3 处陷阱2 道例题2 道练习4 份可运行代码

读这一页之前,先知道读完要能做什么

  • 写出来 mt19937 rng(seed) 播种 + uniform_int_distribution 取区间,并说清「同一个 seed 那一串就固定」
  • 说清 生成器为什么必须手动塞边界 —— 造不出来的数据,你永远测不到
  • 写出来对拍三件套:造数据 → 暴力 → 正解 → 逐字比输出;用 assert 自查不变量

这是什么

5 段

一句话:对拍是让 bug 自己跑出来的动作 —— 造一份数据,喂给两份独立写的实现,答案必须一个字不差。不一致的那份数据就是反例,直接交给正解去查。

★ 三件事缺一不可:种子写死(错了能复现)、边界塞进去(造得出题面的极端情况)、暴力显然对(它是题面的直译,慢但诚实)。

★ mt19937 rng(seed) 用 seed 播种:同一个 seed 之后那一串数一个字都不差,换 seed 整串换掉。所以出错时把种子抄进命令行,那组数据就能原样再现。

★ 要落在 [lo, hi] 里就交给 uniform_int_distribution<int>(lo, hi)(rng) —— 它给的是均匀分布;rng() % n 在 n 不整除 2³² 时是偏的。

⚠ 对拍比的是输出,不是「感觉对」:两份实现要各自独立写,错在同一个地方(同一个误解题面)的时候,对拍是抓不出来的 —— 所以暴力那份越直译越好。

完整动作表

10 条

复杂度写的是这个动作自己的代价,不是整个程序的。「这一栏最容易错的地方」那一列是重点。

写法干什么代价
mt19937 rng(seed);用 seed 播种,开一个随机数引擎
★★★ 同一个 seed ⇒ 序列一模一样;换 seed ⇒ 整串换掉 —— 对拍能复现全靠这一条(陷阱 ①)
O(1)
rng()下一个随机数(unsigned,整个范围)
★ 想要区间就别自己 % n,交给下面那个分布
O(1)
uniform_int_distribution<int>(lo, hi)(rng)落在 [lo, hi] 里的均匀整数
★★★ 造数据就用它;掷骰子就写 uniform_int_distribution<int>(1, 6)(rng)
O(1)
unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1u;种子走命令行参数,不给就用默认值 1
★★★ 出错时 ./a.out 12345 把那一组造回来 —— 这是本书所有 gen.cpp 的开头三行
O(1)
shuffle(v.begin(), v.end(), rng)随机打乱一个 vector
★ 已经有一组数想随机化就用它,比较器在这一句里不用写
O(n)
srand(time(0)); rand();老写法(C 风格)
⚠ 种子是时间 ⇒ 每次跑都不同、% n 有偏、序列也和 mt19937 不是一回事 —— 竞赛里别再用
O(1)
assert(条件)条件不成立就打印文件名、行号、那句条件并终止
★★ 自查「不变量」:排好序了吗、元素丢了吗;⚠ 里面别写带副作用的表达式(陷阱 ③)
O(1)
gen → brute → fast → diff对拍:同一份输入喂两份实现,输出逐字比
★★★ 暴力要一眼看得出正确,正解负责快 —— 两份独立写,错到一块去的概率才小
—
O(n²) 的暴力对拍里的标准答案:慢,但就是题面本身
★ 本书每章的 brute.cpp 就是它 —— 数据一换,它和正解必须一字不差
O(n²)
if (brute(x) != fast(x))抓到不一致:把那组数据原样打印出来
★★★ 连种子带数据一起打 —— 只说「好像错了」就再也查不下去(陷阱 ①)
O(n)

⭐ 这一节最容易错的地方(3 处)

这一节排在例题前面是故意的:读到一个坑,再去看正确的例子,记得住。 下面每一条都是「编译得过去、答案可能是错的」那种 —— 它们才是 OI 里真正扣分的地方。

① 种子用 `srand(time(0))`:错的那一次再也跑不回来

种子是「当前时间」的话,每次跑的序列都不一样。 ★ 于是对拍抓到一次不一致,你却拿不到那组数据 —— 想单步查它,重跑一百遍也碰不回原来那一串。错误从「可复现」变成「听说有」。
实测:同一份 `srand(time(0))` 的程序,隔一秒跑两次(两次不一样)
71 49 20 49 67 
77 46 53 10 83 
改成
unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1u;
mt19937 rng(seed);              // ★ 种子是个写死的数,命令行能覆盖

// 出错时: ./a.out 12345   →  那一组数据原样回来
★★★ 种子写成一个具体的数(常量或命令行参数)。时间种子只能用在「就是要每次都不同」的场合,对拍里一次都不能用。

② 生成器只随机、不塞边界 —— 造不出来的数据永远测不到

随机是均匀的,而题面的极端情况是稀有的:全是同一个数、只有一个元素、已经排好、正好等于上界…… ★ 结果就是「跑 1000 组全过」给你的假信心 —— 提交上去照样 WA,而那一类数据你的生成器根本没造出来过。
实测:一个「全随机」生成器和一个「塞了边界」生成器的对比
纯随机 1000 组(n=5,值 1..10):「五个数全相同」命中 0 组
手动塞 4 组:                                      命中 4 组
改成
vector<int> aMin(n, lo);      // 全最小(也是「全相同」)
vector<int> aMax(n, hi);      // 全最大
vector<int> aDec(n);          // 从大到小
for (int i = 0; i < n; i++) aDec[i] = hi - (long long)i * (hi - lo) / (n - 1);
vector<int> aRnd(n); for (int &x : aRnd) x = uni(rng);   // 最后才轮到随机
★★★ 先写死的边界组,再写随机组。本书 08-binary-search/gen.cpp 和 21-dp-intro/stairsGen.cpp 都是这么写的:注释里明确写着「边界值要手动塞进生成器里」。

③ 把带副作用的表达式写进 `assert` —— 关掉断言时它连同副作用一起消失

assert(x++) 在正常编译时会执行 x++;一旦编译命令里带了 -DNDEBUG,整句话被预处理器删掉,x++ 也就不执行了。 ★ 程序悄悄换了行为:本机对、评测机上错(或者反过来),而代码一个字没改。
实测:上行正常编译,下行加了 `-DNDEBUG`(那句连 i++ 一起没了)
assert 之后 i = 1
assert 之后 i = 0
改成
assert(i < n);        // ★ 条件里只放「读」:比较、下标、状态查询
i++;                  // ★ 改状态的写在外面

// 另外:assert 是给**自己看的**,别拿它当唯一的正确性检查 ——它只在调试时起作用。
★★ assert 里只写纯条件。「排好序了吗」「下标在范围内吗」这种不变量才是它该查的东西。

本书哪一章真的用到它

4 处

⚠ 下表里的文件路径和那串 probe 是逐字验过的(check:docs 第 ⑳ 条)—— 改了那份代码这儿会红,正是要的。点章号直接去看那一页。

章书里的哪一份代码用它来干什么
第 8 章code/08-binary-search/gen.cpp★★ 第 8 章的造数据器:种子走命令行,同一个种子永远造出同一份数据
第 21 章code/21-dp-intro/stairsGen.cpp★ 同一个套路;这份的注释写着「边界值要手动塞进生成器里」—— 陷阱 ② 的原产地
第 8 章code/08-binary-search/brute.cpp★★ 对拍里的「标准答案」:从头扫到尾的 O(n) 暴力,就是题面那三个定义的直译
第 27 章code/27-tree-dp/gen.cpp★ 造树的编号时把子树顺序打乱 —— shuffle + 同一个 rng,形状每次都不同但可复现

例题(2 道)

每道都给全套:题面 → 样例 → 参考代码 → 一步步的讲解。先自己读题想一遍,再看代码。

例题 ① mt19937:种子固定,那一串就固定

rand-seed.cpp

读一个 n,用 mt19937 生成 n 个 [0, 99] 的随机数;再用同一个种子重新播一次,看两串是不是一模一样;然后换个种子、再掷 10 次骰子。

★ 这一题想让你亲眼看到对拍的地基:种子写死 ⇒ 输出可复现。

输入格式一个整数 n。种子从命令行读(不给就用默认的 1)。
输出格式两串随机数、两者是否相同、换个种子之后的第三串,以及 10 次骰子点数。

输入

8

输出

seed = 1   n = 8
第一次 : 41 99 72 93 0 12 30 99
再来一遍: 41 99 72 93 0 12 30 99
两次完全一样 ✓   ← 种子固定,序列就固定
换 seed+1: 43 18 2 93 54 94 43 48
掷 10 次骰子: 1 2 1 3 2 3 3 5 3 6
★ 第一次和再来一遍逐个相同,而 seed+1 那一行整个换掉 —— 这就是「同一个种子、同一串数」;命令行换个数字再跑,你会拿到另一串。
rand-seed.cpp参考代码
// STL 专题 · 对拍 —— mt19937:种子固定,跑出来的那一串就固定
//
// ★ mt19937 rng(seed); 用 seed 播种,之后 rng() 每次给下一个数。
// 同一个 seed ⇒ 那一串数**一个字都不差**;换 seed ⇒ 整串换掉。
//
// ★ 要落在 [lo, hi] 里就用 uniform_int_distribution<int>(lo, hi)(rng) —— 它是均匀的。
//
// ⚠ 种子写死成一个具体的数,别写 srand(time(0)):那样每次跑都不一样,
// 「跑出来一次错,然后怎么也复现不了」—— 对拍就白做了。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char **argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1u; // ★ 种子:命令行不给就用 1
int n;
if (!(cin >> n)) return 0;
uniform_int_distribution<int> uni(0, 99); // ★ [0, 99] 上的均匀整数
mt19937 rng(seed);
vector<int> a(n);
for (int &x : a) x = uni(rng);
mt19937 again(seed); // 同一个种子,重新播一次
vector<int> b(n);
for (int &x : b) x = uni(again);
cout << "seed = " << seed << " n = " << n << "\n";
cout << "第一次 :";
for (int x : a) cout << ' ' << x;
cout << "\n再来一遍:";
for (int x : b) cout << ' ' << x;
cout << "\n" << (a == b ? "两次完全一样 ✓" : "两次不一样 ✗")
<< " ← 种子固定,序列就固定\n";
mt19937 other(seed + 1); // 换个种子,整串都换
cout << "换 seed+1:";
for (int i = 0; i < n; i++) cout << ' ' << uni(other);
cout << "\n";
uniform_int_distribution<int> die(1, 6); // 掷骰子:[1, 6]
cout << "掷 10 次骰子:";
for (int i = 0; i < 10; i++) cout << ' ' << die(rng); // rng 已经走过 n 个数了,接着用
cout << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
这段代码是怎么想出来的
  1. ★★ unsigned seed = (argc > 1) ? atoi(argv[1]) : 1u; —— 种子放在命令行上,不给就用默认值 1:既可复现,又能随时换一组。
  2. ★★ mt19937 rng(seed) 播种,uniform_int_distribution<int>(0, 99)(rng) 取数 —— 一个「引擎」配一个「分布」,要什么范围就写什么范围。
  3. 再开一个 mt19937 again(seed):同一个种子重新走一遍,两串数逐个相同 —— 程序里直接 a == b 比出来打了 ✓。
  4. 换个种子(seed + 1)整串都换掉 —— 所以「换一组数据」在对拍里就是换个数字。
  5. 掷骰子用 uniform_int_distribution<int>(1, 6),不用自己写 % 6 + 1(那玩意儿在部分取值上是偏的)。
记住这一句

★★★ 对拍里没有「大概随机」——只有「哪个种子」。把种子当成数据的名字:./a.out 12345 就能把那一组原样造回来。

用到的写法:mt19937 rng(seed); rng() uniform_int_distribution<int>(lo, hi)(rng) unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1u;

例题 ② 造数据:四组里有三组是写死的边界

gen-edge.cpp

读 n、lo、hi,造出四组数据:全最小、全最大、从大到小、随机,然后打印一份「边界覆盖检查」。

★ 这一题是整块的定性题:前三组一个随机数都没用 —— 它们就是「塞边界」。

输入格式一行三个整数 n lo hi(要求 n ≥ 2、lo < hi)。种子从命令行读。
输出格式四组数据(每组一行),以及四条边界覆盖说明。

输入

6 1 5

输出

全最小: 1 1 1 1 1 1
全最大: 5 5 5 5 5 5
逆序  : 5 5 4 3 2 1
随机  : 3 5 4 5 1 1
边界检查:
  ✓ 全相同(下界)  —— 排序 / 去重 / 最值题最容易漏的一类
  ✓ 全相同(上界)  —— 顺便看有没有越出 [lo, hi]
  ✓ 已经排好是反的  —— 冒泡、归并、扫描类的题在这儿栽
  ✓ 随机            —— 兜住剩下的情况
覆盖 6 个数、值域 [1, 5],四类都造齐了
★ n=6, lo=1, hi=5 时「逆序」是 5 5 4 3 2 1 —— 值域只有 5 个数却要 6 个数,所以必然重复,这正好又是一个边界情况。
gen-edge.cpp参考代码
// STL 专题 · 对拍练习 —— 造数据:光随机不够,边界要手动塞进去
//
// ★ 生成器的头号盲区:**它造不出来的东西,你永远测不到。**
// 本题一次给出四组:全最小、全最大、逆序、随机 —— 前三组就是「塞边界」。
//
// ⚠ 二分、排序、最大值这类题,错几乎全出在边界上(全是同一个数、只有一个元素、
// 已经排好、正好等于上界……)。纯随机造 1000 组,这些情况可能一次都不出现。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char **argv) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1u; // ★ 种子:命令行不给就用 1
int n, lo, hi;
if (!(cin >> n >> lo >> hi)) return 0;
if (n < 2 || lo >= hi) return 0; // 这道题要求 n ≥ 2、lo < hi
mt19937 rng(seed);
uniform_int_distribution<int> uni(lo, hi);
vector<int> allMin(n, lo); // ① 全是下界(同时是「全相同」)
vector<int> allMax(n, hi); // ② 全是上界(也是「全相同」)
vector<int> dec(n); // ③ 从大到小
for (int i = 0; i < n; i++)
dec[i] = hi - (int)((long long)i * (hi - lo) / (n - 1));
vector<int> rnd(n); // ④ 随机
for (int &x : rnd) x = uni(rng);
auto show = [](const char *name, const vector<int> &v) {
cout << name << ":";
for (int x : v) cout << ' ' << x;
cout << "\n";
};
show("全最小", allMin);
show("全最大", allMax);
show("逆序 ", dec);
show("随机 ", rnd);
cout << "边界检查:\n"
<< " ✓ 全相同(下界) —— 排序 / 去重 / 最值题最容易漏的一类\n"
<< " ✓ 全相同(上界) —— 顺便看有没有越出 [lo, hi]\n"
<< " ✓ 已经排好是反的 —— 冒泡、归并、扫描类的题在这儿栽\n"
<< " ✓ 随机 —— 兜住剩下的情况\n"
<< "覆盖 " << n << " 个数、值域 [" << lo << ", " << hi << "],四类都造齐了\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
这段代码是怎么想出来的
  1. ★★ 全最小 / 全最大:vector<int> allMin(n, lo); —— 这两组同时是「所有数都相同」,排序、最值、去重的题最容易在这儿出事。
  2. 逆序:dec[i] = hi - i * (hi - lo) / (n - 1) —— 从 hi 匀着降到 lo,保证不越界、不重复出界,而且一定不是升序。
  3. ★★ 随机放在最后:uniform_int_distribution<int>(lo, hi)(rng) —— 它兜住前面三组没覆盖的情况,但它不是主力。
  4. 边界检查那段输出不是装饰:它就是你交数据前要问自己的四个问题。
记住这一句

★★★ 生成器里写死的那几行,比随机那几行值钱。本书 08-binary-search/gen.cpp 会把值域压到 0..8 逼出重复、让 n 取到 1 —— 都是同一个思路。

用到的写法:mt19937 rng(seed); uniform_int_distribution<int>(lo, hi)(rng) unsigned seed = (argc > 1) ? (unsigned)atoi(argv[1]) : 1u;

练习(2 道)

只给题面、样例和一句提示,答案是折叠着的 —— 先自己写一版。

练习 ① assert:给「排完序」写三条自查

assert-check.cpp

读 n 和 n 个数,排序,然后用 assert 断言三件事:排完确实非降、一个元素都没丢(排前后总和相等)、两端是排之前的最小与最大。

★ 三条各打印一行 ✓ —— 哪一条挂了,程序就停在那一行,顺手把 <= 改成 < 就能看到失败的样子。

输入格式第一行 n。第二行 n 个整数。
输出格式三行自查结果、排序后的序列,以及一句「怎么让它失败」的提示。

输入

8
7 3 9 1 4 8 2 6

输出

✓ ① 排完确实非降
✓ ② 一个元素都没丢(排前后总和相等)
✓ ③ 两端是排之前的最小 / 最大
排序: 1 2 3 4 6 7 8 9
四条自查都过了 —— 想看它失败的样子,把 ① 的 <= 改成 < 再跑一遍
提示

★ assert(条件) 不成立就报错终止;⚠ 条件里只放读的东西(比较、min_element 的结果),别把 i++ 写进去(陷阱 ③)。

assert-check.cpp参考答案
记住这一句

★★ 断言写的是「不该发生的事」:排完序不是非降的、元素变多了、端点跑掉了 —— 这三种一旦发生,程序当场停在出事的那一行,比对着输出猜快得多。

用到的写法:assert(条件)

练习 ② 对拍:暴力和正解跑 50 组,看谁对

stress-diff.cpp

一个文件里装两份实现:brute() 是 O(n²) 的直译版(最大子段和),fast() 是 O(n) 的正解。读 T 和 bug,跑 T 组数据逐组比答案。

★ bug = 1 时 fast() 是故意写错的经典版本(「允许一个都不选」,初值 0)——先跑 bug=1 看对拍抓不抓得到它,再跑 bug=0 看它是不是全过。

输入格式一行两个整数 T bug(bug 是 0 或 1)。种子从命令行读。
输出格式bug=1 时打印抓到的反例(组号、数据、两份答案)和不一致总数;bug=0 时打印「全部一致」。

输入

50 1

输出

✗ 第 1 组不一致  n = 1
  数据: -37
  暴力 -37   正解 0
✗ 第 2 组不一致  n = 8
  数据: -50 -50 -50 -50 -50 -50 -50 -50
  暴力 -50   正解 0
✗ 共 2 / 50 组不一致 —— 把种子 1 抄下来,就能单独把这一组再跑一遍
★ bug=1:第 1、2 组是写死的边界数据(只有一个负数、全是负数)—— 这两组当场抓住「返回 0」的错;第 48 组是随机造出来的全负数,也一起被抓到。

输入

50 0

输出

✓ 50 组全部一致(种子 1)
★ bug=0:50 组全过。注意两次的种子、数据完全一样 —— 只有 fast() 那一处不同。
提示

★ 第 1、2 组数据是手写的({-37} 和 vector<int>(8, -50))—— 想清楚为什么纯随机很可能造不出「全是负数」;⚠ 抓到不一致时要连种子和数据一起打印,否则复现不了。

stress-diff.cpp参考答案
记住这一句

★★★ 对拍的产出不是「过了几组」,是那个反例。有了它,你才能把正解单步跑一遍看它在哪儿岔开;而两份实现得独立写 —— 都按同一个误解去写,对拍永远是绿的。

用到的写法:gen → brute → fast → diff if (brute(x) != fast(x)) mt19937 rng(seed); uniform_int_distribution<int>(lo, hi)(rng) for (int t = 1; t <= T; t++)

做完了去哪儿

← 上一块:bitset / array / pair:三个顺手的小件 · 回 STL 专题目录 · 后面几块还没写

⚠ 本页说的是 STL 怎么用。算法本身(Dijkstra、单调栈、状压……)在53 章正课里,而每一章末尾都会告诉你它用到了哪些 STL。