☰
makedata.h:C++测试数据生成器,覆盖随机数、图、树与字符串
2026/10/1 7:55:06 网站建设 项目流程

想靠手写样例把一道题的数据造全,几乎不可能。随机化测试数据生成是OI出题绕不开的一步,但很多初学者第一次尝试时,都会卡在"怎么写一个顺手的数据生成器"上:用临时脚本生成几组数据,文件路径乱、格式不统一,生成完还要手动跑一遍标准程序,稍不注意边界就漏了。我一直习惯用C/C++写数据生成器,后来整理成makedata.h这个头文件库,几行代码就能完成随机数、数组、图、树、字符串这类常见数据的生成。这篇文章面向想出题、想给校内训练出模拟赛、或者想给自己的代码做对拍的读者,我会把makedata.h的核心用法、设计思路和我在实际出题中踩过的坑一起讲清楚。

1. 出题人在生成测试数据时真正需要什么——痛点与目标

先聊一个很现实的场景:你刚刚写完了题面、确定了一个正解算法和暴力程序,正准备把测试数据造出来,然后发现手头没有一个顺手的工具。

很多人第一反应是拿Python临时写脚本。Python当然能造数据,但问题也不少:语言运行环境不稳定、随机数种子没有统一管理、数据类型和格式在转存文件时容易出错,而且如果一个OI选手要在一个没有Python的评测机环境里再生成数据,就很尴尬。C/C++数据生成器最大的优势,是它可以和你的标程、暴力程序共用同一个编译环境和工具链,生成的数据文件格式完全可控,性能也足够。

另一个更隐蔽的痛点是:测试数据的"质量"比"数量"重要得多。你随手生成了几十组随机小数,结果错误算法也能轻松通过,这种数据等于白出。真正有价值的测试数据,需要刻意覆盖边界值、极端大小、重复元素、图不连通、树退化成链这类情况,而这些恰好是随机数裸生成给不了的东西。makedata.h存在的意义不是帮你多写几行随机数代码,而是把"造常规数据"这件重复劳动压到最低,让你把精力花在构造那些真正能卡住错误解法的数据上。

所以makedata.h的设计目标可以拆成三条:

  1. 接口足够短:一行代码出一类数据,生成过程不要占据心智。
  2. 格式可控:能严格控制文件输出格式,适配题目输入要求的各种情况。
  3. 结构可组合:边界数据、随机数据、极端数据可以自由混用,方便批量生成。

1.1 自己写数据生成脚本的尴尬

我自己早期造数据时,每道题的生成器都从零开始写。随机数要手动处理:rand()的分布不均匀、RAND_MAX在Windows和Linux下不一致、想生成一个long long范围的随机数还要自己拼;想生成一棵树,得先想一个不会出现环的加边策略;想生成一个字符串,又得单独处理字母范围和长度。

这些代码写完之后还有个更麻烦的问题:它们没有统一风格。每道题的生成器长得完全不一样,等到下一场比赛要改数据时,光看自己写的代码都需要好几分钟,更不用说复用了。这种经历一多,我下定决心把常用操作全部收进一个头文件里,取名makedata.h,从此所有生成器都以同样的风格写,代码量大幅缩水。

1.2 makedata.h的设计思路:接口统一、开箱即用

makedata.h本质上是一个轻量级的“自定义测试数据生成库”。它不是像搜索引擎一样需要联网拉取的大框架,而是一个直接放进工作目录就能#include的头文件。它把随机数生成、文件输出、常用数据结构生成三大类操作封装成函数,使用者只需要了解接口名和参数含义。

为什么用头文件而不是源文件加链接?因为生成器场景需要的代码量本身不大,如果为了用个随机数还要维护makedata.cpp、写头文件声明、搞编译链接,反而违背了"简洁"的初衷。头文件一旦放好,编译器在预处理阶段就把它并进去了,每个生成器都是单文件编译,做对拍、批量生成时拉起来就跑,没有任何多余步骤。

另外,makedata.h特别强调“随机数可控”。每个函数都允许传入随机数种子,不传时用当前时间做种子。这意味着批量生成大批数据时可以全部使用时间种子保证随机性,而一旦发现某组数据能把别人的程序卡掉,又能立刻用固定种子把这一组精确复现出来。这一点在出题和调试中极其重要,后面我会用专门一节讲为什么固定种子是数据生成器的灵魂。

2. makedata.h的关键接口与一次生成体验

makedata.h在不同人群中流传的版本接口略有差池,但核心思路是一致的。下面这套是我自己维护的版本,所有命名都很直白,你可以直接抄走当模板。

2.1 核心接口清单

函数名作用典型使用
gen(seed)初始化随机种子gen(time(0))或gen(2333)
rint(l, r)生成[l, r]范围内的随机整数rint(1, n)
rlong(l, r)生成long long范围的随机整数rlong(1e18, 1e18 + 100)
rperm(n, start)生成n个元素的随机排列排列、映射关系
rshuffle(vec)将容器内元素随机打乱权值重排
rstring(len, charset)按字符集生成随机字符串指定大小写字母/数字
rtree(n, flag)生成n个节点的树,flag控制是否退化成链树形DP题
rgraph(n, m, flag)生成n点m边的图,flag控制是否保证连通图论题
split()开始写入输入文件配合文件流使用
case_end()结束一组数据多组样例时标记

输出部分我采用手动控制文件流的方式,这样格式可控性最强。核心思路是先生成一个std::ofstream对象指向某个输入文件,然后像写cout一样往里写数据。

2.2 一个真实例子:生成整数序列的数据

假设我现在要出一题:给定长度为n的整数数组a,求最大子段和。输入格式第一行是一个正整数T,表示测试组数;每组第一行是n,第二行n个整数。用makedata.h生成常规随机数据,代码如下:

#include <bits/stdc++.h> #include "makedata.h" using namespace std; int main() { gen(time(0)); ofstream out("data1.in"); int T = rint(3, 5); out << T << "\n"; for (int t = 0; t < T; t++) { int n = rint(1, 10); out << n << "\n"; for (int i = 0; i < n; i++) { out << rint(-100, 100); if (i + 1 < n) out << " "; } out << "\n"; } out.close(); return 0; }

这就是"最简洁"想表达的体感:你不需要在生成器里写任何std::mt19937、uniform_int_distribution之类的东西,rint帮你处理了分布,gen帮你处理了种子,剩下的逻辑完全在描述"题目输入长什么样"。

2.3 多组样例输出与文件名约定

实际出题时,一套数据通常由若干组文件组成,命名我习惯用data1.in、data2.in递增编号。多组样例需要特别注意的是:T的大小和每组数据的规模要协调。有些题T很大,那么单组数据规模就得小;有些题单组规模大,T就只能是1。我通常把数据规模分成几档:

  • 小数据:n在1到10之间,T比较多,专门用来卡极端小值。
  • 中数据:n在1000到10000之间,让暴力程序能跑得动。
  • 大数据:n取题目约束的最大值,T取1,用于验证正解的时间复杂度。

如果一次性要生成20组数据,可以在生成器外层加一个循环,每次ofstream打开不同的文件名。这样写是完全重复的模板代码,我一般会再封装一个make_data(int id, int T, int type)函数,内部根据type决定生成策略,生成器主函数就只剩一行循环调用。这个习惯省了我大量时间。

3. 各种题型数据的生成套路

不同题型的输入结构差异很大,但仔细观察会发现,OI题目常用的数据结构无非是序列、树、图、字符串、矩阵这几大类。makedata.h针对每类都设计了对应的生成方式。

3.1 生成数组与区间询问数据

序列类问题是最常见的。除了单纯随机填充数组,出题人更重要的是构造三类数据:

单调数据:让数组整体递增、递减,或先递增后递减。这类数据能卡掉很多只在随机数据上有效、却对单调性处理不佳的算法。生成方式就是for循环里a[i] = a[i - 1] + rint(0, 5)。

重复数据:所有元素相同,或者只有极少数不同取值。比如把所有a[i]都设成0或1,能有效检验程序对相同元素压缩、离散化去重的处理。

区间询问类:如果是区间和、区间最值这类题,询问的构造方式要随机中带一点设计。我常用随机生成l、r的方式,但会故意加入一个全区间询问[1, n]和大量小长度询问[x, x],因为这两类边界最容易被线段树或树状数组的边界条件卡住。

生成区间询问的典型写法如下:

void genQuery(int n, int m, ofstream& out) { for (int i = 0; i < m; i++) { int type = rint(1, 10); int l, r; if (type <= 2) { // 大约20%的询问覆盖全区间 l = 1, r = n; } else if (type <= 4) { // 20%的询问是单点 l = rint(1, n), r = l; } else { l = rint(1, n); r = rint(l, n); } out << l << " " << r << "\n"; } }

注意rint(l, n)这里我固定让r不小于l,这样生成的询问天然合法,不会因为格式错误把选手程序带偏。

3.2 图与树的生成

生成一棵树是很多初学者容易懵的地方,因为他们会本能地想到"随机加边然后判环",这样写既慢又容易出bug。更简洁的方式是先固定根,然后对每个非根节点,随机指定一个编号小于自己的节点作为父节点。这样生成的图天然无环且连通,n-1条边就是一棵树。

void genTree(int n, bool chain, ofstream& out) { for (int i = 2; i <= n; i++) { int fa; if (chain) fa = i - 1; // 退化成链 else fa = rint(1, i - 1); // 随机父亲 out << fa << " " << i << "\n"; // 注意:如果有边权,在这里追加 rint(1, W) } }

chain参数用来控制是否退化成链。链是树形DP题最容易卡递归栈深度的数据,如果标程用了非递归写法而选手程序用了递归,链数据一测就暴露问题。随机树则用来验证常规情况的正确性。

生成图比树麻烦一些,因为要避免生成出重边和自环。我简单一点的策略是:如果m接近n-1,就先生成树,再随机补边;如果m很大,比如接近完全图,就用set<pair<int,int>>记录已有边,随机生成直到数量足够。对于"是否保证连通",我的经验是在正式数据里至少要保留一组不连通图,用于检验选手程序是否假设了图一定连通,这种弱假设往往会导致运行时错误。

3.3 字符串与特殊边界数据

字符串题目里,随机串其实是最容易生成的,因为只需要一个字符集参数。但出题人真正关心的是特殊模式:所有字符都相同、字符串只由两种字符交替出现、字符串的某个前缀是另一个串的循环节。这些模式直接影响字符串哈希、KMP、后缀数组等算法的表现。

rstring(len, "abc")的封装方式我很喜欢,因为它把字符集参数暴露给了使用者。生成随机串之外的变体时,我推荐在生成器里单独写一个genSpecialString函数,里面可以拼循环节、拼全相同字符串。不要指望一个通用库把所有特殊模式都覆盖到,这种特殊数据的构造本来就应该由出题人针对题目思维设计。

4. 从"能生成"到"够刁钻":边界、强度与去重

数据生成的另一半学问在于"卡"与"查"。随机数据只能保证你有一堆数据,不能保证它们有区分度。一场好的比赛,数据必须能筛出错误算法,而这个目标靠的是刻意构造。

4.1 极限数据与边界值的来源

边界值通常来自题目描述里的约束条件。如果n的范围是1 <= n <= 1e5,那么n=1、n=2、n=1e5、n=99999这四组数据几乎是必须的。别小看n=1:很多程序在n=1时会访问不存在的下标,或者循环条件出错,在n比较大时这些错误反而被掩盖了。

权值方面,如果a[i]范围是-1e9 <= a[i] <= 1e9,我会专门构造一组全部等于-1e9的数据和一组全部等于1e9的数据。这样做能暴露两类问题:一是最大值累加时是否溢出,二是最小值状态初始化是否正确。最大子段和问题经典错误就是初始化ans=0,导致全负数数据直接输出0,这样的数据就是靠全负构造卡出来的。

一个我常用的边界数据构造技巧叫"平移法":先构造一组结构非常整齐的数据,比如所有元素随机但在某一个位置塞入一个极大值,然后整体加减一个偏移量。这样程序如果对值域范围处理不当,就会在边界上栽跟头,而题面看起来又足够自然。

4.2 固定随机种子与数据复现

这一点是我特别想强调的。生成测试数据时的随机种子管理,直接决定你的调试效率。

最初我图省事,每次生成都用gen(time(0))。有一天我用对拍工具发现某组数据能让暴力程序和一个WA的程序结果不一致,但因为没有记录当时的种子,这组数据无法复现,我只能重新大规模跑对拍等它再次出现,浪费了将近一小时。后来我把所有gen(time(0))改成了一套编号规则:生成第k组数据时,用gen(k * 10007 + 某个固定大质数)。这样只要我记录生成器参数,每个数据文件都能根据它的编号精确还原,出题复盘时能直接定位是哪组数据出了问题。

更严谨一点的做法是给每个数据文件附一个seed.txt,内容就是生成该组用到的种子。跟我合作过的命题组小伙伴都逐渐采用了这个习惯,它的价值在命题现场debug时简直是救命级的。

4.3 数据去重与合法性校验

一组数据生成完,最怕的是两份输入文件完全一样,或者数据内部出现不合法情况。完全一样的两个数据文件会让评测机做很多无用功,也可能导致"为什么20组数据只有19组有效"这种离奇问题。

去重最简单的方法是生成后计算每个输入文件的哈希值,然后比较所有哈希值是否重复。但这只解决了最表面的问题。更值得投入的是对每个文件跑一遍数据合法性校验程序,我通常写一个独立的validator.cpp,严格按照题面逐条检查:n是否在范围内、a_i是否在范围内、图的边是否有重边自环、字符串长度是否匹配、T与总数据规模是否冲突。校验程序也是用C++写的,和标程、生成器放在同一个目录里。

我见过不少选手和出题人用Python写validator,但我的个人体会是校验程序一定要和生成器同语言同风格,因为同一个选手对同一种语言的边界处理习惯是一致的,这样反而能发现生成器里"自以为生成了正确的数据,实际输入格式已经越界"的问题。

5. 生成器与标程组合成出题流水线

数据生成只是出题流程的一环。一个完整的出题流水线,通常包含数据生成、标准答案生成、数据打包和最终校验四步。makedata.h在这条流水线里承担的是第一环,但它的接口设计让后面几环衔接很顺畅。

5.1 输入、答案、打包的标准流程

我出题时的工作目录一般长这样:

problem/ ├── makedata.h ├── gen.cpp // 数据生成器 ├── std.cpp // 标准程序 ├── brute.cpp // 暴力程序 ├── validator.cpp // 合法性校验 └── data/ ├── 1.in ├── 1.out └── ...

生成数据的流程是:

  1. 运行gen.cpp,生成data/1.in到data/20.in。
  2. 运行validator.cpp,逐组检查输入合法性。
  3. 运行std.cpp,将每个.in文件读入,结果写入对应的.out文件。
  4. 用diff或批处理脚本检查.out文件不为空、行数正确,然后整体打包成data.zip。

标准答案生成这一步,很多人直接写成std.cpp内部循环打开20个文件,但这样会造成标准程序和评测机上的行为不一致。我更推荐的做法是让std.cpp只读单个文件、输出单个文件,然后通过shell循环或Windows批处理逐个调用:

for i in $(seq 1 20); do ./std < data/$i.in > data/$i.out done

这样做的好处是,std.cpp就是你在评测系统上提交的那个版本,它与真实测评行为完全一致,不会出现本地生成答案和在线评测结果不一致的情况。

5.2 对拍场景下的组合用法

对拍是我日常调试中使用makedata.h最频繁的场景。流程非常简单:先用makedata.h写一个gen.cpp,生成一组随机小数据,然后同时让std.cpp和待测程序跑这份数据,比较输出。

对拍数据的生成和正式测试数据的生成有一个重要区别:对拍要求数据规模适中,既能触发错误,又不能让暴力程序跑太慢。我通常让对拍生成器的数据量远小于正式数据,比如n在10到20之间,图点数在8个左右。因为对拍的核心目标是快速暴露差异,而不是压性能。

写对拍脚本时,我习惯在生成器里强制固定一个时间种子,但每轮循环都让n随机变,这样既保证每轮数据不同,又保留了"这轮数据如果出问题,下一轮还能复现同一个n"的可能。

5.3 评测时的数据量控制

最后想提醒一个容易踩的坑:数据总大小。生成器如果没控制好,很容易生成出一份几百MB的输入文件。比如n很大时还生成了m也接近n^2的稠密图,文件体积急剧膨胀,评测系统可能直接拒收。

我的习惯是生成完数据后立刻检查一下du -sh data/,如果整体超过50MB,就要考虑压缩数据规模或减少数据组数。还有一个技巧是对于超大图数据,不一定要把文件写到磁盘再跑答案,可以直接让std.cpp从标准输入读取,然后用管道把生成器的输出直接送给标准程序,这样既节省磁盘空间又加快生成速度。但正式比赛数据还是要落盘,因为需要持久化存档。

6. 在VSCode里把生成器跑顺手

makedata.h本身只是一个头文件,但很多OI选手习惯用VSCode写代码,而VSCode对C/C++的include路径、智能提示和编译任务的配置有一些容易让人卡住的细节。这里我把自己调顺VSCode的经验整理一下,尤其是和头文件库相关的部分。

6.1 include路径与智能提示优先级

#include "makedata.h"之所以用双引号而不是尖括号,是因为双引号会优先在当前文件所在目录查找头文件。这也是为什么makedata.h和gen.cpp放在同一个目录就能直接编译。

但VSCode的智能提示(IntelliSense)默认不一定认识这个头文件。如果不做任何配置,打开gen.cpp时makedata.h下面会出现红色波浪线,提示找不到源文件。这个问题的核心在于IntelliSense的include路径和编译器实际的include路径并不完全一致。

解决办法是在.vscode/c_cpp_properties.json里配置includePath:

{ "configurations": [ { "name": "Linux", "includePath": [ "${workspaceFolder}/**" ], "defines": [], "compilerPath": "/usr/bin/g++", "cStandard": "c11", "cppStandard": "cpp17", "intelliSenseMode": "linux-gcc-x64" } ], "version": 4 }

${workspaceFolder}/**表示把工作区下所有目录都纳入搜索范围,这样不仅makedata.h能被识别,任何子目录下的自定义头文件也都能找到。需要特别注意的是,如果你开了多个工作区,或者把makedata.h放在了一个不在当前工作区的公共目录里,记得把那个目录也加进来。智能提示的路径优先级是:当前文件所在目录优先于includePath列表,includePath列表的条目从左到右依次查找,所以不要把一些无关路径放在前面,否则可能出现同名头文件被意外匹配的问题。

6.2 结构体成员补全错误的排查

很多人在VSCode里写C++结构体时,会遇到成员补全一直跳错、提示找不到成员名的情况。这个问题和makedata.h未必直接相关,但它在写自定义生成器、validator时会频繁出现,所以一并说一下。

最典型的原因是IntelliSense的缓存没有刷新。当你新增了一个结构体成员或者修改了头文件里的接口,VSCode的代码分析器可能还停留在旧缓存上。此时按Ctrl+Shift+P输入"C/C++: Reset IntelliSense Database"(重置IntelliSense数据库),然后重新加载窗口,通常就能解决。

第二个原因是C++标准设置太低。makedata.h里如果用到了C++11之后的特性(比如auto、unordered_map、std::shuffle),而c_cpp_properties.json里的cppStandard设成了c++98,智能提示就会出现大量误报。把它改成c++17即可。这个设置不影响编译,它只影响编辑器的代码分析,但分析结果会直接影响你写代码的效率。

6.3 tasks.json与编译退出代码

最后是编译运行的问题。VSCode的调试和编译高度依赖.vscode/tasks.json,我通常把它配置成多任务模式,一键编译生成器:

{ "version": "2.0.0", "tasks": [ { "label": "build gen", "type": "cppbuild", "command": "/usr/bin/g++", "args": [ "-std=c++17", "-O2", "-Wall", "-o", "gen", "gen.cpp" ], "group": "build", "problemMatcher": ["$gcc"] } ] }

写完生成器后按Ctrl+Shift+B执行编译,如果编译失败,VSCode的"问题"面板会直接列出错误位置。这里有个小技巧:如果编译全部通过但运行时没有任何输出,一个常见原因是没有查看程序的退出代码。VSCode终端里运行./gen后,如果程序崩溃,终端会显示退出码比如exit code 139(段错误)、exit code 134(断言失败)。根据退出代码能快速判断问题方向,避免在用户输出为空时一头雾水。

我曾经遇到过一次非常奇怪的问题:生成器单独运行时一切正常,但通过VSCode的Run Code插件运行时报"退出代码1"。排查了半天发现是插件默认工作目录和我的工作目录不一致,程序找不到要打开的输出文件路径。解决办法是统一在tasks.json里设置"options": {"cwd": "${workspaceFolder}"},让所有任务都在工作区根目录运行。这类环境问题看起来很小,但在多文件出题流程里会浪费大量时间。


聊到这里,makedata.h能做什么、怎么用、以及搭配VSCode如何跑得顺,已经说得很完整了。最后再分享一个我自己的习惯:我并没有把makedata.h当成一个不能动的固定库,它在我的目录里是持续演进的。每当我发现某种题型的数据构造有共性的模式,我就会给它加一个函数,并附上简短的注释。几年下来,这个头文件从最初的几百行变成了千行级别,但每一次扩展都让下一道题的出题工作快一点。你也可以试一试,从抄一份核心接口开始,把它慢慢变成你自己最顺手的出题工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询