读这一页之前,先知道读完要能做什么
- 写出来
mt19937 rng(seed)播种 +uniform_int_distribution取区间,并说清「同一个 seed 那一串就固定」 - 说清 生成器为什么必须手动塞边界 —— 造不出来的数据,你永远测不到
- 写出来对拍三件套:造数据 → 暴力 → 正解 → 逐字比输出;用
assert自查不变量
这是什么
一句话:对拍是让 bug 自己跑出来的动作 —— 造一份数据,喂给两份独立写的实现,答案必须一个字不差。不一致的那份数据就是反例,直接交给正解去查。
★ 三件事缺一不可:种子写死(错了能复现)、边界塞进去(造得出题面的极端情况)、暴力显然对(它是题面的直译,慢但诚实)。
★ mt19937 rng(seed) 用 seed 播种:同一个 seed 之后那一串数一个字都不差,换 seed 整串换掉。所以出错时把种子抄进命令行,那组数据就能原样再现。
★ 要落在 [lo, hi] 里就交给 uniform_int_distribution<int>(lo, hi)(rng) —— 它给的是均匀分布;rng() % n 在 n 不整除 2³² 时是偏的。
⚠ 对拍比的是输出,不是「感觉对」:两份实现要各自独立写,错在同一个地方(同一个误解题面)的时候,对拍是抓不出来的 —— 所以暴力那份越直译越好。
完整动作表
复杂度写的是这个动作自己的代价,不是整个程序的。「这一栏最容易错的地方」那一列是重点。
| 写法 | 干什么 | 代价 |
|---|---|---|
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))`:错的那一次再也跑不回来
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 组(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++ 也就不执行了。
★ 程序悄悄换了行为:本机对、评测机上错(或者反过来),而代码一个字没改。assert 之后 i = 1
assert 之后 i = 0assert(i < n); // ★ 条件里只放「读」:比较、下标、状态查询
i++; // ★ 改状态的写在外面
// 另外:assert 是给**自己看的**,别拿它当唯一的正确性检查 ——它只在调试时起作用。assert 里只写纯条件。「排好序了吗」「下标在范围内吗」这种不变量才是它该查的东西。本书哪一章真的用到它
⚠ 下表里的文件路径和那串 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:种子固定,那一串就固定
读一个 n,用 mt19937 生成 n 个 [0, 99] 的随机数;再用同一个种子重新播一次,看两串是不是一模一样;然后换个种子、再掷 10 次骰子。
★ 这一题想让你亲眼看到对拍的地基:种子写死 ⇒ 输出可复现。
n。种子从命令行读(不给就用默认的 1)。输入
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 那一行整个换掉 —— 这就是「同一个种子、同一串数」;命令行换个数字再跑,你会拿到另一串。// 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;}点「运行 ▶」看结果
- ★★
unsigned seed = (argc > 1) ? atoi(argv[1]) : 1u;—— 种子放在命令行上,不给就用默认值 1:既可复现,又能随时换一组。 - ★★
mt19937 rng(seed)播种,uniform_int_distribution<int>(0, 99)(rng)取数 —— 一个「引擎」配一个「分布」,要什么范围就写什么范围。 - 再开一个
mt19937 again(seed):同一个种子重新走一遍,两串数逐个相同 —— 程序里直接a == b比出来打了 ✓。 - 换个种子(
seed + 1)整串都换掉 —— 所以「换一组数据」在对拍里就是换个数字。 - 掷骰子用
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;
例题 ② 造数据:四组里有三组是写死的边界
读 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 个数,所以必然重复,这正好又是一个边界情况。// 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;}点「运行 ▶」看结果
- ★★ 全最小 / 全最大:
vector<int> allMin(n, lo);—— 这两组同时是「所有数都相同」,排序、最值、去重的题最容易在这儿出事。 - 逆序:
dec[i] = hi - i * (hi - lo) / (n - 1)—— 从hi匀着降到lo,保证不越界、不重复出界,而且一定不是升序。 - ★★ 随机放在最后:
uniform_int_distribution<int>(lo, hi)(rng)—— 它兜住前面三组没覆盖的情况,但它不是主力。 - 边界检查那段输出不是装饰:它就是你交数据前要问自己的四个问题。
★★★ 生成器里写死的那几行,比随机那几行值钱。本书 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:给「排完序」写三条自查
读 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(条件)
练习 ② 对拍:暴力和正解跑 50 组,看谁对
一个文件里装两份实现: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))—— 想清楚为什么纯随机很可能造不出「全是负数」;⚠ 抓到不一致时要连种子和数据一起打印,否则复现不了。
★★★ 对拍的产出不是「过了几组」,是那个反例。有了它,你才能把正解单步跑一遍看它在哪儿岔开;而两份实现得独立写 —— 都按同一个误解去写,对拍永远是绿的。
用到的写法: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。