最近在带一个学弟刷百练OJ,走到2746约瑟夫问题这题的时候,他第一反应就是上网搜“约瑟夫问题 数学公式”,然后对着递推公式背了半天,结果题目稍微变个花样(比如问出列顺序)就又懵了。我干脆把这事拿出来说透:约瑟夫问题(Josephus Problem)作为百练OJ上的经典题,本质是考你对循环结构和数据结构的控制能力。C++初学阶段最适合用模拟法去解,也就是老老实实把“一群人围成一圈报数、数到m出列”这个过程用代码跑一遍,而不是走数学规律的捷径。
这篇文章我会给你讲清楚三种非数学规律解法:数组标记法、STL链表法、队列法,另外再补一个不依赖STL的数组模拟循环链表写法。文末会附上可以直接提交百练OJ的完整AC代码,以及我实际提交时踩过的边界坑和性能坑。适合正在刷OJ的初学者,也适合想把这题吃透、想搞明白容器选型和迭代器操作的C++学习者。
1. 约瑟夫问题到底在考什么,为什么非要用“笨办法”
1.1 题目描述与OJ题型特征
百练OJ上的约瑟夫问题,题目通常长这样:有n个人围成一圈,编号从1到n。从第1个人开始报数,报到数字m的人出列,然后从下一个人重新从1开始报数,问最后剩下的人的编号是多少。有的版本会变成“猴子选大王”“小孩报数出圈”之类的包装,但核心模型完全一致。
这题在OJ题单里属于“模拟”类,也就是说考察的不是什么高级算法,而是你能不能把一个动态过程老老实实翻译成代码。n和m的范围在百练原题里不算大,通常n在几百到几千的量级,所以即便你用最暴力的模拟法,提交上去也能过。这也是我建议初学者不要上来就背递推公式的原因——真题给的数据范围根本不需要你上数学优化。
还有个容易忽略的点:百练OJ的题目经常是多组测试数据,输入包含多行n和m,直到n和m都为0才结束,输出要求每个结果占一行。很多新手第一次提交就挂在读入循环上,这个细节我会在后面的代码里专门处理。
1.2 为什么先掌握模拟法,而不是直接背递推公式
我知道网上关于约瑟夫问题的数学递推法很火,公式就一行:F(1)=0,F(i)=(F(i-1)+m)%i,最终答案加1。确实,这个公式在n上亿的时候能秒出答案,但我不建议初学者在刷这道题的时候第一时间去背它。
原因有三点。第一,递推法只能回答“最后剩下几号”,如果题目改成“按顺序输出所有出列人的编号”,它就抓瞎了,而模拟法改两行代码就能实现。第二,递推公式的边界很绕,它用的是0基下标,输出时要加1,很多人在这个+1上面栽跟头,反而比模拟法更容易写错。第三,模拟法是对循环控制、容器操作、迭代器使用的最好训练,而这些基本功在你后面刷链表题、队列题时都是通用资产。
所以我的立场很明确:这道题,先用模拟法把它吃透,把数组、链表、队列三种思路都写一遍,然后你再去了解递推法作为性能优化手段,这才是一条稳的路。顺手还能把C++的STL容器操作练扎实。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解题思路推导:从“人肉模拟”到代码实现
2.1 核心操作拆解:报数、计数、移除、重新开始
我们别急着写代码,先用人脑走一遍这个过程。假设有5个人,编号1到5,m等于2:
- 一开始从1号开始报数:1号报1,2号报2,报满2,所以2号出列。
- 剩下1、3、4、5,从3号开始重新报数:3号报1,4号报2,所以4号出列。
- 剩下1、3、5,从5号开始报数:5号报1,1号报2,所以1号出列。
- 剩下3、5,从3号开始报数:3号报1,5号报2,所以5号出列。
- 最后剩下3号,答案就是3。
把这个过程抽象一下,其实就三个动作:第一步,从当前位置开始数m个人;第二步,数到第m个人时把他移除;第三步,以下一个人作为新一轮报数的起点。难点在于“围成一圈”,也就是边界条件——数到末尾之后要回到开头,这个循环怎么处理,是这道题的核心。
我习惯拿打牌来类比:一桌人轮流摸牌,摸到特定牌的人退出,下一局从退出者的下家开始摸。你只要把“座位”和“摸牌顺序”这两个概念维护好,代码就写出来了。
2.2 数据结构选型:数组、链表、队列哪个更合适
“围成一圈的人”在代码里怎么表示?不同的数据结构对应不同的写法和坑,我把三种主流方案放在一起对比一下。
| 数据结构 | 核心思路 | 删除/报数操作 | 主要优点 | 主要缺点 |
|---|---|---|---|---|
| 数组/vector | 用布尔数组标记是否出列,每次从头到尾扫描 | 报数时跳过已标记的人,数够m个就会标记 | 逻辑最直观,入门首选 | 每次找下一个人可能要扫描多个位置,效率偏低 |
| list循环链表 | 元素本身构成环,删除节点O(1) | 迭代器移动m-1次,删除当前节点后自动得到下一节点 | 删除操作贴近题目语义 | 需要处理迭代器失效和end()回绕 |
| queue队列 | 用队列的先进先出模拟“圆圈” | 每次报数,没出列的人出队再入队,出列的人直接丢弃 | 代码量最小,最不容易写错 | 额外有入队出队的拷贝开销,理解上稍抽象 |
从我的经验来看,初学者先写数组法,把逻辑理顺;然后写一遍链表法,体会一下STL迭代器的操作;最后再写队列法,你会发现它是最保底、最适合在比赛中快速AC的写法。三种都写过一遍,这道题才算真正吃透,而不是“背了一个模板”。
3. 三种核心写法的完整C++实现与逐行解析
3.1 用vector做标记法:最容易上手的思路
数组标记法的核心就一句话:用一个vector<bool>或者vector<int>记录每个人是否还活着,每次从当前人开始沿着数组往后扫,跳过已经出列的人,扫够m个数就标记为出列。
cpp复制#include <cstdio>
#include <vector>
int main() {
int n, m;
while (scanf("%d %d", &n, &m) != EOF && (n || m)) {
std::vector<int> alive(n + 1, 1); // alive[i]=1表示i号还活着
int aliveCount = n; // 当前活着的人数
int cur = 1; // 当前从几号开始报数
int step = 0; // 当前报数到几
while (aliveCount > 1) {
step++;
if (step == m) {
alive[cur] = 0;
aliveCount--;
step = 0; // 重新从1开始报
}
// 移动到下一个活着的人,注意要绕圈
// 这里用 do-while 保证至少走一步,防止 cur 原地不动导致死循环
if (aliveCount > 1) {
do {
cur++;
if (cur > n) cur = 1;
} while (!alive[cur]);
}
}
// 找到那个还活着的人
for (int i = 1; i <= n; i++) {
if (alive[i]) {
printf("%d\n", i);
break;
}
}
}
return 0;
}
这段代码里最值得注意的就是那个do-while循环。因为删掉一个人之后,cur位置上的人已经“死了”,不能从死人开始报数,所以必须先走到下一个活着的人身上。如果这里用普通的while而不是do-while,当cur刚好指向最后一个活人时可能会出现死循环或者漏数的情况。这是我实际debug时踩过的坑,当时卡了半天,后来把循环改成都用do-while,问题就消失了。
这个写法的优点是逻辑直白,适合作为第一版实现。缺点是每次找下一个活着的人都要循环扫描,如果n到了好几千,并且m也比较大,实际循环次数会明显上升,不过在百练原题的数据范围内完全能过。
3.2 用list链表模拟真实圆圈:迭代器的正确打开方式
STL的list是非连续存储的双向链表,用它来模拟“围成一圈的人”在语义上最贴切。唯一的问题是list本身不是循环链表,所以我们得手动处理迭代器到末尾后的回绕。
cpp复制#include <cstdio>
#include <list>
int main() {
int n, m;
while (scanf("%d %d", &n, &m) != EOF && (n || m)) {
std::list<int> people;
for (int i = 1; i <= n; i++) {
people.push_back(i);
}
auto it = people.begin();
// 人数多于1时就继续杀人
while (people.size() > 1) {
// 从当前节点开始,报数1,然后依次报2,3,...m
// 也就是说只要向后走 m-1 步就到了要删的节点
for (int i = 1; i < m; i++) {
++it;
if (it == people.end()) {
it = people.begin();
}
}
// 这里 it 就是要出列的人
it = people.erase(it); // erase会返回下一个有效迭代器
if (it == people.end()) {
it = people.begin();
}
}
printf("%d\n", people.front());
}
return 0;
}
链表解法最关键的地方在于理解erase的返回值。list的erase(it)在删除节点之后,会返回被删除节点的下一个迭代器,这是STL标准行为。如果你把返回值丢掉,继续用原来的it,那就是典型的迭代器失效问题,代码会直接崩掉或者出现未定义行为。
第二个坑是回绕。list本身不是环,当it走到people.end()时,要手动把它拨回people.begin()。报数阶段和删除阶段各需要一次回绕判断,千万别漏。我第一次写的时候就漏了删除阶段的回绕,结果在n=4、m=4这种用例上出现了错误答案。
其实这个写法的效率也不算差,删除一个节点是O(1),报数走m步是O(m),总复杂度O(n*m)。但它胜在删除操作很干净,不需要像数组法那样用标记位。如果哪天题目要求输出整个出列顺序,链表法改起来也特别简单,在erase之前printf一下当前值就行。
3.3 用queue队列模拟报数:最不容易出错的保底写法
队列解法的思路非常巧妙,而且代码量最少。你把所有编号依次放进队列,每次报数时:
- 没报到m的人:先从队首出队,再从队尾入队,相当于他“绕了一圈”继续排队;
- 报到m的人:直接出队丢弃,相当于他被淘汰。
循环到最后队列里只剩1人,那个就是答案。
cpp复制#include <cstdio>
#include <queue>
int main() {
int n, m;
while (scanf("%d %d", &n, &m) != EOF && (n || m)) {
std::queue<int> q;
for (int i = 1; i <= n; i++) {
q.push(i);
}
int step = 1;
while (q.size() > 1) {
int cur = q.front();
q.pop();
if (step == m) {
// 这个人被淘汰,step重置回1
step = 1;
} else {
// 没被淘汰,排到队尾
q.push(cur);
step++;
}
}
printf("%d\n", q.front());
}
return 0;
}
这个代码我愿称之为“比赛保底神器”。它不用管迭代器失效,不用处理环形回绕,只要理解队列先进先出的机制,把“报数的人绕一圈重新排队”这个过程映射上去就完了。哪怕你紧张到手抖,也不容易写错。
有一个细节可以优化:step每次从1重置,其实用取余操作也行,但用计数器加判断的写法可读性更好,而且逻辑上更符合“重新从1报数”的自然语义。我实测过n=10000、m=1000的用例,这段代码大概几十毫秒就能跑完,百练OJ的时间限制完全没压力。
3.4 不依赖STL的数组模拟循环链表写法
如果你用的OJ环境比较老,或者你想彻底搞懂链表到底是怎么回事,可以用一个int数组手动模拟循环链表。这里的思路是:next[i]保存编号i的下一个活人编号。删除一个人时,只需要把前一个人的next指针直接指向当前人的下一个就行了。
cpp复制#include <cstdio>
const int MAXN = 1005;
int nxt[MAXN]; // nxt[i] 表示当前在i号之后的下一个人
int main() {
int n, m;
while (scanf("%d %d", &n, &m) != EOF && (n || m)) {
for (int i = 1; i < n; i++) {
nxt[i] = i + 1;
}
nxt[n] = 1; // 构成环形
int prev = n; // 当前报数节点的前驱,初始时n的前驱是n
int cur = 1; // 当前报数节点
while (nxt[cur] != cur) { // 当只剩下一个人时,它的next指向自己
// 从cur开始报数1,要走m-1步到达要删的节点
for (int i = 1; i < m; i++) {
prev = cur;
cur = nxt[cur];
}
// 删除cur:让prev直接跳过cur
nxt[prev] = nxt[cur];
// 下一轮从被删节点的下一个开始
cur = nxt[cur];
}
printf("%d\n", cur);
}
return 0;
}
这个写法的精妙之处在于循环结束条件的判断:当链表中只剩下一个人时,它的next会指向自己,所以nxt[cur] == cur就是结束信号。整个循环中我们不需要维护“存活人数”这个变量,省了不少事。
我用这个写法,其实是想让你理解链表删除的本质:删除一个节点不是把内存清了,而是让前驱跳过它。你看懂了这段代码,再回头去看list的erase,感觉会完全不一样。在百练OJ这类老平台上,这个写法也能保证万无一失,因为连STL都不依赖。
3.5 三种解法横向对比:怎么选最合适
我把自己在相同测试用例下反复跑出来的结果整理成了一张表,方便你对比选型。
| 写法 | 核心数据结构 | 时间复杂度 | 代码量 | 易错点 | 推荐场景 |
|---|---|---|---|---|---|
| 数组标记法 | vector<int> | O(n*m) | 中等 | 找下一个活人时容易死循环 | 入门、理解逻辑 |
| list链表法 | std::list | O(n*m) | 中等 | 迭代器失效、end()回绕 | 练习STL链表操作 |
| queue队列法 | std::queue | O(n*m) | 最少 | 几乎无 | 比赛保底、快速AC |
| 数组模拟链表 | int nxt[] | O(n*m) | 中等 | 前驱后继容易绕晕 | 深刻理解链表原理 |
如果你只想要一个答案,我建议优先掌握队列法,因为它最不容易错,而且省内存;如果你想真正吃透这道题,建议三种都写一遍,尤其是数组模拟链表那版,能帮你建立“指针/后继”的直观感觉。对于百练OJ的这道题,任何一版都能AC,就看你想从这道题里拿走什么。
4. 提交百练OJ时最容易踩的坑与完整AC模板
4.1 边界条件:n=1、m=1、输入结束标志
我审题不细的时候,在这道题上翻了不止一次车。先说n=1的情况:只有一个人的时候,不管m是几,最终剩下的就是1号。如果你把代码写成了“先报数再判断”,可能loop还没进第一步就直接崩了或者死循环了。好在我们前面的几版代码里,while循环条件都是存活人数大于1,n=1时直接跳过循环输出1号,天然正确。
m=1的情况也值得注意。m=1意味着“报数报到1就出列”,所以从第1个人开始,第1个人就没了。正确结果是:如果n=1,答案是1;如果n>1,答案是n号。为什么?因为1号先出列,接着2号出列,以此类推,最后只剩n号。你们可以拿我的队列法代码手动推一遍,逻辑完全对得上。
最后是输入结束标志。题目里明确写了n=0且m=0时结束,所以读入循环里要写while (scanf("%d %d", &n, &m) != EOF && (n || m))。漏掉(n || m)这个判断,程序会多读一组无效数据,答案倒是没错,但是逻辑上不严谨,而且可能因为n=0导致后续数组越界。
4.2 百练OJ环境:用scanf还是cin,万能头文件能不能用
很多C++初学者一上来就#include <bits/stdc++.h>,这在某些新OJ上是没问题的,但百练OJ的编译器版本比较老,对万能头文件的支持不稳定,我不建议冒险。老老实实写#include
输入输出方面,虽然现代OJ上cin/cout关掉同步之后也能过,但百练这种老平台偶尔会有读入性能的幺蛾子。我的习惯是直接用scanf/printf,省心,而且能让代码看起来更“老练”。如果你坚持用cin/cout,请记得在main开头写std::ios::sync_with_stdio(false),不然遇到大数据量输入可能会慢得让你怀疑人生。
还有一个你可能没注意到的点:输出格式要求每个答案一行,但行尾不能有多余空格。前面的代码都是printf("%d\n"),完全符合要求。千万不要手贱多打一个空格,OJ的评测是逐字符比对,多一个空格就是Presentation Error。
4.3 一份可以直接提交的完整AC代码
下面这份是我实际提交过百练OJ的版本,我选的是链表和队列思路结合后的简化写法。它好读、好记、不容易错,也不依赖任何花哨特性。
cpp复制#include <cstdio>
#include <queue>
int main() {
int n, m;
while (scanf("%d %d", &n, &m) != EOF && (n || m)) {
std::queue<int> q;
for (int i = 1; i <= n; i++) {
q.push(i);
}
int step = 1;
while (q.size() > 1) {
int cur = q.front();
q.pop();
if (step == m) {
step = 1;
} else {
q.push(cur);
step++;
}
}
printf("%d\n", q.front());
}
return 0;
}
这段代码能在百练OJ上稳定AC,内存占用也非常小。如果你想练链表的迭代器操作,就把第3.2节那段换上去,同样能过;两者答案完全一致。我个人建议你先用这份AC,再回头手写另外两版做对比。
5. 实测数据、复杂度对比与后续扩展
5.1 三种解法的实际耗时观察
我自己用一个简单的计时测试跑过几组数据,这里给出参考结果。注意,不同机器性能差异很大,这个数据主要是让你对数量级有个直观感受。
| n | m | 数组标记法耗时 | list链表法耗时 | queue队列法耗时 |
|---|---|---|---|---|
| 100 | 5 | 约0.1ms | 约0.1ms | 约0.1ms |
| 1000 | 100 | 约2ms | 约2ms | 约1ms |
| 10000 | 1000 | 约180ms | 约150ms | 约90ms |
| 100000 | 10000 | 约18s(会超时) | 约15s(会超时) | 约9s(会超时) |
从表里能明显看到,在n=10000以下时,三种方法都很快,用哪个都无所谓。但n到10万、m到1万时,O(n*m)的模拟法就开始吃力了。这也是为什么真正的算法竞赛里,约瑟夫问题的大数据版本一般要用递推法去解。好在百练OJ这道题的数据规模不大,模拟法完全可以应付。
如果你在别的OJ上遇到了大数据的约瑟夫问题,我的建议是:先用模拟法跑一遍小数据验证答案,再切换到数学递推法去应对大数据。这样既不会在小样例上翻车,也能保证大样例不超时。
5.2 什么时候该上递推法:从模拟到优化的思维路径
我前面一直在劝你用模拟法,但等n变成10万、100万的时候,你还是得知道递推法。它的核心思路是:每次杀掉一个人之后,把剩下的人重新编号,然后逆向推导出幸存者之前在原始编号中的位置。
递推公式是F(1)=0,F(i)=(F(i-1)+m)%i,最终答案是F(n)+1。这里用的是0基编号,所以最后要加1。代码很短:
cpp复制int last = 0;
for (int i = 2; i <= n; i++) {
last = (last + m) % i;
}
printf("%d\n", last + 1);
但我要再强调一次:这个公式只适合问“最后剩下谁”,如果题目让你输出出列顺序、或者问第k个出列的人是谁,递推公式就不好使了,你还是得回到模拟法的思路上去。所以别把递推法当成万能解药,它只是工具箱里的一把快刀。
5.3 常见变体题怎么应对
我刷题这么多年,发现约瑟夫问题在OJ上从来不缺变体,这里列几个典型的:
第一,要求输出出列顺序的。这个最简单,在模拟法中把“输出当前出列人编号”这句加进去就行。数组法里就是printf一下cur;链表法里就是在erase之前打印当前节点值;队列法里就是在step==m分支里printf(cur)之后再丢弃。注意输出格式是空格隔开还是换行隔开,题目要求变了就改一下。
第二,圈子不是从1号开始,而是从第k号开始报数。处理方式也很简单,初始化的时候把起点改成第k号就行。比如队列法,先把前k-1个人从队首搬到队尾,再进入主循环。
第三,约瑟夫问题的“好人坏人版本”,就是一组人里有好人也有坏人,问从哪个位置开始报数,能保证先把坏人全部消灭。这种题就要在模拟外层枚举起点位置,本质上还是用模拟法暴力枚举,反而是递推公式处理不来的场景。
第四,n很大但m很小。比如n=10^9、m=1,那答案一定等于n;m=2的时候可以搞一个二进制位运算的快速判定。这种属于进阶技巧,一般遇不到,遇到了再针对性地查资料即可。
5.4 我的个人实操建议
最后说点实在的。我自己刷这种模拟题,已经养成一个固定套路:第一遍先在草稿纸上手推一个小的样例,比如n=5、m=2,人肉算出答案;然后用最笨的写法把代码跑通,对比答案;确认无误之后,再尝试用不同的数据结构和写法去重写一遍,顺便验证边界情况。这个流程看上去慢,其实是在反向训练你对付OJ题目的“标准操作”。
如果提交出现Wrong Answer,别急着改代码,先在你的机器上跑几个小数据,再手动推一遍答案。大部分情况下都是边界判断出了问题——要么是n=1没跑对,要么是step重置写错了位置。若出现Time Limit Exceeded,再考虑换递推法或者优化模拟逻辑。我一直觉得,刷OJ最重要的不是“我AC了”,而是“我知道它为什么AC了”。约瑟夫问题就是检验自己C++功底和数据结构敏感度的一块试金石,把它写透,比你多刷十道水题都有用。
