公司动态

约瑟夫问题三种解法详解:从数组模拟到队列与数学递推

📅 2026/8/16 19:27:55
约瑟夫问题三种解法详解:从数组模拟到队列与数学递推
1. 约瑟夫问题从一道经典算法题说起如果你刚开始接触算法和数据结构或者正在准备编程竞赛那么“约瑟夫问题”这个名字你大概率不会陌生。它就像算法世界里的“Hello World”看似简单却蕴含着循环、链表、队列、递归乃至数学归纳等多种解题思路的巧妙碰撞。今天我们不聊那些高深莫测的复杂算法就从一个最经典的版本——洛谷P1996题入手手把手带你从零开始用几种不同的方法实现它并深入探讨每种方法背后的“为什么”以及在实际编码中那些教科书上不会告诉你的“坑”。洛谷P1996的题目描述非常经典n个人围成一圈从第一个人开始报数数到m的人出列然后从他的下一个人开始重新报数数到m的人再出列依次类推直到所有人出列为止。要求按出列顺序输出每个人的编号。n和m由输入给定。这个问题之所以经典是因为它完美地模拟了一个“动态淘汰”的循环过程是理解线性数据结构尤其是循环链表和队列在动态场景下应用的绝佳案例。很多面试官也喜欢用它来考察候选人对基础数据结构的掌握程度和逻辑思维能力。那么面对这样一道题一个合格的开发者会如何思考呢直接上最“笨”的模拟法还是追求效率的数学公式法不同的选择背后是时间复杂度、空间复杂度以及代码可读性之间的权衡。接下来我们就逐一拆解。2. 方案一最直观的模拟——数组标记法当我们拿到一个问题最自然的想法往往是模拟整个过程。对于约瑟夫问题模拟的核心在于如何表示“围成一圈”以及“出列”这个动作。2.1 核心思路与数据结构选择“围成一圈”意味着当报数到最后一个人时下一个应该回到第一个人。用数组来存储这n个人是一种非常直接的方式。我们可以用一个布尔型数组vis来标记每个人是否已经出列。初始时所有人的标记都是“在圈内”例如vis[i] false。模拟过程需要两个关键变量当前报数者的位置pos从0假设编号从0开始或1编号从1开始起步。当前报的数字count从1开始累加数到m时触发出列。那么为什么选择数组而不是其他结构数组支持随机访问我们可以通过索引pos直接定位到当前位置的人检查其状态。对于“围成一圈”的特性我们只需要在每次pos移动后判断它是否超过了n-1或n如果超过了就让它回到起点pos 0或pos 1。这个过程可以用取模运算pos (pos 1) % n来优雅地实现循环但需要注意处理编号从1开始和标记已出列人员的情况。2.2 详细步骤与代码实现让我们一步步拆解这个模拟过程并附上详细的C代码注释。这里我们采用编号从1开始的约定更符合题目直觉。#include iostream #include vector using namespace std; int main() { int n, m; cin n m; // 步骤1初始化 // 使用一个vectorbool来标记每个人是否出列初始都为false在圈内 vectorbool out(n 1, false); // 多开一个空间方便下标从1开始 int currentPos 0; // 当前指向的人的编号从0开始便于取模 int remaining n; // 圈内剩余人数 // 步骤2模拟淘汰过程直到无人剩余 while (remaining 0) { // 步骤2.1找到下一个应该报数的人 // 我们需要数m个“有效”的人即未出列的人 int count 0; while (count m) { // 当前位置向前移动一位模拟报数动作 currentPos; // 实现“围成一圈”如果超过n则回到1 if (currentPos n) { currentPos 1; } // 如果当前位置的人尚未出列则这是一个有效的报数 if (!out[currentPos]) { count; } // 如果此人已出列则while循环会继续currentPos会继续寻找下一个未出列的人 } // 步骤2.2此时currentPos指向的就是第m个未出列的人将其淘汰 out[currentPos] true; // 标记为出列 remaining--; // 剩余人数减一 cout currentPos ; // 输出出列编号 } cout endl; return 0; }2.3 时间复杂度分析与实操心得我们来分析一下这段代码的效率。外层while循环执行n次每次淘汰一人。内层的while (count m)循环最坏情况下每次都需要遍历很多已经出列的人。具体来说当圈内人越来越少时为了找到一个未出列的人我们可能需要在很多已出列的“空位”上跳过。可以粗略估算总的时间复杂度在O(n * m)级别。当n和m都很大时例如都是10^5这个算法可能会超时。实操心得一关于currentPos的初始化与移动代码中currentPos初始化为0而不是1。这是因为我们在内层循环的一开始就执行了currentPos。这样设计的好处是循环逻辑统一每次寻找下一个有效位置时都是先移动再判断。如果你初始化为1那么第一次移动前就需要特殊处理代码会稍显冗余。这种“先移动后判断”的模式在处理环形遍历时很常见。实操心得二警惕“死循环”陷阱有一种常见的错误写法在内层循环中只进行currentPos (currentPos 1) % n的移动而没有正确关联编号从1开始和数组下标。例如如果n5, m2currentPos从0开始取模后范围是0~4但我们的标记数组out下标是1~5这就产生了错位极易导致访问越界或逻辑错误。务必确保你的位置变量和数据结构下标范围一致。数组模拟法最大的优点是直观几乎就是照着题目描述翻译成代码。它的缺点是效率较低尤其是在m较大时内层循环空转很多次。但它作为理解问题本质的第一步是完全合格且必要的。3. 方案二更高效的模拟——队列Queue法既然数组模拟法低效的原因在于需要跳过已出列的人我们能不能用一种数据结构直接只维护“还在圈内”的人呢队列Queue就是一个完美的选择。3.1 队列如何模拟约瑟夫环队列的特点是“先进先出”FIFO。我们可以把所有n个人的编号按顺序入队从1到n这样队首就是当前要报数的人。模拟过程变得异常清晰从队首开始报数不是m的人我们将其从队首取出然后立刻重新放入队尾。这模拟了他在本轮报数后安全留在圈内并排到队伍后面等待下一轮。当某个人报数正好是m时我们将其从队首取出并且不再放回队列同时输出他的编号。这模拟了他的出列。这个过程巧妙地利用了队列的特性维护圈内人员队列中始终是未出列的人。实现循环将队首元素移到队尾天然形成了环形结构。动态更新出列操作就是简单的出队无需标记数组。3.2 完整实现与逻辑推演下面是使用C标准库queue的实现。#include iostream #include queue using namespace std; int main() { int n, m; cin n m; queueint q; // 初始化队列1, 2, 3, ..., n for (int i 1; i n; i) { q.push(i); } // 开始模拟报数 int count 0; // 当前报的数字 while (!q.empty()) { // 步骤1队首的人报数 int person q.front(); q.pop(); count; // 步骤2判断是否报到m if (count m) { // 报到m此人出列 cout person ; // 重置报数器下一轮从1开始 count 0; } else { // 没报到m此人回到队尾相当于留在圈内排到后面 q.push(person); } } cout endl; return 0; }让我们手动推演一下n5, m2的情况以加深理解初始队列:[1, 2, 3, 4, 5],count0。person1出队count1不等于21入队尾。队列:[2, 3, 4, 5, 1]。person2出队count2等于2输出2count重置为0。队列:[3, 4, 5, 1]。person3出队count1不等于23入队尾。队列:[4, 5, 1, 3]。person4出队count2等于2输出4count重置为0。队列:[5, 1, 3]。... 依次类推最终输出顺序为2 4 1 5 3。3.3 性能对比与适用场景队列法的时间复杂度非常容易分析。每个元素都会入队一次、出队一次无论是否被淘汰。对于被淘汰的人是一次出队对于未被淘汰的人是一次出队加一次入队。因此总操作次数是O(n)级别的因为每个人被处理的次数是常数。这比数组标记法的O(n*m)要好得多尤其是在m很大时。但是队列法也有其局限性。它需要额外的空间来存储整个队列空间复杂度是O(n)。而数组标记法的空间复杂度也是O(n)所以在这方面持平。队列法的优势在于逻辑清晰和常数时间操作避免了数组法中内层循环的空转。实操心得三count的重置时机注意代码中count的重置是在输出出列者编号之后。这意味着下一个人报数将从1开始。这个逻辑必须和题目理解一致。有些变体的约瑟夫问题可能是“从出列者的下一个人开始报数但报数数字继续累加”那重置逻辑就不同了。P1996明确是“重新报数”所以我们的重置是正确的。实操心得四关于STL queue的使用使用queue时pop()函数不返回被移除的元素所以需要先用front()获取队首元素再pop()。这是一个常见的易错点。此外queue底层默认由deque实现入队出队操作都是O(1)性能有保障。队列法是解决此类“循环淘汰”问题的利器代码简洁效率高是面试和竞赛中的推荐写法。4. 方案三追求极致的效率——数学递推法前面两种方法都是模拟时间复杂度至少是O(n)。有没有可能更快甚至达到O(m)或O(log n)答案是肯定的这就是约瑟夫问题最精妙的部分——数学递推公式。4.1 从暴力到数学的思维跃迁我们换个角度思考。假设f(n, m)表示n个人报数到m时最终胜利者的编号这里“胜利者”指最后剩下的人对于P1996是最后一个出列的人但原理相通。我们考虑第一轮淘汰过程第一轮报数到m的人被淘汰他的编号是(m-1) % n 1考虑m可能大于n所以取模。注意这里编号从1开始。这个人被淘汰后剩下n-1个人。并且下一轮报数将从他的下一个人即编号为(m % n) 1的人开始。关键来了这剩下的n-1个人构成了一个新的约瑟夫环。但是这个新环的编号规则变了。它不再是从1到n-1而是从(m % n) 1开始的一个“偏移”后的序列。如果我们能知道在这个新编号系统下n-1个人的胜利者编号x f(n-1, m)那么我们只需要将这个“新编号”映射回最初的“旧编号”系统就能得到f(n, m)。4.2 递推公式的推导与理解设旧环编号为1, 2, 3, ..., m-1,m, m1, ..., n。 淘汰m后新环从m1开始编号为m1, m2, ..., n, 1, 2, ..., m-1。 我们可以给这个新环赋予临时编号1, 2, 3, ..., n-1。现在寻找新旧编号之间的映射关系新环中的第1个人临时编号1对应旧环的m1。新环中的第2个人对应旧环的m2。...新环中的第n-m个人对应旧环的n。新环中的第n-m1个人对应旧环的1。...新环中的第n-1个人对应旧环的m-1。观察这个映射可以发现一个规律旧编号 (新编号 m - 1) % n 1。我们来验证一下当新编号1时(1m-1)%n1 m%n1。如果mn这就是m1如果mn取模后也能得到正确结果。同理当新编号n-1时(n-1m-1)%n1 (m-2)%n1如果m-2n这就是m-1符合。因此我们得到了著名的约瑟夫递推公式f(n, m) (f(n-1, m) m - 1) % n 1其中f(1, m) 1只有一个人时他就是胜利者。这个公式的意义是规模为n的问题的答案可以由规模为n-1的问题的答案经过一个简单的线性变换得到。4.3 算法实现与输出整个序列对于P1996我们需要输出整个出列序列而不仅仅是最后一个。利用递推思想我们可以从f(n,m)倒推出f(n-1,m)但更直接的方法是正向模拟这个递推过程。我们可以维护一个“当前环的起始编号”和“当前环的大小”。每次淘汰的人就是当前环中从起始位置开始数第m个人取模。淘汰他之后新的起始位置就是他的下一个位置环的大小减一。下面是利用数学思想进行高效模拟的C实现时间复杂度O(n)但避免了队列法的入队出队操作常数更小。#include iostream #include vector using namespace std; int main() { int n, m; cin n m; vectorint person(n); // 初始化人员编号 for (int i 0; i n; i) { person[i] i 1; } int start 0; // 当前轮次报数开始的位置下标从0开始 while (!person.empty()) { // 计算要出列的人在当前vector中的下标 // (start m - 1) % person.size() 是因为start是下标从0开始报数 // 我们要找的是第m个人所以需要加 m-1 int idx (start m - 1) % person.size(); // 输出出列者编号 cout person[idx] ; // 从vector中删除这个元素。删除后它后面的元素会自动前移。 person.erase(person.begin() idx); // 新的开始位置就是被删除元素的位置因为后面的元素已经前移过来了 // 如果删除的是最后一个元素那么 idx 会等于新的size()此时 start 应为 0取模后正好是 0。 start idx % person.size(); // 注意person.size() 可能为0循环已结束所以这里安全。 } cout endl; return 0; }4.4 数学法的优势与隐藏的坑这种方法非常高效每次计算淘汰位置是O(1)但vector的erase操作在中间删除元素是O(n)的因为需要移动后续元素。所以整体复杂度仍是O(n^2)。但是它的优势在于思维上的优雅和对问题本质的揭示。在实际竞赛中如果只是求最后幸存者我们可以用O(n)的递推直接计算效率极高int josephus(int n, int m) { int winner 0; // f(1, m) 1对应下标0 for (int i 2; i n; i) { winner (winner m) % i; } return winner 1; // 转回1-based编号 }这个O(n)求最终胜利者的代码是数学法的终极体现。实操心得五vector::erase的性能陷阱上面输出序列的代码中person.erase(person.begin() idx)是性能瓶颈。每删除一次平均需要移动n/2个元素。当n很大时比如10^5这会导致超时。因此虽然数学法思想高级但用vector存储并频繁删除并不是输出序列的最佳实践。对于需要输出序列的P1996队列法通常是更优的选择。数学法的价值更多在于理解问题和求解最终结果。实操心得六下标从0开始与从1开始的转换数学推导和代码实现中下标从0开始会简洁很多因为取模运算自然从0开始。递推公式winner (winner m) % i就是基于0-index的。如果题目或你的思维习惯是1-index一定要小心转换。记住核心0-index的答案加1就是1-index的答案。5. 方案对比与实战选择指南至此我们已经掌握了三种解决约瑟夫问题的方法。在实际面对洛谷P1996或类似问题时该如何选择呢下表从多个维度进行了对比特性维度数组标记法队列法数学递推法 (求序列)数学递推法 (求最终幸存者)核心思想模拟用数组标记状态模拟用队列维护圈内人利用数学规律直接计算位置利用递推公式迭代计算时间复杂度O(n*m)O(n)O(n²) (因vector删除)O(n)空间复杂度O(n)O(n)O(n)O(1)代码复杂度中等简单中等简单最佳适用场景n, m 较小时帮助理解需要输出完整序列n较大理解数学原理n较小时仅需最终幸存者编号n极大可读性一般优秀较差需理解下标计算优秀代码极简给新手的实战建议理解优先首先用数组标记法手动模拟几遍小数据彻底理解“报数”、“淘汰”、“循环”这三个核心过程。这是基础。竞赛与面试遇到需要输出完整序列的约瑟夫问题如P1996队列法是首选。它效率高、逻辑清晰、不易出错是经过验证的“标准答案”。追求极限如果问题只要求输出最后一个幸存者这是约瑟夫问题的另一个常见问法并且n非常大比如10^9那么必须使用数学递推法的O(n)迭代版本甚至还有基于m和n性质的O(m log n)更优算法当m较小时。避免踩坑不要试图用数学法配合vector删除去输出大规模数据的序列erase操作会成为性能杀手。清晰比过早优化更重要。约瑟夫问题就像一把钥匙打开了算法学习中“模拟”、“数据结构应用”和“数学归纳”三扇大门。从最笨拙但可靠的模拟开始到利用合适的数据结构优化流程再到洞悉其背后的数学规律这个过程本身就是一个程序员思维成长的缩影。下次再遇到它不妨先问问自己这次的需求是什么是求序列还是求结果数据范围有多大想清楚了这些问题你自然就能选出最合适的那把“钥匙”。