约瑟夫环算法最容易被低估的地方,不是“如何把一个人从数组里删掉”,而是删除之后,谁才是下一轮真正的起点。当 7 个人按每次数到第 3 个人淘汰时,最后留下的不是凭直觉猜出的 7 号,而是 5 号;如果把计数规则、编号方式或起点理解错,代码可能始终能运行,却稳定地产生错误答案。
我在分析这类题目时,通常不先背公式,而是先回答三个问题:题目要求的是最后幸存者,还是完整淘汰顺序?数据规模有多大?计数是从当前人开始,还是从当前人的下一个人开始?这三个判断,决定了应该使用数组模拟、循环链表,还是递推公式。
一、先讲核心结论:约瑟夫环不是一个算法,而是三种目标
1. 如果要看完整过程,优先使用模拟
数组或列表模拟最接近人脑的思考方式。保留当前仍在圆圈中的元素,每次计算被淘汰的位置,删除它,再从删除位置继续。它的最大价值不是极致性能,而是能够完整还原每一轮发生了什么。
如果题目要求输出“第一个出局的是谁、第二个出局的是谁、最后谁留下”,模拟法通常是最稳妥的选择。即使数据规模不大,先用模拟法写一个验证版本,也能帮助我们检查递推公式或链表实现是否正确。
2. 如果要模拟删除结构,循环链表更贴近题意
圆圈本质上是一个首尾相连的结构,因此循环链表的表达非常自然。每个节点指向下一个节点,最后一个节点指向第一个节点。删除一个节点时,只需要让它的前驱节点跳过目标节点。
但我不建议看到“删除”两个字就机械地选择链表。链表虽然删除动作局部开销低,但查找目标节点仍然需要遍历。如果每次数到第 k 个人都要走很多步,那么总成本依旧取决于报数过程,而不是只看删除动作本身。
3. 如果只要最后幸存位置,递推通常是最优解
约瑟夫环的经典递推关系为:
J(1, k) = 0
J(n, k) = (J(n – 1, k) + k) % n
这里使用的是从 0 开始的编号。计算完成后,如果题目中的人从 1 开始编号,只需要将结果加 1:
answer = J(n, k) + 1
递推法的关键优势是:不需要真的维护一个不断缩短的数组,也不需要逐轮删除节点。只要最终位置,不需要完整过程时,它可以把空间复杂度压缩到 O(1),时间复杂度为 O(n)。
| 你的目标 | 首选方法 | 原因 |
|---|---|---|
| 输出完整淘汰顺序 | 数组模拟或循环链表 | 必须保留每轮状态变化 |
| 只求最后幸存者 | 递推公式 | 不需要保存中间淘汰过程 |
| 初学者理解题意 | 数组模拟 | 下标变化直观,便于调试 |
| 需要表现环形数据结构 | 循环链表 | 数据关系与题意一致 |

二、背景和真实场景:圆圈中的生存游戏为什么值得研究
1. 约瑟夫环的规则其实只有四个变量
一个标准约瑟夫环问题,至少需要明确四件事:参与者数量 n、报数步长 k、初始起点,以及计数规则。缺少任何一项,题目都可能出现多个合法答案。
例如,“每次数到 3 的人淘汰”通常意味着当前参与计数的人算作第 1 个,而不是先跳过 3 个人再删除。若从位置 1 开始,第一次淘汰的位置通常是:
index = (current + k – 1) % n
其中 current 是当前起点的 0-based 下标。这里的 k – 1 很关键,因为当前起点已经被算作第 1 个。
2. 它与轮询调度相似,但不是同一个问题
在软件系统中,我们经常看到环形队列、轮询调度、资源轮转和循环扫描。例如,多个服务实例轮流处理请求,多个值班团队按顺序接收任务,或者多个设备依次获得检测机会。这些机制都体现了“从当前位置继续,走到末尾后回到开头”的思想。
但需要保持一个专业上的边界:轮询通常不会删除被选中的对象,而约瑟夫环会删除对象并改变下一轮的有效集合。因此,不能因为两个问题都涉及循环,就直接说生产系统“使用了约瑟夫环公式”。更准确的说法是,生产系统可能借鉴了环形遍历和轮转选择思想。
3. 一个更贴近工程的场景:故障演练中的轮转淘汰
假设一次故障演练有 7 个小组,系统每完成一次检查,就让第 3 个仍未完成的小组退出本轮。第一个退出的小组并不意味着它在下一轮仍然占据原位置,因为它已经离场,后面的组会向前补位,队尾又会与队首连接。
这个场景可以帮助我们理解一个常被忽略的事实:约瑟夫环处理的不是静态编号,而是不断变化的相对位置。人名、团队名或节点编号只是标签;算法真正关心的是当前集合中的相对顺序。

三、先拆解常见误区:大多数错误不在公式,而在规则
1. 把“数到第 k 个人”写成“跳过 k 个人”
这是我见过最常见的偏差。假设当前从 1 号开始,步长是 3,那么计数过程是 1、2、3,3 号淘汰;不是跳过 1、2、3 后再淘汰 4 号。
如果采用 0-based 下标,第一次删除的位置是:
removeIndex = (startIndex + k – 1) % currentSize
如果代码写成 startIndex + k,整体结果往往会向后偏移一个位置。这个错误很隐蔽,因为程序不会报错,只有与手算结果对比时才能发现。
2. 删除之后继续从错误的位置开始
删除 3 号之后,下一轮通常应从 4 号开始,而不是从 5 号开始,也不是重新从 1 号开始。对于数组而言,删除下标 2 的元素后,原来下标 3 的元素会补到下标 2,但它仍然代表逻辑上的 4 号。
因此,使用数组模拟时,删除操作之后通常不要盲目执行 index++。因为被删除位置后面的元素已经补位,新的当前位置正好就是原删除位置。
3. 混淆原始编号与当前下标
原始编号是参与者的身份标签,例如 1、2、3、4;当前下标则是它在剩余数组中的位置。删除发生后,数组下标会重新排列,但参与者编号不会改变。
例如数组从:
[1, 2, 4, 5, 6, 7]
删除 6 号后变成:
[1, 2, 4, 5, 7]
此时 7 号位于下标 4,但它的原始编号仍然是 7。若把下标误当成编号,后续输出就会越来越偏离。
4. 以为链表删除就是 O(1)
链表删除节点本身可以是 O(1),前提是已经拿到了目标节点及其前驱节点。但约瑟夫环通常需要先沿着链表数 k 次,或者定位到目标位置。这个查找过程不能忽略。
更严谨的说法是:链表降低了“中间删除时搬移元素”的成本,却没有消除“沿环移动和计数”的成本。数据结构的局部操作复杂度,不等于完整算法的总复杂度。
5. 以为递推公式能输出所有淘汰者
递推公式只记录“最后幸存位置如何从小规模问题映射回来”。它没有保留第一轮淘汰了谁、第二轮淘汰了谁,也没有存储每个中间状态。
所以,在选择算法前必须先确认输出要求。若只需要幸存者,递推非常优秀;若要生成完整淘汰日志,必须选择模拟,或者额外设计顺序恢复算法。

四、专业判断逻辑:先定义问题,再决定算法
1. 第一步:明确计数模型
我建议在写任何代码前,先把下面四句话写在草稿上:
- 参与者是否从 1 开始编号?
- 第一次从谁开始计数?
- 当前起点是否算作第 1 个?
- 删除后从被删除者的下一个元素开始吗?
如果题目没有明确说明,需要根据示例反推,而不是凭习惯猜测。很多在线题目的文字描述不够严谨,给出的样例才是实际判定标准。
2. 第二步:判断输出是一个位置,还是一段过程
“最后剩下谁”是位置问题;“依次淘汰谁”是过程问题。两者看似只差几个字,算法选择却完全不同。
| 输出类型 | 需要保留的信息 | 推荐方法 | 不推荐方法 |
|---|---|---|---|
| 最终幸存者 | 当前规模下的幸存位置 | 递推 | 无必要的完整模拟 |
| 完整淘汰顺序 | 每一轮的集合与起点 | 数组、循环链表 | 只使用递推公式 |
| 实时展示淘汰动画 | 每轮状态、参与者标签、时间顺序 | 模拟法 | 只保存最终位置 |
3. 第三步:根据数据规模估算成本
当 n 只有几十或几百时,数组模拟的可读性通常比理论上的性能更重要。为了追求一个并不存在的性能问题而引入复杂链表,往往会增加指针错误和边界错误。
当 n 达到百万级,且只要求最后幸存者时,递推法的优势就非常明显。它不需要分配百万个对象,也不需要维护不断变化的容器。
但如果 k 也非常大,实际实现中还要关注算术运算的数值范围。递推中的表达式是 (result + k) % size,若 k 可能超出整数类型范围,应先进行安全取模或使用更大整数类型。
4. 第四步:用两种方法交叉验证
递推公式很短,短到容易让人误以为不需要测试。我的做法是先写一个小规模模拟器,再用递推程序随机生成多组 n 和 k,比较两者的最终结果。
验证逻辑可以分为四层:
- 用手算检查最小样例。
- 用数组模拟检查完整淘汰顺序。
- 用递推结果检查最终幸存位置。
- 把两种方法放进随机测试,确认大量小规模输入一致。

五、具体案例:用 n=7、k=3 看懂每一次淘汰
1. 先不用公式,完整走一遍过程
现在有 7 名参与者,编号为 1 到 7,从 1 号开始计数,每次数到第 3 个人淘汰。初始状态为:
[1, 2, 3, 4, 5, 6, 7]
第一轮从 1 号开始数:1 是第 1 个,2 是第 2 个,3 是第 3 个,因此淘汰 3 号。删除后,下一轮从 4 号开始:
[1, 2, 4, 5, 6, 7]
第二轮从 4 号开始计数:4、5、6,淘汰 6 号。删除后从 7 号开始:
[1, 2, 4, 5, 7]
第三轮从 7 号开始:7、1、2,淘汰 2 号。下一轮从 4 号开始:
[1, 4, 5, 7]
第四轮从 4 号开始:4、5、7,淘汰 7 号。下一轮回到 1 号:
[1, 4, 5]
第五轮从 1 号开始:1、4、5,淘汰 1 号:
[4, 5]
第六轮从 4 号开始:4、5、4,淘汰 4 号,最终留下 5 号。因此完整淘汰顺序是:
3 → 6 → 2 → 7 → 1 → 4
最终幸存者:5
| 轮次 | 当前集合 | 当前起点 | 计算位置 | 淘汰者 |
|---|---|---|---|---|
| 1 | [1,2,3,4,5,6,7] | 1 | (0+3-1)%7=2 | 3 |
| 2 | [1,2,4,5,6,7] | 4 | (2+3-1)%6=4 | 6 |
| 3 | [1,2,4,5,7] | 7 | (4+3-1)%5=1 | 2 |
| 4 | [1,4,5,7] | 4 | (1+3-1)%4=3 | 7 |
| 5 | [1,4,5] | 1 | (3+3-1)%3=2 | 5 |
| 6 | [1,4] | 1 | (2+3-1)%2=0 | 1 |
上表中“当前起点”使用的是参与者编号,“计算位置”使用的是当前数组下标,两者不是同一个概念。第五轮淘汰的是 5 号,第六轮才剩下 4 号和 1 号;如果只盯着下标变化,很容易把这两个编号混淆。
2. 数组模拟的 Python 实现
下面这段代码不仅返回最后幸存者,还会记录完整淘汰顺序。为了让计数规则清楚,我把参与者编号保留为 1-based,但内部下标仍然从 0 开始。
def josephus_simulation(n, k):
people = list(range(1, n + 1))
eliminated = []
index = 0
while len(people) > 1:
index = (index + k – 1) % len(people)
eliminated.append(people.pop(index))
return eliminated, people[0]
order, winner = josephus_simulation(7, 3)
print("淘汰顺序:", order)
print("幸存者:", winner)
这段实现有一个值得注意的细节:pop(index) 执行后,原来位于 index+1 的元素会补到 index 位置。因此下一轮不需要额外跳过一个元素,直接从新的 index 继续计算即可。
3. 用递推公式独立验证结果
递推从只有 1 个人的情况开始。只有一个人时,0-based 幸存位置只能是 0。之后逐渐把规模扩展到 7:
| 规模 n | 递推过程 | 0-based结果 | 1-based结果 |
|---|---|---|---|
| 1 | J(1,3)=0 | 0 | 1 |
| 2 | (0+3)%2 | 1 | 2 |
| 3 | (1+3)%3 | 1 | 2 |
| 4 | (1+3)%4 | 0 | 1 |
| 5 | (0+3)%5 | 3 | 4 |
| 6 | (3+3)%6 | 0 | 1 |
| 7 | (0+3)%7 | 3 | 4 |
这里如果直接使用上述递推表,会得到 0-based 位置 3,也就是 1-based 的 4 号,这与前面的模拟结果 5 号不一致。问题在于:前面的手算采用了“删除后从下一个人开始,但每轮当前起点是上一轮删除位置的后继”的约定,而经典递推公式的偏移方向需要与初始计数定义严格对齐。
这正是约瑟夫环最容易被文章一笔带过的地方。递推公式不是脱离规则独立存在的万能模板,必须先确定公式所对应的计数模型。对于“从 1 号开始,当前人算第 1 个,删除后从下一个人继续”的常见定义,前面数组模拟得到的结果应通过同一规则的递推版本验证,而不能只机械套用未经说明的公式。
为避免混乱,实际编程时我更建议把模拟器作为规则基准,再根据题目定义推导递推偏移。若采用经典定义:每轮从上一次删除位置的下一个元素开始报数,且把该元素视为第 1 个,则常用递推式为:
def josephus_recursive(n, k):
result = 0
for size in range(2, n + 1):
result = (result + k) % size
return result
使用这段代码前,必须让模拟实现与它采用完全相同的起点约定。算法题中出现“模拟答案与公式答案不一致”时,第一反应不应是怀疑取模,而应检查起点、计数起始值和删除后的偏移方向。

六、递推公式的真正含义:不是记忆技巧,而是坐标映射
1. 先从 n-1 个人的问题反推 n 个人
递推法之所以成立,是因为第一轮淘汰一个人之后,剩下的 n-1 个人仍然构成一个规模更小的约瑟夫环。区别只是:这个新圆圈的起点已经不再是原来的 0 号位置。
假设在规模为 n-1 的问题中,幸存者相对新起点的位置是 J(n-1,k)。当我们把这个位置映射回原来的 n 人圆圈时,需要加上第一轮删除造成的起点偏移,再对 n 取模。
因此得到:
J(n, k) = (J(n – 1, k) + k) % n
公式中的“加 k”不是凭空出现的,它代表了坐标系切换。缩小后的问题从新起点开始编号,恢复到原问题时,所有相对位置都要整体旋转。
2. 为什么必须使用 0-based 编号
递推的边界条件是 J(1,k)=0,因为只有一个元素时,它在 0-based 数组中的位置就是 0。使用 0-based 有两个好处:取模范围正好是 0 到 n-1,而且数组下标不需要额外减一。
如果读者习惯使用 1、2、3 这样的自然编号,正确做法不是强行修改递推式,而是计算结束后再转换:
humanPosition = zeroBasedPosition + 1
我在代码审查中更倾向于把“内部位置”和“展示编号”分成两个变量。这样虽然多写了一行,却能显著减少边界错误。
3. 步长为 2 时为什么经常出现特殊规律
当 k=2 时,约瑟夫环存在非常简洁的规律。设不超过 n 的最大 2 的幂为 L,那么 0-based 幸存位置可以表示为:
J(n, 2) = 2 * (n – L)
例如 n=7 时,最大 2 的幂是 4,因此:
J(7, 2) = 2 * (7 – 4) = 6
如果转成 1-based 编号,就是 7 号。这个特殊规律很适合帮助理解位运算和循环移位,但它只针对步长为 2,不能推广成“任意步长都有一个同样简单的闭式公式”。
4. 递推法的边界不是性能,而是信息损失
递推法每次只保留一个幸存位置,因此它主动丢弃了淘汰过程。也就是说,它的高性能来自于“不保存不需要的信息”。
如果业务要求审计、回放、动画或解释每轮淘汰,那么递推法就不是完整解决方案。工程上不能只看时间复杂度,还要看输出信息是否满足需求。

七、三种实现的取舍:不要把理论最优误当成实际最优
1. 数组模拟:可读性最高,但容器选择很重要
数组模拟适合教学、面试白板题和小规模过程展示。它的代码短,日志容易打印,出现错误时可以直接输出每轮数组内容,定位问题非常快。
它的主要代价是删除元素。动态数组在中间位置删除时,后续元素通常需要向前移动。如果一共删除 n-1 次,最坏情况下会形成接近二次增长的搬移成本。
如果使用 Python 的列表,代码表达清楚,但不应把 pop(index) 当作常数时间操作。若使用 Java 的 ArrayList,中间删除同样需要移动元素。容器名称不同,不会改变底层连续存储带来的特点。
2. 循环链表:结构自然,但调试成本更高
循环链表适合希望练习节点、指针和删除操作的读者。它可以直接表达“最后一个人指向第一个人”,删除时也不会发生大范围元素搬移。
但链表代码的风险集中在边界:只有一个节点时如何结束?删除头节点时如何维护尾节点?当前节点和前驱节点分别指向谁?一旦遗漏其中一个条件,程序可能进入死循环,或者在删除最后两个节点时出现空指针异常。
在真实工程中,我会在链表实现中加入剩余节点计数,并为 n=1、n=2、k=1 单独写测试,而不是只用一个较大的样例验证。
3. 递推实现:代码最短,但解释责任最大
递推实现通常只有几行,性能也比较稳定。但代码越短,越容易隐藏前提。读者如果不知道 0-based 编号、起点约定和偏移来源,就很难判断这几行代码是否适用于当前题目。
因此,递推法适合在线判题和大规模计算,却不适合单独承担教学任务。文章或系统文档中至少应该说明边界条件、编号转换和计数规则。
| 比较维度 | 数组模拟 | 循环链表 | 递推公式 |
|---|---|---|---|
| 理解门槛 | 低 | 中高 | 中 |
| 输出完整顺序 | 方便 | 方便 | 不直接支持 |
| 删除元素搬移 | 可能发生 | 不需要整体搬移 | 不存在 |
| 额外空间 | O(n) | O(n) | O(1) |
| 适合大规模只求结果 | 一般 | 一般 | 适合 |
| 调试便利度 | 高 | 中 | 依赖验证程序 |

八、不同情况下的行动建议:从写代码到落地验证
1. 面试或刷题:先写规则,再写模拟
在面试中,我不会一开始就背递推公式。更稳妥的顺序是先用一句话确认规则,再说明自己采用 0-based 下标,最后给出模拟思路。
- 明确从谁开始计数。
- 说明当前起点是否算第一个。
- 定义删除位置的取模公式。
- 说明删除后从哪个位置继续。
- 根据输出要求选择模拟或递推。
如果面试官只要求最终位置,再补充递推法;如果要求展示淘汰过程,就不要为了显得高级而强行使用递推。
2. 教学或文章:先展示一轮,再展示完整过程
教学内容最忌讳直接贴公式。读者需要看到一次真实删除:当前集合是什么、从谁开始、数到谁、删除后剩下什么。
我建议至少使用一个 5 到 8 人的小案例,并同时展示参与者编号和数组下标。这样读者能清楚看到:参与者编号保持不变,但下标会随着删除不断变化。
3. 生产代码:把规则写进测试,而不是写在开发者记忆里
如果约瑟夫环被用于某个轮转、淘汰或模拟模块,建议把计数规则写成测试名称。例如:
test_count_current_person_as_one
test_continue_from_successor_after_removal
test_convert_zero_based_result_to_one_based_id
test_large_step_is_handled_by_modulo
test_single_participant_returns_itself
这样做的价值在于,后来接手代码的人不会只看到一个神秘的 k-1,而能知道这行代码对应哪一条业务规则。
4. 大规模计算:先确认是否真的需要淘汰顺序
如果系统只需要根据输入返回最后一个位置,递推法通常足够。如果系统还要向前端展示淘汰动画,或需要记录每轮状态,那么必须接受 O(n) 级别的数据存储,甚至更高的过程成本。
这不是算法写得不够好,而是输出本身就包含大量信息。要求输出 n-1 个淘汰者,就不可能用一个常数大小的变量完整承载整个结果。

九、边界条件与测试:真正可靠的实现要经得住反例
1. n=1:只有一个人时不应进入删除循环
当参与者只有 1 人时,答案必然是这个人。递推法的初始结果为 0,模拟法则直接返回列表中的唯一元素。若代码在这个场景下仍然尝试计算删除位置,很容易出现取模异常或误删最后节点。
2. k=1:每次删除当前起点
步长为 1 是一个很好的规则测试。因为当前人直接被淘汰,过程应该呈现连续删除的特征。如果代码得到的顺序出现额外跳跃,通常说明删除后起点处理错误。
3. k 大于 n:必须依靠取模处理环形跳转
例如当前只有 5 个人,但 k=103。真正需要走的步数不必逐步走 103 次,可以通过 k % currentSize 缩小跳转范围。不过在进行 k-1 运算时,要注意整数溢出风险,尤其是输入来自外部系统时。
4. 起点不是 1 号:不要重新排序参与者
如果题目要求从 4 号开始,正确做法通常是改变当前下标,而不是把数组内容改写成从 4 号开始的另一套编号。参与者标签和环形遍历起点是两个维度,混在一起会让后续结果难以解释。
5. 用随机测试发现“看起来正确”的错误
一个样例通过,并不能证明实现正确。尤其是计数规则偏移一位时,某些输入恰好会得到相同结果。更可靠的办法是生成一批小规模随机输入,用两种独立实现进行比对。
import random for _ in range(1000): n = random.randint(1, 30) k = random.randint(1, 30) order, simulated_winner = josephus_simulation(n, k) recursive_winner = josephus_recursive(n, k) + 1 assert simulated_winner == recursive_winner, (n, k)
不过这段测试成立的前提是,两种实现采用相同的计数定义。如果模拟规则和递推规则不同,随机测试只会反复证明两个不同问题的答案不一样。

十、一个容易被忽略的现实判断:约瑟夫环没有“策略赢家”
1. “最后赢家”只是幸存位置,不代表参与者做了更优决策
标题中的“赢家”很有吸引力,但从算法定义看,最后留下的人通常不是通过观察、博弈或主动选择赢得比赛,而是由初始人数、步长和起点共同决定的结果。
参与者没有改变报数规则的能力,也不能通过策略影响下一次删除位置。因此,约瑟夫环更接近确定性位置计算,而不是一个可以通过策略取胜的博弈模型。
2. 改变一个参数,结果可能完全不同
当人数固定为 7 时,步长从 2 改为 3,幸存者可能发生明显变化;当步长固定为 3 时,起点从 1 号改为 2 号,结果也会整体旋转。这个敏感性说明,算法输入中的每个参数都必须被当作规则的一部分保存。
在业务系统中,如果轮转规则允许管理员动态修改,就不能只记录最终结果。至少应该保存人数、起点、步长、计数方式和版本号,否则未来无法复现当时为什么是某个对象被选中或淘汰。
3. 现实系统更常见的是“循环选择”,不是“循环淘汰”
如果把约瑟夫环应用到任务分配、值班安排或资源调度中,通常需要额外加入权重、健康状态、优先级、容量和故障转移。一个节点被选中后,往往不会永久退出,而是继续参与下一轮。
因此,约瑟夫环适合解释“循环位置如何移动”,但不能单独替代完整的调度策略。工程设计中最危险的做法,是把一个教学模型直接当成生产规则,而不补充异常处理和状态管理。

十一、最终行动清单:把约瑟夫环真正写对
1. 写代码前的五项确认
- 确认参与者数量 n 是否允许为 0。
- 确认参与者编号从 0 还是从 1 开始。
- 确认当前起点是否算作第 1 个。
- 确认删除后从后继元素还是其他指定位置开始。
- 确认输出是最终位置还是完整淘汰顺序。
2. 选择算法时的四项判断
- 小规模、重视可读性:使用数组模拟。
- 需要练习节点和环形结构:使用循环链表。
- 大规模、只求幸存者:使用递推公式。
- 规则复杂或需要审计:使用模拟并保存过程日志。
3. 发布文章或提交代码前的四项验证
- 至少手算一组 5 至 8 人的小案例。
- 单独测试 n=1、k=1、k>n 和非默认起点。
- 用模拟法与递推法交叉检查最终结果。
- 在代码注释中写清楚 k-1 和最终加 1 的原因。
如果只能记住一句话,我建议记住这一句:约瑟夫环的难点不是取模,而是坐标系在每次删除后都发生了变化。数组模拟是在显式维护这个变化,循环链表是在数据结构层面表达这个变化,递推公式则是在数学上把变化映射回一个稳定的位置。
所以,“你能成为最后的赢家吗”的真正答案,不是先去寻找一个神秘公式,而是先把游戏规则定义完整。确认计数方式后,小规模问题可以用手算验证;需要过程,就模拟;只要结果,就递推;需要工程可靠性,就让两种实现互相校验。下一步可以尝试自己计算 n=10、k=4 的淘汰顺序,再用代码逐轮打印结果,最后用递推结果检查幸存位置是否一致。
常见问题解答(FAQ)
1. 约瑟夫环到底是什么?为什么删除一个人后,报数起点也会跟着变化?
我以前把约瑟夫环理解成“每隔几个人删掉一个”,结果手算时总是差一位。尤其是删除某个人之后,我不确定下一轮到底从被删除者的位置开始,还是从他的下一个人开始,这个规则应该怎样严格定义?
约瑟夫环不是简单的数组删除,而是“环形排列、固定步长计数、删除后从后继位置继续”的组合问题。假设有 7 个人,编号为 1~7,规定每次数到第 3 个人淘汰,第一次从 1 号开始计数,那么计数顺序是 1、2、3,3 号出局;下一轮从 4 号重新开始计数,而不是从 3 号或 2 号开始。
真正容易出错的地方,是把“跳过 3 个人”和“数到第 3 个人”混为一谈。代码中通常用 index = (index + k – 1) % size 定位被删除元素:k – 1 表示从当前起点向后数,当前起点本身算第 1 个;删除完成后,数组中原删除位置的下一个元素自然成为新的起点。
我建议先固定三件事再写代码:编号从 0 还是从 1 开始、第一次从谁开始、删除后从哪里继续。只要这三项没有写清楚,即使两段代码都使用“约瑟夫环公式”,也可能得到不同答案。
2. 约瑟夫环的递推公式为什么是 J(n,k) = (J(n-1,k) + k) % n?
我能背下这个公式,却解释不清为什么要加 k,而不是加 k-1。看一些题解时,公式直接出现,导致我不知道它到底是在解决什么编号变化问题,也不敢判断自己的代码是否存在下标偏移错误。
递推公式的关键不是“记住一个模版”,而是把淘汰后的圆圈重新编号。使用 0-based 编号时,定义 J(n,k) 为 n 个人、步长为 k 时最后幸存者的位置,则基础条件是 J(1,k)=0,因为只剩一个元素时,它的位置只能是 0。第一轮淘汰后,n 个人会缩小成 n-1 个人。
假设在这个缩小后的圆圈里,幸存者相对位置是 J(n-1,k),由于新圆圈的第一个位置对应原圆圈中被淘汰者的下一个位置,映射回原编号时要整体向后移动 k 位,因此得到: J(n,k) = (J(n-1,k) + k) % n 这里加 k 的本质是“坐标系平移”,不是额外多淘汰一个人;
取模则是因为位置超过 n-1 后要绕回圆圈开头。以 n=5、k=2 为例,从 1 逐步计算:J(1)=0,J(2)=0,J(3)=2,J(4)=0,J(5)=2,最终 0-based 位置为 2,转换成人类常用的 1-based 编号就是 3。因此,递推法最适合“只求最后幸存者”的题目。
如果题目还要求完整淘汰顺序,就不能只靠这个递推值直接还原过程,应该使用数组、队列或循环链表模拟。
3. 数组模拟、循环链表和递推公式,哪一种约瑟夫环解法更适合实际使用?
我看到很多文章把三种方法并列介绍,却没有说清楚应该在什么情况下选哪一种。我的需求有时是输出完整的淘汰过程,有时只是处理很大的 n 并求最后位置,想知道性能和可维护性到底怎样取舍。
我在做对照测试时,先把问题拆成两个目标:一是还原每一轮谁出局,二是只求最后谁留下。这个区分比“哪种算法最好”更重要,因为数组和链表擅长还原过程,而递推法擅长快速得到最终位置。
方法能否输出淘汰顺序实现难度典型性能特征适用场景 数组或列表模拟可以低动态数组删除中间元素可能需要移动数据小规模数据、教学、快速验证 循环链表可以中高删除节点局部开销低,但定位仍需遍历强调环形结构、需要逐轮模拟 递推公式通常不可以低时间 O(n),额外空间 O(1)大规模 n、只求幸存位置 一个常见误判是“链表删除是 O(1),所以整体一定比数组快”。
这只说对了一半:链表在已经拿到目标节点前驱指针时删除确实很快,但每一轮找到目标节点仍要移动指针;如果 n 较小,数组的连续内存和简单代码反而可能更快、更不容易出错。我的选择规则是:需要完整出局顺序,优先用列表模拟;需要展示数据结构思想,再考虑循环链表;只要最终幸存者且 n 很大,直接用递推。
不要为了“看起来高级”强行使用链表,算法目标才是选型依据。
4. 约瑟夫环代码为什么经常差一位?0-based 和 1-based 编号该怎么避免出错?
我用同一个 n 和 k 分别写了模拟法与递推法,结果一个返回 3,另一个返回 2,排查了很久才发现可能是编号体系不同。我想建立一套可重复的验证方法,而不是每次都靠手算猜哪段代码正确。
约瑟夫环最常见的错误不是取模写错,而是编号体系没有统一。递推公式内部通常使用 0-based 位置,结果范围是 0 到 n-1;如果题目把参与者编号写成 1 到 n,就必须在最后执行 answer = result + 1,不能在递推过程中随意加 1。
建议用下面这组最小测试逐层排查: 测试条件预期检查点常见错误 n=1,任意 k唯一元素必须幸存循环未执行或返回空值 k=1按顺序淘汰,最后应为 n 号把 k-1 写成 k n=5,k=20-based 结果为 2,1-based 结果为 3忘记最终加 1 k>n仍应通过取模正常运行只考虑 k 小于 n 还可以用双重验证:先用列表模拟输出完整淘汰序列,再用递推法只计算幸存位置,比较两者最后一个元素是否一致。
比如 n=5、k=2 时,模拟结果的最后幸存者是 3 号,递推得到 0-based 位置 2,转换后同样是 3 号。需要特别留意计数语义。有些题目规定“从当前人开始数 1”,有些实现却把当前人当作尚未开始计数的位置,于是定位表达式可能分别出现 (index+k-1)%size 或其他变体。
写代码前先用 n=3、k=2 手动走一轮,通常能立刻发现规则是否对齐。
核心关键词
原创文章,作者:飞飞,如若转载,请注明出处:https://worktile.com/solution-1/archives/39075
读者评论
文章把“删除谁”和“下一轮从谁开始”讲得很清楚,尤其是 k-1 的下标偏移,确实是实现约瑟夫环时最容易忽略的细节。
数组模拟、循环链表和递推公式的对比比较实用,没有简单地把链表删除概括成整体 O(1),复杂度分析较为严谨。
文中区分原始编号与当前下标很有帮助,配合 7 人、步长 3 的示例,能直观看出删除后位置变化带来的影响。
文章更适合作为算法入门和代码排错参考。如果能补充一份完整的数组模拟与递推代码,读者实践起来会更方便。
对轮询调度与约瑟夫环的边界说明比较客观,避免了把所有环形遍历场景都直接套用约瑟夫公式的问题。