ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

约瑟夫环问题全解析:从暴力模拟到数学递推的算法进阶

约瑟夫环问题全解析:从暴力模拟到数学递推的算法进阶 约瑟夫环问题真的不只是个“报数游戏”1. 约瑟夫环是什么从一段历史故事到编程模型1.1 问题描述与数学定义约瑟夫环问题几乎每一个学过数据结构的人都会遇到。它描述的是这样一个场景有n个人围成一圈从某个位置开始报数每报到m的人就退出圈子然后下一个人重新从1开始报数如此循环直到最后只剩下一个人这个人的编号就是我们要找的答案。原版故事发生在古代一批人被围困后决定站成一圈每隔固定人数处决一人约瑟夫巧妙地站到了最后存活的位置问题由此得名。用严谨一点的数学语言说我们有编号为1到n的n个节点排成一个环形序列。从编号k通常k1开始计数数到m的人出列从出列者的下一位继续从1计数。这个过程重复执行直到圈内只剩一个人。注意两点一是“出列者的下一位继续报数”二是圈是环形流动的没有绝对的“队首”。面试里最常见的变体就是“从1号开始、每次数到m删除、求最后剩余的人编号”。你别看它描述简单背后涉及的“循环结构”、“状态模拟”、“递归思想”和“数学归纳”几乎是算法基础能力的集中检验。1.2 为什么这个问题值得反复研究很多人刷题遇到约瑟夫环第一反应是“这不就是个链表删除节点吗”然后写个循环链表模拟一遍就过了。但约瑟夫环的价值远不止于此。首先它是少数能把“暴力模拟”和“数学推导”放在一起对比的经典题。n和m较小时模拟毫无压力n上了百万级模拟的时间复杂度就完全扛不住。而数学递推解法只需要O(n)甚至O(1)特定条件下就能算出结果这种从“现象”到“规律”的思维跃迁是编程能力分水岭的标志。其次它的变体极多。m很大、n很大、从任意位置开始报数、隔几个人反向报数、不止淘汰一个人而是淘汰一批人……每一种变体都在考验你对原问题的理解深度。很多大厂面试官也喜欢从约瑟夫环切入逐步加码考察候选人的代码能力和逻辑推导能力。所以我一直觉得与其背答案不如把约瑟夫环从暴力到数学彻底吃透。2. 暴力模拟法链表和循环数组的实现细节2.1 循环链表模拟——最直接的思路暴力模拟的核心思想就是“照着过程走”。既然题目描述的就是一个环形结构那最容易想到的数据结构当然是循环链表。每个节点代表一个人节点内保存编号尾节点指向头节点形成闭环。每次从当前节点开始数m-1步因为当前节点算第1个第m个节点就是淘汰者把它从链表中删除然后从它的下一个节点继续。这里有个非常容易踩坑的细节当m1时不需要数步当前节点就是要删除的节点。如果你写成“数m-1步”那结果就是当前节点的前一个节点被删完全错误。更麻烦的是删除节点后指针的移动方向——如果删除的是当前节点那“下一个节点”就是被删除节点的后继节点这个逻辑要提前想清楚否则指针丢失就会陷入死循环。链表解法的时间复杂度是O(n*m)因为每次删除都需要走m步。如果n和m都是十万级别这个开销可能直接让程序运行几十秒非常不划算。但它胜在直观适合第一次接触问题时用来验证自己对过程的理解也适合作为后续数学解法的验证工具。2.2 数组模拟——更“轻”的暴力解法除了链表数组也能模拟这个过程而且写起来往往更顺手。思路是维护一个“存活数组”用0和1标记每个人是否还在圈子中每次从当前位置开始扫描数到第m个存活的人就将其标记为0然后继续。当数组遍历到尾部时通过取模运算回到头部。数组模拟的代码比链表好写不用处理指针但它的时间复杂度同样是O(n*m)而且如果m很大每次扫描会做大量无效循环反复跳过已经标记为0的人。我见过有人在循环里不加判断直接扫描结果统计的“报数次数”里掺杂了很多已淘汰的人程序虽然跑得出结果但逻辑上已经是错误实现只是测试数据碰巧没戳破而已。所以暴力模拟可以但一定要明确“每数一个数都要落在存活者身上”也就是说跳过已淘汰者的动作是操作的一部分而不是额外的“清零”工作。理解了这一点数组模拟才对后续优化有启发性。2.3 暴力法的适用边界我需要在这里把话说清楚暴力模拟不是一无是处。它适合n和m都在几千以内的场景适合快速验证结论也适合作为测试用例的基准答案。做算法题时我常常先写一个暴力版本生成结果再和优化版本做对拍确保优化版本没写错。这一步在面试现场特别管用——你先给面试官展示一个能跑的方案再讨论优化双方的沟通效率会高很多。实际工程里如果数据规模不大暴力模拟完全可以直接上线。“杀鸡用牛刀”才是最让人头疼的不是所有算法题都需要用数学魔法合理评估数据规模永远是第一优先级。但如果我们想真正掌握约瑟夫环就必须往数学递推的方向再走一步。3. 数学递推解开约瑟夫环的“天眼”3.1 老约瑟夫的“聪明之处”传说中约瑟夫不是靠运气活下来的他算出了自己的位置。这种“算出位置”的能力放到算法里就是数学递推的解法。思考方式不再是“一遍遍模拟谁被淘汰”而是直接问一个问题如果我站在最后我所站的这个位置从一开始的序列里应该对应哪个编号我们先看有n个人参与的完整过程。第一轮报数会淘汰一个人圈子里还剩n-1个人。关键在于剩下的n-1个人重新组成的新圈和“一开始就有n-1个人”的约瑟夫环在结构上是完全同构的。两者的差异只有一个编号的“起点”偏移了。如果我们能知道n-1规模下最后留下的“相对位置”再把这层编号偏移还原回去就得到了n规模下的真实编号。这就构成了一种“自相似”的结构可以用递归或递推来求解。3.2 递推公式的推导过程我们重新定义一个更顺手的下标体系假设人的编号从0到n-1编程语言里方便做取模运算并规定“从0号开始报数报到m的人出局”。我们记f(n, m)为n个人、步长为m时最后留下的人在这个0基编号体系中的编号。先看基础情况当n1时唯一的一个人当然是最后留下的人所以f(1, m)0。现在考虑n1的情况。第一轮报数从0号开始数到m-1的那个人编号为m-1取模n会被淘汰。淘汰后从编号(m) mod n的人开始重新组成一个规模为n-1的环。为了方便分析我们把“下一个开始报数的人”视为新环的0号。也就是说新环中的第i号对应旧环中的第((mi) mod n)号。如果已知新环中最后留下的人相对编号为f(n-1, m)那么它在旧环中的真实编号就是 (f(n-1, m) m) mod n所以递推公式就是 f(1, m) 0 f(n, m) (f(n-1, m) m) mod n, n1这就是约瑟夫环数学解法的核心公式。3.3 从递推到代码一个O(n)的解法有了递推公式写代码就非常简单了。我们自底向上把f从1算到n就能得到答案。需要注意编号体系是0基的如果题目要求返回从1开始编号的人那最终结果记得加1。int josephus(int n, int m) { int res 0; for (int i 2; i n; i) { res (res m) % i; } return res 1; // 因为题目通常要求1基编号 }这段代码的时间复杂度是O(n)空间复杂度O(1)。和暴力模拟O(n*m)相比在n为一百万、m为一万时暴力可能要跑几百亿步数学递推却只需要一百万次取模运算性能差距是几个数量级。这里有个让我自己当年困惑很久的问题为什么循环变量从2开始而不是从1因为f(1, m)0是初始条件所以我们从i2开始推。当i2时res算出的是2个人时的结果i3时res算出的是3个人时的结果。注意取模的分母是i不是n每一轮规模都在变化这是整个递推最容易写错的地方。如果你写成了res % n那算出来的结果大概率是错的。还有一个常见疑问为什么取模的“模数”是i而不是m因为当前需要考虑的环的规模是i编号范围是0到i-1任何超出这个范围的编号都必须折回到这个环内。m可能比i大也可能比i小只有当(m res)超过i时才需要取模。聊到这里我想再额外分享一个优化技巧。当m远大于n时每一轮取模操作都是O(1)已经很快了。但当n很大而m相对较小时取模结果也跳不远O(n)已经是底线。如果面试官再追问“能不能更快”就需要利用“m远大于n时大量连续的取模过程中resm可能会连续多轮小于模数”这一特性用乘法一次性跳过多轮。这个优化LeetCode上有人提过叫“分段跳跃”。面试中能写出O(n)解法已经很扎实分段跳跃属于加分项但平时可以了解一下思路毕竟面试官最喜欢追着候选人的解法继续“压榨”。4. 约瑟夫环的变体与应用场景4.1 报数起点不固定从第k个人开始报数原始问题通常默认从第1个人开始报数但实际面试中经常改成“从第k个人开始报数”。处理方式很简单你可以把整个序列的“视角”旋转一下把第k个人视为新的0号然后用标准递推公式算出结果最后再映射回原有编号。具体来说假设原序列编号1..n从第k个人开始报数。第一步先把“第k个人”当作新序列的0号。新序列0号对应旧序列k号新序列1号对应旧序列(k1)%n号这里需要注意取模的边界用0基编号取模运算更稳妥。算完递推得到新序列下的最终剩余编号res后将它映射回旧序列answer (res k) % n。这里的取模结果如果是0说明答案是n号。我在实际写题时碰过一次坑当k和n都特别大时如果k没有提前取模后续的映射结果可能直接溢出或者偏大导致最后答案错误。所以写代码时最好一开始就把k取模到0..n-1范围内再去参与运算。4.2 两种方向上的人双向约瑟夫环另一种变体是“报数方向会变化”。比如第一轮顺时针数m个人淘汰下一轮逆时针数m个人淘汰再下一轮又顺时针。这种变体我最早在某个算法竞赛练习题里见过当时看得一头雾水后来想通了才明白它本质上就是“当前方向的步进数”在变而递推公式里的“m”不是一个固定值而是一个随轮次变化的变量。不过在数学递推中这种变体不太好套用公式因为每一轮的“方向不同”会导致剩余序列的排序方式变化简易的f(n,m)递推不再成立。这种情况下老老实实写循环链表模拟反而是最稳妥的方案。你要在节点里增加一个“方向属性”删除节点后根据当前方向决定指针是next还是prev。还记得当年写双向约瑟夫环模拟时我把指针移动方向写反了纠结了半小时才发现是“删除后下一个开始位置”算错了这属于典型的实现细节问题。4.3 动态约瑟夫环与大数据量场景在真实工程场景中约瑟夫环稍加变化就变成了“动态淘汰”问题。例如有一个在线等待队列每个用户有一个优先级每隔一段时间从队列中淘汰一个人淘汰规则是“从当前指针位置开始跳过若干个用户把指针指向的用户移出队列”。这其实是约瑟夫环的“动态版本”因为用户数量n会随着新用户加入随时变化。这时候O(n)的数学递推就不太适用了因为n不是固定的。如果要支持n的动态增加和删除一般会选择用“平衡二叉树节点计数”来维护“下一个要淘汰的位置”每次查找和删除复杂度都是O(log n)。这已经超出经典约瑟夫环的范围但它说明了一个道理经典算法的价值不是让你背模板而是让你理解“环形结构按规则剔除”这一模型然后根据场景选择合适的数据结构。另一个大数据量场景是离线统计有n个人m特别大比如n是十亿m也是一亿。如果直接用O(n)递推在单机上是不可行的需要利用“当res m i时res在一段时间内是等差数列递增”这一性质把连续的若干轮合并成一次计算。这种优化可以形象理解为“本来一步一步走后来换成跳台阶”。虽然面试中极少考到这种极端数据但了解原理可以帮你更好地理解递推公式的本质而不是死记hardcode。4.4 约瑟夫环的相关应用场景聊完变体再看应用。我在实际工作中观察到约瑟夫环的思想能迁移到不少看似无关的场景。第一个场景是操作系统里的“进程调度模拟”。某些调度算法会从进程列表中周期性选择进程执行如果一个进程执行完毕或时间片用完就被移出列表剩余进程继续轮转——这种操作和约瑟夫环的“移出继续从下一位开始”几乎一模一样。理解约瑟夫环能帮你理解为什么有时候调度顺序会“看起来不太均匀”。第二个场景是“环形缓冲区”中的删除策略。环形缓冲区本身就是一个环形结构当写入或读取位置越界时会回绕到开头。在某些自定义协议里缓冲区会按规则“跳过”某些数据块并动态移除其他数据块这同样仿照了约瑟夫环的计数与删除逻辑。第三个场景更偏日常一些偏游戏化的产品做“抽奖/淘汰活动”比如“从第一个人开始每隔几个人淘汰一个直到最后一个人获奖”。这种活动规则如果不在后端正确模拟就可能出现获奖者和预设规则不符的bug。用约瑟夫环的模拟逻辑或递推逻辑做一个“预演”能提前看出会不会出问题。5. 常见问题与调试经验5.1 边界条件n1和m1容易写错很多错误都是边界条件引起的。n1时答案就是1本身不需要进入循环。m1时每次淘汰的恰好是当前报数者最后留下的就是报数起点。如果用递推公式m1时每轮res (res 1) % i配合初始状态算出来结果就是当前的起点对应编号所以也成立但很多人会担心这个边界干脆写个if特判。我的建议是兼容并蓄。暴力模拟时m1要特别小心因为“数m个人再删除”和“数m-1步再删除”的边界容易混淆递推解法则不需要特判直接套公式即可。如果有输入限制n和m都比较大直接用递推公式最省心。5.2 索引偏移0基和1基的切换我自己在刷题时最容易翻车的就是编号体系的切换。递推公式建立在0基编号上但题目通常要求1基编号。如果忘记在最后加1调试时可能感觉“结果差不多”但总差一位。更隐蔽的问题是当你要把结果作为下标去数组里取东西时0基结果可以直接用作下标而1基结果需要再减1。这一个小细节曾经造成一个线上事故——有人用1基结果直接索引数组导致越界访问排查很久才发现是编号体系没理清楚。我的经验是写代码之前在注释里明确“本函数内部使用0基编号返回值是0基编号最终展示时统一1转成1基”。这种小注释在刷题时看起来多余但在工程代码里能省掉大量沟通成本。5.3 取模与溢出数据规模一大就翻车如果n和m的上限是10^9那么resm可能会超过int的范围。在C里int默认是32位最大值约2.1*10^910^910^9就溢出了。我见过不少人在LeetCode上约瑟夫环题目的错误答案就是栽在溢出上。解决方案很直接把res、m、i全部声明为long long或者使用更大的整型。Python就不用担心这个问题因为它的整数是任意精度的。不过即使语言支持任意精度你在思想上也要有数值范围的意识否则在C/C这种底层语言里迟早踩坑。还有一个小细节当m很大时可以先对当前规模i取模因为“每i轮报数会绕一个整圈回到原点”。对循环链表模拟来说这个取模可以省掉大量无意义的“数数”过程对递推公式来说res (res m % i) % i其中m % i是可选优化但不影响正确性。只不过提前取模可以降低中间值大小避免溢出。5.4 面试答题的策略与节奏如果你在面试中遇到约瑟夫环我的建议是分三步走。第一步先复述问题确认编号是0基还是1基、m是从当前人开始数还是从下一个开始数这两点不确认清楚后续写出来全是白搭。第二步先抛一个暴力模拟方案把循环链表的思路讲清楚让面试官知道你“能动手实现”。第三步再推导递推公式给出O(n)解法。这个过程既展示基本功也展示数学能力。更关键的是在推导递推公式时你可以主动在纸上画一下“第一轮淘汰后剩下的人的编号如何映射”的示意图边说边画面试官会更容易跟上你的思路。如果你只是背住了公式却解释不清推导过程一旦面试官问“为什么这里是i而不是n”场面就会非常尴尬。所以我的建议是哪怕你熟得不能再熟也要自己从头推导一遍直到不看资料也能把“编号重映射”讲清楚为止。我在实际辅导过的小伙伴里几乎所有人卡住的地方都一模一样“为什么取模的分母是i”这个问题的答案永远是回到“当前环的规模”去思考。只要抓住“规模在变小、编号在重映射”这两个关键约瑟夫环的所有变体都不再可怕。最后再分享一个我个人的小习惯学完一个算法我会用两种语言各实现一遍比如C写一遍递推Python写一遍暴力模拟然后随机生成多组n和m对拍结果。对拍通过那一刻你对这个算法的信心会猛地上升面试时也自然而然更笃定。约瑟夫环这种题做到“闭着眼睛都能推出来”的程度才算真正拿下了。
返回列表