题单 · 习题解析

洛谷 P1563 玩具谜题

四种朝向缩成一句 dir[cur] == a;⚠ C++ 的 % 对负数给负数,而样例第一条指令就踩到它

原题:洛谷 P1563出自 第 5 章 枚举与模拟 的题单题面本地存档:2026-08-26
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库, 连原题那张图一起(这道题的「朝内朝外」全靠它才看得懂)。

转录自洛谷 P1563,日期见页头。两边不一致时信原站。

题目背景:NOIP2016 提高组 D1T1。

题目描述

小南有一套可爱的玩具小人,它们各有不同的职业。

有一天,这些玩具小人把小南的眼镜藏了起来。小南发现玩具小人们围成了一个圈, 它们有的面朝圈内,有的面朝圈外。如下图

原题里那张「玩具小人围成一圈」的图

这时 singer 告诉小南一个谜题:「眼镜藏在我左数第 3 个玩具小人的右数第 1 个玩具小人的 左数第 2 个玩具小人那里。」

小南发现,这个谜题中玩具小人的朝向非常关键,因为朝内和朝外的玩具小人的左右方向是相反的: 面朝圈内的玩具小人,它的左边是顺时针方向,右边是逆时针方向; 而面向圈外的玩具小人,它的左边是逆时针方向,右边是顺时针方向。

小南一边艰难地辨认着玩具小人,一边数着:

singer  朝内,左数第 3 个是 archer
archer  朝外,右数第 1 个是 thinker
thinker 朝外,左数第 2 个是 writer

所以眼镜藏在 writer 这里!

虽然成功找回了眼镜,但小南并没有放心。如果下次有更多的玩具小人藏他的眼镜,或是谜题的长度更长, 他可能就无法找到眼镜了。所以小南希望你写程序帮他解决类似的谜题。这样的谜题具体可以描述为:

n 个玩具小人围成一圈,已知它们的职业和朝向。现在第 1 个玩具小人告诉小南一个包含 m 条指令的谜题,其中第 z 条指令形如「向左数/右数第 s 个玩具小人」。 你需要输出依次数完这些指令后,到达的玩具小人的职业。

输入格式

输入的第一行包含两个正整数 nm,表示玩具小人的个数和指令的条数。

接下来 n 行,每行包含一个整数和一个字符串,以逆时针为顺序给出每个玩具小人的朝向和职业。 其中 0 表示朝向圈内,1 表示朝向圈外。保证不会出现其他的数。 字符串长度不超过 10 且仅由英文字母构成,字符串不为空,并且字符串两两不同。整数和字符串之间用一个空格隔开。

接下来 m 行,其中第 i 行包含两个整数 aᵢsᵢ,表示第 i 条指令。 若 aᵢ = 0,表示向左数 sᵢ 个人;若 aᵢ = 1,表示向右数 sᵢ 个人。 保证 aᵢ 不会出现其他的数,1 ≤ sᵢ < n

输出格式:输出一个字符串,表示从第一个读入的小人开始,依次数完 m 条指令后到达的小人的职业。

数据范围(原站是一张 20 行的测试点表,这里把「同上」的 ^ 都展开了):

测试点 n m 全朝内 全左数 sᵢ = 1 职业长度为 1
1 20 1000
2 20 1000
3 20 1000
4 20 1000
5 20 1000
6 20 1000
7 20 1000
8 20 1000
9 20 1000
10 20 1000
11 20 1000
12 20 1000
13 20 1000
14 20 1000
15 20 1000
16 20 1000
17 10⁵ 10⁵
18 10⁵ 10⁵
19 10⁵ 10⁵
20 10⁵ 10⁵

其中几列的意思是:全朝内 = 该测试点保证所有玩具小人都朝向圈内; 全左数 = 所有指令都向左数(对任意 za_z = 0); sᵢ = 1 = 所有指令都只数 1 个人;职业长度为 1 = 所有职业都是长度为 1 的字符串。

输入输出样例

输入

7 3
0 singer
0 reader
0 mengbier 
1 thinker
1 archer
0 writer
1 mogician 
0 3
1 1
0 2

输出

writer

样例 1 就是【题目描述】里那个例子。上面那段输出是仓库里的 p1563.cpp 真跑出来的。

输入

10 10
1 C
0 r
0 P
1 d
1 e
1 m
1 t
1 y
1 u
0 V
1 7
1 1
1 4
0 5
0 3
0 1
1 6
1 2
0 8
0 4

输出

y

样例 2:10 个人、10 条指令,答案是 y

1先在纸上把方向定下来(这一步不做完,代码写不对)

输入是按逆时针顺序给的,所以下标 +1 就是逆时针、-1 就是顺时针。 把题面那两句话翻成四种情况:

朝内(0) 左数(0)  ->  左边是顺时针  ->  -s
朝内(0) 右数(1)  ->  右边是逆时针  ->  +s
朝外(1) 左数(0)  ->  左边是逆时针  ->  +s
朝外(1) 右数(1)  ->  右边是顺时针  ->  -s
★ 盯着这张表看十秒:它可以缩成一句话

朝向和方向一样就往回走(-s),不一样就往前走(+s

cur = (dir[cur] == a) ? cur - s : cur + s;

四个 if 抄错一个就全错,而这一句压根没有可抄错的地方。 ⇒ 第 47 章那条老规矩: 能改成「一整类错误不存在」的写法,就别留着「小心别写错」。

⚠ 还有一处一句话带过、但错了就全错的:朝向只看出发的那个人。 一条指令认的是「我」的朝向,走过路过的人朝哪边跟这一条指令没关系。

2第 ① 版:老老实实一步一步走(对,但跑不完)

「向左数第 s 个」就真的走 s 步 —— 题意的逐字翻译,而且它是对的: 走一步和一次走 s 步落点完全一样。

p1563Step.cpp第 ① 版:一步一步挪
样例秒出。⚠ 它挂在 n = m = 10⁵ 那四个测试点上 —— 见下面的秒表。
// P1563 的第 ① 版:老老实实一步一步走
//
// 「向左数第 s 个」就真的走 s 步,每一步问一次「现在这个人朝哪边」—— 题意的逐字翻译,
// 而且**它是对的**:走一步和一次走 s 步,落点完全一样(朝向只看出发的那个人)。
//
// ⚠ 它只是慢:n, m <= 1e5,而 s 可以到 n - 1 ⇒ 最坏要走 m * (n-1) ≈ 10^10 步。
// 正文第 ④ 步有秒表:本机 n = m = 1e5 的最坏数据要跑十几秒,题目限时 1 秒。
//
// ★ 这一版仍然值得写出来:它是标准答案,也是对拍里那一栏「一定对」的东西。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false); // 1e5 行输入,关掉同步(第 45 章量过)
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<int> dir(n);
vector<string> job(n);
for (int i = 0; i < n; i++) cin >> dir[i] >> job[i];
int cur = 0;
for (int i = 0; i < m; i++) {
int a, s;
cin >> a >> s;
int step = (dir[cur] == a) ? -1 : 1; // 方向只由**出发的那个人**决定
for (int k = 0; k < s; k++) cur = (cur + step + n) % n; // 一步一步挪
}
cout << job[cur] << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

本机实测(B 机:原生 Ubuntu 7.0.0-30 / i5-10210U 8 线程 / 18 GB,2026-08-26,独占; 数据是 p1563GenBig.cpp 造的最坏形状:每条指令都取 s = n - 1):

n = m 要走的步数 ① 一步一步 ② 一次跳过去
10 000 10⁸ 1.00 秒 0.00 秒
30 000 9 × 10⁸ 9.01 秒 0.01 秒
100 000 10¹⁰ 108.47 秒 0.02 秒
p1563GenBig.cpp大数据生成器:把旋钮拧到头
⚠ 「大数据」和「最坏数据」不是一回事

生成器里那句 s = n - 1故意的:顺手写 s = 随机 1..n-1,平均只有 n / 2, 量出来的是「一般有多慢」,而我们要问的是「它到底能不能跑完」。

要量上限,就把旋钮拧到头第 51 章那次是「要又大又深」, 这一次是「要顶格的 s」)。

3⚠ 第 ② 版的第一个坑:C++ 的 % 对负数给的是负数

一次跳 s 格,很自然地写成:

cur = (dir[cur] == a) ? cur - s : cur + s;
cur = cur % n;                    // ⚠ 少了一步
p1563Neg.cpp错法演示:忘了 + n
★ 它在样例上就崩 —— 第一条指令是「singer 朝内,左数 3」,0 - 3 = -3。
// P1563 的错法演示:忘了「C++ 的 % 对负数给负余数」
//
// cur = (cur - s) % n; // ⚠ cur 变成负数,下标就飞出去了
//
// ★ 为什么这份用 job.at(cur) 而不是 job[cur](第 45 章那一课):
// `job[cur]` 在 cur 为负时是**未定义行为** —— 它可能崩、可能打出一串乱码、
// 也可能在 -O2 下被优化成别的样子,**每台机器给的结果都不一样,教材写不了**。
// 换成 .at() 就变成一个确定的 std::out_of_range,谁跑都一样。
//
// ⇒ 演示错误写法时,要把那个错**钉成可复现的**,不能留给 UB 去发挥。
//
// ⚠ 另一条(做这一页时量出来的):**一次「崩溃退出」在本机要 1.17 秒**
// (abort 走的是 core dump 那条路),而正常跑完只要几毫秒。
// 所以这一版只在样例上跑一次,**不进 300 轮对拍表** —— 那会让 check:viz 多花六分钟。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false); // 1e5 行输入,关掉同步(第 45 章量过)
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<int> dir(n);
vector<string> job(n);
for (int i = 0; i < n; i++) cin >> dir[i] >> job[i];
int cur = 0;
for (int i = 0; i < m; i++) {
int a, s;
cin >> a >> s;
cur = (dir.at((size_t)cur) == a) ? cur - s : cur + s;
cur = cur % n; // ⚠ 少了 + n:-3 % 7 还是 -3
}
cout << job.at((size_t)cur) << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ `-3 % 7` 在 C++ 里是 -3,不是 4

数学上的取模总落在 [0, n),而 C++ 的 % 保的是商向零取整, 于是负数进去、负数出来。下标一负,vector 一访问就飞。

cur = (cur % n + n) % n;          // ★ 这里 s < n,减完最小是 -(n-1),一句 + n 就够

★ 这道题样例第一条指令就踩到它0 - 3 = -3)—— 属于「样例挡得住」的那种坑。 ⚠ 但要挡得住有个前提:你真的跑了样例

p1563Trace.cpp两种写法的下标逐步对照
左边一栏是 (cur % n + n) % n,右边一栏是只写 cur % n。第 1 步就分家了。
// 把「负数取模」摊开看:同一组指令,两种写法的下标逐步对照
//
// 用法:./p1563Trace 跑样例 1(7 个人 3 条指令)
// ./p1563Trace csv 只打 `键,值`,给 check:viz 用
//
// ★ 看第一步就够了:`0 - 3 = -3`,而 C++ 的 `-3 % 7` 给的是 **-3**,不是 4。
// 下标一旦为负,`vector` 一访问就飞 —— 而**样例的第一条指令就会踩到它**。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
bool csv = (argc > 1 && string(argv[1]) == "csv");
const int n = 7;
int dir[7] = {0, 0, 0, 1, 1, 0, 1};
const char* job[7] = {"singer", "reader", "mengbier", "thinker",
"archer", "writer", "mogician"};
int cmd[3][2] = {{0, 3}, {1, 1}, {0, 2}};
int cur = 0;
int badAt = 0, badIdx = 0; // 只写 % n 的那版:第几步出界、出界成了几
if (!csv) printf("步 指令 算出来 正确 (cur %% n + n) %% n 只写 cur %% n\n");
for (int i = 0; i < 3; i++) {
int a = cmd[i][0], s = cmd[i][1];
int raw = (dir[cur] == a) ? cur - s : cur + s;
int good = (raw % n + n) % n;
int bad = raw % n;
if (!badAt && bad < 0) { badAt = i + 1; badIdx = bad; }
if (!csv)
printf("%d %s %d %3d %d (%s) %d%s\n", i + 1,
a == 0 ? "左数" : "右数", s, raw, good, job[good], bad,
bad < 0 ? " <- 负下标!" : "");
cur = good; // ★ 往下走的是正确的那一支
}
if (csv) {
printf("answer,%s\nbadAt,%d\nbadIdx,%d\n", job[cur], badAt, badIdx);
} else {
printf("\n正确写法落在 %d 号(%s)—— 正是样例的答案\n", cur, job[cur]);
printf("只写 %% n 的那版:第 %d 步就成了 %d —— vector 一访问就飞\n", badAt, badIdx);
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
⚠ 顺带一条工具经验(做这一页时量出来的)

这个错版是崩溃退出的,而本机一次崩溃要 1.17 秒abort 走的是 core dump 那条路), 正常跑完只要几毫秒。⇒ 它只在样例上跑一次,不进 300 轮的对拍表 —— 否则光是崩 300 次就要六分钟,check:viz 会平白慢一大截。

4★ 正解:一句话定方向 + 一次跳过去

p1563.cpp正解(能 AC)
// P1563 玩具谜题(NOIP 2016 提高组 D1T1)—— 能 AC 的那一版
//
// 输入是**按逆时针顺序**给的,所以下标 +1 就是逆时针、-1 就是顺时针。
// 于是四种情况可以摆成一张表(0 = 朝内 / 朝外,a = 0 左数 / 1 右数):
//
// 朝内(0) 左数(0) -> 左边是顺时针 -> -s
// 朝内(0) 右数(1) -> 右边是逆时针 -> +s
// 朝外(1) 左数(0) -> 左边是逆时针 -> +s
// 朝外(1) 右数(1) -> 右边是顺时针 -> -s
//
// ★ 盯着这张表看十秒:**朝向和方向一样就往回走,不一样就往前走** —— 一句 dir == a 就够了。
// 四个 if 抄错一个就全错,而这一句压根没有可抄错的地方。
//
// ⚠ C++ 的 % 对负数给的是负余数(-3 % 7 == -3),所以要 (x % n + n) % n。
// 这里 s < n,减完最小是 -(n-1),一句 + n 就够了。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false); // 1e5 行输入,关掉同步(第 45 章量过)
cin.tie(nullptr);
int n, m;
cin >> n >> m;
vector<int> dir(n);
vector<string> job(n);
for (int i = 0; i < n; i++) cin >> dir[i] >> job[i];
int cur = 0;
for (int i = 0; i < m; i++) {
int a, s;
cin >> a >> s;
cur = (dir[cur] == a) ? cur - s : cur + s; // ★ 一句话代替四个 if
cur = (cur % n + n) % n; // ⚠ 负数取模
}
cout << job[cur] << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

两行是全部:

cur = (dir[cur] == a) ? cur - s : cur + s;    // 方向:一样就往回,不一样就往前
cur = (cur % n + n) % n;                      // 取模:先 % 再 + n 再 % 一次

★ 对拍:check:viz 每次跑 600 轮(300 轮随机 + 300 轮「全朝内 + 全左数 + s 尽量大」, 后者专门逼下标往负的方向跑),第 ① 版和正解逐字节相同

5三个版本并排

版本 做法 样例 n = m = 10⁵ 能过吗
p1563Step 一步一步挪 108.47 秒 ✗ 后四个点 TLE
p1563Neg 一次跳,但只写 % n 第一条指令就崩 ✗ RE
p1563 一次跳 + (x % n + n) % n 0.02 秒
和算法无关、但会挂人的两条
  • 职业名带空格:原题样例里 0 mengbier 后面是有个空格的。 用 cin >> s 读就自动没事(它按空白切),用 getline 就得自己处理。
  • nm 都到 10⁵,而且每行还有一个字符串 —— cin 记得 ios::sync_with_stdio(false)第 45 章量过:读 10⁵ 行的差距不止一倍)。
这一页记住三句话
  1. 四种情况能缩成一句 dir[cur] == a 不是为了短,是为了 让「抄错一个 if」这类错误不存在
  2. C++ 的 % 对负数给负数,下标要写成 (x % n + n) % n。 这道题样例第一条指令就踩到它 —— 前提是你真的跑了样例。
  3. 「大数据」不等于「最坏数据」。 顺手随机的 s 平均只有 n / 2, 要问「能不能跑完」,就得把 s 拧到 n - 1