1. 先看清题目在问什么:输入输出与边界条件
洛谷P1996那道约瑟夫问题,是我刚开始刷算法题时遇到的一道经典题。当时满脑子都是排序、搜索,突然被一道“围成一圈报数踢人”的题难住了,后来才发现它其实是数据结构与数学推导的交叉地带,比表面上看起来有营养得多。
先还原一下题目本身。给定n个人,编号从1到n,围成一圈,从第1个人开始从1报数,报到m的人出圈,然后从出圈者的下一个人重新从1报数,重复这个过程,直到所有人都出圈,要求输出出圈顺序。输入是n和m,输出就是按出圈顺序排列的编号。比如输入10和3,输出就是
3 6 9 2 7 1 8 5 10 4这个结果很多人第一次用手算都对不上,原因在于“出圈后从下一个人重新报数”这个动作很容易被忽略。你按下一个人是前一个人的“下一个编号”,但圈里已经少了人,跳过了就要继续找下一个人,而不是简单地加1。
洛谷P1996有一个让新手容易栽跟头的点:它明确说了m大于n也是合法输入。比如n=5、m=12,你照样要跑完一遍全部出圈流程,报数12意味着在5个人里面绕两圈多。很多人在写循环模拟时只考虑m小于n的情况,就会在边界上卡住。
这道题适合谁来读?第一是刚学完数组、指针、队列相关数据结构的初学者,想找个题目把理论落地;第二是准备蓝桥杯、ACM这类比赛的人,虽然题目本身简单,但它是很多进阶算法的基础;第三是准备面试的人,约瑟夫环变形题在面试里出现频率不低,很多人死记公式却讲不清楚原理,看这篇可以帮你把事情彻底搞明白。
2. 这题到底想考你什么:从现实原型到算法抽象
2.1 约瑟夫问题的历史背景与数学模型
约瑟夫问题流传出来的版本很多,有的说是一群人在围城中决定每隔几个人处死一个人,有的说是报数淘汰游戏。不管故事怎么变,数学结构都一样:一个首尾相接的环形序列,按固定步长删除元素,删除后从后继继续,直到序列为空。
从算法角度看,这个问题牵涉两个核心操作:一个是“数数”,本质上是对环形结构进行周期遍历;另一个是“删人”,本质上是集合的删除操作。这两个操作在什么数据结构上做,决定了算法的时间复杂度。
如果只用一个dead数组和当前指针,每次找下一个活着的人,复杂度是O(n^2)。对于洛谷这道题n≤100,哪怕写得很粗糙也能过,但如果你把它当成一个入门题目去深入思考,会打开很多思路。比如当n到达10^7、m到达10^9时,纯模拟就直接报废了,这时就轮到数学递推或者带优化的数据结构出场。
2.2 为什么“出圈后从下一位重新报数”是关键
很多初学的人把约瑟夫问题理解为“每数m个人删一个人”,这没错,但要注意“从下一个人重新计1”这句规则,它决定了出圈后的游标位置。如果把所有人下标从0开始编号,当前删除位置每次增量都是m-1,而不是m。这个细节在写代码时非常容易出错,我见过不少人把增量写成m,结果输出序列从第四个数字开始就乱掉。
举个具体例子,n=5、m=3,手动模拟一遍:
- 初始[1,2,3,4,5],从1开始报,报到3的3出列,剩[1,2,4,5]。
- 从4开始重新报1,报到3的是1(4报1,5报2,1报3),1出列,剩[2,4,5]。
- 从2开始报1,4报2,5报3,5出列,剩[2,4]。
- 从2开始报1,4报2,2报3,2出列,剩[4]。
- 4最后出列。
完整出列顺序应该是3、1、5、2、4。如果第二步你是从3的后一位“4”开始没错,但如果你错误地认为每次移动m位,就会算成另外的结果。这道题上手简单,但能把细节做对的人,才是真的理解了游标和长度变化之间的关系。
2.3 不同数据范围对应不同解法
初学者通常只知道一种模拟写法,但实际使用中要根据数据范围选择不同方案:
- n、m都很小(100以内):数组模拟、队列模拟、链表模拟随便选。
- n比较大、m较小:用循环链表或静态链表,删除操作O(n),总数O(n^2),在n=10^6级别会吃力。
- n很大、只需要最后一个幸存者:用递推公式O(n)。
- n很大、还要输出完整出圈序列:线性递推做不到,需要树状数组+二分或线段树,达到O(n log n)。
P1996属于第一种,数据范围极小,理论上怎么写都能过。但这道题的价值在于,它是学习“算法选择依赖数据范围”的绝佳样板。你完全可以在一道题里面,把多种思路全部实现一遍,再对比它们的时间表现。
3. 解法一:数组模拟,最直观也最容易踩坑
3.1 思路拆解:用vector当作动态圈
数组模拟有两种常见形态,一种是用bool标记,一种是直接vector删除。洛谷数据量小,两种都能过,但vector删除的写法更简洁,也更贴近“圈越来越短”的直觉。
用vector 保存存活者的编号,用idx记录当前报数到的下标,每次让idx向后移动m-1步,再对当前vector长度取模,然后输出并删除这个元素。删除之后,vector的大小自动减1,下一次继续从idx开始,正好对应“从下一位重新报数”。
为什么是m-1而不是m?因为idx自己已经被算过一次了。比如当前指向下标0,m=3时,0号是第1个数,1号是第2个数,2号是第3个数,所以要移动2步,即m-1。取模则是因为模数在不断变化,删掉一个人后圈的长度就改变了。
3.2 C++写法
#include <bits/stdc++.h> using namespace std; int main() { int n, m; cin >> n >> m; vector<int> a(n); for (int i = 0; i < n; i++) a[i] = i + 1; int idx = 0; while (!a.empty()) { idx = (idx + m - 1) % a.size(); cout << a[idx] << " "; a.erase(a.begin() + idx); } return 0; }输出时每个编号后面带一个空格是完全没问题的,洛谷对行末空白并不敏感。如果你特别在意格式,也可以用printf("%d%c", a[idx], a.empty() ? '\n' : ' '),但并没必要。
3.3 三个容易出错的细节
第一,idx的更新顺序。要先根据当前长度算出删除位置,再删除。如果先删除再计算位置,长度变了之后下标完全对不上。我见过很多次有人把erase放在赋值前面,然后找半天为什么答案错一半。
第二,取模的分母必须用删除前的size。这一点和上面那条是一个意思,因为在计算idx时,a.size()还是删除前的长度。写成
a.erase(a.begin() + idx); idx = (idx + m - 1) % a.size();就会立刻出问题,因为你已经删掉元素了,idx可能指向完全不同的位置。
第三,m输入等于0或负数的非法情况。洛谷P1996保证m为正整数,所以你不需要考虑,但如果在工程项目里处理类似逻辑,应该先做合法性判断,避免模0导致运行时错误。
3.4 Python写法,适合竞赛党参考
n, m = map(int, input().split()) a = list(range(1, n + 1)) idx = 0 ans = [] while a: idx = (idx + m - 1) % len(a) ans.append(str(a.pop(idx))) print(" ".join(ans))这个写法在功能上和C++版本完全等价。Python的list.pop是O(n)的,但这里n很小,完全可用。
4. 解法二:队列轮转,把“数人”变成“出队再入队”
4.1 队列怎么模拟一个环
队列是一个先进先出的线性结构,但你可以通过“出队再入队”的操作,让它模拟一个首尾相接的环。核心思想是:要数m个人,就把前m-1个人从队头搬到队尾,第m个人自然就到了队头,把它弹出并输出,这正好是出圈。
这种思路非常适合入门,因为不需要关心下标和取模,只需要记住一个固定的“搬人”操作。代码写起来也几乎不会出现下标越界的问题。
4.2 队列实现的完整代码
#include <bits/stdc++.h> using namespace std; int main() { int n, m; cin >> n >> m; queue<int> q; for (int i = 1; i <= n; i++) q.push(i); while (!q.empty()) { for (int i = 1; i < m; i++) { int t = q.front(); q.pop(); q.push(t); } cout << q.front() << " "; q.pop(); } return 0; }代码本身的逻辑是闭合的。每次循环把m-1个元素从队头搬到队尾,当第m个人来到队头时弹出,队列缩减,直到空了为止。
4.3 队列解法的两个注意点
第一,m可能比当前队列长度还大。只要m是正整数,for循环里的次数就是m-1,它不需要对当前长度取模,因为出队后入队的操作是安全的,即使m超过队列长度很多,也只是多绕几圈,最终第m个人一定会出现在队头。
第二,这个做法的时间复杂度仍然是O(n*m),没有质变。如果你算m=10^9,n=10^5,for循环就要跑将近10^14次,完全不可接受。所以队列适合解决题目明确指定m不太大的场景,或者用来帮助初学者建立“环形移动”的概念。
5. 解法三:数学递推,用O(n)秒杀大范围数据
5.1 递推到底在推什么
如果你只需要最后一个幸存者,也就是约瑟夫环问题的最根本形态,那么完全不用模拟,可以用递推直接算。这个方法在很多面试题里被称为“约瑟夫环O(n)解法”,真正理解它之后,你会发现它比背公式有趣得多。
设计一个状态f[i],表示有i个人围成一圈,编号从0到i-1,从0号开始报数,报到m-1的人出圈,最终幸存者的编号。注意这里的编号是当前环里的相对编号,不是最开始的绝对编号。
只有一个幸存者的情况是平凡成立的,f[1]=0,因为只有0号一人时,幸存者就是它。现在考虑从i个人递推到i+1个人的情况。
5.2 推导过程:把问题倒过来看
假设有i+1个人,第一轮出圈的是第 (m-1) mod (i+1) 号。出圈后,从下一个位置开始重新编号,原来的(m mod (i+1))号就变成了新环中的0号,之后新环里继续玩这个游戏。于是,在新环中最终幸存者编号是f[i],它对应到旧环中的编号就是 (m % (i+1) + f[i]) % (i+1)。
更一般地,递推式是:
f[1] = 0 f[k] = (m + f[k-1]) % k (k从2到n)这里的k是当前环的大小。最终f[n]就是n个人从0开始编号时的幸存者编号。输出时加1,就是题面要求的1-based编号。
为什么是m + f[k-1]而不是m-1 + f[k-1]?因为当一个人出圈后,第一个被重新编号为0的人是出圈者的下一位,而下一位的相对位置比出圈者落后1个身位。假设上次报数从0号开始,数到m-1号出圈,出圈者是第m-1号,那么下一位是m号。新编号0对应旧编号m,所以旧编号 = 新编号 + m,再对k取模。
5.3 只求幸存者的代码
#include <bits/stdc++.h> using namespace std; int main() { int n, m; cin >> n >> m; int ans = 0; for (int i = 2; i <= n; i++) { ans = (ans + m) % i; } cout << ans + 1 << endl; return 0; }这个代码非常短,跑起来也飞快。n到达千万级别也只需要几次取模运算,不会超时。
5.4 能不能用递推输出完整出圈序列
经常有人问我这个问题:能不能用递推思路把出圈顺序全部算出来?严格来说不能直接套上面的递推,因为上面那个递推只是在每一层生存到最后的那个人编号,它不会告诉你中间哪些人出圈了。要输出完整序列,你需要知道每次出圈的具体位置,这个信息在递推过程中被压缩掉了。
如果n很大,但还要输出完整出圈顺序,一个经典方案是线段树或树状数组维护“当前幸存者的相对位置”,再用二分查找定位第k个存活者。大体思路是,出圈位置 = (当前游标 + m - 1) % 剩余人数,然后在线段树里找到这个相对位置对应的真实编号,删除并记录。这个做法能达到O(n log n),是竞赛中处理超大约瑟夫问题的标准套路。P1996不需要用它,但如果你以后打算深入算法竞赛,值得了解。
6. 四种解法对比与适用场景
| 解法 | 时间复杂度 | 空间复杂度 | 代码难度 | 适用范围 | 能否输出完整序列 |
|---|---|---|---|---|---|
| 数组vector模拟 | O(n^2) | O(n) | 低 | n小,推荐入门 | 能 |
| 队列轮转模拟 | O(n*m) | O(n) | 低 | n和m都不大 | 能 |
| 循环链表模拟 | O(n^2) | O(n) | 中 | n中等,链表演示 | 能 |
| 数学递推 | O(n) | O(1) | 中 | 只需要幸存者,n可以很大 | 不能 |
| 树状数组+二分 | O(n log n) | O(n) | 高 | 大范围且要完整序列 | 能 |
表格里的时间复杂度和实际表现很有关系。vector模拟虽然理论复杂度是O(n^2),但因为erase在vector里删除元素需要移动后面所有元素,实际开销偏高;链表删除本身是O(1),但寻找第m个节点需要遍历m步,所以整体仍然是O(n*m)。队列模拟本质也一样。
我建议做题时不要只背一种模板,而是把所有解法都写一遍,对照运行时间。尤其是链表和队列两种写法,能帮助你从不同角度理解“环形结构”。我当年刷洛谷P1996时就是一次性写了四个版本,后来遇到约瑟夫环变形题,反应明显比别人快一截。
7. 做题时常见的坑与排查实录
7.1 死循环与越界
使用数组模拟时最典型的死循环来源是:把取模写成对n取模,而不是对当前存活人数取模。因为圈子不断变小,如果你始终用原始n做模,idx很快会指向一个已经删除的位置,后面就全乱了,甚至可能因为vector越界直接RE。
排查方法很简单:在循环里打印当前idx和a.size(),用n=5、m=3的小数据手动核对。
7.2 m特别大导致超时
遇到m=10^9这种情况,队列写法直接歇菜。即使是链表模拟,也要走m步才能找到出圈者,战线拉得非常长。一个优化技巧是,在一轮里出不了一个人时可以跳过整圈:当前剩余人数为cnt时,理论上要走m步,但可以先把m对cnt取模,再决定走多少步。也就是说走的有效步数是(m-1) % cnt,替代原来的m-1。这不能改变O(n^2)的复杂度上限,但在m远大于cnt时能大幅缩短实际运行时间。
int step = m % a.size(); idx = (idx + step - 1) % a.size();注意这里仍然用m-1的等价写法,把step = m % a.size()后,确实可以利用取模跳过整圈。这是大m小n时非常实用的优化。
7.3 输出格式与多余空格
洛谷这类OJ通常不检查行末空格,所以每次输出后加空格是安全的。但如果你在某些严格逐字符对比的平台上提交,就需要注意。可以用一个小技巧,输出时区分第一项和后续项:
for (int i = 0; i < n; i++) { if (i) cout << ' '; cout << val; }这样最后一个数字后面没有多余空格,任何OJ都不会判错。
7.4 C++读入性能误区
洛谷P1996只有两个整数,cin cout完全可以应付。但如果你用cin又没有取消同步,在数据量大的题里可能超时。养成习惯写
ios::sync_with_stdio(false); cin.tie(nullptr);没坏处。当然如果输入量极大,直接用scanf也不要有什么心理负担。
7.5 n等于1的边界情况
当n=1时,环里只有一个人,无论m是多少,这个人第一次报数就出圈。数组模拟写法里,while循环的唯一一次迭代输出1,正确;递推写法里for循环从2到n根本不执行,ans保持0,输出ans+1即1,也正确。这个边界看似简单,但在复杂变形题中经常被忽略。
7.6 把递推公式背错方向
递推时是“从前往后”推的,f[k]由f[k-1]得到。很多人写代码时喜欢把循环写成从n到1递减,结果算出来的数完全不对。理解公式里新旧环的对应关系比背公式更重要。
当f[k-1]已经得出时,你把它映射回旧环中要加上m,这是因为新环中编号0对应旧环中的出圈者下一位,而下一位在旧环中比出圈者靠后m个身位。如果你忘记取模,编号会不断膨胀,结果当然错误。
8. 从这道题延伸出去:它到底训练了哪些能力
8.1 环形结构思维
约瑟夫问题最核心的训练点是“当你在一个会缩小的环里移动时,下标变化规律是什么”。这个能力几乎贯穿所有算法竞赛题目中的循环、游标、窗口类问题。你熟悉了idx=(idx+m-1)%size这个动作,以后做循环队列、环形缓冲区计数的题目,都会顺手非常多。
8.2 “删除后如何接续”的数据结构选择
不同数据结构处理删除的方式完全不同。数组是搬移,链表是改指针,树状数组则是用前缀和维护存活者的位置。这种“根据删除成本选择数据结构”的思维,是区分普通码农和算法选手的重要分水岭。P1996虽然小,但这道题的抽象过程涵盖了这个主题的核心。
8.3 数学转化与回溯推导
递推算幸存者那一套,实际上是动态规划中“状态压缩”的雏形。你有i个人,只需要知道i-1个人的答案,然后建立一个映射关系,把旧环的新编号还原回去。这个“从最后一个反推第一个”的思维方式,在很多组合数学、概率DP题目里都会反复出现。
我遇到不少选手,约瑟夫环的递推公式背得滚瓜烂熟,但要他解释为什么是(m + f[k-1]) % k,就支支吾吾。公式可以背,但推导必须自己亲手走一遍。我自己的建议是,拿n=2、m=3这种极端数据手算一遍递推过程,你会真正明白模运算的意图。
8.4 一个扩展练习,检验你是否真的懂了
不妨试试这个变形:n个人站成一圈,编号从1到n,这次不是报到m出圈,而是每次从当前人开始,报数报到第k个素数(素数序列为2、3、5、7、11……)的人出圈。你怎样修改模拟代码?递推法还能用吗?
这个问题能把你有没有理解约瑟夫问题的本质给测出来。出圈步长一直在变,所以简单递推不再适用,但队列模拟只要把step = kth_prime和取模优化结合,依然成立。这种“步长动态变化”的题目在面试中反而是高频考点,很多人平时只练固定m,遇到变步长就懵。
9. 写在最后的一个小建议
洛谷P1996太简单了,简单到很多人顺手AC之后就不再去想它,这其实很可惜。我刷题时养成了一个习惯,遇到题目AC后,至少再写一版不同思路的做法,同时把复杂度分析写清楚。这道题特别适合做这种“一题多解”训练,因为它覆盖了模拟、队列、链表、数学递推四个截然不同的方向,每个方向的写法差别巨大,但答案却完全一致。
你在实际写代码时,如果遇到队列写法超时,不妨想想能不能用取模跳过整圈;如果遇到递推公式算错,不妨打印中间每一步f[i]的值,和模拟结果对比。排查这类问题,最重要的不是重新看代码,而是用一个小数据量样例把每一步都摊开。
我个人在带新人时,经常用约瑟夫问题作为“数据结构选择”的第一课。不是因为题目难,而是因为它能把“复杂度分析”这个概念从一个抽象术语变成具体体验——你试一次n=100000、m=100000的模拟版和递推版,就再也不会忘记它们之间的差别了。