使用Python构建算法竞赛题目设计工具:从出题到自动化验证的完整指南
简介面向算法竞赛出题人、在线评测系统管理员与编程教学教师这份Python源码工具把题目生成、模板排版、数据测试与答案校验整合为一条完整工作流显著降低题目制作门槛并兼顾不同OJ平台对题目格式的要求。资源共78个文件核心由15个Python脚本、14个Jinja模板、7个JSON配置、6个Markdown文档组成另含输入输出样例、答案文件、Python头文件及PNG题图等辅助素材压缩包约7.85MB。其中Python脚本覆盖题目打包、数据生成、评测安装等自动化环节Jinja模板适配多种比赛题面与排版样式JSON配置可自定义评分规则、数据限制等参数。通过内置样例题目、空模板与说明文档读者能快速理解工具结构直接基于模板和脚本搭建自己的出题流程并在题目发布前用输入文件与答案文件完成自测。已有325人学习浏览适合需要系统化组织竞赛题目、批量生成规范题面与测试数据的开发者和教学人员。1. 算法竞赛题目的源码工具先解决出题人的三个痛点多数人以为算法竞赛的难点只在解题真正上手出题的人才知道把一道题从灵感到可提交的评测数据中间隔着造数据、写校验器、对拍验证、整理题目包这一长串脏活。用 Python 写一套「基于 Python 的算法竞赛题目设计源码工具」本质是把出题流程脚本化从一份题目配置出发自动生成目录骨架、批量造测试数据、调用编译器和评测脚本做对拍最后把题面、数据、标程打包成可直接导入 OJ 的题目源码包。这套东西解决的不是「怎么想题」而是「怎么把题变成能用的题」。适合三类人给训练平台出题的集训队老队员、带竞赛的教练、以及想把自己题库系统化的独立出题人。新手能照步骤把第一个工具跑起来熟手能直接拿走配置思路和数据生成策略改到自己项目里。2. 题目源码包的标准结构为什么出题工作流里缺一个 Python 编排层在没有工具之前出题人常用做法是手写几个数据文件、改题面、然后对着 OJ 后台上传。文件一多就乱改一版数据要手动同步好几个文件。要理解 Python 工具在中间扮演什么角色先得看一个题目做完之后到底要交付哪些东西。2.1 一个完整题目的最小交付物无论你用的是 Hydro、DOMjudge 还是自己写的评测机一个题目落地后都逃不开下面几类文件组成作用常见格式题面描述题目背景、输入输出格式、样例Markdown / LaTeX / HTML题目配置时限、空间、标签、数据组数YAML / JSON测试数据每组输入与对应输出.in / .out或 .ans标程出题人自己写的标准解法C / Python校验器特判SPJ时自定义判题逻辑checker.cpp / checker.py这些文件在服务器上通常要按固定目录名和命名规则摆放比如data/1.in、data/1.out题面叫problem.md配置叫problem.yaml。手动维护这套结构改个数据组数就要同步改目录、改配置、重新命名文件出题时间一大半耗在这种重复劳动上。2.2 为什么选 Python 做编排层而不是直接手写 JSON有一种反方向的做法是纯手写 JSON 或者 YAML 配置交给 OJ 后台去解析。问题在于手写配置只能描述「题目长什么样」没法描述「数据怎么来」。而题目数据几乎都是程序生成出来的——随机树、随机区间、带权图这些生成逻辑本身就是代码。用 Python 写编排层核心优势是「生成数据、编译标程、运行对拍」三个阶段可以写在同一个脚本里用subprocess直接互相调用数据生成结果能立刻被评测脚本消费。再加上出题人有不少辅助需求恰好是 Python 的强项批量统计数据的最大值、去重、可视化分布比如把随机数据画出来确认没跑偏、把数据按难度分层。其他语言不是不能做但 Python 在「快速改写、随时跑一遍看结果」这件事上迭代效率最高出题阶段的需求本来就多变这里不必追求极端性能。2.3 工具链的最小闭环我一般会把工具拆成两个脚本一个负责「生成」一个负责「验证」。生成脚本读config.yaml按照配置产出目录骨架、测试数据、题面文件验证脚本做对拍——把标程和待测代码扔进同一组数据跑一遍看输出差异。这俩脚本合起来就是这个「源码工具」的最小闭环。接下来第 3 章把生成脚本写出来第 4 章做对拍验证一章一条路径直接落地。3. 从零搭一个 Python 题目设计工具环境、配置文件与骨架生成这个环节的目标很明确只写一份题目配置文件跑一条命令就能得到一个结构完整、可以直接往里填内容的题目文件夹。先不要急着碰复杂功能把最小链路跑通后续加数据生成器才有地方可挂。3.1 环境准备Python 版本与依赖库首先确保本机有 Python 3.10 或更高版本。macOS 和 Linux 自带 Python 3Windows 用户去官网装完记得在安装界面勾选「Add Python to PATH」。建议在项目根目录下建虚拟环境避免依赖冲突mkdir problem-tool cd problem-tool python3 -m venv .venv source .venv/bin/activate # Windows 下执行 .venv\Scripts\activate pip install pyyaml jinja2这里只装了两个包pyyaml负责读取 YAML 配置jinja2负责渲染题面模板。其他功能全部用 Python 标准库完成后面写生成器、对拍脚本都不需要额外依赖。不要一上来就上click、typer这类命令行框架题目工具的交互没复杂到那个程度标准库argparse够用且少一层黑匣子。3.2 写一份题目配置config.yaml 的字段设计配置是整个工具的核心字段怎么设计直接决定后续扩展顺不顺手。下面是一份最小可用的配置我特意把注释写全方便你按自己出题习惯删改# 题目基本信息 problem: id: P1001 title: 区间求和 time_limit: 1.0 # 秒 memory_limit: 256 # MB tag: [数据结构, 前缀和] # 测试点分组每个组对应一种数据规模 test_groups: - name: sample count: 2 # 样例组生成 2 组数据 generator: gen_sample.py params: n_range: [5, 10] # 数组长度范围 val_range: [1, 100] - name: small count: 5 generator: gen_random.py params: n_range: [100, 1000] val_range: [1, 10000] - name: large count: 3 generator: gen_stress.py params: n_range: [100000, 200000] val_range: [1, 1000000000] # 标程与对拍配置 solution: source: solution.cpp compile: g -O2 -o solution solution.cpp这里把测试点分成sample / small / large三组每组独立指定生成器和参数。这种分组方式的好处是样例组保证题目可读小数据组用于快速验证正确性大数据组压时限和空间。以后要加一个「极限数据组」只需要在test_groups里追加一段不需要改脚本代码。3.3 骨架生成脚本一张图看清工具怎么干活config 文件写好之后需要一个脚本把配置转化成实际目录。下面的gen_structure.py就是干这件事的import os import shutil import yaml from pathlib import Path def load_config(path): with open(path, r, encodingutf-8) as f: return yaml.safe_load(f) def build_structure(cfg): root Path(cfg[problem][id]) # 以题目ID作为目录名 if root.exists(): shutil.rmtree(root) # 已存在则清空重建保证幂等 (root / data).mkdir(parentsTrue) (root / scripts).mkdir() (root / solutions).mkdir() # 写入题目配置拷贝一份到题目目录保持自包含 shutil.copy(config.yaml, root / problem.yaml) # 初始化题面模板 template root / problem.md if not template.exists(): template.write_text(# {title}\n\n## 题目描述\n\n## 输入格式\n\n## 输出格式\n\n.format( titlecfg[problem][title])) print(f[OK] 目录已生成: {root}) if __name__ __main__: cfg load_config(config.yaml) build_structure(cfg)逻辑说明脚本用Path对象操作路径保证跨平台不会出现反斜杠问题shutil.rmtree加上后续重建保证了脚本重复执行结果一致——这一点很关键题目改版时重跑一次就能得到干净的目录不会残留旧数据。problem.yaml直接拷贝一份到题目目录里让题目文件夹保持自包含拷给别人或者上传 OJ不需要再附带根目录的 config。参数说明root由problem.id决定建议用有意义的标识而不是「test1」这种临时名数据、脚本、标程三个子目录分别对应测试数据、生成器脚本、标准解法代码。跑完python gen_structure.py后你会得到一个可直接开写的题目目录P1001/ ├── data/ ├── problem.md ├── problem.yaml ├── scripts/ └── solutions/3.4 用模板渲染题面把 Markdown 里的重复劳动去掉题目改版最烦的是题面里的标题、数据范围、样例得手动同步。用 jinja2 可以把这些信息直接从配置里渲染进 Markdown。写一个render_problem.pyfrom pathlib import Path import yaml from jinja2 import Template def render(cfg): tpl_text Path(templates/problem.md.j2).read_text(encodingutf-8) tpl Template(tpl_text) out tpl.render(problemcfg[problem]) Path(cfg[problem][id]) / problem.md target.write_text(out, encodingutf-8) print(f[OK] 题面已渲染: {target}) if __name__ __main__: cfg yaml.safe_load(open(config.yaml, encodingutf-8)) render(cfg)对应的模板文件templates/problem.md.j2里写# {{ problem.title }} | 项目 | 数值 | | ---- | ---- | | 时间限制 | {{ problem.time_limit }}s | | 空间限制 | {{ problem.memory_limit }}MB | | 标签 | {{ problem.tag | join(, ) }} |这一版的收益在题面改动时最明显改config.yaml里一个 title全题面跟着变不会再出现题目文件夹和提交页标题不一致的尴尬。配置先行这条路走到这里目录骨架、题面、数据分组都已经有了明确落点。下一步就是最关键的数据生成与对拍验证。4. 自动造数据与对拍器让题目在交付之前先自己跑通题目工具里含金量最高的一块不是目录生成而是「造数据」和「验数据」。造数据决定题目的区分度对拍决定题目答案的正确性。这两步做不到位题目交到别人手里就会翻车要么数据水到暴力都能过要么标程本身就是错的。4.1 数据生成器的两种写法随机生成与针对性构造我先写一个通用的随机数据生成器gen_random.py它会被 config 里 small 组调用。关键点在于必须支持从命令行读取参数这样脚本才能被统一调度import random import sys def parse_args(): n_range sys.argv[1].split(,) # 例如 100,1000 v_range sys.argv[2].split(,) seed int(sys.argv[3]) # 固定种子保证可复现 return (int(n_range[0]), int(n_range[1])), (int(v_range[0]), int(v_range[1])), seed def main(): (n_lo, n_hi), (v_lo, v_hi), seed parse_args() random.seed(seed) n random.randint(n_lo, n_hi) print(n) print( .join(str(random.randint(v_lo, v_hi)) for _ in range(n))) if __name__ __main__: main()逻辑说明生成器只负责往标准输出打印数据不负责写文件——这是刻意为之。把「生成数据」和「落盘」分离生成器可以被对拍脚本直接调用也可以被批量脚本调用复用性高很多。seed必须由外部传入不能写死在代码里否则跑多组数据时每组都一样。参数说明三个命令行参数分别对应长度范围、数值范围、随机种子。实际调用时调度脚本会把 config 里的params和当前组序号拼成命令行参数这样每一组数据的内容都是可追朔的。随机数据之外针对性构造数据更考验出题功力。比如说前缀和题目随机数组里出现大量负数容易让long long溢出这时候需要单独写一个构造器专门生成「前缀和接近 LLONG_MAX」的极限数据。针对性生成器往往没有通用模板我的做法是先把题目的边界情况列成清单一个边界一个文件空数组、单元素、全相同、逆序、最大规模。每个边界一个独立生成器或者一个生成器里用参数区分。4.2 数据落盘脚本把生成器输出转成 .in/.out 文件生成器打印到标准输出要变成 OJ 认识的.in/.out需要一个调度脚本。这个脚本读 config、调生成器、编译运行标程、写文件相当于把所有环节串起来import subprocess from pathlib import Path import yaml def gen_data(cfg): root Path(cfg[problem][id]) data_dir root / data data_dir.mkdir(exist_okTrue) # 编译标程 sol_cfg cfg[solution] subprocess.run(sol_cfg[compile], shellTrue, checkTrue) case_id 0 for group in cfg[test_groups]: for i in range(group[count]): case_id 1 p group[params] prefix f{group[name]}{i1} # 调用生成器把输出写为 .in 文件 in_file data_dir / f{case_id}.in gen_cmd [python, group[generator], f{p[n_range][0]},{p[n_range][1]}, f{p[val_range][0]},{p[val_range][1]}, f{case_id}{group[count]}] # 用组序号做种子 with open(in_file, w) as f: subprocess.run(gen_cmd, stdoutf, checkTrue) # 运行标程生成 .out out_file data_dir / f{case_id}.out with open(in_file, rb) as fin, open(out_file, wb) as fout: subprocess.run([./solution], stdinfin, stdoutfout, checkTrue) print(f[OK] 第 {case_id} 组: {prefix}) if __name__ __main__: cfg yaml.safe_load(open(config.yaml, encodingutf-8)) gen_data(cfg)逻辑说明subprocess.run负责所有外部调用生成器的输出通过stdoutf直接重定向到文件避免在 Python 里读完整字符串再手动写文件大数据量时省内存。标程编译一次后续所有测试点复用同一个可执行文件不会每跑一组数据就编译一次。参数说明种子取的是case_id拼接group[count]相当于「第几组数据的第几个用例」这样不管怎么重跑只要 config 不变数据永远一样。文件名用递增数字1.in、1.outOJ 普遍支持的命名方式后续如果要插入特殊数据可以约定前缀后缀比如1_sample.in。4.3 对拍器拿暴力解和标程互相验证数据生成之后最怕标程写错数据也按错的答案生成了。对拍就是拿一个保证正确但可能很慢的暴力解和你的标程高效解跑同一组随机数据对比输出。这里需要一个独立的对拍脚本stress_test.pyimport subprocess import random import sys def run(executable, input_file): with open(input_file, rb) as fin: res subprocess.run([executable], stdinfin, capture_outputTrue, timeout5) return res.stdout def main(): # 用法: python stress_test.py brute solution brute_exe, sol_exe sys.argv[1], sys.argv[2] for i in range(1000): seed random.randint(1, 10**9) # 随机生成小规模数据 n random.randint(1, 20) data f{n}\n .join(str(random.randint(-100, 100)) for _ in range(n)) with open(tmp.in, w) as f: f.write(data) out_brute run(brute_exe, tmp.in) out_sol run(sol_exe, tmp.in) if out_brute ! out_sol: print(f[WA] 第 {i1} 组对拍不一致seed{seed}) print(暴力解输出:, out_brute.decode()) print(标程输出:, out_sol.decode()) sys.exit(1) if (i 1) % 100 0: print(f[OK] 已通过 {i1} 组) print([PASS] 全部通过) if __name__ __main__: main()逻辑说明对拍的三个关键点是数据规模一定要小、数据范围一定要覆盖边界、循环次数一定要多。n限制在 20 以内暴力解和标程都能瞬间跑完这样 1000 组测试不至花费太多时间。输出对比直接用字节串比较不经过文本解码最大程度避免编码问题带来的误判。参数说明两个可执行文件路径从命令行传入没有硬编码。timeout5防止某个极端数据让标程陷入死循环导致对拍卡死。跑出来的tmp.in要记得在脚本末尾清理否则下次对拍可能读到残留文件——实践中我已经被这个坑过两次了。4.4 对拍的执行策略什么时候跑暴力解、什么时候直接信任需要澄清一点对拍不是所有题目都要跑。如果标程本身就是朴素算法比如 O(n²) 暴力那对拍对象应该换成一个理论上更慢的枚举解或者干脆做随机数自测。常见做法是给每道题配一个「暴力版标程」——和标程思路完全不同但结果一定正确比如标程用线段树暴力版就纯 for 循环。对拍跑不过最常见的两个原因不是标程错而是两个程序对同一组数据的输出格式不同多了一个空格、endl位置不对、浮点数打印精度不到位。所以对拍发现不一致时先看输出差异是不是只有空白字符再看数据是否命中了某条边界逻辑最后才怀疑算法本身的正确性。5. 五个常见翻车点排查从随机种子到换行符的连环坑工具链跑通不难难在稳定复现和跨平台交付。下面这几条都是实际出题时踩过、也帮学生排查过的坑按「现象 → 原因 → 解决」写清楚每一条都能直接对号入座。5.1 翻车一Windows 下生成的 .out 在 Linux 评测机上判 WA现象本地对拍全部通过传到 OJ 后同一个测试点显示 Wrong Answer而且是大量测试点同时 WA。原因Windows 下 Python 打开文件默认用\r\n换行生成的 .out 里每一行结尾都多了一个\r。评测机一般会把 .in 原样喂给程序但比对 .out 时按字节读\r\n和标的\n不一致直接判错。解决写文件时显式指定换行符统一用newline\nwith open(in_file, w, newline\n) as f: subprocess.run(gen_cmd, stdoutf, checkTrue)更保险的做法是生成数据后跑一段小脚本把所有.out文件里的\r全部剔除一劳永逸解决跨平台问题。5.2 翻车二随机种子没固定出了 WA 却复现不了现象对拍时发现一组数据能让标程和暴力解结果不一致但第二次再跑对拍脚本同一组数据消失了复现不出来。原因生成器内部自己调用了random.seed()或者种子用的系统时间导致每次跑出来的数据序列完全不同。解决种子必须显式传入而且要对每个测试点做记录。我习惯在生成数据的同时生成一个manifest.txtwith open(data_dir / manifest.txt, a) as f: f.write(fcase{case_id}: generator{group[generator]} seed{seed}\n)这样哪天某组数据出了问题直接看 manifest 找到对应种子一条命令就能重新生成现场。5.3 翻车三对拍脚本跑了几百组突然卡死现象对拍循环跑到第 300 组左右进程不再输出既不报 WA 也不继续像是卡住了。原因遇上了一组极端数据标程时间复杂度退化timeout5虽然存在但subprocess.run的 timeout 只在超时后抛出异常异常没有被捕获程序直接终止且没有任何提示。解决把对拍循环里的subprocess.run包在try/except里遇到超时就把当前数据和种子打印出来方便定位是哪组数据让标程退化try: out_sol run(sol_exe, tmp.in) except subprocess.TimeoutExpired: print(f[TLE] seed{seed}标程超时数据已留存 tmp.in) sys.exit(1)5.4 翻车四数据文件名大小写不一致OJ 或脚本找不到文件现象本地跑通所有步骤但把题目包发给别人或者上传 OJ报「找不到测试数据文件」。原因sample1.IN和sample1.in在 Windows 的 NTFS 文件系统里被视为同一个文件但在 Linux 的 ext4 里是两个不同文件。工具生成的虽然是小写但后续手动添加测试数据时可能无意中用了大写扩展名或者是拷贝过程中文件系统自动转换了大小写。解决在打包前做一个强制检查——标记所有.in/.out文件名的规范并列出与规范不一致的文件import re from pathlib import Path for p in Path(data).glob(*): if re.search(r\.[A-Z], p.name): p.rename(p.with_suffix(p.suffix.lower())) print(f[FIX] {p.name} 已改为小写)5.5 翻车五数据造得太猛标程都跑不完现象大数据组时限开 1 秒标程本机跑 0.8 秒感觉稳了结果加到 10 组极限数据后整体评测时间翻了几倍被判超时。原因标程在单组数据上的耗时接近时限但评测时每个测试点独立计时单组数据没问题真正翻车的是某些题目的多组数据模式——一个 .in 文件里包含多组询问数据量翻倍后标程复杂度不是线性增长而是 O(n²) 甚至更高。解决生成大数据后先跑一遍标程并计时观察每组数据耗时分布不能只看最快的一组。把耗时代码加到数据生成脚本里time ./solution data/10.in data/10.out如果某组数据耗时明显超过预期优先减小该组规模而不是去优化标程——题目数据原本就应该给选手留出优化空间。6. 进阶技巧用配置文件批量生成多组测试点并与评测脚本联动到这一步工具的基本闭环已经成立。剩下一个值得做的增强把「生成数据 → 编译标程 → 跑选手代码 → 收集结果」做成一条命令。这节给一个可以直接抄的技巧能让你的题目工具从「半自动」进化到「全自动」。6.1 给 config 增加验证段把评测参数也纳入配置在第 3 章的配置基础上增加一个verify段用来描述选手代码如何被验证verify: compile: g -O2 -o main main.cpp run: ./main samples_from: sample # 只对样例组做逐字比对run字段不需要包含输入输出重定向调度脚本会用subprocess来接管。这样设计的用意是验证阶段和生成阶段的配置在同一个文件里改一个参数就能同时影响数据和验证不会出现「数据生成了但忘了同步选手侧命令」的情况。6.2 一键验证脚本生成、对拍、计时一把梭这个脚本是整套工具的收口跑一次就能知道题目能不能交付import subprocess import time from pathlib import Path import yaml def verify_all(cfg): root Path(cfg[problem][id]) v cfg[verify] subprocess.run(v[compile], shellTrue, checkTrue) data_dir root / data results [] for in_file in sorted(data_dir.glob(*.in)): out_file in_file.with_suffix(.out) start time.time() with open(in_file, rb) as fin, open(out_file, wb) as fout: subprocess.run(v[run], stdinfin, stdoutfout, checkTrue, timeout10) elapsed time.time() - start results.append((in_file.name, round(elapsed, 3))) for name, t in results: print(f{name}: {t}s) if __name__ __main__: cfg yaml.safe_load(open(config.yaml, encodingutf-8)) verify_all(cfg)这个脚本做了一件事把每一组 .in 数据都喂给选手代码生成输出文件同时记录耗时。跑完看到的表格里如果某组数据耗时特别长说明这题的大数据可能过于极限如果所有数据都在时限的 30% 以内说明数据强度可能不够需要回去加大n_range或增加针对性构造组。6.3 一个数据组一个配置段的维护习惯最后分享一个我自己坚持的维护习惯每个测试组在 config 里独立成段不搞全局覆盖。比如small组有自己独立的n_rangelarge组再写一份更大的样例组单独指定一个专门的生成器确保样例数据是「手造」的而不是随机的。这样做的代价是 config 文件会变长但收益远大于成本——题目的每组数据是怎么来的、生成参数是什么、种子多少全部一目了然三个月后回来改题也不用重新读代码猜逻辑。这也是整套工具最值钱的地方不是代码多精妙而是出题的过程从「不可复述的黑匣子」变成了「可回放、可修改、可迁移」的工程产物。去年我帮训练队把十道老题重造数据时靠的就是这套配置化思路——每道题花十分钟改参数重跑而不是重新写生成器。希望帮到你。本文还有配套的精品资源点击获取