【详细教程】对拍 0 基础学习小课堂 [内附例题演示]
以下文章专门面对 c++ & Windos 选手,使用其他语言或其他环境请另寻高明。
笔者是万能头文件党,非特殊情况文章里不会介绍头文件使用(默认万能头)。
以下使用函数默认为 std 库标准函数,即直接使用不加 std:: 的前缀。
有相当一部分题目的样例十分寒碜,有时样例全对但交上去不到 50 分。
这时候我们需要自己造数据,但一组组数据手动输入等输出肯定是不现实的。
解决这种问题需要用到对拍。
0.简述
对拍,是一种进行检验或调试的方法,通过对比两个程序的输出来检验程序的正确性。
可以将自己程序的输出与其他程序的输出进行对比,从而判断自己的程序是否正确。
一般我们对拍,需要四个程序:
(1)待测 / 出现错误的程序:sol.cpp (solution)
这是准备交上去的程序,已经尽量保证速度,但还未测验正确性。
(2)暴力 / 标准的程序:bru.cpp (brute)
这是你找的标程或是自己打的暴力,速度可能不尽人意,但输出保证绝对正确。
(3)数据生成器:gen.cpp (generator)
这是你自己打的数据生成器,通常用随机数函数生成。
保证这个程序输出的都是符合题意,且完美覆盖到所有边界情况的数据。
(4)对拍脚本:st.cpp (stress test)
这是一个自动循环程序,将执行以下操作:
- 调用 gen.cpp 生成输入
- 将输入分别喂给 sol
.cpp和 bru.cpp- 比较两个程序的输出是否一致
- 若不一致,保存当前输入并终止,便于调试
1.高质量随机数生成
rand 家族
首先我们把目光放在随机数生成函数上,相信大家都听过 rand() 函数。
这个函数在 Linux 环境下的随机数生成范围是 ,
而在 Windos 环境下却只有 ,这太小了,完全不够我们日常使用。
不但如此,rand() 即使有好种子作为生成基础,但分布和周期仍不佳,大量使用环境还是不推荐。
srand(time(0)); // 用当前时间作为种子
int x = rand() % 100; // 生成 [0, 99] 的随机整数
(如上为用每秒都变化(不易重复)的时间函数作为种子,种子可以理解为每次玩 mc 输入的那个数字,凭借这个种子生成地图(随机数)。一样的种子生成的东西就一样)
如果你做过随机乱搞题,你应该对 random_shuffle() 也不陌生。
它用于随机打乱一个数组,同样以 rand() 为基底,现已被时代淘汰。
如今我们要达到同样的效果,会使用 shuffle 函数配上高质量随机引擎(见下文代码)。
高级货 MT 19937
这个高质量随机引擎名为梅森旋转算法(Mersenne Twister),
是一种广泛使用的伪随机数生成器,周期为 ,是一个梅森素数,因此得名。
具体使用如下:
#include<bits/stdc++.h>
using namespace std;#define uid uniform_int_distribution<int>
// 这是一个 "一致分布" 映射函数,后面经常跟着一段区间
// 用于把随机数生成器生成出大的数映射到这段区间里
// 大的数取模会有某个数字出现频率更高的情况,而 uid 能保证所有数分布概率一样 // 当然,你如果要 long long 的直接改类型就好,就像这样
// #define uid uniform_int_distribution<long long>
// 这样后面跟着的区间就可以到 long long 的范围
// 不过,区间常数也要是 long long 类型
// 比如这样:dist(1, 1000000000000LL)int main() {// 我个人习惯关同步流,不关也行,关了快一点 ios::sync_with_stdio(false);cin.tie(0); // 创建一个 mt19937 类型的随机数引擎 rngmt19937 rng(chrono::steady_clock::now().time_since_epoch().count());// 这一大长串东西是一个时间戳,和 time(0) 差不多,但是纳秒级精细度// time(0) 是以每秒的时间做种子,在快速运行时可能连续几次生成完全相同的数据// 可能无法快速测到一些边界情况/* chrono::steady_clock::now() 获取当前时间点(time_point).time_since_epoch() 从时间起点到当前时间的时长.count() 将该时长转换为一个整数(通常是纳秒或微秒数)*/uid dist(1, 100); // 生成 [1, 100] 的随机整数(严格闭区间) cout << dist(rng) << "\n";// 以后想生成 [1, 100] 的随机整数,直接调用 dist(rng) 就好// 还需要更多范围的数 uid dist1(1, 10000); // [1, 10000] uid dist2(1, 1000000); // [1, 1000000]cout << dist1(rng) << "\n"; // 直接调用 cout << dist2(rng) << "\n";vector<int> v = {1, 2, 3, 4, 5};shuffle(v.begin(), v.end(), rng); // 用 rng 打乱 vectorfor (int x: v) {cout << x << " ";} cout << "\n";return 0;
}
看起来多,真正用的时候,只需要记得这四句:
#define uid uniform_int_distribution<int>
mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
uid dist(1, 100);
cout << dist(rng) << "\n";
多打几遍,直到完全熟练。
例题实战
接下来尝试实战下,以这道题为例:P1141 01迷宫 - 洛谷
大家可以自己先试试,然后用文件输出看看生成的怎么样。
这里提供标程:
// gen.cpp
// 生成 P1141 01迷宫 的合法测试数据
// Windows 下可用 g++ 或 MinGW 编译运行#include <bits/stdc++.h>
using namespace std;#define uid uniform_int_distribution<int>int main() {ios::sync_with_stdio(false);cin.tie(0);mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());uid n_dist(1, 1000); // n 范围 [1, 1000]uid m_dist(1, 100000); // m 范围 [1, 100000]// ****真正使用时可以适当缩小数据范围,防止暴力程序美美卡住 ***int n = n_dist(rng);int m = m_dist(rng);cout << n << " " << m << "\n";// 生成 n x n 的 01 矩阵for (int i = 1; i <= n; i ++) { for (int j = 1; j <= n; j ++) {cout << (rng() & 1); // 快速生成 0 或 1}cout << "\n";}// 生成 m 个查询uid x_dist(1, n);uid y_dist(1, n);for (int i = 1; i <= m; i ++) {cout << x_dist(rng) << " " << y_dist(rng) << "\n";}return 0;
}
每道题都有不同的数据生成程序 gen.cpp,不过大部分都是换汤不换药。
有一些特殊的,比如说生成无自环、无重边的二叉树,
就得在 gen 里面加上一些小智慧或者算法(如 vector 记录可作为父亲的点,再随机选取)。
这些我先暂时不讲,后面有时间补上。
2.对拍脚本
这是很关键的一步,要保证你的程序能自个儿循环 or 找错,对拍脚本是不能少的。
你需要先将四个程序(待测,暴力,数据生成,对拍脚本)全部放在同一个文件夹里。
然后运行待测和暴力程序以及数据生成器,保存三个 exe 在上文的相同文件夹里。
接下来运行这个程序:
// stress test/*x < y:将 x 作为 y 的输入x > y:将 x 的输出写入 y其中 a 是程序,b 是文本 可以理解为尖尖朝哪边,哪边就是被放入数据的
*//*system 函数是一个系统调用函数成功了会返回 0,非成功返回非 0 (通常是 1)
*/
#include<bits/stdc++.h>
using namespace std; int main() {// 这里可不能关同步流!!不然没缓冲看不到文字输出 int tot = 0; // 计数的 // 循环持续生成测试数据并进行对拍,直到发现错误 while (1) {tot ++;cout << "Generating test case " << tot << "\n"; // 这里用英文,有些环境下中文会乱码 // 1. 调用数据生成器 gen.exe// 将生成的测试数据放到文件 test.in(输入数据) 里 system("gen > test.in");// 2. 运行第一个程序(sol.exe)// 待测 sol 程序从 test.in 读取输入,将输出放到 a.outsystem("sol.exe < test.in > a.out");// 3. 运行第二个程序(bru.exe)// 暴力 bru 程序从 test.in 读取输入,将输出放到 b.outsystem("bru.exe < test.in > b.out");// 4. 比较两个输出文件 a.out 和 b.out 是否一致// fc 是 Windos 自带文件比较函数 // 如果文件完全相同,fc 返回 0// 如果文件不同,fc 返回非 0 值(通常为 1)// system("fc a.out b.out") != 0,说明输出不一致。if (system("fc a.out b.out > nul")) {// fc 会自动输出一些东西,我们这里用不到就把它放到 nul 空文件里cout << "The test case " << tot << " is wrong!" << "\n";// 要把上面这句话放在 pause 上面才能看到 system("pause"); // pause 暂停程序,方便你查看控制台输出的差异信息// 立即退出对拍器// 此时 test.in 中保存的就是导致错误的输入数据// 可以用该数据单独调试 sol 和 bru return 0;}// 如果输出一致,循环继续,生成下一组测试数据if (tot > 10000) { // 拍了一万组 cout << "A possible solution." << "\n"; // 可能也许大概是正确的? system("pause");return 0;} }
}
这个 Windows 脚本对于所有题都是适用的,只要你保证在同一个文件夹、有 exe、无同名程序。
3.待测程序与暴力程序
这个放到后面来讲,是因为比起前面两个,这俩玩意是因题而异。
就拿之前那道例题来说:P1141 01迷宫 - 洛谷
虽然聪明的同学们一看就知道是广搜 + 带权并查集,但假设我们还没有那么聪明。
傻傻的把每一个询问点都拿去广搜,也就有了我们的 70 分 TLE 暴力 bru 程序。
时间复杂度 :
// bru.cpp
#include <bits/stdc++.h>
using namespace std;const int N = 1010;
const int dx[4] = {0, 0, 1, -1};
const int dy[4] = {1, -1, 0, 0};char s[N];
int a[N][N];
bool v[N][N];
int n, m;int main() {ios::sync_with_stdio(false);cin.tie(0);cin >> n >> m;for (int i = 1; i <= n; i++) {cin >> (s + 1);for (int j = 1; j <= n; j++) {a[i][j] = s[j] - '0';}}for (int i = 1; i <= m; i++) {int x, y;cin >> x >> y;// 每次查询清空 vismemset(v, 0, sizeof(v));queue<int> Q;Q.push((x - 1) * n + (y - 1)); // 0-indexed 每个点唯一编码v[x][y] = 1;int cnt = 0;while (!Q.empty()) {int head = Q.front(); Q.pop();int x = head / n + 1; // 转回 1-indexed 行int y = head % n + 1; // 转回 1-indexed 列cnt ++;for (int d = 0; d < 4; d++) {int xx = x + dx[d];int yy = y + dy[d];if (xx >= 1 && xx <= n && yy >= 1 && yy <= n && !v[xx][yy]) {if (a[x][y] != a[xx][yy]) {v[xx][yy] = 1;Q.push((xx - 1) * n + (yy - 1));}}}}cout << cnt << "\n";}return 0;
}
然后我们再打一个待测程序,故意出错,比如说点转编号时错误。
以下程序通过样例,但交上去 10 分,时间复杂度 。
// sol.cpp
#include <bits/stdc++.h>
using namespace std;const int N = 1010;
char s[N];
int a[N][N], mp[N][N];
int n, m;
int siz[N * N], fa[N * N];int findfa(int x) {if (fa[x] == x) return fa[x];return fa[x] = findfa(fa[x]);
}queue<int> Q;
bool v[N * N];
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};void bfs() {while (!Q.empty()) {int head = Q.front(); Q.pop();if (v[head]) continue;v[head] = true;int x = head / n + 1, y = head % n;// 如果 head 本来的点 y = n,那这里转编号就会错 for (int i = 0; i < 4; ++i) {int xx = x + dx[i];int yy = y + dy[i];if (xx < 1 || xx > n || yy < 1 || yy > n) continue;if (a[xx][yy] == a[x][y]) continue;int np = mp[xx][yy];Q.push(np);int tx = findfa(head);int ty = findfa(np);if (tx != ty) {fa[tx] = ty;siz[ty] += siz[tx];}}}
}int main() {ios::sync_with_stdio(false);cin.tie(0);cin >> n >> m;for (int i = 1; i <= n; ++i) {cin >> (s + 1);for (int j = 1; j <= n; ++j) {a[i][j] = s[j] - '0';mp[i][j] = (i - 1) * n + j; // 这里 j 没有 - 1 fa[mp[i][j]] = mp[i][j];siz[mp[i][j]] = 1;}}memset(v, 0, sizeof(v));for (int i = 1; i <= n; ++i) {for (int j = 1; j <= n; ++j) {int id = mp[i][j];if (!v[id]) {Q.push(id);bfs();}}}while (m--) {int x, y;cin >> x >> y;int pfa = findfa(mp[x][y]);cout << siz[pfa] << '\n';}return 0;
}
4.实操
好啦,美美开工。
注意注意!!!这里要先把 gen.cpp 的数据生成范围改小,这样才方便调试!
uid n_dist(1, 30); // n 范围 [1, 30]uid m_dist(1, 500); // m 范围 [1, 500]不然暴力程序就会一直运行(毕竟时间复杂度高),出不来结果!
还有请事先保证:
sol.cpp和bru.cpp的输出格式完全一致!(包括换行符、空格、无多余空行),否则
fc可能误报错误。
首先先建一个文件夹,为了模仿比赛,这里建在 D 盘。
然后把所有程序都放进去,命好名,除了 st 都在 dev 里运行得到 exe(点运行然后关掉黑框)。
搞好了就长这样:

现在开始运行 st.cpp,会出现这样:

第一组就错了,回到文件夹点开三个文本看看。

很好,找到问题!
修改完后,再次运行出 exe,运行下 st.cpp 看看。
(就是改了这两句)
27 int x = head / n + 1, y = head % n + 1;
57 mp[i][j] = (i - 1) * n + j - 1;
然后你就会看到:

嘛,如果你愿意等并且不怕电脑烧坏的话,就可以拿到 "A possible solution." 成就!。
参考资料
常见技巧 - OI Wiki
Don't use rand(): a guide to random number generators in C++ - Codeforces
后记
对拍是很重要的技能,需要多练习。
但不要每道题都盲目使用对拍,进一步调试的前提是自己仔细检查每一行代码。
这算是干了一件一直想干的事情,希望能帮助到你。
后面会在这里给一些链接,作为对拍例子博客。
