ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用 Python 打造算法竞赛出题全流程工具:生成、对拍与验证

用 Python 打造算法竞赛出题全流程工具:生成、对拍与验证

简介:这份基于Python的算法竞赛题目设计工具,面向出题人、在线评测系统管理员与编程教学教师,旨在简化题目从生成、测试到发布的全流程,提升竞赛题目的制作效率与质量。压缩包共78个文件,约7.85MB,核心由15个Python脚本、14个Jinja模板、7个JSON配置及6个Markdown文档组成:脚本完成题目的自动化生成与测试,模板保障题目在不同平台上的展示一致性,JSON可调节评分标准等参数,Markdown则提供使用说明与文档支持;此外还包含输入样例、答案文件、题目图片以及可执行组件,构成一套相对完整的赛题制作与管理链路。目前已有326人学习下载。借助该工具,读者既能通过自带样例与模板快速搭建出题工作流,也能结合项目结构和配置示例理解算法竞赛题目的组织方式,应用于校内OJ、日常教学或竞赛备题时,可减少重复劳动,对算法竞赛相关人群具有较高的实用价值。

1. 这个源码工具帮你把算法题从「想出来」变成「能上线判题」

做过算法竞赛出题的人都有过这种体验:脑子里的题面一句话就能讲完,但真要把这道题做成能挂到评测机上的成品,得写数据生成器、标程、暴力解、对拍脚本,再手动造几百个测试点,还要反复验证边界数据不会让选手交上来的代码莫名暴毙。基于 Python 的算法竞赛题目设计源码工具,就是把这条流水线里的重复劳动脚本化:你用 Python 描述数据生成规则,用同一套工具生成测试点、跑标程、跟暴力解对拍、统计时间内存开销,最后输出一套可直接导入评测系统的题目包。本文适合两类人:一是社团或训练平台里负责出题的学生,手里有不少题目想法但没时间手工造数据;二是打算把自家题库工具化的个人开发者,想用一套能维护的源码减少每场练习赛的筹备成本。你会看到完整的目录结构、生成器写法、对拍逻辑,以及我踩过几次后总结的参数边界,照着改就能用。

2. 数据生成器设计:题目能不能测出水平,全看这一步

2.1 为什么先写测试点再写题面

大多数人的出题顺序是:先想好题面,再随便写个生成器,最后把数据一传就完事。这顺序在题目设计上几乎是反的。常见做法是先定义这道题要考察的算法边界,再倒推测试点的形态。比如你要考察“区间最值+单点修改”的线段树,那测试点里必须有一组是只查不改、一组只改不查、一组交替操作,还要有一组操作数拉满到 2e5 来逼出 O(n^2) 做法超时。等你把这些条件写进数据生成器,题面不过是对生成器参数的一次自然描述,反而不会出现“题面说了 1e9 但生成器只造了 1e5”这种事故。

这套工具里生成器就是一个 Python 脚本,入参通常是随机种子和测试点编号,输出一个标准输入格式的数据文件。之所以选 Python,因为random、numpy、itertools这几个库组合起来,构造排列、图、区间操作、大数输入都很快,出题人在调试生成器时也不用编译。代价是生成大数据时比 C++ 慢,但生成 2e5 级别的数据也就一两秒,完全在可接受范围内,除非你的测试点动不动到 4e6 规模,那才需要考虑用 C++ 写生成器再调进 Python 流水线。

2.2 确定性随机是出题的生命线

生成器第一个原则是:同样参数必须能复现同样数据。如果生成器里直接调random.randint而不固定种子,一旦后续发现某组数据有歧义或标程错了,你根本找不回当时那份数据,只能重新生成,可能连带影响已公布的题面范围。这就是为什么我每个生成器都接受seed和test_id两个参数,并把它们拼进随机种子:

import random import sys def parse_args(): seed = int(sys.argv[1]) test_id = int(sys.argv[2]) return seed, test_id def generate(seed, test_id): rng = random.Random(seed * 1000 + test_id) n = rng.randint(1, 100) print(n) for _ in range(n): print(rng.randint(-10**9, 10**9))

这段代码的逻辑很简单:seed * 1000 + test_id让每个测试点都有自己的随机流,互不影响。这样你重跑python gen.py 42 3拿到的永远是同一个数据文件。random.Random()这个写法比直接改全局random.seed()更安全,不会污染其他模块的随机状态,后面接对拍时尤其重要。

参数说明:这里的-10**9和10**9是数值边界,对应题面里的数据范围。生成器里写边界时必须和题面一字不差,否则算数据不符。我习惯在每个测试点文件的最前面写一行注释记录这个测试点的意图,比如“全是负数”“n=1”“最大值出现两次”,但注意最终的数据文件里不能有这行注释,评测机会按错误格式处理。

2.3 按难度梯度和边界梯度组织测试点

测试点不是等概率随机撒,而是按“暴力能过到暴力必挂”的梯度组织。我的惯例是一道题 20 个测试点:前 8 个点数据范围只有最大值的一半甚至更小,用来放走暴力;中间 6 个点数据随机但范围拉满,测的是常数和实现正确性;最后 6 个点直奔极限并专门构造最坏情形,比如链式图、全相同元素、操作恰好打满上限。每个测试点由一个配置文件描述,格式用简单的 JSON 或 YAML,而不是把测试点直接写死在 Python 脚本里。这样加测试点不用改代码,只加配置项。

配置样例大致是这样:

{ "id": 14, "type": "worst", "n_max": 200000, "m_max": 200000, "duplicate_ratio": 1.0, "answer_constraint": "exact" }

生成器读这个配置后,按type分支决定数据构造逻辑。这样做的好处是题面改数值范围时,只需批量改配置,随后重新生成所有点再对拍一遍,不会出现某几个点漏改导致数据跟题面对不上。

这一步里最容易翻车的是“边界太整齐”。比如所有n都恰好等于n_max,选手可以用数组开多大来反推测试数据,虽然这不违规,但会让题目失去客观性。我一般让 70% 的点取范围内的随机值,30% 的点精确等于边界,并把边界拆成“最大值”和“最小值+1”两类,让极端情形真实存在于数据里但不成为规律。

3. 标程、暴力解与对拍:给每道题加上后悔药

3.1 标程和暴力必须双轨实现

题目设计的核心矛盾是:标程可能错,暴力可能慢,如果只有一个实现,你永远不知道自己是“做对了”还是“恰好蒙对”。所以这套工具里我固定写两份代码:一份是标程,代表你想让选手写的做法,通常是你自己想到的最优解;一份是暴力解或朴素解,逻辑极简单,正确性一眼能看穿,只要数据范围小二十分钟内能跑完即可。常见做法是暴力写 BFS、枚举、全排列暴力校验,标程写线段树、并查集、KMP 之类。两道程序吃同一个输入,输出做 diff,只要不一致,必然是至少一份代码出问题。

这一步的价值不能只理解成“查错”,它还直接决定题目难度是否合理。如果你构造的极限数据里连标程都跑了 3 秒,那这道题对选手就不公平,因为评测机机器可能比你本地慢;反过来,如果暴力在最大数据上竟然只跑了几百毫秒,说明你数据范围定小了,题目区分度会崩塌。对拍就是给难度这个玄学问题一个可量化的校准手段。

3.2 对拍脚本:多测点循环与哈希比较

对拍脚本本身也是一个 Python 脚本,流程是:循环调用生成器、运行标程、运行暴力、比对输出。有一个细节是输出比较前要做归一化,否则“行尾空格不同”“多打一个空行”这类问题会被误判为答案错误。归一化思路是把输出按空白符拆成 token 列表再比较,既能宽容空白差异,又能保留数字和字符串的顺序。

import subprocess import sys seed = int(sys.argv[1]) test_id = int(sys.argv[2]) def run(cmd, infile): with open(infile, 'r') as f: res = subprocess.run(cmd, stdin=f, capture_output=True, text=True) return res.stdout.strip() def normalize(text): return text.split() gen_input = f"gen_data/test_{test_id}.in" subprocess.run(f"python gen.py {seed} {test_id}", shell=True, check=True) std_out = normalize(run(["./std"], gen_input)) brute_out = normalize(run(["python", "brute.py"], gen_input)) if std_out != brute_out: print(f"Mismatch at test {test_id}") sys.exit(1) print(f"test {test_id} ok")

逻辑说明:先让生成器产出数据文件,再分别运行标程和暴力,normalize函数保证比较时只比 token 序列。标程编译一次后每次直接执行可执行文件,暴力则每次用 Python 解释器跑,按题目规模决定是否提前把小数据测试点对拍完再放开大数据,防止暴力在大数据上卡住整个流程。

参数说明:capture_output=True收集程序 stdout,text=True让输出以字符串处理;如果标程需要从 stdin 读题面里的输入文件,命令里要自己重定向。我这里没重定向stdin,而是在run()里读取infile再传入,这是为了后续并行对拍时避免文件句柄冲突。实际对拍时通常不逐点跑,而是跑一个循环,遇到第一个 mismatch 就停。下边的循环写法适合快速验证一组数据:

for tid in range(1, 21): ret = subprocess.run(["python", "duipai.py", str(seed), str(tid)]) if ret.returncode != 0: print(f"break at {tid}") break

这里把单个测试点的对拍脚本当作子进程调用,好处是某个点崩溃不会拖着整个循环死掉。坏处是每点都重新启动解释器,速度慢;如果测试点很多,建议把对拍逻辑写成函数放进同一个进程里跑。启动开销这个坑在测试点只有几十个时不明显,但如果你的题目包需要上千个数据点,就得改成函数式循环。

3.3 对拍结果里有哪些不一致需要人工判断

对拍发现 mismatch 时,先别急着改代码,要人工判断是哪边错了。常见情况有三种:一是标程确实错,二是暴力在特殊数据下超出自身约束导致结果非法,三是两者都对但比较逻辑不完善。第三种最隐蔽,典型例子是“最小解”带多种形式,比如图的最短路可以有多条,或者“字典序最小”与“任意合法”共存。这时对拍脚本只做 token 比较是不够的,要把比较逻辑换成校验器。

校验器有两种形态:一种是把两边的输出都读进 Python 再写断言逻辑,逻辑灵活但慢;另一种是用题目本身的条件做 check,比如生成随机区间后,确认输出里的区间左右端点合法、集合内元素互不重复且满足约束。我一般对每题单独写一个checker.py,读入输入文件和选手输出,返回 0 或 1,这个文件其实就可以直接转成评测系统的 special judge 输入格式。对拍脚本只在跑随机数据时用 diff,到了极端数据改用 checker,两者是不同环节的工具。

4. 时间限制与内存限制怎么定才不会翻车

4.1 用标程在目标机器上实测而不是拍脑袋

时间限制不是按“我觉得这题 O(n log n) 应该一秒跑完”来定的。评测机、本地机器、语言实现、编译器优化等级都会影响。常见做法是把标程用评测系统相同的方式编译,在目标环境跑满全部测试点,取最大运行时间再乘系数。我的经验系数是 2 到 3 倍:标程跑了 0.4s,限时 1s 是底线;如果标程在极限点跑出 0.9s,那这道题限时 2s,给常数差的选手留一条活路。

内存限制同样要实测,/usr/bin/time -v是最简单的方式:

/usr/bin/time -v ./std < test_14.in

输出里看Maximum resident set size那一行,单位是 KB。标程内存用到多少,限时和限内存一起写在题目配置里。这里有个血泪经验:内存限制不要设成恰好等于标程峰值,比如标程峰值 246MB,你限 256MB,选手代码多开几个 vector 就直接 MLE,题目反馈区分度全无。留 20% 到 30% 余量比较合适。

4.2 子任务分布是数据设计的一部分

很多评测系统支持子任务,也就是 subtask。设计子任务不能简单按“小数据、中数据、大数据”切三份,而应该按算法复杂度阶梯来切。比如一道题最优是 O(n log n),暴力是 O(n^2),那子任务一可以用 n≤2000 保证暴力可过,子任务二用 n≤2e5 只让正解过,中间加一个 n≤5e4 的宽容档放行 O(n log^2 n) 做法。每个子任务内部的数据必须覆盖该范围内的边界和最坏情形,不能只放随机数据。

子任务下的测试点编号可以有跨子任务的复用逻辑,但生成器配置里要写明每个点属于哪个子任务。很多评测系统读题包时要求按目录或配置字段声明这种映射,在配置里写清楚比后期改 shell 脚本靠谱得多,后者在题量一大时根本维护不动。子任务还有一个作用:选手没做出满分也能得部分分,这部分反馈数据能帮你判断题目难度曲线是否合理,做出来的人集中在哪个子任务区间,哪里区分度不足。

4.3 特殊判题的构造:不只是比对字符串

有些题目没有唯一答案,比如输出任意合法括号序列、任意一组可行解、近似最优解。此类题必须配 special judge,评测系统的 check 器接管比较。本源码工具里,我会把 3.3 里的checker.py改造成评测系统的输入输出接口:读入输入、选手输出、答案文件,再执行校验逻辑。

import sys def read_int(f): line = f.readline() if not line: raise ValueError("unexpected EOF") return int(line) inp = open(sys.argv[1], 'r') out = open(sys.argv[2], 'r') ans = open(sys.argv[3], 'r') try: n = read_int(inp) arr = [read_int(inp) for _ in range(n)] chosen = read_int(out) if out.readline() != '' else None # 校验 chosen 是否合法、是否确实达到题面要求的目标函数值 except Exception: print("WA") sys.exit(0) print("AC")

逻辑说明:这里只给出交互骨架,真正的校验逻辑按每道题写。注意out与ans的读取模式要和评测系统保持一致,有的系统传文件路径,有的直接往 stdin 塞;最稳妥的是先查评测系统文档确认参数顺序。sys.argv[1]到sys.argv[3]分别对应输入文件、选手输出、答案文件,这是大多数 OJ 的约定,但仍有例外。

参数说明:special judge 被调用时,输入和输出文件通常已经落盘,不要往 stdout 里打印额外日志,评测系统会把这些输出当结果处理。工程里常见做法是日志写到 stderr,评测机上能看但不会干扰判题结果。万幸的是我们这套工具本地生成数据时也能用同一个 checker 对拍,不用准备两套验证逻辑。

5. 避坑与排查:生成器和数据里最常翻车的 4 个点

5.1 数据文件里混入了生成器的调试输出

现象:选手提交后大面积 WA 或 RE,但本地对拍完全没有问题。原因:生成器脚本里某处print忘了删,比如打印了“test_id=3”或“生成完毕”。评测系统读数据文件不会跳过这些多余行,直接把字符串塞给选手程序,导致解析崩溃。

解决:给生成器增加一个--clean模式,所有调试输出统一走logging.debug,最终写文件时只从标准输出里取正式数据。还有一个笨办法,每次生成完数据后写一个校验脚本重新读取全部文件并按题面格式解析,能解析成功才放行。笨办法见效快,我建议两个都上。

5.2 随机种子的雷区:多进程生成导致数据无法复现

现象:同一组配置重跑生成器,后一半测试点数据变了。原因:有人用 multiprocessing 并行生成数据,每进程都调用random.seed(seed),但系统时间或进程号被混进了种子。

解决:凡并行生成,种子必须以(seed, test_id, chunk_index)构建,不能依赖random模块的全局状态,也不要用当前时间。用multiprocessing.Pool时要显式传种子参数,因为fork之后子进程默认继承了父进程的随机状态,一旦父进程在主线程里消耗过随机数,所有子进程继承到的状态都不一样。这个坑我在一次 200 个测试点的大题上踩过,重新生成后发现 30 多个点变了,全部重新对拍了一轮。

5.3 暴力解在极限数据上跑不完导致对拍挂死

现象:对拍脚本到某个测试点后卡住不动,CPU 占用却很高。原因:暴力解的时间复杂度是 O(n^2),而测试点 n 拉到了 2e5,暴力自然跑不完。这个往往发生在“先设计好测试点、后写暴力”的场景,暴力本来只该在小数据上跑。

解决:对拍脚本里对暴力解要限定运行时间,超时就跳过该点并输出警告。这一步有没有自动化很影响效率,用subprocess.run(timeout=...)即可,捕获TimeoutExpired后继续下一轮。设计上暴力解只管 1 到 12 号测试点,标程跑全部 20 个点,两者交集只在前 12 个,后期数据用 checker 验证。这个配置在 setup 文件里写明,不要靠记。

5.4 答案文件与标程输出不一致,但比对时被 normalize 掩盖

现象:对拍一直 AC,上线后评测系统却报 WA。原因:对拍时normalize把所有空白都折叠了,但评测系统按行比较或者逐字符比较,行尾空格和空行都会影响结果。

解决:以评测系统实际使用的比较方式为准。多数 OJ 忽略行尾空格但保留空行,严格模式连多余空行都判 WA。我在工具里内置strict比较选项,提交前用严格模式再跑一遍生成器的全部数据,确认标程输出格式与题面描述完全一致再发布。养成这个习惯之后,“数据没问题但选手全 WA”的翻车率几乎降为零。

6. 把整套流程做成一条命令:自动化流水线与验证

验证是不是靠谱题目包,最直接的证据是:一条命令重建全部数据并完成对拍,中途不用人工干预。我的做法是一个build.py调度脚本,按题号读配置、跑生成器、编译标程、跑对拍、跑 checker、统计运行时间,最后输出汇总报告。这个脚本不用写得很复杂,但把每个人工步骤变成可重复调用的函数后,题目包的质量会稳定很多。

import yaml import subprocess import json with open("problem.yaml", "r") as f: config = yaml.safe_load(f) for tid in range(1, config["test_total"] + 1): subprocess.run(["python", f"generators/gen_{config['id']}.py", str(config["seed"]), str(tid)], check=True) subprocess.run(["g++", "-O2", "-std=c++17", "solutions/std.cpp", "-o", "build/std"]) subprocess.run(["python", "tools/duipai.py", str(config["seed"]), str(tid)], check=True) # 将测试点文件打包到 config["output_dir"]

逻辑说明:这个流水线把生成、编译、对拍都串起来,任何一个步骤非零退出都会让构建失败并停止,避免发布一个只过了一半数据验证的题包。生成器按题号统一命名,目录结构成为约定俗成的一等公民,而不是散落在各个临时文件夹里。

参数说明:timeout参数在对拍和 checker 环节必须显式设置,我统一用timeout=20标记每个测试点的最长验证时间,超时就报错,因为正常题包测试点不该有某个点验证超过 20 秒。此外建议在总控脚本末尾做一个汇总输出:测试点数量、标程最大运行时间、内存峰值、失败点列表、每题子任务覆盖情况。这个报告在投稿或上线前做最后一道人工审核用,比对着评测系统等结果再返工快得多。

流水线跑通以后,我习惯每道题发布前再做一次“反向验证”:随机找一种错误的选手思路(比如把贪心当 DP、少开 long long),把这类错误实现扔进对拍循环,确认至少有部分测试点能把它判 WA。这不是浪费时间的仪式,它直接验证了题目数据的区分度。看到错误实现被大点打爆、小点却侥幸通过的场景,你就会明白那些测试点梯度不是玄学,是真实可感知的防线。

最后唠叨一句我自己的教训:永远不要在发布前最后一天临时加测试点而不重跑整套流水线。我这么干过一次,新增的边界点没有经过 checker 校验,上线后 RE 一片。事后重建整个数据只花了十分钟,但选手已经做了一遍无效提交。现在任何改动,哪怕只是调整一个数据范围,我都从 build.py 重新走一轮。这套流程坚持下去,题目设计的返工被压缩到最小,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进