简介:面向信息学奥赛(NOI/NOIP)备赛者与C++算法学习者,资料围绕《信息学奥赛一本通》的题目数据整理而成,可用于日常自测、刷题巩固和查漏补缺。除配套书本的练习数据外,包内还收录了一些不在教材中的补充题目,方便读者扩展视野、接触更多题型与难度;另附有多份PDF文档,适合结合题目进行知识点梳理和考前复盘。压缩包整体大小约143MB,内容以题目数据文件为主,配合PDF学习文档,目录颗粒度适中,便于按章节或专题检索使用。目前已有2438人学习下载,对于需要批量自测、缺少题库资源的信息学竞赛初学者和进阶选手,都是较为实用的参考材料。
1. 信息学奥赛一本通的题目数据:为什么样例过了心里还是不踏实
有过这种经历吗?把《信息学奥赛一本通》上的题写完,样例在本地跑一遍完全一致,可一上题库就判错。问题往往不在算法,而在你手里根本没有完整的题目数据。信息学奥赛一本通的题目数据,指的是和书里每道题配套的标准输入输出文件对,它们决定了代码是否真正通过,而不只是“看着通过”。很多人翻遍全网只找到几个样例,或者根本找不到能对应上题号的测试点。这篇文章讲清楚数据从哪里找、怎么识别完整度、如何组织成一套本地自动化评测环境,以及这一路上的常见坑。适合正在刷题备战竞赛的学生、带竞赛课的老师,以及想把这套书变成自测题库的开发者。
2. 数据从哪来:先学会辨别“完整数据”和“只有样例”
2.1 题号映射:书内章节号不等于评测数据编号
《信息学奥赛一本通》的题目在书里按章节排列,第 2 章第 3 节的例题、习题混在一起,书内编号通常是“章节号 + 序号”的形式,但网上能找到的题目数据存放目录多半是题库系统的流水号。拿到一个数据包,第一件事不是急着解压跑评测,而是先把书内题号和目录名映射起来,否则后面全乱套。
我一般会先建一个book_to_data.csv,列三列:书内编号、题目名、数据目录名。这个动作看起来很基础,但能省下后面几小时的排查时间。一份常见的映射表长这样:
book_id,title,data_dir 2-3-1,汉诺塔,P183 3-1-2,排队接水,P227 4-2-1,最大子段和,P311注意,这张表必须自己维护,不要指望网上流传的映射表完全对得上。因为不同版本的书章节编号会有微调,同一道题在两个版本里可能编号不同,而数据目录名通常是按题库系统的历史题号排的。校验映射对不对,最直接的办法是看目录下的样例文件内容是否和书上的样例一致。
import csv # 读取映射表,检查每个数据目录是否存在 with open('book_to_data.csv', encoding='utf-8') as f: for row in csv.DictReader(f): data_dir = row['data_dir'] print(row['book_id'], row['title'], data_dir)这个脚本不复杂,但要注意编码问题。CSV 文件里有中文题目名,在 Windows 下用 Excel 编辑后经常变成 GBK 编码,Python 打开会报错。保存时统一用 UTF-8 with BOM,或者读取时指定encoding='utf-8'并忽略错误。映射表的维护是个体力活,但值得做。做完了之后,任何脚本都可以通过book_id定位数据目录,评测流程才算有根基。
2.2 拿到数据包后的三个验证动作:目录、配对、文件大小
网上流传的“一本通题目数据”大体分三类:某题库系统同步出来的全套测试点、爱好者手工整理的样例合辑、以及爬虫抓到一半就中断的半成品。后两类占比不小,所以拿到数据后必须用命令验证,不能信压缩包里的 README 吹得天花乱坠。
第一个动作是看目录结构。一个题目的数据目录里,正常情况下是成对的.in和.out文件。先看整体形态:
cd /path/to/bookdata/P183 # 列出该题所有数据文件,按名称排序 ls -la # 如果不喜欢 ls,可以用 find 统一看 find . -maxdepth 2 -type f | sortls -la里能看到文件大小,这一步能快速筛掉“假数据包”——如果一个目录下所有.in文件都只有几十字节,那基本可以断定它只是样例合辑,不是完整测试点。第二个动作是检查 in/out 配对,这是最关键的一步:
for f in *.in; do base="${f%.in}" if [ ! -f "$base.out" ]; then echo "missing output for $base" fi done这个循环的意义在于:.in和.out必须同名成对出现,后缀分别是.in和.out。如果缺失.out,这一组数据没法评测。有些数据包是.in和.ans配对,有些是.in和.out.txt配对,都需要提前归一化。
第三个动作是看数据规模分布。用wc -l统计每个输入文件的行数,能看出数据点覆盖范围:
# 只看行数最大的几个输入文件 wc -l *.in | sort -n | tail -20正常的一套题,数据应该从小到大分布:小数据测正确性,大数据测复杂度。如果所有文件行数都在 5 到 10 之间,说明这套数据只能当语法测试用,真正的边界值一个都没覆盖。遇到这种情况的题目,我建议直接标记为“缺大数据”,后面用对拍补,别耗在现有数据上。
2.3 统一目录与命名:让评测脚本只认一套规则
数据来源杂,命名必然杂。有的目录叫P183,有的叫1-2-3,还有的直接用中文题目名建目录。评测脚本没必要去适配所有命名风格,我通常先写一个归一化脚本,把数据统一成题目名/1.in、题目名/1.out这种格式。
import os import shutil import re import sys def normalize_data_dir(src_dir: str, dest_dir: str) -> None: """把散乱的测试点文件复制到 dest_dir,并重命名为 1.in 1.out 形式""" os.makedirs(dest_dir, exist_ok=True) in_files = [f for f in os.listdir(src_dir) if f.endswith('.in')] in_files.sort() # 排序保证编号稳定 for idx, in_name in enumerate(in_files, start=1): base = os.path.splitext(in_name)[0] out_name = None for cand in (base + '.out', base + '.ans', base + '.out.txt'): if os.path.exists(os.path.join(src_dir, cand)): out_name = cand break if out_name is None: print(f'{src_dir}: missing output for {base}') continue shutil.copy2(os.path.join(src_dir, in_name), os.path.join(dest_dir, f'{idx}.in')) shutil.copy2(os.path.join(src_dir, out_name), os.path.join(dest_dir, f'{idx}.out'))这段脚本有几个参数需要说清楚:src_dir是原始数据目录,dest_dir是整理后的目录。排序用sort()是因为网上有些文件是P183_1.in、P183_10.in、P183_2.in这种乱序,字符串排序会把10排在2前面。如果出现这种情况,可以改用自然排序,例如给文件名补零,或者用正则提取数字后排序。跑完这个脚本后,所有题目的数据格式完全一致,后面写的评测脚本不需要再关心数据来源。
这里有个需要小心的地方:shutil.copy2会保留文件元信息,但不会检查同名冲突。如果某个目录下已经存在1.in,再复制过去会直接覆盖。所以我一般会在脚本开头加一个参数,指定目标目录必须为空,避免误覆盖。整理完数据后,原始压缩包不要删,留给后面交叉核对用。
3. 本地评测的最小闭环:读入方式判断与判定脚本
3.1 先判断题目是标准输入还是文件输入输出
《信息学奥赛一本通》的题目里,输入方式不统一是最大的隐藏陷阱。一部分题是标准输入输出,直接在终端读键盘、打印屏幕;另一部分是文件输入输出,要求代码里写freopen("xxx.in", "r", stdin)这类语句,数据放在指定文件里。如果题目要求文件 IO,而评测脚本把.in文件当作标准输入流喂给程序,程序根本读不到数据,结果全是错误。
判断方法很直接:打开题目原书或原题的题目描述,看输入格式那一栏有没有“从文件读入”“文件输入输出”“输入文件名为 xxx”之类的字样。有,就是文件 IO。还有一个很实用的判断技巧:看测试点文件名是否固定。标准输入输出的题目,测试点文件可以随便命名;文件 IO 的题目,.in文件的名字必须在题目描述里写死,因为程序要靠文件名找到它。
# 判断一个数据目录是否属于固定文件名类型 # 如果所有 .in 文件名完全相同,大概率是文件 IO ls /path/to/bookdata/P183/如果看到data.in、data.out这种,基本可以断定是文件 IO。这时候评测脚本的运行方式就不一样了:不能把.in文件重定向给进程,而是要把当前工作目录切到数据目录,或者把数据文件复制到程序所在目录,让程序自己打开文件。
我处理的模拟项目 X 里,就吃过这个亏。当时某道题本地评测全绿,线上却全 WA,最后发现我用的数据文件名和程序里freopen写死的文件名不一致。程序在等number.in,我的数据文件叫1.in,它当然找不到。从此我定了条规矩:拿到题目的第一件事,先确认输入方式是哪一类,再决定后续评测流程。
3.2 最小可用的单人判题脚本:judge_one.py
写一个能跑单道题、能判 AC/WA/TLE/RE 的最小脚本,是整套本地评测环境的核心。这里用 Python 的subprocess实现,核心逻辑是:编译、运行、比对输出、判结果。
#!/usr/bin/env python3 import subprocess import sys import os import time def normalize_output(data: bytes) -> bytes: """归一化输出:处理 CRLF、行尾空格、末尾空行""" lines = data.replace(b'\r\n', b'\n').split(b'\n') lines = [line.rstrip() for line in lines] while lines and lines[-1] == b'': lines.pop() return b'\n'.join(lines) + b'\n' def judge_one(exe_path: str, in_file: str, out_file: str, time_limit: float): """ 运行一个测试点。 exe_path: 可执行文件路径 in_file: 输入数据文件 out_file: 标准输出文件 time_limit: 超时秒数,支持小数 """ start = time.time() try: with open(in_file, 'rb') as f: proc = subprocess.run( [exe_path], stdin=f, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=time_limit ) except subprocess.TimeoutExpired: return 'TLE', None, None elapsed = time.time() - start if proc.returncode != 0: return 'RE', proc.stdout, proc.stderr # 读取标准输出并归一化 user_out = normalize_output(proc.stdout) with open(out_file, 'rb') as f: expected_out = normalize_output(f.read()) if user_out == expected_out: return 'AC', proc.stdout, proc.stderr return 'WA', proc.stdout, proc.stderr if __name__ == '__main__': # 参数:可执行文件 输入文件 输出文件 超时秒数 result, out, err = judge_one(sys.argv[1], sys.argv[2], sys.argv[3], float(sys.argv[4])) print(result)这段脚本不长,但几个关键点要说清楚。timeout用的是subprocess.run的timeout参数,它会在超时后杀掉子进程并抛异常。这里有个容易翻车的细节:TLE的判定要把编译时间排除在外,所以编译是在调用judge_one之前完成的。normalize_output做了两件事:把\r\n换成\n,处理 Windows 下换行符差异;把每行末尾空格去掉、末尾空行去掉。这两步是各类评测系统默认行为,但必须注意只能处理行尾空白,不能把所有空白都删掉,否则会误判输出内部有多个连续空格的情况。
另外,stdin=f是以二进制模式读取输入文件,这样对二进制输入也安全。程序里如果有scanf或cin,它会按文本解释这些字节,没问题。stderr被保留下来,后面排查 RE 时会用到。脚本最后打印的结果只有一个字符串,方便外壳脚本解析。
3.3 数据点规模分布:为什么“样例过了”没有参考价值
《信息学奥赛一本通》里不少题目的样例非常温和,通常是 n=5 或 n=10 这种小规模,只验证算法思路是否正确。但数据评测的判定点是完整的测试点集合:小数据点检查边界条件,大数据点检查时间复杂度和空间复杂度。样例过了,只能说明主逻辑没崩,覆盖不了数组越界、整数溢出、死循环这类问题。
我拿到一套数据后,第一件事不是跑代码,而是看一眼wc -l *.in的结果。如果最大输入只有十几行,这套数据对这道题基本没有评测价值。真正有用的数据包,输入规模应该从几十到上万个递增,而且大点至少要有一个。这样跑代码时,如果结果在小点全 AC、大点全 TLE,就能立刻判断是算法复杂度超了,需要换思路。
数据点规模也直接决定评测时间的参数设置。一个 n=100000 的测试点,如果时限 1 秒,我的脚本会设置time_limit=1.0。但这种本地评测不等同于线上评测,本地机器快不代表线上机器快,只能作为参考。后面第 4 章的批处理脚本里,超时参数需要做成可配置的,针对不同题目单独调整。
4. 批量跑全书:循环执行与结果汇总
4.1 批处理外壳脚本:batch_run.sh
单题判完,接下来要能批量跑整本书。批处理脚本的职责很简单:遍历所有整理好的数据目录,对每个目录调用judge_one.py,把结果汇总到一张表里。但真正用起来,还得处理编译、版本选择和日志保留这些事。
#!/bin/bash # 用法: ./batch_run.sh <题目目录根路径> <语言: cpp|c> set -e DATA_ROOT="$1" LANG_TYPE="$2" RESULT_FILE="summary.txt" > "$RESULT_FILE" for data_dir in "$DATA_ROOT"/*/; do prob_name=$(basename "$data_dir") src_file="$data_dir/main.cpp" # 约定每题目录下放置 main.cpp if [ ! -f "$src_file" ]; then echo "$prob_name NO_SOURCE" >> "$RESULT_FILE" continue fi # 编译,失败直接跳过 g++ -O2 -std=c++17 "$src_file" -o "$data_dir/a.out" 2>/dev/null if [ $? -ne 0 ]; then echo "$prob_name CE" >> "$RESULT_FILE" continue fi # 遍历当前题目的所有测试点 ac_count=0 total_count=0 for in_file in "$data_dir"/*.in; do [ -e "$in_file" ] || continue total_count=$((total_count + 1)) base="${in_file%.in}" out_file="$base.out" result=$(python3 judge_one.py "$data_dir/a.out" "$in_file" "$out_file" 1.0) if [ "$result" = "AC" ]; then ac_count=$((ac_count + 1)) else echo "$prob_name $base $result" >> detailed_log.txt fi done echo "$prob_name $ac_count/$total_count" >> "$RESULT_FILE" done echo "batch done"这个脚本有几个参数需要说明。DATA_ROOT是整理好数据的根目录,每个子目录是一道题。我约定每道题的源码叫main.cpp,如果有多个语言的解决方案,可以按main.cpp、main.py分开命名,再用LANG_TYPE参数决定编译哪一份。RESULT_FILE汇总每题 AC 数和总数据点数的比例,detailed_log.txt记录具体哪个测试点挂了、挂的原因,方便后面定位问题。
编译参数里有个取舍:-O2是竞赛最常用的优化级别,但优化后的程序可能出现未定义行为暴露出来,和-O0结果不一致。我倾向于用-O2 -std=c++17,和主流题库一致。2>/dev/null把编译错误丢弃了,只保留“能不能编译”的结果,排错时要打开这个重定向单独看。建议给脚本加一个VERBOSE参数,平时跑批量不开,追查 CE 时打开。
4.2 结果汇总和失败现场保留
只统计 AC 数量是不够的,WA 的测试点必须留下对比信息。判题脚本只返回了一个结果字符串,所以我会在批处理脚本里加一段:结果不是 AC 时,把测试点文件名、程序输出和标准输出差异保存下来。
# 在 judge_one.py 返回 WA 后,保存现场文件 if [ "$result" != "AC" ]; then mkdir -p "wa_logs/$prob_name" cp "$in_file" "wa_logs/$prob_name/${base}.in" cp "$out_file" "wa_logs/$prob_name/${base}.out" "$data_dir/a.out" < "$in_file" > "wa_logs/$prob_name/${base}.user" 2>&1 || true diff -u "wa_logs/$prob_name/${base}.out" "wa_logs/$prob_name/${base}.user" \ > "wa_logs/$prob_name/${base}.diff" || true fi这里要特别提diff的模式。直接diff比较的是逐字节内容,但前面judge_one.py已经做了行尾空格归一化,diff 的结果可能显示很多行有差异,实际上那些差异只是行尾空格。排查时不要迷信 diff 的数量,重点看有没有非空字符的差异。遇到大量“看起来一样”的 diff,优先怀疑换行符问题。
另外,RE 的现场也值得保留。程序崩溃时stderr通常有输出,比如数组越界的警告、段错误的提示,这些信息对定位问题很有用。我一般会在 RE 时额外把stderr存成.err文件,虽然很多运行时错误不会打印任何内容,但只要有输出,就能省大量时间。
4.3 本地判 AC 不等于线上 AC:评测环境的差异
本地数据全绿,扔到题库上却出问题,最常见的原因有三个。第一是内存限制。本地机器内存充足,一个程序开 500MB 数组也能跑,但题库限制 128MB 时,直接 MLE。这个问题在本地没法完全模拟,但可以用一个小技巧:ulimit -v限制进程虚拟内存。在 batch 脚本里对单题运行加ulimit -v 131072,代表限制 128MB,能提前筛掉不少 MLE 的代码。
第二是栈空间。竞赛题目经常递归,递归深度一大就爆栈。本地 Linux 默认栈空间可能达到 8MB,而题库的栈大小可能只有 1MB 甚至更小。这个问题可以在编译时加-Wl,--stack=1048576或者运行时用ulimit -s限制,不过最稳妥的办法是直接把递归改成循环,或者用显式栈模拟。
第三是数据顺序和执行顺序的影响。本地测试时,如果脚本按文件名排序遍历,但题库测试点顺序不同,某些需要状态累积的题目会受影响,不过这类题很少。真正常见的是浮点数精度差异:本地double运算和题库机器上的long double运算结果不一致,导致 WA。数据比较时要留意差值比较而不是相等比较,本题目前的 judge 脚本里没有处理浮点,后面第 6 章的对拍脚本里我会补一段相对误差判别逻辑。
5. 数据评测避坑:五个最常见的翻车现场
5.1 数据文件明明存在,程序却报“无法打开文件”
现象:本地直接运行程序没问题,但用 judge_one.py 跑测试点时,程序返回 RE,错误信息是freopen找不到文件。
原因:判题脚本的工作目录不对。subprocess.run默认继承当前脚本的工作目录,而.in文件在另一个路径下。如果程序里写的是freopen("1.in", "r", stdin),它是在当前目录找1.in,找不到就崩溃。
解决:给subprocess.run加cwd参数,把工作目录切到数据文件所在目录,或者让 judge_one 接收一个工作目录参数。我通常用后一种方案,因为这不会污染脚本当前环境。代码改动很小:
proc = subprocess.run( [exe_path], stdin=f, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=time_limit, cwd=os.path.dirname(in_file) # 切到数据目录 )5.2 肉眼看输出一模一样,diff 却刷出一堆差异
现象:某道题的输出文件里有多余空格,或者换行符是\r\n,diff 把每一行都标成不同。
原因:题目数据的.out文件来自题库导出的文本,可能带有 Windows 行尾;也可能程序输出比标准答案多了一个行尾空格。
解决:必须用归一化后的输出做比较,而且只能归一化行尾空白。注意不要把所有空格都删掉,因为输出格式里有相当一部分题对中间空格敏感。我见过有人图省事直接re.sub(r'\s+', '', data)再比较,结果把1 2和12判成相等,批量跑出一堆假 AC。正确做法是只处理行尾。
def normalize_output(data: bytes) -> bytes: lines = data.replace(b'\r\n', b'\n').split(b'\n') lines = [line.rstrip() for line in lines] while lines and lines[-1] == b'': lines.pop() return b'\n'.join(lines) + b'\n'5.3 线上 AC 的代码,本地数据包却 WA
现象:某道题在题库里已经 AC,换成自己整理的本地数据包,反而多个测试点 WA。
原因:题目是文件输入输出,线上题库自动处理了文件重定向,而本地判题直接把它当作标准输入流。程序实际读不到输入,输出自然不对。
解决:回到题目描述确认输入方式。如果题目要求从filename.in读入、输出到filename.out,那本地评测时不能把.in内容喂给 stdin,而是要把对应的filename.in放到程序工作目录。我的 batch_run.sh 里专门针对这种题目配置一个FILE_IO开关,开启时脚本会先把数据文件复制成题目要求的名字,再运行程序,并且只比对程序生成的输出文件,不比对 stdout。
5.4 没有死循环,程序却被判 TLE
现象:某个测试点本地跑只要 0.3 秒,批处理跑出来却是 TLE。
原因:timeout参数设置得太死。不同机器性能差距很大,或者编译时没开-O2,导致常数太大。还有一种是 judge_one.py 里 timeout 传的是整型秒数,题目时限 1 秒但想要 0.5 秒细粒度控制时无效。
解决:timeout 参数用浮点秒数,subprocess.run的 timeout 完全支持0.5、1.5这种值。另外,批量脚本里的默认时限不要写死,从外部传入。如果程序在某个测试点上超时,先在本地单独跑一遍确认是算法问题还是机器性能问题,别急着改代码。
5.5 测试点缺斤短两:in 和 out 数量对不上
现象:解压出的数据包,.in文件有 10 个,.out文件只有 4 个。或者数据只有小样例,跑完一片绿,实际没有任何参考价值。
原因:数据源自爬虫抓取时中断,或者这个题目的完整输出数据本来就不对外公开,网站只允许下载输入文件。
解决:先用第 2 章的配对脚本把所有缺失情况列出来,把缺失 out 的题目标记为“数据不全”,不要参与批量评测。对“全绿但数据点太少”的题目,我的做法是拿暴力算法和正解对拍,用随机生成的数据补测。具体的对拍方式在下一章展开,这里记住一个经验:数据包里的测试点数量不能作为选题标准,数据规模的覆盖面才是。
6. 给数据做个体检:规模统计与对拍补测
跑完批量评测,下一步别急着宣告“全部 AC”,先给每道题生成一份数据体检报告。这里有一个很实用的脚本:遍历所有数据目录,统计每个测试点的输入规模,比如行数、最大数值,然后标记出“缺大数据”的题目。这个操作能帮你看清哪些题值得继续投入,哪些数据只是摆设。
for in_file in "$DATA_ROOT"/*/*.in; do lines=$(wc -l < "$in_file") echo "$in_file $lines" >> scale_report.txt done规模报告出来后,重点看那些所有输入文件行数都小于 20 的题,这些题必须补测。补测的常见做法是对拍:写一个保证正确但可能很慢的暴力解,和一个待验证的优化解,用随机数据生成器制造输入,反复跑两边对比结果。对拍的本质是制造“新的测试点”,绕开原始数据不完整的限制。随机数据生成器要覆盖边界值:最小规模、最大规模、全零、全负数、递增序列、递减序列。
我自己的习惯是,每个周末跑一次全量自测,把新做的题目的数据体检报告和评测结果一起存档。这比零散打卡有用得多,因为你能清楚看到哪类题的数据覆盖不足,哪类题的正确性还没被验证。本地评测环境不是竞赛系统的替代品,但它能提供即时反馈,让你在交代码之前就知道自己处在什么水平。希望这些整理数据、写评测脚本的经验能帮到你,少走几步弯路。
本文还有配套的精品资源,点击获取