OI圈的日常是什么?刷题、补题,还有出题。尤其到了校内模拟赛、周赛这种节点,总要有人被拉去当出题人。出题最磨人的其实不是想思路,也不是写题解,而是造数据。数据弱了,暴力劈里啪啦 AC;数据强了,正解反而 WA。我试过很多偷懒的办法,最后一直留在手里的是一个单头文件工具:makedata.h。这篇就聊聊我为什么还在用这套最简洁的 C/C++ 测试数据生成工具,以及怎么用它快速造出能打的 OI 测试数据。
不管你是校赛出题人、训练赛组织者,还是单纯想给自己刷题时多造点数据验证正确性,甚至只是做数据结构课程设计需要批量“植物百科数据”这种结构化测试内容,这套方法都适用。我会把 makedata.h 的接口拆开讲清楚,再用一道图论题完整演示“出数据-写暴力-对拍-交付评测”全流程,最后把踩过的坑按速查表整理出来,希望能让你的出题效率直接翻倍。
1. OI出题场景中,测试数据是硬骨头
1.1 判题数据决定了题目质量
做过题的人都知道,一道题能不能成为“好题”,很大程度上取决于数据强度。数据弱了,选手随便写个近似正解的暴力也能过;数据太刁钻,大家又容易集体翻车。出题人必须照顾多种情况:小数据、大数据、链、星、随机图、满二叉树、最坏复杂度构造……这些场景靠手写生成器去一个一个覆盖,劳动量非常大,而且容易漏。
我最早出题时,造数据的方式很原始:写一个专用生成器,再用freopen输出到某个.in文件,循环改参数,再手动检查格式。遇到图论题还得自己拼边,动不动写上百行生成逻辑。最崩溃的是某次模拟赛,我写了三个不同的生成脚本,输出格式里多打了一个空格,评测机上所有选手全部读挂。从那时起我就决定,一定要找一套统一、简洁、不容易出错的生成工具。
1.2 为什么是“头文件型”工具,而不是脚本
现在造数据的主流方案其实不止一种。有人用 Python 随机生成,再重定向到文件;有人用 shell 循环拼接文本;还有人干脆手写一堆 C++ 生成器。这些方案各有问题:Python 生成大数据慢,shell 拼文本可读性差,手写生成器代码复制粘贴严重,改一个格式要动所有文件。
makedata.h 选择的是“单头文件 + C/C++ 原生”这条路线。它把所有常用随机逻辑、树图生成逻辑、文件输出辅助封装在一个头文件里,用的时候#include "makedata.h"就行。好处非常直接:拷贝即用,不依赖第三方库,不要求判题机装 Python;底层和 OI 评测环境一致,生成的随机数类型、输出精度都能精准控制;编译运行速度快,生成百万级点的数据也就几秒钟。对需要频繁出题的场景来说,这套方案的性价比非常高。
1.3 一个“最简洁”的库应该解决什么问题
我这里说的 makedata.h,并不是一个躺在网盘里的“官方库”,而是我在多次出题过程中反复提炼出来的一个头文件集合。核心功能可以归纳成五块:随机基础值生成(整数、实数、字符)、序列生成(排列、去重集合)、树生成(随机树、链、星、菊花图)、图生成(连通图、稀疏图、稠密图、带重边自环)、文件输出辅助。每个模块尽量精简,函数接口控制在三五行以内,真正使用时几乎不用看文档,靠函数名就能猜出用法。
这套设计思路的原则就是:生成器代码必须足够短,短到出题人愿意每次重新写,而不是从旧项目里复制一个改半天。后面我会按这套思路逐一拆解。
2. makedata.h 的核心设计与 API 拆解
2.1 随机数引擎与种子管理:稳定性和可复现同样重要
随机数是数据生成的底座。早期很多 OI 选手习惯用srand(time(0)) + rand(),但这在 Windows 和 Linux 下的行为不一致,rand()的周期短、低位随机性差,造大量数据时容易出现肉眼可见的规律。makedata.h 里我默认用 C++11 的mt19937_64,周期够长,分布也均匀,而且它在各个平台表现一致,对出题人来说这是最重要的。
种子管理上,我保留了“随机种子”和“固定种子”两条路。默认用chrono::steady_clock::now().time_since_epoch().count()取当前时间,保证每次生成的数据都不重复;同时提供一个set_seed(u64 x)接口,方便需要复现某组数据时固定种子。造 10 组数据时,我通常会让每组使用不同的种子偏移,防止多组之间随机序列高度相似。
一个容易被忽略的点:uniform_int_distribution的闭区间是[l, r],也就是生成结果包含r。如果手写rnd() % (r - l + 1) + l,要确认上下界设计正确,不然边界数据容易差 1,这也是对拍时数据造偏的常见原因。
2.2 基础生成模块:整数、排列、字符串
基础模块我习惯提供这些接口:
long long rnd(long long l, long long r); // 整数 [l, r] double rndf(double l, double r); // 浮点数 [l, r],用于几何/概率题 void shuffle_vector(vector<int>& v); // 随机打乱 vector<int> gen_permutation(int n); // 生成 1..n 的排列 string gen_string(int len, int charset_type); // 随机字符串,可限定字符集这里有一个很实用的设计:rnd接收long long,而不是int。原因很简单,OI 题经常出现n = 1e9、m = 1e18这样的上限,如果接口只支持int,生成大范围数据时还得自己强转,非常影响写生成器的流畅度。
gen_permutation我用“初始排序 +std::shuffle”实现,而不是随机插入,这样排列的均匀性更好,不会出现某些位置总是偏大的情况。gen_string支持三种字符集:小写字母、大小写字母、全字符,表达式中常用于模拟题目里的字符串约束。
2.3 树与图生成模块:随机树不一定适合所有题
树是 OI 数据里的主角,但不同题对树的形态要求完全不同。链、星、完全二叉树、随机树,它们的性质差别极大,不能一个生成函数包打天下。makedata.h 里我按形态拆成几个函数:
vector<pair<int,int>> gen_random_tree(int n); // 随机父节点法 vector<pair<int,int>> gen_chain(int n); // 1-2-3-...-n 链 vector<pair<int,int>> gen_star(int n); // 1 号点连接其余所有点 vector<pair<int,int>> gen_binary_tree(int n); // 尽量均衡地生成二叉树其中gen_random_tree的实现是经典的“随机父亲法”:从 2 号点开始,每个点随机选择一个编号比它小的点作为父亲。这样生成的树是随机有根树,形态比较均匀。但实际出题时,很多树上题需要“重链”结构来卡树链剖分或者长链剖分,这时纯随机树反而不够用,我会把gen_random_tree生成的树打乱编号后,再在部分节点之间“拉直”出一条长链。
图生成的核心是先保证连通。我采用“先树后边”的方式:先用gen_random_tree(n)生成树边,再不断添加随机边直到达到目标边数m。这样既能保证连通性,又能控制稀疏程度。对于允许重边和自环的情况,把随机边改为(rnd(1,n), rnd(1,n))即可,不用单独写函数。
2.4 文件输出辅助:多文件命名和缓冲是隐藏痛点
数据生成器最繁琐的部分其实是文件输出。如果每组数据都用freopen,循环里面切来切去很容易出错。我的习惯是直接在生成器里用fopen按01.in、02.in的规则命名,每次循环关闭文件,这样生成器本身就能独立运行,不依赖外部重定向。
很多 OI 评测系统要求测试点文件必须叫01.in、02.in,有的还需要01.out。文件名补零位数要提前约定好,如果一共有 20 组数据,命名就应该是01.in到20.in,而不是1.in到20.in,否则排序会乱。我通常用sprintf(name, "%02d.in", tc)生成文件名,格式统一,不容易漏。
对于输出量特别大的数据(比如几十万行),要注意文件流缓冲。直接fpritnf到文件句柄一般没问题,但如果用std::ofstream且没关同步,可能慢得离谱。出数据时多花点心思在写出效率上,后面会省很多等待时间。
3. 实操:用 makedata.h 从零生成一套完整测试数据
3.1 选一道例题:求无向图连通块数量
为了把流程讲透,我用一道非常基础的图论题做演示:给定n个点和m条无向边,求连通块数量。数据范围设成1 <= n <= 200000,0 <= m <= 400000。题目很常规,但正因为常规,才能看出数据造得是否全面。
如果用手写生成器,我需要分别处理:最少数据(1 个点 0 条边)、n=3 的小图、随机小图、树形态的连通图、稀疏随机图、稠密图、大数据下的链和大量孤立点。每种边界情况都要单独写一段逻辑,代码很快就会膨胀到一两百行。而用 makedata.h,整个生成器可以控制在 80 行左右,关键形态的区分只靠几个if。
3.2 编写最终生成器:用“tc 分情况”覆盖所有档位
这里给出完整的数据生成器代码,编译后一次性生成 10 组.in文件:
#include "makedata.h" int main() { set_seed(20250101); // 固定种子,方便以后复现同一批数据 for (int tc = 1; tc <= 10; tc++) { int n, m; vector<pair<int,int>> edges; if (tc == 1) { // 最小数据:单点无边 n = 1; m = 0; } else if (tc == 2) { // 小规模,手造一个连通与非连通混合样本 n = 4; edges = {{1, 2}, {2, 3}}; m = (int)edges.size(); } else if (tc == 3 || tc == 4) { // 小规模随机图 n = 10; m = rnd(5, 15); edges = gen_random_graph(n, m, true); } else if (tc == 5) { // 一棵随机树,n=1000,m=n-1,一定连通 n = 1000; edges = gen_random_tree(n); m = (int)edges.size(); } else if (tc == 6) { // 稀疏随机图 n = 1000; m = 2000; edges = gen_random_graph(n, m, true); } else if (tc == 7) { // 稠密图,m 接近 n*(n-1)/2 的比例 n = 1000; m = 300000; edges = gen_random_graph(n, m, true); } else if (tc == 8) { // 大数据,大量孤立点 n = 200000; m = 0; } else if (tc == 9) { // 大数据,一条链 n = 200000; edges = gen_chain(n); m = (int)edges.size(); } else { // 大数据随机图,混合结构 n = 200000; m = 400000; edges = gen_random_graph(n, m, true); } char name[32]; sprintf(name, "%02d.in", tc); FILE* fout = fopen(name, "w"); fprintf(fout, "%d %d\n", n, m); for (auto& e : edges) { fprintf(fout, "%d %d\n", e.first, e.second); } fclose(fout); printf("case %d: n=%d, m=%d\n", tc, n, m); } return 0; }这里有一个值得说的细节:tc == 9的链,我用的是gen_chain(n),生成的边是(1,2), (2,3), ...。如果题目要求边的顺序随机,可以先把所有边shuffle一遍再输出,这样选手程序读入时不会因为“看到边有序”而占到便宜。出题人常犯的一个错误就是生成树后不随机化边的顺序,导致某些排序类算法在测试数据上表现异常。
3.3 编译运行和生成结果检查
在命令行编译时,我把生成器命名为gen.cpp,执行:
g++ -O2 gen.cpp -o gen ./gen输出应该是 10 行提示,告诉你每组数据的规模和边数。务必检查每一行的m是否和实际边数一致。最稳妥的方法是写一个“数据校验器”,读取每个.in文件,统计边数并检查是否连通,但简单场景下用文件大小和抽样展示也能判断。
我在 Linux 下会顺手跑一句:
for f in *.in; do echo "$f: $(wc -l < $f) lines"; done确保每个文件的“行数 = m + 1”。这个习惯帮我拦住过很多次低级错误,比如边数少了一条、最后一行没换行之类。Windows 下可以在资源管理器里看文件属性,但最好也用一个小脚本检查。
3.4 对拍脚本:让暴力和正解替你验证数据
生成的数据是不是“合法”且“有区分度”,得靠对拍来验证。所谓对拍,就是用暴力程序保证答案正确,再用正解程序跑同一组数据,两个结果一致,就说明数据的格式和范围没有坑;不一致,说明某个程序有 bug 或数据违背了题面约束。
我习惯把目录按这样组织:
data/ gen.cpp brute.cpp std.cpp run.sh 01.in 02.in ...暴力程序写法不唯一,仍以这题为例,用 DFS 求连通块数量的代码如下:
#include <bits/stdc++.h> using namespace std; const int N = 200005; vector<int> g[N]; bool vis[N]; void dfs(int u) { vis[u] = true; for (int v : g[u]) if (!vis[v]) dfs(v); } int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 0; i < m; i++) { int u, v; scanf("%d%d", &u, &v); g[u].push_back(v); g[v].push_back(u); } int ans = 0; for (int i = 1; i <= n; i++) { if (!vis[i]) { ans++; dfs(i); } } printf("%d\n", ans); return 0; }正解std.cpp用并查集实现,这里就不再贴全文了。对拍脚本批量跑所有.in,把两边输出做 diff:
#!/bin/bash for f in *.in; do echo "testing $f" ./brute < "$f" > brute.out ./std < "$f" > std.out diff brute.out std.out || echo "WA on $f" doneWindows 下对应的run.bat思路完全一样,只是一条条命令手写。对拍全部通过后,这批数据才算是“能交付”的。顺带提一个经验:对拍时建议把暴力程序和正解程序都编译成-O2,否则某些暴力在临界数据上可能超时或者递归栈溢出,反而干扰判断。
4. 常见问题与排坑实录
4.1 随机数据重复或分布不均
用rand()时最容易出现这个问题。解决方案上面提过,底层换mt19937_64之后基本不再担心重复问题。但即使换了引擎,如果你在同一个进程内连续生成 10 组数据,且种子都是同一个初始种子,下一组数据往往会继承上一组的状态,结果就是 10 组数据高度相似。解决办法是每组数据前调用set_seed(some_new_seed),或者在每次循环开始时用当前时间重新初始化。
如果遇到需要多组“同分布但不同形态”的数据,我会在种子中加入组号,比如set_seed(20250101 + tc * 1000003),这样每组数据都不同,又不会因为时间种子导致生成顺序抖动。
4.2 格式化输出错误:多空格、少换行、类型不匹配
这类问题最常见,却最致命。输出边时我把(u, v)写成fprintf(fout, "%d %d\n", u, v),如果题目要求的是u和v之间用空格分隔,那就不能多打一个空格。有些题目对行末空行不敏感,但许多 OI 评测系统要求严谨格式,最好养成“用校验程序检查”的习惯。
还有一个坑是类型不匹配。题目说n <= 10^9,但生成器里n是int,输入输出时用的是%d,看起来没问题;可如果边权是long long,输出时写成%d而后面传的是long long,行为就是未定义,数据会随机出错,而且很难排查。我的原则是:生成器里的变量类型必须和题面数据范围一致,输出格式符必须和类型一致,这个不能靠肉眼,要靠编译器告警和校验器双重把关。
4.3 边界数据缺失
很多新手出题人会把注意力放在随机大数据上,结果全是“若干随机图”,反而把最容易考到的基础边界漏掉了:空图、1 个点、完全图、0 边、只含自环的图、只含两条边的小连通块。这些数据往往能卡掉选手程序里“未初始化数组”“错误认为图必定连通”“根节点编号从 0 开始”等隐蔽 bug。
我的策略是在生成器前几个tc分支里强制手写边界,后几个分支才交给随机。上面例子中tc == 1和tc == 8就是在做这件事:单点无边、大数据零边。手写边界时不要偷懒,按题面逐条对照:最小值、最大值、只有一条边、所有点连通、所有边重合等,整理成一个检查清单,每道题过一遍。
4.4 VSCode 下编译 makedata.h 报错“找不到头文件”
这个坑在 Windows 上特别常见。你明明把makeadata.h和gen.cpp放在同一个目录,但 VSCode 的 C/C++ 插件不一定认识当前目录,编译时甚至根本不进入当前目录,导致头文件路径搜索不到。热词里提到的“vscode配置c/c++环境”“includePath 优先级”,指的基本就是这类问题。
解决办法有两层。第一层:如果只是临时编译,直接命令行进入data目录再g++ gen.cpp -o gen,不要依赖 VSCode 的运行按钮。第二层:想用 VSCode 的 F5 或运行任务,需要在.vscode/c_cpp_properties.json里配置"includePath",把当前目录${workspaceFolder}加进去,并且在tasks.json里把编译命令的cwd设为${workspaceFolder},或者干脆使用绝对路径包含#include "D:/path/to/makedata.h"。插件有时候对头文件路径的解析优先级和编译器不同,会出现编辑界面上找不到符号、但实际编译能通过的情况,这时以编译器的实际行为为准。
4.5 浮点数生成与判定精度
如果题目涉及浮点数,注意rndf(l, r)生成的是double,输出时最好用"%.10f"固定小数位,避免科学计数法或多余精度导致选手端读入精度变化。对拍时,暴力程序和正解如果都在同一个精度下输出,diff 可以过;但如果答案要比较大小,我一般会写一个带eps的 checker 程序,而不是直接 diff 原始输出。出题时数据规模较大,浮点误差累积到 1e-4 是常有的事,提前规划好误差范围能省去很多调试时间。
4.6 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 多组数据完全相同 | 种子固定且未随组号变化 | 每组重置种子,或使用time(0) + tc |
| 边数打印和实际边数不一致 | 生成器逻辑少 push 一条边 | 写校验器统计边数 |
| 程序读入超时但文件不小 | 输出行数过多且无缓冲 | 使用fprintf或手动加大缓冲 |
| VSCode 报头文件找不到 | includePath 未配置 | 配置c_cpp_properties.json |
| 图中连通性无法保证 | 直接用随机边 | 先随机树再加边 |
| 浮点数误差导致对拍失败 | 输出精度和 eps 未统一 | 固定小数位,checker 带 eps |
5. 出题实测中的一点体会
用这套工具出一整套题的效率,比我以前手写生成器高太多了。按我的经验,熟练之后从拿到题面到交付 10 组数据,快则二十分钟,大部分时间花在构思边界和检验格式上,而不是反复改生成代码。
还有一个自己的小习惯:我会专门建一个“生成器模板仓库”,里面放好 makedata.h 的通用版本,以及一个伪随机校验脚本和一个对拍脚本。每次出题只拷贝目录,改gen.cpp里的题目逻辑就行。时间久了,连调试的套路都固化下来,出题这件事就不再是临时抱佛脚,而是一个稳定的流程。
如果你是第一次用这类工具,我不建议一上来就追求复杂的树形结构生成,先把整数、排列、随机图这三个模块玩熟,配合对拍脚本跑通一套数据,后面自然会发现它值得继续扩展。