实用指南:【详细教程】对拍 0 基础学习小课堂 [内附例题演示]

以下文章专门面对 c++ & Windos 选手,使用其他语言或其他环境请另寻高明。

笔者是万能头文件党,非特殊情况文章里不会介绍头文件使用(默认万能头)。

以下使用函数默认为 std 库标准函数,即直接使用不加 std:: 的前缀。


有相当一部分题目的样例十分寒碜,有时样例全对但交上去不到 50 分。

这时候我们需要自己造数据,但一组组数据手动输入等输出肯定是不现实的。

解决这种问题需要用到对拍。


0.简述

对拍,是一种进行检验或调试的方法,通过对比两个程序的输出来检验程序的正确性。

可以将自己程序的输出与其他程序的输出进行对比,从而判断自己的程序是否正确。

一般我们对拍,需要四个程序:

(1)待测 / 出现错误的程序:sol.cpp  (solution)

         这是准备交上去的程序,已经尽量保证速度,但还未测验正确性

(2)暴力 / 标准的程序:bru.cpp (brute)

         这是你找的标程或是自己打的暴力,速度可能不尽人意,但输出保证绝对正确

(3)数据生成器:gen.cpp (generator)

         这是你自己打的数据生成器,通常用随机数函数生成。

         保证这个程序输出的都是符合题意,且完美覆盖到所有边界情况的数据。

(4)对拍脚本:st.cpp (stress test)

         这是一个自动循环程序,将执行以下操作:

  1. 调用 gen.cpp 生成输入
  2. 将输入分别喂给 sol.cpp 和 bru.cpp
  3. 比较两个程序的输出是否一致
  4. 若不一致,保存当前输入并终止,便于调试

1.高质量随机数生成

rand 家族

首先我们把目光放在随机数生成函数上,相信大家都听过 rand() 函数。

这个函数在 Linux 环境下的随机数生成范围是 [0,2^{31} -1]

而在 Windos 环境下却只有 [0,32767],这太小了,完全不够我们日常使用

不但如此,rand() 即使有好种子作为生成基础,但分布和周期仍不佳,大量使用环境还是不推荐

srand(time(0));           // 用当前时间作为种子
int x = rand() % 100;     // 生成 [0, 99] 的随机整数

(如上为用每秒都变化(不易重复)的时间函数作为种子,种子可以理解为每次玩 mc 输入的那个数字,凭借这个种子生成地图(随机数)。一样的种子生成的东西就一样)

如果你做过随机乱搞题,你应该对 random_shuffle() 也不陌生。

它用于随机打乱一个数组,同样以 rand() 为基底,现已被时代淘汰

如今我们要达到同样的效果,会使用 shuffle 函数配上高质量随机引擎(见下文代码)。

高级货 MT 19937

这个高质量随机引擎名为梅森旋转算法(Mersenne Twister)

是一种广泛使用的伪随机数生成器,周期为 2^{19937}-1,是一个梅森素数,因此得名。

具体使用如下:

#include
using namespace std;
#define uid uniform_int_distribution
// 这是一个 "一致分布" 映射函数,后面经常跟着一段区间
// 用于把随机数生成器生成出大的数映射到这段区间里
// 大的数取模会有某个数字出现频率更高的情况,而 uid 能保证所有数分布概率一样
// 当然,你如果要 long long 的直接改类型就好,就像这样
// #define uid uniform_int_distribution
// 这样后面跟着的区间就可以到 long long 的范围
// 不过,区间常数也要是 long long 类型
// 比如这样:dist(1, 1000000000000LL)
int main() {
	// 我个人习惯关同步流,不关也行,关了快一点
	ios::sync_with_stdio(false);
	cin.tie(0);
    // 创建一个 mt19937 类型的随机数引擎 rng
    mt19937 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 v = {1, 2, 3, 4, 5};
    shuffle(v.begin(), v.end(), rng);   // 用 rng 打乱 vector
    for (int x: v) {
    	cout << x << " ";
	}
	cout << "\n";
	return 0;
}

看起来多,真正用的时候,只需要记得这四句:

#define uid uniform_int_distribution
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 
using namespace std;
#define uid uniform_int_distribution
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
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.out
        system("sol.exe < test.in > a.out");
        // 3. 运行第二个程序(bru.exe)
        // 暴力 bru 程序从 test.in 读取输入,将输出放到 b.out
        system("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 程序

时间复杂度 O(N^2+MN)

// bru.cpp
#include 
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;
        // 每次查询清空 vis
        memset(v, 0, sizeof(v));
        queue 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 分,时间复杂度 O(N^2 + M)

// sol.cpp
#include 
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 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.cppbru.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

后记

对拍是很重要的技能,需要多练习。

但不要每道题都盲目使用对拍,进一步调试的前提是自己仔细检查每一行代码。

这算是干了一件一直想干的事情,希望能帮助到你。

后面会在这里给一些链接,作为对拍例子博客。

posted on 2025-11-25 08:05  ljbguanli  阅读(0)  评论(0)    收藏  举报