公司动态
约瑟夫环问题详解:从暴力模拟到数学递推,附华为OD机试多语言代码
1. 项目概述从一道经典机试题看算法思维最近在帮几个准备华为OD机试的朋友做模拟练习发现“约瑟夫问题”这道题的出现频率相当高。它不仅是华为OD机试真题库里的常客在各大公司的笔试面试中也屡见不鲜。这道题本身描述很简单N个人围成一圈从第一个人开始报数报到M的人出列然后从他的下一个人继续报数如此循环直到所有人都出列求最后剩下的人的编号或者求整个出列顺序。但就是这么一道看似简单的题却能把很多人的思路卡住尤其是当N和M的数值稍大时暴力模拟的方法就显得力不从心。今天我就结合自己多年刷题和带新人的经验把这道题的来龙去脉、核心解法、代码实现以及那些容易踩的坑掰开揉碎了讲清楚。无论你是用C、Java、Python还是C语言、JavaScript这篇文章都能给你提供一个清晰的思路和可直接复现的代码模板。2. 约瑟夫问题的核心思路与数学解析2.1 问题本质与暴力模拟法约瑟夫问题的核心是一个“环形淘汰”游戏。最直观的解法就是暴力模拟整个过程。我们可以用一个数组或者链表来模拟这N个人初始状态都标记为“在圈内”。然后我们维护一个当前报数的起始位置current和一个计数器count。从current开始遍历这个环形结构每遇到一个“在圈内”的人count就加1。当count等于M时就将当前位置的人标记为“出圈”或从数据结构中移除记录其编号然后将count重置为0并从下一个人开始继续报数。这个过程一直持续到所有人都出圈为止。暴力模拟的优缺点分析优点思路极其直观几乎不需要任何数学推导代码写起来也相对简单特别适合在面试中快速阐述思路。缺点时间复杂度高。当N和M都很大时比如N10^6, M10^3模拟每一步的淘汰过程会非常慢时间复杂度为O(N*M)。在机试或笔试这种对时间有严格限制的场景下很容易超时。注意在华为OD机试中题目通常会给出数据范围。如果N和M的范围较小例如N 1000使用暴力模拟是完全可行的代码简单不易出错。但如果范围很大就必须寻求更优的数学解法。2.2 递推公式法约瑟夫环数学解为了高效解决大规模数据的问题我们需要引入约瑟夫环的数学递推公式。这是解决此类问题的核心与精髓。公式推导与理解我们定义f(n, m)表示当有n个人报数到m时最后存活下来的人的编号编号假设从0开始这样推导公式更简洁。第一轮淘汰当有n个人时第一个被淘汰的人的编号是(m-1) % n。淘汰他之后还剩下n-1个人。重新编号与映射接下来我们从被淘汰者的下一个人即编号为m % n的人开始重新进行游戏。但此时总人数变成了n-1。为了利用f(n-1, m)的结果我们需要建立一个新旧编号的映射关系。旧环n个人淘汰一人后下一个起点是k m % n。新环n-1个人我们将这个起点重新编号为0。那么旧环中编号为x的人在新环中的编号x是多少呢观察可得x (x - k) % n不对因为新环只有n-1个人。实际上x (x - k n) % n这个映射是在同规模下调整索引。更准确地说旧编号x与新编号x的关系是x (x k) % n。因为新环的0号对应旧环的k号。建立递推关系我们知道f(n-1, m)给出了在新环n-1人规则下最终存活者的新编号。那么这个存活者在旧环n人中的编号根据上面的映射关系就是f(n, m) ( f(n-1, m) m ) % n这里m就是k因为k m % n而在模n运算下(a m%n) % n等价于(a m) % n。递推的初始条件当只有1个人时n1无论m是多少这个人都存活所以f(1, m) 0。公式总结f(1, m) 0f(n, m) ( f(n-1, m) m ) % n(n 1)这个公式的时间复杂度是O(N)空间复杂度如果递归是O(N)但我们可以用循环轻松优化到O(1)。这比暴力模拟的O(N*M)高效得多。编号转换上述公式得到的结果是基于从0开始编号的。如果题目要求从1开始编号只需将最终结果1即可。2.3 不同场景下的方法选择在实际解题尤其是应对华为OD机试时选择哪种方法需要快速判断求最后幸存者编号首选递推公式法。代码简洁效率极高几乎适用于所有数据范围。求完整的出列顺序如果N不大比如N 10^4可以使用模拟法直观且易于输出每一步结果。如果N很大但需要完整顺序模拟法可能超时。此时可以结合公式进行优化或者考虑使用数据结构如线段树、树状数组来加速“查找第M个未出列的人”这一过程但这已超出一般机试范围。华为OD真题中求完整顺序的题目其N通常不会设置得过大。3. 多语言代码实现与逐行解析下面我将分别用C、Java、Python、C语言和JavaScript实现**求解最后幸存者编号从1开始编号**的核心算法并附上详细的注释和思路说明。3.1 C 实现C实现注重效率和工程性。我们使用循环迭代来实现递推公式。#include iostream using namespace std; /** * 使用递推公式解决约瑟夫环问题求最后幸存者编号 * param n 总人数 * param m 报数到m的人出列 * return 最后幸存者的编号从1开始 */ int josephus(int n, int m) { // 边界条件处理 if (n 0 || m 0) { return -1; // 或根据题目要求抛出异常 } int survivor 0; // f(1, m) 0 (编号从0开始) // 从i2开始递推直到in for (int i 2; i n; i) { survivor (survivor m) % i; } // 将编号从0-based转换为1-based并返回 return survivor 1; } int main() { int n, m; cout 请输入总人数n和报数值m: ; cin n m; int result josephus(n, m); cout 最后幸存者的编号是: result endl; return 0; }代码解析与心得核心循环for (int i 2; i n; i)这里的i代表当前轮次的总人数。survivor变量始终保存着当人数为i-1时的幸存者编号0-based。通过(survivor m) % i计算出人数为i时的幸存者编号。为什么从2开始因为基础情况f(1,m)0我们已经直接赋给了survivor。循环从2个人开始递推。模运算% i是关键它确保了编号始终在[0, i-1]的范围内循环模拟了环状结构。效率时间复杂度O(N)空间复杂度O(1)。这是最优解法之一。3.2 Java 实现Java实现与C类似但更注重代码的健壮性和可读性。import java.util.Scanner; public class JosephusProblem { /** * 递推法解约瑟夫环 * param n 总人数 * param m 报数值 * return 最后幸存者编号从1开始 */ public static int josephus(int n, int m) { if (n 1 || m 1) { throw new IllegalArgumentException(n和m必须为正整数); } int survivor 0; // f(1, m) for (int i 2; i n; i) { survivor (survivor m) % i; } return survivor 1; // 转换为1-based编号 } /** * 模拟法解约瑟夫环用于输出完整序列或验证结果 * param n 总人数 * param m 报数值 * return 出列顺序列表从1开始编号 */ public static ListInteger simulateJosephus(int n, int m) { ListInteger result new ArrayList(); ListInteger people new LinkedList(); for (int i 1; i n; i) { people.add(i); } int index 0; while (!people.isEmpty()) { // 找到要出列的人的位置 index (index m - 1) % people.size(); result.add(people.remove(index)); // 删除后index自动指向了下一个人无需再1 } return result; } public static void main(String[] args) { Scanner scanner new Scanner(System.in); System.out.print(请输入n和m: ); int n scanner.nextInt(); int m scanner.nextInt(); scanner.close(); System.out.println(【递推法】最后幸存者: josephus(n, m)); System.out.println(【模拟法】完整出列顺序: simulateJosephus(n, m)); // 模拟法的最后一个元素即为最后幸存者可用于验证 ListInteger sequence simulateJosephus(n, m); System.out.println(模拟法得到的最后幸存者: sequence.get(sequence.size() - 1)); } }代码解析与心得递推法与C版本逻辑完全一致体现了算法核心的跨语言通用性。模拟法实现这里展示了如何使用LinkedList进行模拟。关键点在于index (index m - 1) % people.size()。m-1是因为当前index指向的人已经报过数了可以认为他报了“1”下一个人才报“2”。所以需要移动m-1步找到第m个人。% people.size()实现了环状遍历。people.remove(index)会返回被移除的元素并自动将后续元素前移此时index已经指向了“下一个人”的位置所以下一轮循环直接从这个index开始即可不需要index这是一个常见的错误点。验证在main方法中同时调用两种方法可以相互验证结果的正确性这在调试时非常有用。3.3 Python 实现Python代码以其简洁著称非常适合快速实现算法原型和教学。def josephus_formula(n: int, m: int) - int: 使用递推公式解决约瑟夫环问题 :param n: 总人数 :param m: 报数值 :return: 最后幸存者编号从1开始 if n 1 or m 1: return -1 survivor 0 # f(1, m) 0 for i in range(2, n 1): survivor (survivor m) % i return survivor 1 def josephus_simulation(n: int, m: int) - list: 使用数组模拟解决约瑟夫环问题返回出列顺序 :param n: 总人数 :param m: 报数值 :return: 出列顺序列表从1开始编号 people list(range(1, n 1)) result [] index 0 while people: # 计算要出列的位置 index (index m - 1) % len(people) result.append(people.pop(index)) # pop之后index已经指向了下一个人 return result if __name__ __main__: try: n int(input(请输入总人数 n: )) m int(input(请输入报数值 m: )) except ValueError: print(输入错误请输入整数。) exit(1) last_one josephus_formula(n, m) print(f【递推公式法】最后幸存者的编号是: {last_one}) sequence josephus_simulation(n, m) print(f【模拟法】完整的出列顺序是: {sequence}) print(f模拟法验证最后幸存者是: {sequence[-1]})代码解析与心得列表的灵活运用Python的list的pop(index)方法非常方便直接移除并返回元素完美契合模拟过程。循环条件while people:判断列表是否为空写法非常Pythonic。公式法的简洁性核心循环for i in range(2, n 1):清晰表达了递推过程。Python的动态类型和简洁语法让算法逻辑一目了然。交互与错误处理使用try...except处理输入非整数的情况增强了程序的健壮性。3.4 C语言实现C语言实现需要更关注底层细节和数组操作。#include stdio.h #include stdlib.h // 递推公式法 int josephus_formula(int n, int m) { if (n 1 || m 1) return -1; int survivor 0; // f(1, m) for (int i 2; i n; i) { survivor (survivor m) % i; } return survivor 1; // 转1-based编号 } // 数组模拟法动态分配内存 int* josephus_simulation(int n, int m, int* returnSize) { // 创建并初始化人员数组 [1, 2, ..., n] int* people (int*)malloc(n * sizeof(int)); for (int i 0; i n; i) { people[i] i 1; } // 结果数组 int* result (int*)malloc(n * sizeof(int)); int resultIdx 0; int currentSize n; // 当前圈中剩余人数 int index 0; // 当前报数索引 while (currentSize 0) { // 找到第m个人的位置 index (index m - 1) % currentSize; // 记录出列者 result[resultIdx] people[index]; // 将出列者从数组中移除后续元素前移 for (int j index; j currentSize - 1; j) { people[j] people[j 1]; } currentSize--; // 圈内人数减1 // index 已经指向了下一个开始报数的人无需调整 } *returnSize n; free(people); // 释放临时数组 return result; } int main() { int n, m; printf(请输入总人数n和报数值m: ); if (scanf(%d %d, n, m) ! 2 || n 0 || m 0) { printf(输入无效。\n); return 1; } // 方法1公式法 int last josephus_formula(n, m); printf(【递推公式法】最后幸存者编号: %d\n, last); // 方法2模拟法 int size; int* sequence josephus_simulation(n, m, size); printf(【数组模拟法】出列顺序: ); for (int i 0; i size; i) { printf(%d , sequence[i]); } printf(\n); printf(模拟法验证最后幸存者: %d\n, sequence[size - 1]); free(sequence); // 释放结果数组 return 0; }代码解析与心得手动管理内存C语言需要显式地使用malloc和free。在模拟法中我们动态分配了people和result数组并在使用完毕后释放防止内存泄漏。数组元素的移除C语言没有内置的列表删除操作。我们通过一个for循环将index位置之后的所有元素向前移动一位来实现“移除”效果。这是模拟法在C语言中的标准实现方式时间复杂度为O(N^2)。参数返回大小josephus_simulation函数通过指针参数returnSize返回结果数组的大小这是C语言中处理动态数组返回的常见模式。输入验证使用scanf的返回值判断输入是否成功并检查输入值的合法性。3.5 JavaScript (Node.js) 实现JavaScript在Web前端和后端(Node.js)都有应用这里以Node.js环境为例。/** * 递推公式法解决约瑟夫环问题 * param {number} n - 总人数 * param {number} m - 报数值 * returns {number} - 最后幸存者编号从1开始 */ function josephusFormula(n, m) { if (n 1 || m 1) return -1; let survivor 0; // f(1, m) for (let i 2; i n; i) { survivor (survivor m) % i; } return survivor 1; } /** * 数组模拟法解决约瑟夫环问题 * param {number} n - 总人数 * param {number} m - 报数值 * returns {number[]} - 出列顺序数组从1开始编号 */ function josephusSimulation(n, m) { // 初始化人员数组 let people Array.from({ length: n }, (_, i) i 1); let result []; let index 0; while (people.length 0) { // 计算要出列的位置 index (index m - 1) % people.length; // 将出列者移出数组并加入结果 result.push(people.splice(index, 1)[0]); // splice方法会改变原数组移除元素后index自动指向下一个人 } return result; } // 主程序 const readline require(readline).createInterface({ input: process.stdin, output: process.stdout }); readline.question(请输入总人数n和报数值m以空格分隔: , input { const [n, m] input.trim().split( ).map(Number); if (isNaN(n) || isNaN(m) || n 0 || m 0) { console.log(输入无效请输入两个正整数。); readline.close(); return; } const lastOne josephusFormula(n, m); console.log(【递推公式法】最后幸存者的编号是: ${lastOne}); const sequence josephusSimulation(n, m); console.log(【数组模拟法】完整的出列顺序是: [${sequence.join(, )}]); console.log(模拟法验证最后幸存者是: ${sequence[sequence.length - 1]}); readline.close(); });代码解析与心得数组方法splicepeople.splice(index, 1)是模拟法的关键。它从index位置删除1个元素并返回被删除元素的数组我们用[0]取出这个元素。这个方法直接修改原数组people非常方便。Array.from初始化Array.from({ length: n }, (_, i) i 1)是一种优雅的生成序列数组[1, 2, ..., n]的方法。Node.js 交互使用readline模块进行命令行交互适合本地测试算法。函数式风格代码结构清晰将算法逻辑封装成纯函数便于测试和复用。4. 华为OD机试真题实战与思路拓展华为OD的机试题往往不是直接问“求最后幸存者”而是会进行一些变体或包装。我们结合“约瑟夫问题”的核心来分析几种可能的考法。4.1 真题变体一求第K个出列的人题目描述N个人围成一圈从1开始报数报到M的人出列求第K个出列的人的编号。1 K N思路分析 这要求我们不仅要关注最后一个人还要关心中间任意一轮的结果。暴力模拟法可以直接解决输出第K个被pop或remove的元素即可。但如果N和M很大而K很小比如只求第10个出列的人我们有没有更优解优化思路我们可以尝试逆向思维。如果我们知道当剩余i个人时下一个要出列的人在当前剩余队列中的位置是否可以推导实际上递推公式f(n, m)求的是“幸存者”是最后一轮的结果。对于第K个出列的人他出列时总人数是N - K 1因为在他之前已经出列了K-1个人。我们可以从后往前推吗有点困难。更直接的方法是修改递推过程记录每一轮的出列者。在递推公式f(i, m) (f(i-1, m) m) % i中f(i, m)是人数为i时最终幸存者在当前轮次的编号0-based。如果我们想知道第(N - i 1)个出列的人即当总人数从N减少到i时被淘汰的那个人的原始编号计算会非常复杂。因此对于这类问题在机试时间限制内如果N不是特别巨大例如N 10^5使用模拟法并记录前K个结果是更稳妥、更不易出错的选择。代码只需在之前的模拟循环中增加一个计数器当出列人数达到K时跳出循环并返回即可。4.2 真题变体二报数规则变化题目描述N个人围成一圈从1开始报数。第一个人报1第二个人报2...第M个人报M出列。然后下一个人重新从1开始报数。但是每次有人出列后M的值会发生变化例如变为出列者编号的个位数若为0则取10。求出列顺序。思路分析 这增加了动态规则。暴力模拟法的优势就体现出来了因为规则再复杂模拟的过程是清晰的。我们只需要在每一轮淘汰人之后根据新的规则更新m的值即可。递推公式在这里完全失效因为它依赖于固定的m。解题要点依然使用数组或链表模拟人员。维护当前报数索引index和当前报数值currentM。每淘汰一个人先记录其编号outNum。然后根据题目给定的规则用outNum计算出新的currentM例如currentM outNum % 10; if(currentM 0) currentM 10;。继续下一轮报数。心得遇到规则变化的约瑟夫类问题第一时间考虑模拟法。关键在于将题目描述的不规则报数逻辑准确翻译成代码中更新index和m的规则。4.3 真题变体三结合其他数据结构题目描述N个人编号1-N每次淘汰第M个人但每次淘汰后队伍会按照某种规则重新排序例如按剩余人编号升序然后再从队首开始报数。思路分析 这引入了“中间处理”步骤。模拟法仍然是主体框架但在每一轮淘汰后需要对剩余的人员列表进行一次排序或其他操作。这可能会增加时间复杂度。如果N很大需要评估排序O(N log N)在循环中执行的总代价。有时题目会限制N的大小使得O(N^2 log N)的复杂度也能接受。解题框架def special_josephus(n, m): people list(range(1, n1)) result [] index 0 while people: index (index m - 1) % len(people) out_person people.pop(index) result.append(out_person) # 关键淘汰后的额外操作 people.sort() # 例如重新排序 # 注意排序后下一轮的报数起点可能需要重新定义 # 通常题目会明确“从队首即新people[0]开始报数”这意味着index要重置为0。 index 0 return result核心陷阱执行额外操作如排序后报数起点index的处理。必须仔细阅读题目明确“从谁开始继续报数”。是继续从被淘汰者的下一个人在原顺序中还是从新序列的头部这是极易出错的地方。5. 常见错误与调试技巧在实现约瑟夫问题的过程中无论是自己编码还是看别人的代码以下几个坑点需要特别注意5.1 下标与编号混淆这是最常见的一类错误。公式法递推公式f(n, m) (f(n-1, m) m) % n默认的编号是从0开始的。如果题目要求输出从1开始的编号最终结果一定要1。在递推过程中所有的运算都是基于0-based编号的不要中途加1。模拟法初始化数组时要清楚里面存的是编号1,2,3...还是索引0,1,2...。通常存编号更直观。计算下一个出列位置时公式index (index m - 1) % current_size中的m-1是基于“当前人报1”的假设。如果理解有偏差很容易写成(index m) % current_size这会导致报数多一位。在移除元素后新的index指向了下一个人的位置千万不要再执行index 1。调试技巧用一组非常小的数据如n5, m2手动模拟整个过程将每一步程序计算出的index、剩余队列people和结果result打印出来与你的手动计算进行比对。5.2 边界条件处理不当n或m为0或负数函数开头应添加合法性检查返回错误值或抛出异常。m1的情况这是特殊情况。报1出列意味着就是依次出列。公式法(survivor 1) % i计算后结果恒为00-based最后1变成1符合直觉最后一个人幸存。模拟法也能正确处理。但可以作为一个测试用例验证。n1的情况无论m是多少幸存者就是那一个人。公式法循环从i2开始不会进入直接返回011。模拟法也能正确处理。确保你的代码能覆盖这个情况。5.3 大数据量下的性能问题模拟法超时当N和M很大时O(N*M)或O(N^2)的模拟法必然超时。第一反应应该是尝试递推公式法其O(N)的复杂度通常可以应对10^7甚至10^8的数据量取决于具体时间限制。公式法的局限性公式法只适用于求最后幸存者且报数规则固定为“报m出列”。如果规则变化公式法失效。内存占用模拟法如果使用链表如JavaLinkedList频繁的删除操作可能产生大量小对象增加GC压力。使用数组并通过移动元素来模拟删除在C/C中更可控但在JavaScript/Python中splice或pop的内部操作可能涉及数组重组也有开销。对于极大的N即使O(N^2)时间能接受也要注意内存是否足够。5.4 多组输入输出的格式处理华为OD机试通常是标准输入输出。题目可能要求处理多组测试用例直到输入结束。C/C示例while (cin n m) { // 或 while(scanf(%d%d, n, m) 2) cout josephus(n, m) endl; }Java示例Scanner sc new Scanner(System.in); while (sc.hasNextInt()) { int n sc.nextInt(); int m sc.nextInt(); System.out.println(josephus(n, m)); } sc.close();Python示例import sys for line in sys.stdin: if not line.strip(): continue n, m map(int, line.split()) print(josephus_formula(n, m))关键务必理解题目输入的格式说明是单组数据还是多组数据输出是否要包含“Case #x: ”这样的前缀。错误的输入输出处理会导致大量丢分。6. 总结与进阶思考约瑟夫问题是一道经典的算法入门题它像一面镜子能清晰地反映出解题者对基础数据结构数组、链表的掌握程度、对数学归纳法的理解深度以及将问题抽象和转化的能力。在华为OD等企业机试中它往往不是孤立的而是作为考查循环、模拟、数学思维和代码实现基本功的载体。对于准备机试的同学我的建议是掌握双解法务必同时掌握暴力模拟法和递推公式法。模拟法是保底思路公式法是高效法宝。根据题目数据范围灵活选择。理解本质不仅要会背公式更要理解f(n, m) (f(n-1, m) m) % n这个递推关系是如何从“重新编号”的映射中推导出来的。理解了本质才能应对变体。勤于测试用多组数据测试你的代码特别是边界情况n1, m1; n5, m1; n5, m5; n5, m5 等。对比模拟法和公式法的结果是否一致。关注变体多找一些约瑟夫问题的变体来练习例如求第K个出列、报数规则动态变化等锻炼自己将复杂描述转化为模拟步骤的能力。最后代码的简洁性和鲁棒性同样重要。清晰的变量命名、必要的注释、良好的输入验证和错误处理这些细节在机试评分和日常工程中都是加分项。希望这篇近万字的解析能帮你彻底吃透约瑟夫问题在下次遇到它时能够从容不迫地写出正确而高效的代码。