约瑟夫问题大概是每个刷OJ的人都会撞上的一道题。不管你是第一次在百练(OpenJudge)上翻到它,还是在某个学校OJ的入门题单里看到它,题面描述基本一样:n个人围成一圈,从某个人开始报数,报到m的人出列,然后从下一个人重新开始报数,直到最后只剩一个人,请你输出那个人的编号。这道题几乎成了“模拟”类题目的代名词,也是C++初学者练手的好素材。
我写这篇东西的起因是,上周有个学弟在百练上卡在这题,跑过来问我的时候,一开口就是“网上题解怎么全是数学递推啊,一行公式都看不懂”。这话戳中我了。约瑟夫问题的数学规律解法确实优雅,代码短到离谱,但对一个刚学完数组和循环的人来说,那行公式跟天书一样。所以这篇我完全不碰递推公式,只写最直观的模拟解法,用数组模拟一遍报数过程,再用链表模拟一遍,把每一步到底发生了什么讲透。
1. 约瑟夫问题的题面与模拟思维
1.1 题目到底让咱们干什么
先明确一下输入输出。百练的约瑟夫问题原题是这么描述的:n只猴子围成一圈,从第1只开始报数,报到m的猴子退出,下一只猴子重新从1开始报数,如此循环,直到圈里只剩一只猴子,输出这只猴子的编号。输入包含多组数据,每组一行两个整数n和m,当n=0且m=0时输入结束。
注意几个隐藏细节:
- 报数是从1开始数的,报到m的人出列,不是从0开始数。
- 出列后,下一轮从出列者的下一位开始报数,也就是说下一位报的是1。
- 数据是多组的,必须循环读入,读到n=0、m=0才停。
很多新手漏掉第三个细节,写了个单组数据的程序交上去,结果一直输出不了结果。这种输入格式在OJ里很常见,凡是看到“多组测试数据”或“以0 0结束”这种字眼,基本就是让你写while (cin >> n >> m)循环处理的套路。
再交代一下背景。据说这个问题源自公元1世纪的一个传说,约瑟夫斯是当时的一位犹太历史学家,他和40名士兵被围困,大家决定宁死不降,围成一圈约定每数到第三个人就杀掉他。约瑟夫斯不想死,他算好了自己的位置,最后活了下来。这个传说真伪已经无从考证,但问题本身流传了下来,成了计算机科学史上非常经典的入门题。我第一次看到这个故事的时候,还挺震撼的——一个将近两千年前的问题,现在变成了大学里几乎人人要写的代码,这大概就是算法的魅力。
1.2 为什么不用数学递推公式
网上搜约瑟夫问题,十个题解里有八个会直接甩出一行递推代码:
cpp复制int ans = 0;
for (int i = 2; i <= n; i++) {
ans = (ans + m) % i;
}
cout << ans + 1 << endl;
这代码如果原样交上去,能过,答案也对,但它有一个致命问题:你看不懂它为什么对。如果有人问你“循环为什么从2开始”“ans初始为什么是0”“最后为什么要加1”,大概率答不上来。面试的时候如果手撕这道题,直接背公式很容易被面试官追问到崩溃。
我当然不是说递推解法没用。恰恰相反,当你把模拟解法吃透之后,回头再看这个公式,会体会到精妙之处。但我不建议新手一上来就背公式,原因有两点:
第一,这个问题的本质是“人围成一圈轮流报数”,天然适合用模拟来理解。模拟是初学算法的人最应该掌握的思维工具——先用程序把现实过程完整地“演”一遍,演明白了,才谈得上优化。
第二,递推公式虽然代码短,但推导过程涉及状态压缩,是把“某一轮删掉谁”抽象成了“剩余人数和起始位置之间的关系”。这种抽象能帮你应付大数据的题,却在入门阶段剥夺了你亲手“数一遍”的过程。说白了,做题不只是为了AC,更是为了建立手感。
所以这篇我把递推公式放在一边,只讲两套完整的模拟方案:数组模拟和链表模拟。两套方案都能过百练原题,而且每一步都可以在纸上还原。
1.3 模拟法的心智模型
想清楚模拟解法,先在心里建立三个状态:
- 当前还有哪些人在圈里。
- 当前从圈里的哪个人开始报数。
- 当前圈里总共有多少人。
整个过程就是一个循环:确定本轮报数m的人,把他从圈里删掉,把起点移到他的下一位,人数减一,直到圈里只剩一个人。
这个模型可以对应到不同数据结构上。数组天然支持“按下标找人”,可以模拟圈的环形;链表天然支持“删人”,只需要改指针。两种实现各有各的代码细节,我下面分别展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法一:用数组(vector)模拟报数过程
2.1 核心思路和为什么用取模实现环形
数组模拟最自然的想法是:开一个长度为n的数组,存下编号1到n,然后用一个变量pos记录当前报数的人在数组里的下标。报数到m的那个人,在数组里就是从pos开始往后数第m个人。因为人是围成一圈的,数到数组尾部要绕回头部,所以用取模运算%来处理这个“绕圈”的动作。
这里必须讲清楚一个细节,也是很多人第一次写会搞错的地方:从当前这个人开始报数,这个人报的是1,所以报到m的人,是向后偏移m-1个位置的人。也就是说,本轮要被删除的人的下标是:
text复制pos = (pos + m - 1) % 当前人数
为什么不是pos + m?因为当前pos指向的人本人就占一个位置,他数的是1,不是0。你数m个人,第一个是当前位置的人,从pos到目标要跨过m-1个人。这个“算偏移量时先减一”的直觉,在环形问题的很多变体里都会用到,值得记牢。
选中要删的人之后,用vector的erase方法把他从数组中移除。移除之后,被删点后面的所有元素都会往前挪一位,此时原来的pos位置正好落到了被删者的下一位身上——这恰好就是下一轮报数的起点。所以说数组模拟有一个非常巧妙的性质:删完人之后,pos保持不动,下一轮直接用就行,不需要额外调整。
2.2 一个完整手算案例:n=5,m=3
我建议所有刚接触这个问题的同学,先不用写代码,拿纸笔画一遍。最经典的例子:5个人,报到3的出列。
- 初始状态:数组
[1, 2, 3, 4, 5],pos = 0,指向编号1。 - 第一轮:偏移量是
(0 + 3 - 1) % 5 = 2,删除下标2,也就是编号3。数组变成[1, 2, 4, 5],此时pos=2,正好指向原下标2位置上的新元素4,下一轮从4开始报数。 - 第二轮:偏移量是
(2 + 3 - 1) % 4 = 0,删除下标0,也就是编号1。数组变成[2, 4, 5],pos=0,指向编号2。 - 第三轮:偏移量是
(0 + 3 - 1) % 3 = 2,删除下标2,也就是编号5。数组变成[2, 4],pos=2。 - 第四轮:注意此时数组只有两个元素,但pos是2,等于数组长度。这不影响,因为下一轮取模时它会重新回到合法范围。偏移量是
(2 + 3 - 1) % 2 = 0,删除下标0,也就是编号2。数组变成[4]。 - 剩下编号4,答案就是4。
这个手算过程里藏着一个很多人忽略的坑:第三轮删除后,pos=2已经超过了数组的有效范围,但因为我们只是存着这个数字、没有立即访问数组,所以程序不会崩。真正要小心的是,如果你在erase之后立刻去读people[pos],就会下标越界。正确的做法是让pos“带着这个越界的值”进入下一轮,等取模之后再使用。这个细节我不止一次见人踩过。
2.3 完整代码(可直接提交百练)
cpp复制#include <iostream>
#include <vector>
using namespace std;
int main() {
int n, m;
while (cin >> n >> m) {
if (n == 0 && m == 0) {
break;
}
vector<int> people;
for (int i = 1; i <= n; i++) {
people.push_back(i);
}
int pos = 0;
while (people.size() > 1) {
pos = (pos + m - 1) % (int)people.size();
people.erase(people.begin() + pos);
}
cout << people[0] << endl;
}
return 0;
}
有几个实现细节值得单独说明:
第一,people.size()返回的是size_t类型,也就是无符号整数。虽然这里的pos + m - 1始终非负,取模结果不会出问题,但为了保险我在代码里显式转成了int。万一以后你的代码里出现负数运算,无符号整数会把负数变成一个巨大的正数,结果完全不可控。这是一个很好的编程习惯:涉及无符号索引和有符号运算混用时,多留个心眼。
第二,while (people.size() > 1)这个循环条件保证了最后只剩一个人时退出,people[0]就是答案。有人喜欢在循环里输出每个人出列的编号,这样做调试的时候非常有用,能看到每一轮到底删了谁,跟手算结果对比一下,立刻能发现问题。
第三,这段代码是支持n=1的情况的。n=1时,循环一次都不执行,直接输出people[0],也就是1。这是正确答案,因为只有一个人,他当然就是最后的赢家。有些题解会在这里特判,其实对于数组模拟来说,完全没必要。
2.4 复杂度分析和适用场景
数组模拟的复杂度分两块:每一轮删除用erase,vector在删除中间元素时会把后面的元素全部往前移动一位,时间复杂度是O(n),总共要删n-1轮,所以整体时间复杂度是O(n²)。空间复杂度是O(n)。
这个复杂度在百练原题的数据范围内完全没问题,n通常只有几百上千,跑起来飞快。但如果你某天在某个OJ上看到n等于10万甚至100万,数组模拟就会超时,那时候需要用数学递推或者更巧妙的优化。不过这不是本篇的重点,能把O(n²)的模拟写对,你已经理解了问题的核心结构。
另外说一句,虽然很多人觉得数组模拟“笨”,但它的优势在于代码短、调试直观。你可以给vector里的元素打出来看,每一步状态一目了然。对于初学阶段,这种“看得见”的感觉,比任何优化都重要。
3. 解法二:用链表模拟报数过程
3.1 为什么链表适合这道题
刚才数组模拟有个明显的低效点:删除一个元素时,数组需要把后面所有元素往前提,做了很多无用功。链表则完全没有这个问题,删除一个节点只需要修改前后节点的指针,时间复杂度是O(1)。
链表在逻辑上还比数组更贴近“围成一圈”的概念。数组需要靠取模来假装成环,而链表直接让最后一个节点的next指向第一个节点,就是一个真正的环形结构。
我用C++的std::list来实现一版,再额外讲讲面试和竞赛中更常见的手写环形链表,两种都过一遍。先说STL版本。
3.2 用std::list实现的要点
std::list是带头双向循环链表,删除元素很方便。但它不像数组那样支持下标随机访问,你只能靠迭代器一步步往前走。所以找“第m个人”这件事,只能循环移动迭代器。
核心逻辑是:用一个迭代器it指向当前开始报数的人,然后让它向后移动m-1次,就停在要删除的人身上。移动的时候每走一步都要检查是否到了end(),如果到了就把它拉回begin()。这里有一个细节:std::list的end()是最后一个元素后面的位置,不是最后一个元素本身,所以“绕圈”的判断是it == people.end(),绕回去是it = people.begin()。
删除时,关键点在于erase的返回值。很多人在这里翻车:erase(it)之后,it就失效了,不能再用了。而C++11之后,erase会返回被删除元素的下一个迭代器,这个返回值就是下一轮报数的起点。如果被删除的是最后一个元素,返回的是end(),同样要手动拉回begin()。
cpp复制#include <iostream>
#include <list>
using namespace std;
int main() {
int n, m;
while (cin >> n >> m) {
if (n == 0 && m == 0) {
break;
}
list<int> people;
for (int i = 1; i <= n; i++) {
people.push_back(i);
}
auto it = people.begin();
while (people.size() > 1) {
for (int i = 1; i < m; i++) {
it++;
if (it == people.end()) {
it = people.begin();
}
}
it = people.erase(it);
if (it == people.end()) {
it = people.begin();
}
}
cout << people.front() << endl;
}
return 0;
}
这份代码我第一次写的时候栽了个跟头:erase之后我习惯性地写了it++,结果程序要么崩要么死循环。问题的本质是,erase之后旧迭代器已经失效,它的++行为是未定义的。正确做法就是直接把erase的返回值赋给it,那句if (it == people.end())的检查也别忘了,因为list有可能是从末尾删的。
还有一个可能让人困惑的地方:为什么for循环里移动的是m-1次,而不是m次?因为迭代器最开始指向的人已经是在报数状态了,他报的是1,要找到报数为m的人,只需要再向后移动m-1步。这一点和数组模拟里(pos + m - 1)的道理完全一致。这两个解法在这个地方是相通的,建议你把两个代码对着看,会理解得更深。
3.3 手写环形链表:面试官更想看到的东西
如果是在面试现场,面试官不太可能让你用std::list,他更想考察你有没有自己构造链表的能力。虽然代码量比STL版本长,但逻辑上一个环形链表反而更接近问题本身。
我写一个单链表版:节点用struct Node表示,里面存一个int val和一个Node* next指针。初始化时先创建n个节点,把最后一个节点的next指向头节点,做成环。
cpp复制#include <iostream>
using namespace std;
struct Node {
int val;
Node* next;
Node(int v) : val(v), next(nullptr) {}
};
int main() {
int n, m;
while (cin >> n >> m) {
if (n == 0 && m == 0) {
break;
}
Node* head = new Node(1);
Node* prev = head;
for (int i = 2; i <= n; i++) {
prev->next = new Node(i);
prev = prev->next;
}
prev->next = head;
Node* cur = head; // 当前开始报数的节点
Node* p = prev; // cur 的前驱节点,初始时是最后一个节点
while (p->next != p) {
for (int i = 1; i < m; i++) {
p = cur;
cur = cur->next;
}
// 删除 cur
p->next = cur->next;
delete cur;
cur = p->next;
}
cout << p->val << endl;
delete p;
}
return 0;
}
这个版本的核心技巧是维护了一个“前驱指针”p,它始终指向cur的前一个节点。为什么要前驱?因为单链表删除节点时,必须知道被删节点的前驱才能把链表接上。初始时让cur指向第一个节点,让p指向最后一个节点,这样就不需要额外处理“删除头节点”的特殊情况了,因为环里没有绝对的“头”。
删除动作也值得琢磨:p->next = cur->next; delete cur; cur = p->next;。这三行干的事是:让前驱节点跳过被删节点,直连下一个节点,删除被删节点,然后把cur指向下一轮开始报数的人。因为删除后cur被释放了,如果不用p->next提前保存下一轮起点,就没法找到人了。
这段代码里,最后只剩一个节点时,p->next == p,说明p自己指向自己,循环结束。这个判断条件写得很简洁,也很容易理解。唯一要注意的是内存释放,虽然OJ不会因为你忘记delete而判错,但工程习惯还是要有。
3.4 数组和链表,这道题到底选哪个
我发现很多人在学习时会陷入一个误区:觉得链表一定比数组高级,所以优先选链表。实际上在这道题上,两者的取舍没那么简单。
数组模拟的时间复杂度是O(n²),瓶颈在erase的搬移操作;链表模拟的单轮删除是O(1),但找第m个人需要循环走m步,所以整体是O(n*m)。如果m比较大,比如m接近n,链表走的步数会非常多,反而不如数组。
举一个极端例子:n=5,m=1000。数组模拟每次直接pos = (pos + 999) % size,瞬间定位;链表模拟每次要实实在在地走999步,10个节点一轮就要走将近一万步。虽然现代CPU跑这两者都快得没感觉,但数据量一大,差异就出来了。
百练原题的数据范围很小,两种方法都能AC,所以选哪种主要看你现阶段想练什么:想练环形思想的用数组,想练指针操作的用手写链表。我个人的建议是把两种都写一遍,写的过程中你会更清楚每种数据结构的脾气。
4. 常见问题与排查技巧实录
4.1 先给你一张问题速查表
约瑟夫问题的模拟解法虽然不难,但实际编码时会踩的坑真不少。我把见过的高频问题整理成一张表,方便你对着排查:
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 输出结果比正确答案大1或小1 | 编号从0开始还是从1开始没有统一 | 明确人编号从1开始,数组下标访问时加1 |
| 程序卡住或者运行超时 | 报数循环里忘记让循环变量前进 | 检查循环体,确保每次迭代都更新了位置 |
| 直接崩溃、段错误 | 删除元素后还在用旧的迭代器/下标 | erase后必须使用返回值,不要接着访问旧对象 |
| 结果时对时错 | 取模前出现了负数 | 确认pos + m - 1不会为负,必要时加上n再取模 |
| 多组数据测试时第二次结果不对 | 数据结构没有重新初始化 | 确保每组数据都重新构建数组或链表 |
| 读入n和m后没有输出 | 缺少对n=0且m=0的终止判断 | 加上if (n == 0 && m == 0) break; |
这张表是我自己刷题和看别人代码时总结出来的。你能看出,绝大多数问题都出在“状态的更新”上,要么是位置没更新对,要么是数据结构的迭代方式没搞清。
4.2 最容易翻车的三个细节
第一个细节是“erase之后不能访问旧位置”。这一点在STL的list版本里特别明显。很多初学者写完it = people.erase(it);之后,接着加了一句it++;,然后程序就莫名其妙地崩了。这是因为erase之后原来的迭代器已经失效,对它做任何运算都是未定义行为。正确姿势是把返回值接住,然后在返回值上做判断。
第二个细节是“链表循环里要判end()”。std::list的end()是一个“虚位”,不是真实节点。迭代器走到end()之后,必须立刻重置到begin(),否则下一步再++就是未定义行为了。而手写环形链表没有end()这个概念,所以它其实更安全一些,也更好理解。
第三个细节是“取模优化和步数计算的对应关系”。如果你在链表中想减少循环次数,可以先把m - 1对当前剩余人数取模,比如int step = (m - 1) % people.size();,然后循环step次。这个优化对数组也适用,可以避免m特别大时链表一步一步走到天荒地老。但要注意,如果step算出来是0,说明要删除的就是当前节点,for循环一次都不走,直接删it指向的节点,这是对的。很多人在这里犯糊涂,觉得取模后步数变少会报错,其实数学上是完全等价的,因为走一整圈还是回到原点。
4.3 一个我亲身踩过的坑
我刚学这道题的时候,用的是数组模拟,写出第一版代码时把核心那行写成了pos = (pos + m) % people.size();。样例跑出来不对:n=5,m=3,我的程序输出是3,标准答案是4。
当时我盯着代码看了半天,怎么都想不明白,后来我把每一轮pos的值都打印出来,才意识到问题出在“偏移量”上。pos指向的人报1,所以报到m的人应该偏移m-1步,而不是m步。我改成pos + m - 1之后,立刻对了。
这个教训给我最大的启发是:遇到算法题,不要只盯着代码看,把状态打印出来或者用手算一遍,往往一眼就能找到逻辑漏洞。调试工具再多,也不如自己亲手走一遍数据来得踏实。
5. 从这道题能带走的经验
5.1 约瑟夫问题的变体,以后还会遇到
约瑟夫问题的变体非常之多,几乎是各类算法比赛里的常客。最常见的有这么几类:
- \u8f93\u51fa\u6bcf\u4e2a\u51fa\u5217\u8005\u7684\u7f16\u53f7\uff1a\u8fd9\u4e2a\u5f88\u7b80\u5355\uff0c\u628a\u5220\u9664\u90a3\u4e00\u884c\u524d\u9762\u52a0\u4e00\u53e5
cout << people[pos] << " ";\u5c31\u884c\u3002 - \u6bcf\u8f6e\u7684m\u4e0d\u540c\uff1a\u6bd4\u5982\u7b2c\u4e00\u8f6e\u62a5\u5230m1\u51fa\u5217\uff0c\u7b2c\u4e8c\u8f6e\u62a5\u5230m2\u51fa\u5217\uff0c\u8fd9\u65f6\u5019\u6469\u62df\u6cd5\u4f18\u52bf\u5c31\u592a\u5927\u4e86\uff0c\u9012\u63a8\u516c\u5f0f\u57fa\u672c\u6ca1\u6cd5\u7528\uff0c\u53ea\u80fd\u6469\u62df\u3002
- \u8981\u6c42\u8f93\u51fa\u5012\u6570\u7b2ck\u4e2a\u51fa\u5217\u7684\u4eba\uff0c\u6216\u8005\u8981\u6c42\u67d0\u4e2a\u4eba\u662f\u7b2c\u51e0\u4e2a\u51fa\u5217\u7684\uff1a\u53d8\u4f53\u540e\u7684\u95ee\u9898\u5728LeetCode\u3001\u725b\u5ba2\u7f51\u3001\u5404\u7c7bOJ\u4e0a\u90fd\u80fd\u627e\u5230\uff0c\u4f46\u57fa\u672c\u601d\u60f3\u4e00\u6837\uff1a\u6469\u62df\u6216\u8005\u5728\u6469\u62df\u7684\u57fa\u7840\u4e0a\u505a\u72b6\u6001\u538b\u7f29\u3002
面试里也经常出现约瑟夫问题的变体,比如“n个人围成一圈,每隔k个人淘汰一个,用循环链表实现”。这类手写链表的题,考的就是指针操作的熟练度和对环形结构的理解。你在这里花时间把两种模拟都写熟,后面遇到变体就不会慌。
5.2 模拟思维是算法学习的起点
很多刚学算法的人有个误区,觉得所有题都要用最高效的算法解,不然就是“笨”。但我觉得,算法学习有一条很实在的路径:先把朴素解法写对,再优化。约瑟夫问题就是一个绝佳的例子,先写O(n²)的模拟,理解了整个过程,再去学O(n)的递推,收获是翻倍的——因为你看得懂递推公式里每一步在压缩什么了。
“模拟”本身就是一类非常重要的算法题,它考验的是“把现实规则翻译成代码”的能力。你以后会遇到很多看似高深的题,比如大数运算模拟、日期计算模拟、进程调度模拟,核心都是在“用代码复现一个过程”。约瑟夫问题把这些基本功全练到了:循环结构的控制、下标与取模的配合、数据结构的增删操作、边界条件的处理。
5.3 最后分享一个调试技巧
不管用数组还是链表,调试约瑟夫问题时,最有效的办法就是开一个“出列序列”。每次删人之前,把当前状态完整打印出来,包括“当前剩余人数、当前起点位置、本轮要删谁”。我习惯把这行调试代码放在删除之前:
cpp复制cout << "剩余人数: " << people.size()
<< " 起点下标: " << pos
<< " 将删除: " << people[pos] << endl;
打印之后用手算一个小的测试用例,比对输出,十有八九能在第一轮就发现逻辑错在哪里。等程序跑对了,删掉这些调试输出再提交就行。
这道题我前前后后写了不下五遍,从最初傻傻地用数组硬删,到后来理解链表,再到看懂递推公式,每一步都踩过不少坑。现在回想起来,最值得的并不是把题目AC了,而是学会了一个道理:遇到问题先想清楚过程,再用代码去还原过程,而不是直接背一个答案。
