ACM 一个月学习计划 · DAY 29

Day 29 · 模拟赛

今天不学新算法 —— 今天练的是「在 3 小时里把会做的题全部拿到分」

今日目标:按真实比赛节奏限时完成 4 题,赛后把没做出来的题补掉
预计用时:约 4 小时(3 小时模拟赛 + 1 小时复盘补题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1067 / P1328 / P1981 / P1996
← Day 28 · 栈与队列:STL 容器

⏱今日时间安排(按真实比赛节奏走)

0 - 10 min四道题全部读一遍,判断难度,决定做题顺序(先别写代码)
10 - 70 min拿下最容易的 1 ~ 2 题(每题限时 30 分钟)
70 - 130 min啃第 3 题;卡住超过 50 分钟就先去写暴力拿分
130 - 160 min第 4 题 + 回头检查数组大小 / long long / 输出格式
160 - 180 min最后 20 分钟:不写新代码,只做检查
180 - 240 min赛后复盘:把没 AC 的题不看题解重写一遍(这才是重点)
💡 和昨天内容的衔接

Day 28 你刚学完栈和队列。今天的 4 道题里,P1981 表达式求值 和 P1996 约瑟夫问题 正好就是栈和队列的直接应用 —— 出题人就是这么设计的。另外两道(P1067、P1328)是纯模拟, 考的是 Day 1 ~ Day 6 攒下来的基本功:数组、循环、条件判断、边界处理。

1模拟赛到底在考什么

很多人第一次打模拟赛会有一种很憋屈的感觉:这四道题的算法我全都学过,为什么还是拿不到分?

原因通常不是「不会」,而是下面这几种:

丢分原因典型表现今天怎么防
读题太快漏掉「系数为 1 时省略」「输出最后 4 位」这类细节把题面里每一句「要求」抄成清单,写完逐条打钩
顺序选错一上来死磕最难的题,一小时过去 0 分先花 10 分钟全部读一遍,从最简单的开始
卡死不放一道题耗掉 100 分钟,其余三题没时间单题超过 50 分钟就换题,先把能拿的分拿到手
不检查数组开小、忘开 long long、多测没清空留最后 20 分钟专门检查,不写新代码
没有暴力兜底正解写不出就交白卷很多题有子任务分,写个暴力能拿 30 ~ 60 分
🎯 一句话记住今天的主题

比赛比的不是「谁会得更多」,而是「谁丢得更少」。
会做的题全对 + 不会的题骗到部分分,这个分数往往比死磕一道难题高得多。

今天的四道题分别是什么类型

题号题目难度类型
P1067多项式输出普及-细节模拟(特判地狱)
P1328生活大爆炸版石头剪刀布普及/提高-打表 + 模拟
P1981表达式求值普及-栈 / 优先级处理
P1996约瑟夫问题普及-队列模拟

建议顺序:P1996 → P1067 → P1981 → P1328,或者 P1067 → P1996 → P1981 → P1328。 P1996 最短最快,先拿一题稳住心态;P1328 要自己打一张表,读题最花时间,放后面。

2拿到一道题,先做三件事

第一件:把题面翻译成「输入 → 处理 → 输出」

别急着想算法。先用一句话说清楚:程序读进来什么,要吐出去什么。

拿 P1067 举例,翻译完是这样:

环节内容
输入第一行次数 n,第二行 n+1 个整数(从最高次到常数项的系数)
处理按次数从高到低,把非零项拼成一个字符串
输出一行,形如 100x^5-x^4+x^3-3x^2+10

翻译完你立刻就能发现:这题没有算法,只有一个循环加一堆 if。剩下的全是细节,那就去抠细节。

第二件:把「细节」抄成一张清单

题面里凡是出现「如果……则……」「不需要……」「只输出……」的句子,每一句都对应代码里一个 if。

P1067 的清单长这样(这题一共 5 条):

#规则代码里对应什么
1按次数递减输出循环写 for(i = n; i >= 0; i--)
2只输出系数不为 0 的项if(a == 0) continue;
3最高次项为正不输出 +,为负输出 −用 first 标记第一项
4非最高次项用 + / - 连接后面每项都先输出符号
5次数 > 0 且系数绝对值为 1 → 不输出 1
次数为 1 → 写 x;次数为 0 → 只写系数
一层 if(i == 0) / else if(i == 1) / else
✅ 这个清单法能救命

写完代码后,把这张表拿出来一条一条打钩。P1067 这种题,只要你 5 条都实现了,就一定能过; 少一条就是 0 分。比赛里靠感觉写细节题,翻车率极高。

第三件:先用样例手动验算一遍

样例 2 是 n=3,系数 -50 0 0 1,期望输出 -50x^3+1。手动走一遍:

i(次数)系数该输出什么结果拼接
3-50第一项且为负 → 输出 −,|系数|=50 ≠ 1 → 输出 50,次数>1 → x^3-50x^3
20跳过-50x^3
10跳过-50x^3
01非第一项且为正 → +,次数为 0 → 只输出系数 1-50x^3+1

和样例输出一模一样。这一步花你 2 分钟,能省掉 20 分钟调试。

3细节模拟题的通关套路

套路一:用一个 first 标记替代「特判最高次项」

新手最容易写出这样的代码:

// ❌ 又长又容易错:对最高次项单独特判一遍
if(最高次项){
    if(a > 0) cout << a << "x^" << n;
    else      cout << a << "x^" << n;
}
for(int i = n - 1; i >= 0; i--){
    // 这里还要再写一遍系数、次数、符号的处理……
}
🎯 更好的写法:只用一个布尔标记

不要问「这是不是最高次项」,而问「我前面输出过东西没有」。

bool first = true;
// 每一项的处理只有一份,符号部分按 first 分叉:
if(first){ if(a < 0) cout << '-'; }        // 开头:只有负号
else     { cout << (a < 0 ? '-' : '+'); }  // 后面:正负都要
first = false;

这样「系数 / 字母 / 指数」的逻辑只写一遍,不会出现两份代码改了一处忘了另一处的情况。
而且它还顺手解决了「最高次项系数为 0 怎么办」这个题面没明说的边界 —— 因为判断的是「有没有输出过」, 不是「是不是最高次项」。

套路二:能把「关系」写成表,就别写成一堆 if

P1328 石头剪刀布升级版有 5 种手势,两两组合有 25 种情况。用 if 写就是 25 个分支,写完自己都不敢改。

改成二维数组,逻辑就从「代码」变成了「数据」:

int r[5][5] = {
    { 0, -1,  1,  1, -1},   // 剪刀
    { 1,  0, -1,  1, -1},   // 石头
    {-1,  1,  0, -1,  1},   // 布
    {-1, -1,  1,  0,  1},   // 蜥蜴人
    { 1,  1, -1, -1,  0}    // 斯波克
};
// r[a][b] = 1 甲赢 / 0 平 / -1 甲输

查结果就是一句话:r[a][b]。这张表有两条性质,写完可以自查: 对角线全是 0(自己和自己平),关于对角线反对称(r[a][b] == -r[b][a],甲赢就是乙输)。

套路三:周期性的东西用「取模」代替「真的去拼无穷长」

P1328 里两人要出拳 N 次,但每个人的出拳规律是长度 NA、NB 的循环。 根本不用造出一个长度 N 的序列,直接:

int res = r[ a[i % na] ][ b[i % nb] ];   // i % na 自动回到周期开头
⚠️ 取模是 0 下标的好朋友

i % na 的结果正好落在 0 ~ na-1,刚好是数组下标的合法范围,不需要 +1 / −1 折腾。
如果题目给的是「第 i 个人」这种从 1 开始的东西,就要写成 (i - 1) % n —— 用错会越界。

套路四:只要求「后几位」,就每一步都取模

P1981 只要答案的最后 4 位。这时候不需要真的算出完整的大数,每一步都对 10000 取模即可:

a * b % 10000 == ((a % 10000) * (b % 10000)) % 10000   // 恒成立
(a + b) % 10000 == ((a % 10000) + (b % 10000)) % 10000 // 同理
💡 为什么这招这么好用

取模不改变「低位」,只丢「高位」。题目只要低位,高位丢了也不心疼 —— 这就是用信息换范围。
它同时解决了两件事:结果不会溢出 long long,而且数字本身也能读进来就取模(题面里数字可以到 2³¹−1)。

4P1981 的核心:加法和乘法怎么混着算

先想清楚「优先级」到底卡在哪

表达式 1+2*3,如果从左往右一个个算,会得到 (1+2)*3 = 9,答案是错的。乘法的优先级更高,必须先把 2*3 粘在一起。

🎯 关键洞察:加法把式子切成了「一个个项」

因为乘法优先级最高,所以整个式子可以看成:若干「项」用 + 连接起来, 而每个项内部只有乘法。
1+2*3+4*5  =  (1) + (2*3) + (4*5)  =  1 + 6 + 20

所以只要维护两个变量:已经结算完的总和 和 当前正在累乘的这一项。

手推一遍 1+1*3+4

读到的字符numopterm(当前项)ans(总和)说明
11—10数字,继续攒
+0+10第一个数遇到运算符,它就是当前项
11+10数字
*0*11上一项结算进 ans,开始新项
33*11数字
+0+31乘法优先:term = 1 * 3 = 3,不结算
44+31数字
结束——44结算上一项 → ans = 1+3 = 4
收尾———8别忘了最后一项!ans = 4+4 = 8
🚫 这里有一个 90% 的人都会踩的坑

循环里只处理「遇到运算符时结算上一项」,所以最后一项永远不会被结算。
必须在循环结束后补一句 ans = (ans + term) % 10000;。

样例 1+1*3+4 里最后一项是 4,忘了它就是输出 4 而不是 8 —— 一眼就能看出错, 但如果最后一项是 0,你就永远发现不了这个 bug。这种"有时对有时错"的 bug 最要命。

为什么还要在「读数字」的时候就取模

题面说参与运算的数字可以是 0 到 2³¹−1。如果先完整读进 long long 再取模,其实也行(2³¹−1 没超)。 但更省事的做法是边读边取模:

num = (num * 10 + (s[i] - '0')) % 10000;   // 读的时候就只留后四位

因为 num 始终小于 10000,num * 10 + d < 100000,连 int 都不会溢出。 于是整份代码里所有中间量都在 10000 以内,你把 long long 全换成 int 都不会错。

✅ 这才是「取模题」的正确姿势

不是「先算完再取模」,而是让数字从一开始就长不大。范围小 = 不溢出 = 少一类 bug。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译并跑过官方样例, 「实测结果」里写的就是真实输出。

P1067 [NOIP 2009 普及组] 多项式输出 普及- · 细节模拟

题意:给一个一元 n 次多项式从高次到低次的 n+1 个系数,按竞赛规定的格式把它输出成一行字符串。

⚠️ 数据范围 / 关键约束

0 ≤ n ≤ 100,−100 ≤ 系数 ≤ 100。系数可能是 0,也可能是 1 / −1 / 100 / −100 这些边界值。

另外题目还给了一条容易漏的:「多项式只包含系数不为 0 的项」—— 也就是说系数为 0 的项要整个消失,不是输出一个 0x^3。

思路

这题没有算法,只有一个循环加一堆特判。核心是把题面 5 条规则翻译成一张清单(见第 2 节), 然后逐条实现。最关键的技巧是用一个 first 标记区分「第一项」和「后面的项」——

位置正数怎么输出负数怎么输出
第一个被输出的项直接输出,不带 +输出 -
后面所有项输出 +,再输出系数输出 -,再输出系数

然后按次数分三种情况输出后半截:

次数 i|系数| = 1|系数| ≠ 1
i = 0输出 1输出系数
i = 1输出 x输出 3x 这样
i ≥ 2输出 x^i输出 3x^i 这样
样例输入 1
5 
100 -1 1 -3 0 10
样例输出 1
100x^5-x^4+x^3-3x^2+10
样例输入 2
3 
-50 0 0 1
样例输出 2
-50x^3+1

样例 1 一次性考了 4 个特判:系数 1 省掉(x^4、x^3)、系数 0 整项跳过(x 那一项没了)、 常数项只写系数(+10)、以及正负号切换。这就是它被叫做「特判地狱」的原因。

完整代码

#include <iostream>
using namespace std;

int main(){
    int n;
    cin >> n;

    bool first = true;              // 还没有输出过任何一项

    for(int i = n; i >= 0; i--){    // i 就是当前这一项的次数
        int a;
        cin >> a;

        if(a == 0) continue;        // ★ 系数为 0 的项直接不输出

        // ---------- 1. 符号 ----------
        if(first){
            if(a < 0) cout << '-';      // 开头只有负数才输出负号
        }else{
            cout << (a < 0 ? '-' : '+'); // ★ 后面每项都要带符号
        }

        // ---------- 2. 系数 + 字母 + 指数 ----------
        int ab = (a < 0 ? -a : a);      // 系数的绝对值

        if(i == 0){
            cout << ab;                 // 常数项:只输出系数
        }else if(i == 1){
            if(ab != 1) cout << ab;     // ★ 一次项:|系数|=1 时省略 1
            cout << 'x';                //           指数 1 不写
        }else{
            if(ab != 1) cout << ab;     // ★ 高次项:|系数|=1 时省略 1
            cout << "x^" << i;
        }

        first = false;
    }

    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 1 5 / 100 -1 1 -3 0 10 → 100x^5-x^4+x^3-3x^2+10 完全一致

官方样例 2 3 / -50 0 0 1 → -50x^3+1 完全一致

边界 n=0, 系数 10 → 10 正确(n=0 时只有常数项)

边界 n=1, 1 1 → x+1;n=1, -1 -1 → -x-1 正确(系数 ±1 被省略、指数 1 不写)

边界 n=2, 1 6 5 → x^2+6x+5 正确(首项系数 1 省略且不带 + 号)

边界 n=100, 只有 x^100 和常数项 7 → x^100+7 正确(中间 99 个 0 项全部消失)

边界 n=2, 系数 0 0 5 → 5 正确(最高次项系数为 0 时也不多打 + 号)

另外我还写了一个自检程序:随机生成 200000 组 n ∈ [0,100]、系数 ∈ [−100,100] 的多项式, 把输出串再解析回系数数组和原多项式比对(往返一致性),200000 组全部通过、0 组失败。

易错点

  • 符号和系数要分开处理。负系数输出 - 之后,后面只能输出 |a|。 如果直接 cout << a,就会出现 --3x^2 这种两个负号叠在一起的东西。
  • 省略 1 只针对 |系数| = 1 的非常数项。常数项 1 是要老老实实输出 1 的, 别顺手也省掉(那就什么都不剩了)。
  • 「省略 1」和「省略指数 1」是两条独立规则。x(系数 1、次数 1)会同时触发两条; -x 同理。别只写一条。
  • 输出末尾不能有空格。本题的合法输出里根本没有空格, 所以千万不要用「每项后面补一个空格再删掉最后一个」这种写法,容易出错。
  • 题面没明说「最高次项系数为 0」怎么办。用 first 标记的写法会自然处理成「谁最先被输出,谁就是第一项」, 这是社区通行做法,我也按这个实现了。
P1328 [NOIP 2014 提高组] 生活大爆炸版石头剪刀布 普及/提高- · 打表 + 模拟

题意:石头剪刀布的升级版,共 5 种手势(0 剪刀、1 石头、2 布、3 蜥蜴人、4 斯波克)。 小 A 和小 B 各有自己的周期出拳规律,一共比 N 局,赢的加 1 分,问两人各得几分。

⚠️ 数据范围 / 关键约束

0 < N ≤ 200,0 < NA ≤ 200,0 < NB ≤ 200。

数据极小,N 最多 200 —— 这题的难点 100% 在读题和打表,不在效率。 不要想复杂了,就是老老实实模拟 N 局。

最容易错的是手势编号的含义:0 是剪刀不是石头!石头是 1。记反了全盘皆错。

思路

5 种手势两两组合共 25 种,用 if 写就是 25 个分支。正确的做法是把胜负关系存成一张二维表, 用的时候只需要 r[a][b] 查一下。

升级版的 10 条胜负规则(每句记一半,反过来就是输)是:

赢的一方赢法输的一方
剪刀 (0)剪断布 (2)
剪刀 (0)斩首蜥蜴人 (3)
石头 (1)砸碎剪刀 (0)
石头 (1)压扁蜥蜴人 (3)
布 (2)包住石头 (1)
布 (2)揭穿斯波克 (4)
蜥蜴人 (3)毒死斯波克 (4)
蜥蜴人 (3)吃掉布 (2)
斯波克 (4)蒸发石头 (1)
斯波克 (4)砸碎剪刀 (0)

整理成 5×5 的表(行是甲、列是乙):

甲 \ 乙0 剪刀1 石头2 布3 蜥蜴人4 斯波克
0 剪刀 平甲输甲赢甲赢甲输
1 石头 甲赢平甲输甲赢甲输
2 布 甲输甲赢平甲输甲赢
3 蜥蜴人 甲输甲输甲赢平甲赢
4 斯波克 甲赢甲赢甲输甲输平
💡 打完表的两条自查规则

1. 对角线全是 0(自己打自己一定平)。
2. 关于对角线反对称:r[a][b] == -r[b][a](甲赢就等于乙输)。
我这版表两条都满足。你也可以顺便记一个规律:每种手势都恰好赢 2 种、输 2 种。

然后模拟 N 局,第 i 局两人出的是 a[i % na] 和 b[i % nb] —— 取模就实现了循环。

样例输入 1
10 5 6
0 1 2 3 4
0 3 4 2 1 0
样例输出 1
6 2
样例输入 2
9 5 5
0 1 2 3 4
1 0 3 2 4
样例输出 2
4 4

完整代码

#include <iostream>
using namespace std;

// 手势编号:0 剪刀  1 石头  2 布  3 蜥蜴人  4 斯波克
// r[a][b] = 甲出 a、乙出 b 时甲的结果: 1 甲赢  0 平  -1 甲输
int r[5][5] = {
    { 0, -1,  1,  1, -1},   // 剪刀  vs 剪/石/布/蜥/斯
    { 1,  0, -1,  1, -1},   // 石头
    {-1,  1,  0, -1,  1},   // 布
    {-1, -1,  1,  0,  1},   // 蜥蜴人
    { 1,  1, -1, -1,  0}    // 斯波克
};

int main(){
    int n, na, nb;
    cin >> n >> na >> nb;

    int a[205], b[205];
    for(int i = 0; i < na; i++) cin >> a[i];
    for(int i = 0; i < nb; i++) cin >> b[i];

    int sa = 0, sb = 0;
    for(int i = 0; i < n; i++){
        int res = r[a[i % na]][b[i % nb]];   // ★ 取模实现无限循环
        if(res == 1)       sa++;
        else if(res == -1) sb++;
    }

    cout << sa << ' ' << sb << endl;
    return 0;
}
✅ 实测结果

官方样例 1 10 5 6 → 6 2 完全一致

官方样例 2 9 5 5 → 4 4 完全一致

边界 N=1、甲出斯波克(4)、乙出蜥蜴人(3) → 0 1 正确(蜥蜴人毒死斯波克,乙赢)

边界 N=1、甲出剪刀(0)、乙出石头(1) → 0 1 正确

最大规模 N=200, NA=2, NB=3(0 1 / 2 3 4) → 101 99 合理(两人总分 101+99 = 200 = N,说明每局都分出了胜负,没有平局)

易错点

  • 编号记反:0 是剪刀,1 才是石头。写表之前先把这句话抄在草稿纸上。
  • 表的反对称性写错:比如 r[0][3] 填了 1(剪刀斩首蜥蜴人), 那 r[3][0] 必须是 −1。手写表很容易两边都填成 1,一定要回头查一遍。
  • 取模写错:是 i % na 不是 (i + 1) % na。这里的周期序列本来就从下标 0 开始存, 直接取模就对。
  • 数组开小:NA、NB 都是 ≤ 200,我开了 [205]。开成 [200] 就是越界。
  • 用 if 硬写 25 个分支:能过,但改一个规则就要重看一遍,极容易漏。 这类「两两关系」的题,打表永远比 if 稳。
P1981 [NOIP 2013 普及组] 表达式求值 普及- · 栈 / 优先级处理

题意:给一个只含 + 和 * 的表达式(没有括号、没有空格),算出它的值, 但只输出最后 4 位。

⚠️ 数据范围 / 关键约束

参与运算的数字都是 0 到 2³¹−1 之间的整数;保证输入这一行只含 0123456789+* 这 12 种字符。

运算符总数:30% 的数据 ≤ 100,80% 的数据 ≤ 1000,100% 的数据 ≤ 100000。

所以表达式长度大约 10⁶ 级别。O(长度) 的线性扫一遍就够了,别写 O(n²) 的。

思路

用第 4 节讲的「加法切项」思路:维护 ans(已结算总和)和 term(当前正在累乘的项)。

  • 读到数字 → 往 num 里攒,并且边攒边取模 10000。
  • 读到一个数字结束(遇到运算符或到行尾)时,看上一个运算符 op:
    • op 还没记过 → 这是第一个数,直接 term = num
    • op == '*' → term *= num(乘法优先,并进当前项,不结算)
    • op == '+' → 把 term 结算进 ans,然后 term = num 开新项
  • 循环结束后补一句把最后一项结算掉。

整个过程中所有变量都小于 10000,不会有任何溢出风险。

样例输入 1
1+1*3+4
样例输出 1
8
样例输入 2
1+1234567890*1
样例输出 2
7891
样例输入 3
1+1000000003*1
样例输出 3
4

样例 2 说明:1234567890 的后四位是 7890,1 + 7890 = 7891。
样例 3 说明:1000000003 的后四位是 0003 = 3,1 + 3 = 4。 注意这里不能先把 1000000003 当完整数字算完再取模——虽然这题数字没超 int,但读进来就取模更省心。

完整代码

#include <iostream>
#include <string>
#include <cctype>
using namespace std;

int main(){
    string s;
    cin >> s;                       // 这一行只含 0123456789+*

    long long ans  = 0;             // 已经算完的所有项的和
    long long term = 1;             // 当前正在累乘的这一项
    long long num  = 0;             // 正在读的这个数
    char op = 0;                    // 上一个读到的运算符,0 表示还没读到

    for(int i = 0; i <= (int)s.size(); i++){
        if(i < (int)s.size() && isdigit(s[i])){
            num = (num * 10 + (s[i] - '0')) % 10000;   // ★ 只留后四位就够
        }else{
            // 走到这里说明一个数字读完了,用 op 决定怎么合并它
            if(op == 0)        term = num;                          // 第一个数
            else if(op == '*') term = term * num % 10000;           // ★ 乘法优先,先并到本项
            else { ans = (ans + term) % 10000; term = num; }        // ★ 遇到加号:本项结算,开新项

            if(i < (int)s.size()) op = s[i];   // 记下这个运算符
            num = 0;
        }
    }

    ans = (ans + term) % 10000;      // ★ 别忘了最后一项
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 1 1+1*3+4 → 8 完全一致

官方样例 2 1+1234567890*1 → 7891 完全一致

官方样例 3 1+1000000003*1 → 4 完全一致

边界 0(只有数字、没有运算符)→ 0 正确

边界 2147483647(单个最大数)→ 3647 正确(2147483647 mod 10000 = 3647)

9999*9999 → 1 正确(99980001 的后四位 0001)

1+2+3+4+5 → 15;2*3*4*5 → 120 正确(纯加法 / 纯乘法都对)

随机对拍:用 bigint 写了一个「按 + 拆项、项内连乘、最后整体取模」的参考实现, 随机生成 30 组表达式(每项数字到 9×10⁶)对拍,30 组全部一致,0 组失败。

满规模压测:造了一条含 100000 个运算符(长度 1044219 字符)的表达式, 输出 6460,耗时 84 ms(时限 1000 ms,非常宽裕)。

易错点

  • 忘了结算最后一项——详见第 4 节的红色警示框。这是本题第一大坑。
  • 把数字当成单个字符读:数字可以有多位(最多 10 位),必须用 num = num*10 + (c-'0') 攒起来。
  • 从左往右直接算:1+2*3 会算成 9。乘法的优先级必须处理。
  • 用 cin >> n 一个个读:这题没有空格分隔,cin >> int 读 1+1*3+4 会在 1 后面直接失败。要么整行读成字符串自己拆,要么用 cin >> char 逐个读字符。
  • 只对最终答案取模:中间过程会爆 long long(100000 个 2³¹ 量级的数连乘,天文数字)。 必须每一步都取模。
P1996 约瑟夫问题 普及- · 队列模拟

题意:n 个人围成一圈,从第 1 个人开始报数,数到 m 的人出圈,然后从下一个人重新从 1 报数, 直到所有人都出圈。按顺序输出每个出圈人的编号。

⚠️ 数据范围 / 关键约束

1 ≤ m, n ≤ 100。非常小,随便怎么模拟都行 —— 队列、数组、链表都能过。

题目特意提醒了一句:本题要求「全部出圈」,不是像某些教材那样只淘汰 n−1 个人。 所以输出必须是 n 个数,不是 n−1 个。

思路

题目描述本身就已经是算法了:「数到 m 的出圈 → 下一个人重新从 1 开始报数」。 这正是队列的操作方式:

步骤队列操作含义
1q.pop()队头的人报数
2计数器 +1他报的是几
3如果计数器 == m → 输出他出圈,不再回队
4否则 q.push(x)没数到 m,回到队尾继续排队

把队头拿到队尾,循环 n 轮,每个人都恰好被取出并处理到出圈为止。

🎯 为什么队列刚好合适

报数是一个「转圈」的过程。数组要自己维护下标并处理「走到底了绕回开头」, 而队列的 pop + push 天然就是「转了一圈回到队尾」—— 数据结构的形状正好对上题目的形状,代码就短。

样例输入
10 3
样例输出
3 6 9 2 7 1 8 5 10 4

10 个人数到 3 出圈:先出 3、6、9,然后从 10 后面的 1 继续数,出 2、7、1、8、5、10,最后剩 4。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int main(){
    int n, m;
    cin >> n >> m;

    queue<int> q;
    for(int i = 1; i <= n; i++) q.push(i);   // 所有人排队,队头是 1 号

    int cnt = 0;                             // 当前报数报到几了
    bool first = true;

    while(!q.empty()){
        int x = q.front();
        q.pop();
        cnt++;

        if(cnt == m){                        // ★ 数到 m,出圈
            if(!first) cout << ' ';
            cout << x;
            first = false;
            cnt = 0;                         // ★ 下一个人重新从 1 开始报
        }else{
            q.push(x);                       // ★ 没数到 m,回到队尾接着排
        }
    }

    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 10 3 → 3 6 9 2 7 1 8 5 10 4 完全一致

边界 1 1 → 1 正确(1 个人,数到 1 就出圈)

边界 5 5 → 5 1 3 4 2 正确(5 个人数到 5 出圈)

最大规模 100 100 → 输出 100 个数,第一个是 100,最后一个 22,n=100 个数一个不少。

穷举对拍:我又写了一个朴素做法(bool 数组 + 下标绕圈)作参考实现, 对 n = 1…100 × m = 1…100 全部 10000 组逐一比对, 10000 组答案完全一致、0 组失败;同时检查了每组输出都是 1…n 的一个排列(不多不漏不重复),也全部通过。

易错点

  • 输出格式:n 个数用空格隔开,最后一个后面不要多打空格。 用 if(!first) cout << ' '; 这种「先判断再输出」的写法最稳。
  • 报数计数器要清零:出圈时 cnt = 0,等于下一次循环 cnt++ 后正好是 1。 如果忘了清零,报数就错位了。
  • 循环条件用 while(!q.empty()),不要用 for(i = 1; i < n; i++)—— 那样只会淘汰 n−1 个人,少输出一个数(正是题面提醒的那个坑)。
  • 别把「出圈的人」又 push 回去:出圈的分支里不能再 q.push(x),否则死循环。
  • 数据只有 100,队列法 O(n·m) = 10⁴ 次操作,实际上是瞬时完成的, 不需要任何优化(真正的数学做法是递推公式,Day 17 已经见过类似思想,但这里完全不必)。

✗今日常见错误汇总

#错误写法 / 习惯后果正确做法
1 细节题不列清单,凭感觉写 漏掉一条特判就是 0 分,而且样例可能还过 把题面里每句「如果……则……」抄成表格,写完逐条打钩
2 P1067 用 cout << a 直接输出负系数 出现 --3x^2 这种双负号 符号单独输出,系数只输出 |a|
3 P1067 常数项也省略 1 常数项 1 什么都输出不出来 省略 1 只对次数 > 0 的项生效
4 P1328 把 0 当成石头 整张胜负表全错,样例都过不了 0 剪刀、1 石头、2 布、3 蜥蜴人、4 斯波克
5 P1328 手写 25 个 if 分支 改一处漏一处,反对称性对不上 用 5×5 二维数组打表,查 r[a][b]
6 P1981 忘记结算最后一项 答案少了最后一项;最后一项为 0 时又不报错,极难发现 循环后补 ans = (ans + term) % 10000;
7 P1981 数字按单个字符读 1234567890 会变成 1、2、3…… 一串单数字 num = num * 10 + (c - '0') 攒多位数
8 P1981 只在最后取模 中间结果溢出 long long,WA 每读一位、每做一次运算都 % 10000
9 P1996 用 for(i=1;i<n;i++) 只淘汰 n−1 个人,少输出一个编号 用 while(!q.empty()) 直到全部出圈
10 输出多余空格 / 少空格 PE 或 WA。P1996 是空格分隔、P1067 完全没空格 统一用「第一个不加分隔符,后面都加」的写法
11 比赛时不检查就交 数组开小 → RE;int 溢出 → WA 留 20 分钟专门核对数组大小、数据范围、边界样例

✓自测:你真的会了吗

第 1 题:P1067 里 n=2,系数 1 0 1 应该输出什么?

答:x^2+1。

过程:i=2 系数 1 → 是第一项且为正,不打 +;|1| = 1 且次数 > 0 → 省略 1;次数 > 1 → 输出 x^2。 接着 i=1 系数 0 → 跳过。最后 i=0 系数 1 → 不是第一项且为正 → 打 +;次数为 0 → 只输出系数 1。

拼起来就是 x^2+1。注意结尾不是 x^2+1x^0,也不会把常数项的 1 省掉。

第 2 题:P1328 的胜负表为什么必须关于对角线反对称?

答:因为同一场比赛,站在甲的视角和站在乙的视角看是同一件事。 r[a][b] = 1(甲赢)等价于 r[b][a] = -1(乙输),也就是 r[a][b] == -r[b][a]。

这是个很好的自查工具:填完表后逐对检查一遍,能揪出大半的手写错误。

第 3 题:手算 P1981 的 2+3*4+5*6,按本文的算法每一步是什么?

答:

读到optermans
2 ++20
3 **22
4 ++122
5 **514
6 结束*3014
收尾——44

验算:2 + 12 + 30 = 44 ✓(如果从左往右硬算,会得到 ((((2+3)*4)+5)*6) = 150,完全错了)

第 4 题:P1996 中 n=4, m=2 的输出是什么?手动推一遍。

答:2 4 3 1。

队列初始 1 2 3 4:

· 1 报 1 → 回队尾,队列 2 3 4 1
· 2 报 2 → 出圈 2,队列 3 4 1
· 3 报 1 → 回队尾,队列 4 1 3
· 4 报 2 → 出圈 4,队列 1 3
· 1 报 1 → 回队尾,队列 3 1
· 3 报 2 → 出圈 3,队列 1
· 1 报 1 → 回队尾,队列 1
· 1 报 2 → 出圈 1,队列空

结果 2 4 3 1 ✓

第 5 题(比赛策略):还剩 40 分钟,还有一道题完全没思路,怎么办?

答:先判断这题有没有子任务分(题面里常常写「对于 30% 的数据,……」)。 有的话立刻写一个最朴素的暴力版本交上去,把部分分拿到手 —— 这比继续干想正解划算得多。

如果连暴力都想不出,就把前面已过题的边界情况再验一遍,保证会做的题一分不丢。 最后 20 分钟不要写新代码。

▤速查卡(截图存手机)

【细节模拟的三步】
  1. 翻译:输入什么 → 处理什么 → 输出什么
  2. 列清单:题面每句「如果……则……」= 一个 if
  3. 手算样例:拿样例走一遍表格,再写代码

【"第一项"的通用写法】
  bool first = true;
  if(first){ 只有负数才输出负号; }
  else     { 正负都要输出; }
  first = false;
  // 好处:主逻辑只写一份,顺便解决最高次项系数为 0

【两两关系 → 打表】
  int r[5][5] = {...};   // r[a][b]: 1 甲赢 / 0 平 / -1 甲输
  res = r[a[i % na]][b[i % nb]];
  // 自查:对角线全 0 + 关于对角线反对称

【只保留后 n 位的取模技巧】
  num = (num * 10 + (c - '0')) % 10000;   // 边读边模
  term = term * num % 10000;
  ans  = (ans  + term) % 10000;
  // 让所有中间量都长不大 → 永不溢出

【加法切项的表达式求值(无括号,只有 + *)】
  ans = 0; term = 1; num = 0; op = 0;
  扫到数字结尾时:
    op == 0   → term = num;              // 第一个数
    op == '*' → term = term * num % MOD; // 乘法优先,不结算
    op == '+' → ans += term; term = num; // 结算上一项,开新项
  循环结束后:ans += term;               // ★★ 最容易忘的一句

【约瑟夫问题 · 队列模拟】
  q = 1..n;
  while(!q.empty()){
      x = q.front(); q.pop(); cnt++;
      if(cnt == m){ 输出 x; cnt = 0; }   // 出圈,不回队
      else          q.push(x);           // 回队尾继续排
  }

【比赛前 10 分钟 / 最后 20 分钟】
  开局:4 题全读一遍 → 定顺序 → 从最简单的开始
  收尾:数组大小?long long?输出格式?多测清空?边界样例?
  // 会做的题一分不丢,比多做一道难题更重要

→明天预告 · Day 30

高精度运算 + 一个月总结

long long 大概只能装 19 位十进制数,可有些题要算 500 位、2000 位、甚至上万位。 明天学的就是:用字符串/数组把大数拆成一位一位存起来,自己实现加减乘。

四道题是 P1601 高精度加法、P1303 A*B Problem、P2142 高精度减法、P1009 阶乘之和。 我们会写一套统一的模板(倒序存 → 逐位算 → 处理进位 → 去前导零),你以后可以直接抄进自己的代码本。

而且明天是 30 天计划的最后一天,会有一节完整的知识脉络回顾和下一步建议。