以 P10832 为例配置 IO 通信题
前文:https://www.luogu.com.cn/article/84bz48ew
对于需要运行两次的 IO 通信题,评测程序要把第一次运行的输出交给第二次运行,并检查它们是否共同完成了任务。本文以 P10832「[COTS 2023] 传 Mapa」 为例,介绍从输入输出约定、数据文件到评测代码和平台配置的完整做法。
下面直接使用 luogu-communication-lib 提供的接口。读者只需了解每个接口读写什么、何时调用,就能编写本题的评测逻辑,无需了解模板内部实现。文末还提供了一份用于检查评测流程的选手程序;它能够正确恢复数据,但不是 P10832 在最大数据上的满分做法。
题意简述
给定
选手提交的同一个程序需要独立运行两次。第一次读入全部键值对,将它们编码为长度为
编码串必须满足
因此,
评测思路
先考虑第二次运行的输入从哪里来。原始键值对可以提前写进测试文件,编码串却由选手程序决定,不同程序可能输出不同的合法编码。因此,评测时必须先运行编码程序,取得它的实际输出,再用这个输出组织解码输入。
我们把组织这两个阶段的代码写进 grader()。这个函数保存原始键值对,先取得并检查编码串,再把编码串和查询键交给第二次运行的选手程序。原始数据中的值可以直接用来核对解码答案,所以一份测试文件就足以完成整个过程。
要让平台运行这套检查,还需要外层交互器 checker.cpp。它读取测试文件,把原始数据发送给 grader,最后接收检查结果并计分。这样分工后,grader 负责两次运行之间的数据传递和答案检查,checker 负责测试文件与平台判定。注意,文件名中的 checker 不代表普通离线 SPJ;这份代码使用 registerInteraction(),应按交互器运行。
编写代码时,要区分选手与 grader 的通信,以及 grader 与 checker 的通信。前者严格使用题面给出的输入输出格式,后者使用我们约定的消息标记。消息标记只供评测程序使用,选手仍然编写普通的标准输入输出程序。
于是,一次测试点的执行过程为:checker 读取数据并发送给 grader,grader 先后运行编码和解码程序,确认结果正确后报告
测试文件 .in
│ 原始键值对
▼
checker.cpp ←── 检查结果、编码长度 ── grader()
└──────────── 原始数据 ────────────→ │
├─ 第一次运行:编码
└─ 第二次运行:解码
输入输出与测试数据
grader 要向选手发送数据,首先必须确定选手会怎样读取这些数据。本题用第一行的
编码阶段输入如下,其中第一行的 1 表示
1
n
x1 y1
x2 y2
...
xn yn
选手输出长度
k
s
解码阶段输入如下,其中
2
n q k
s
query1
query2
...
queryq
选手按查询顺序输出
answer1
answer2
...
answerq
原题允许
题面还应说明:每个阶段按数量读完输入、输出结果后正常退出,不要继续等待 EOF,也不要向标准输出添加调试信息。本例先发送本阶段的全部输入,选手正常退出时会刷新输出缓冲区。如果其他题在同一阶段内需要多轮问答,则应要求选手每轮输出后主动刷新。调试时也不要依赖这个模板环境中的 stderr 可用。
测试文件只需保存原始键值对。本例沿用所提供的 mapa-01-27.in 格式,第一行固定为 1,随后是
1
3
2 10
3 3
5 7
第一行的 1 由 checker 读掉,编码和解码阶段各自的 .in 就对应一次完整的两阶段测试,不要把两阶段拆成互不关联的两个测试点。如果重新设计数据格式,也可以去掉首行的 1,但必须同步修改 checker 的读取代码。
本例不读取标准答案文件。所提供的 mapa-01-27.in 与 .out 内容完全相同;这份 .out 没有参与答案判定。如果平台要求每个输入文件都有对应的输出文件,可以提供平台接受的占位文件。是否允许空文件需要看平台要求,不能仅凭 checker 没有使用它就省略上传。
编写评测代码
通信接口与消息格式
在编写本题逻辑前,准备好模板头文件 luogu-communication-lib.hpp 和与平台兼容的 testlib.h。前者供 grader 使用,后者供外层 checker 使用。模板应选择已验证的版本并记录提交号,方便今后复现评测环境。
通信模板需要使用的接口只有以下几个:
| 接口 | 用途 |
|---|---|
CommunicationLib::SubProcess::safe_invoke() |
启动一次新的选手程序执行 |
p->fout |
向这次选手程序的标准输入写入数据 |
p->fin |
读取这次选手程序的标准输出 |
p->guard() |
等待这次程序正常退出;异常退出会使评测执行失败 |
COMMUNICATION_LIB_REGISTER_GRADER(grader) |
注册本题的 grader 函数 |
使用时可以始终站在 grader 的角度理解:p->fout 是“我写给选手的数据”,p->fin 是“我从选手读回的结果”。grader 自己的 cin 和 cout 留给外层 checker。两组流各有用途;每发送完一批对方接下来需要读取的数据,就调用 flush,避免数据仍留在缓冲区中。
checker 需要区分“检查失败”和“检查成功”,grader 也需要识别外层发来的数据。为此,本文约定三个字符串常量:INPUT_TOKEN 标记原始数据,WA_TOKEN 标记失败报告,OUTPUT_TOKEN 标记成功报告。两份源码中的常量值必须完全一致;正式配题时可以一起替换为该题专用的字符串。这些标记用于识别消息,运行隔离仍需模板和平台配合。
checker 发送的数据为:
INPUT_TOKEN
n
x1 y1
...
xn yn
grader 检查失败时发送:
WA_TOKEN
单行错误信息
检查成功时发送:
OUTPUT_TOKEN
k
以上 INPUT_TOKEN 等位置应写出常量对应的字符串内容。选手程序的输入输出中不出现这些标记。
interactive_lib.cpp
grader 先接收原始数据,再通过 safe_invoke() 启动编码程序,将
检查编码串不必读入任意长的输出。先确认 setw(k + 1) 最多读取 std::string,setw 指定的是最多提取的字符数,不需要为结束符额外留位。
编码程序正常退出后,grader 新运行一次选手程序,发送 order 保存原键值对的下标,第 x[order[i]],对应的正确答案就是 y[order[i]]。由于 order 是全部下标的一个排列,每个键恰好检查一次;所有比较通过,就说明本次解码执行正确回答了全部查询。
随机种子 0x21792743 沿用案例文件,只用于生成查询顺序,不影响正确答案。固定种子便于在相同实现环境下复现;若更换标准库实现,不应假定 shuffle 的结果一定相同。
将以下代码保存为 interactive_lib.cpp,并在同目录准备模板头文件:
#include <bits/stdc++.h>
#include "luogu-communication-lib.hpp"
// 在洛谷实际使用时,应当把 luogu-communication-lib.hpp 的所有内容复制粘贴进 interactive_lib.cpp 中。
namespace {
const std::string INPUT_TOKEN = "mapa-tutorial-input-v1";
const std::string WA_TOKEN = "mapa-tutorial-wa-v1";
const std::string OUTPUT_TOKEN = "mapa-tutorial-output-v1";
[[noreturn]] void wrong_answer(const std::string &message) {
std::cout << WA_TOKEN << '\n' << message << std::endl;
std::exit(0);
}
void grader() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
std::string token;
if (!(std::cin >> token) || token != INPUT_TOKEN)
wrong_answer("Invalid input token");
int n;
if (!(std::cin >> n) || n < 1 || n > 100)
wrong_answer("Invalid n from checker");
std::vector<int> x(n), y(n);
for (int i = 0; i < n; ++i) {
if (!(std::cin >> x[i] >> y[i]))
wrong_answer("Unable to read data from checker");
}
// 编码阶段。
auto encoder = CommunicationLib::SubProcess::safe_invoke();
encoder->fout << 1 << '\n' << n << '\n';
for (int i = 0; i < n; ++i)
encoder->fout << x[i] << ' ' << y[i] << '\n';
encoder->fout << std::flush;
int k;
if (!(encoder->fin >> k))
wrong_answer("Unable to read encoding length");
if (k < 1 || k > 6000)
wrong_answer("Encoding length is out of range");
std::string s;
// 最多读 k+1 个字符:多于 k 的串会被发现,且不需完整读入。
if (!(encoder->fin >> std::setw(k + 1) >> s))
wrong_answer("Unable to read encoding");
if (s.size() != static_cast<std::size_t>(k))
wrong_answer("Encoding length does not match");
for (char c : s) {
if (c != '0' && c != '1')
wrong_answer("Encoding contains an invalid character");
}
encoder->guard();
// 查询全部键,并打乱顺序。
std::vector<int> order(n);
std::iota(order.begin(), order.end(), 0);
std::mt19937 rng(0x21792743);
std::shuffle(order.begin(), order.end(), rng);
// 解码阶段。
auto decoder = CommunicationLib::SubProcess::safe_invoke();
decoder->fout << 2 << '\n';
decoder->fout << n << ' ' << n << ' ' << k << '\n';
decoder->fout << s << '\n';
for (int id : order)
decoder->fout << x[id] << '\n';
decoder->fout << std::flush;
for (int i = 0; i < n; ++i) {
int answer;
if (!(decoder->fin >> answer)) {
wrong_answer("Unable to read answer #" +
std::to_string(i + 1));
}
if (answer != y[order[i]]) {
wrong_answer("Incorrect answer #" +
std::to_string(i + 1));
}
}
decoder->guard();
// 只有所有阶段通过后才能报告成功。
std::cout << OUTPUT_TOKEN << '\n' << k << std::endl;
}
} // namespace
COMMUNICATION_LIB_REGISTER_GRADER(grader);
wrong_answer() 通过内部协议报告错误;其中的 exit(0) 不表示选手答案正确,外层 checker 会根据 WA_TOKEN 判错。崩溃、超时和非零退出等执行异常,还要由平台运行器处理,最终可能显示为 RE、TLE 或交互失败,不保证全部显示为 WA。
本实现按空白分隔读取字段,不严格检查每个换行位置,也不逐一拒绝合法结果之后的额外输出。如果另一个题必须严格拒绝尾随内容,需要连同输入输出结束方式一起设计。不要直接加入可能一直等待的“读到 EOF”循环,也不要假定 guard() 会主动给选手发送 EOF。
checker.cpp
checker 使用 registerInteraction() 初始化 testlib。inf 读取测试文件,ouf 读取对端返回的信息,cout 向对端发送数据。这里的 ouf 不是测试点的 .out 文件,代码也没有读取标准答案流。
checker 先检查数据范围和键是否重复,确认数据合法后才发送给 grader。接下来只需等待报告:若收到 WA_TOKEN,读取下一行错误信息并判错;若收到 OUTPUT_TOKEN,读取
#include "testlib.h"
#include <bits/stdc++.h>
using namespace std;
const string INPUT_TOKEN = "mapa-tutorial-input-v1";
const string WA_TOKEN = "mapa-tutorial-wa-v1";
const string OUTPUT_TOKEN = "mapa-tutorial-output-v1";
int main(int argc, char *argv[]) {
registerInteraction(argc, argv);
// 保留案例测试文件第一行固定为 1 的格式。
int type = inf.readInt(1, 1, "type");
(void)type;
int n = inf.readInt(1, 100, "n");
vector<int> x(n), y(n);
set<int> keys;
for (int i = 0; i < n; ++i) {
x[i] = inf.readInt(1, 1000000000, "x");
y[i] = inf.readInt(1, 1000000000, "y");
if (!keys.insert(x[i]).second)
quitf(_fail, "Duplicate key in test data");
}
cout << INPUT_TOKEN << '\n' << n << '\n';
for (int i = 0; i < n; ++i)
cout << x[i] << ' ' << y[i] << '\n';
cout << flush;
string token = ouf.readToken();
if (token == WA_TOKEN) {
ouf.readEoln();
string message = ouf.readLine();
quitf(_wa, "%s", message.c_str());
}
if (token != OUTPUT_TOKEN)
quitf(_wa, "Invalid output token");
int k = ouf.readInt();
if (k < 1 || k > 6000)
quitf(_wa, "Invalid encoding length: %d", k);
if (k <= 3000)
quitf(_ok, "Accepted (k=%d)", k);
double score = 1.0 - double(k - 3000) / 3000.0;
quitp(score, "Acceptable (k=%d)", k);
}
错误信息使用 quitf(_wa, "%s", message.c_str()) 输出,避免把消息中的字符当成格式说明。无效长度也显式判错,不依赖“这个分支不会发生”的假设。
quitp() 接收的是 quitf(_ok, "Accepted") 即可。
联调程序与复杂度
配置完成后,先用一份容易核对的选手程序检查数据是否正确传递。
:::info[不重要的复杂度分析]{open}
因为
解码时每次取出连续的 map。固定长度保证每个整数的边界唯一,二进制展开与恢复互为逆操作;由于键两两不同,重建后的映射与原数据相同,因此每次查询都会得到正确的值。
这份程序能够检查编码、转交字符串和解码是否衔接正确。它没有进一步压缩数据,
编码阶段时间复杂度为
另外,评测代码自身也有开销。checker 用 set 检查重复键,时间为 int 范围。
:::
将以下程序保存为 baseline.cpp:
#include <bits/stdc++.h>
using namespace std;
// 每个整数固定写入 30 位,保留前导零以确定解码边界。
void append30(string &s, int value) {
for (int bit = 29; bit >= 0; --bit)
s.push_back(char('0' + ((value >> bit) & 1)));
}
// 从 pos 开始恢复一个整数,并将 pos 移到下一个整数的起点。
int read30(const string &s, int &pos) {
int value = 0;
for (int bit = 0; bit < 30; ++bit)
value = value * 2 + (s[pos++] - '0');
return value;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int type;
if (!(cin >> type)) return 1;
if (type == 1) {
int n;
cin >> n;
string s;
for (int i = 0; i < n; ++i) {
int x, y;
cin >> x >> y;
append30(s, x);
append30(s, y);
}
cout << s.size() << '\n' << s << '\n';
} else if (type == 2) {
int n, q, k;
string s;
cin >> n >> q >> k >> s;
if (k != 60 * n || static_cast<int>(s.size()) != k)
return 1;
map<int, int> values;
int pos = 0;
for (int i = 0; i < n; ++i) {
int x = read30(s, pos);
int y = read30(s, pos);
values[x] = y;
}
for (int i = 0; i < q; ++i) {
int x;
cin >> x;
auto it = values.find(x);
if (it == values.end()) return 1;
cout << it->second << '\n';
}
} else {
return 1;
}
return 0;
}
同一份程序可以用于检查三个评分分支:
| 测试规模 | 编码长度 | 预期得分比例 |
|---|---|---|
所提供的 mapa-01-27.in 满足
验收与常见问题
先把 baseline.cpp 单独编译,运行编码阶段,再将得到的字符串组织成解码输入,核对查询答案。这一步确认选手程序自身没有写错。随后把同一份源码交给目标平台,通过通信库构建和交互运行器再测一次,才能确认评测配置也正确。两次测试检查的对象不同,都需要完成。
正确程序通过后,还要故意构造错误输出和异常执行,确认它们不会得到正常成功分数。建议分别检查以下情况:
| 测试内容 | 构造方法 | 预期结果 |
|---|---|---|
| 满分、部分分、合法零分 | 基准程序分别测试 |
比例分别为 |
| 长度越界 | 输出 |
不能通过 |
| 长度不符 | 声明的长度与实际串不同 | 不能通过 |
| 字符非法 | 将串中的一位改成 2 |
不能通过 |
| 回答错误 | 修改一个查询的答案 | 不能通过 |
| 输出缺失 | 少输出结果并退出 | 不能通过 |
| 异常退出 | 输出后非零退出或崩溃 | 不能获得正常成功分数 |
| 超时 | 某一阶段一直循环 | 平台终止程序,不遗留相关进程 |
| 未实现解码 | 只处理 |
解码阶段不能通过 |
| 查询顺序错误 | 按原顺序输出值,并选取能体现乱序的数据 | 不能通过 |
正式数据还应覆盖
如果出现 Invalid input token,先看两个文件的消息常量是否一致,以及是否通过完整交互运行器启动。如果选手直接读到了 token,则应检查通信库有没有正确加入构建。多个 main() 的链接错误通常意味着误把 checker 与选手代码合并;找不到头文件则应检查依赖是否上传或展开。
程序输出结果后仍然卡住时,优先检查是否继续等 EOF、是否忘记刷新、是否没有退出,以及是否在读取输入前大量输出。编码完成但读不到解码答案时,检查选手是否正确处理了
迁移到其他 IO 通信题时,先重新定义每阶段的输入输出和允许传递的信息,再修改原始数据格式、grader 的各阶段操作及答案检查,最后修改 checker 的计分方式。通信模板仍按接口调用,平台的构建、交互运行和验收步骤也可以沿用。