约瑟夫环(递归与迭代)
问题简述
1 背景故事
公元66年,巴勒斯坦地区的犹太人发动了反抗罗马帝国统治的大起义。约瑟夫斯作为犹太军队的指挥官,在约塔帕塔(Jotapata)要塞被罗马军队围困。
公元67年,要塞最终陷落。在城破之际,他和在场的41名犹太士兵(一说40名)躲进了一个地道,决心宁死不降。于是,他们定下了一个残酷的自杀协议:
所有人围成一个圆圈,从某个人开始,按顺序每数到第3个人(也有版本说每第2或第7个人),这个人就必须自杀。下一个人再从1开始数,直到最后只剩一个人,那个人才可以选择自行了断或投降。
在这场游戏中,约瑟夫斯并不想死。根据他的自传记载,他凭借自己卓越的数学头脑,迅速算出了该站在哪个位置才能成为最后一个幸存者。
于是,他一边劝说同伴不要盲目送死,一边巧妙地将自己安排到了那个“安全”的位置上。
随着一轮轮处决,果然,所有其他人都相继倒下,只剩下他和另一名同伴。这时,他不再执行协议,而是说服了这位同伴一起向罗马军队投降。
最终,约瑟夫斯活了下来,并成为罗马公民,后来用拉丁文写下了《犹太战争史》
2 问题抽象
n个人围成一圈,从第一个人开始报数,每数到第m个人,就将这个人从圈中移除,然后从下一个人继续从1开始报数。问:最后剩下的人的初始位置编号是多少?
一个简化的例子: 在该场景中,我们需要模拟一个严谨的剔除过程:
- 空间设定:共有 个人围成一个圆圈(Circle)。
- 初始状态:每个人在圆圈中拥有一个固定的初始编号(通常从 到 或 到 )。
- 动作流程:
- 从圆圈中的某个人开始,按顺时针方向计数。
- 按照预设的固定步长进行报数,每计数到第 个人时,该人即被剔除出圈。
- 剔除后,从被剔除者的下一个人开始重新从 计数。
- 求解目标:这一剔除过程会一直循环进行,直到最后只剩下唯一的一名幸存者。问题的目标是找到一个算法或数学模型,来确定哪一个初始位置才是最终的存活位。
3 算法思维逻辑
解决约瑟夫环问题的本质在于处理规模递减的相似子问题:
- 问题的缩小:每当一个人被踢出圈外,剩余的 个人依然围成一个圆圈,问题规模随之变小。
- 重复操作:在每一步中对更小的输入执行相同的操作。
- 终止准则:必须存在一个基准情况来停止这种循环,即当圈内只剩最后一个人时。
4 数学表达
为了通过编程或数学手段快速定位幸存者,建立如下递推关系。假设 表示 个人在报数步长为 时的幸存者编号:
-
基准情况:
-
递推情况:
此处假设编号从 开始
4.1 递推公式推导
注意分清“报的数”“编号”“第几个人”
-
第一轮被杀的人 从 开始报数, 号报 , 号报 ,……,第 个人报 ,他的编号是 。如果 可能大于 ,则圆环循环,实际位置是 。因此第一轮被杀者的编号为:
-
移除 后剩下的人及下一轮顺序 移除 后,剩下 个人,按顺时针顺序为: 下一轮将从 开始报 (因为 是被杀者 的下一个人)。
-
重映射到规模 的标准问题 为了利用已知的 ,我们将新环重新编号为 ,规则如下:
新环中第一个报数的人(原 ) → 新编号
下一个(原 ) → 新编号
……
最后一个(原 ) → 新编号 这个映射的数学表达式为:对于原编号 ,新编号 满足 -
利用已知解 在新环(人数 ,规则相同,从新 号开始报 )中,幸存者的新编号 就是 。
逆映射回去,原编号 满足
表示与相差的整数倍 即
-
代入 代入得
-
边界条件 当 时,只有编号 ,显然
最终递推公式
5 结构示意图
圆圈结构是该问题的直观基础,它决定了计数的循环性。如下所示为初始状态的示意(参考 12 人规模的情况):
(1) (2)
(12) (3)
(11) (4) <--- 成员围成圆圈
(10) (5)
(9) (6)
(8) (7)
解决思路
解决约瑟夫环问题的核心在于选择合适的算法范式来模拟“圆圈”结构,并高效处理频繁的“剔除”操作。通过将数组与链表方案归类于迭代范式,并与递归范式进行量化对比,可以深入理解时空开销与实现逻辑的平衡。
1 迭代方案
迭代方案侧重于利用显式的循环和特定的数据结构来模拟约瑟夫环的物理过程。其共同点是空间效率高,通常不需要额外的栈内存。
1.1 数组实现
数组是最基础的实现方式,其核心特性在于内存的连续性。
- 实现原理:
- 利用数组的唯一索引 来代表每个人的初始位置。
- 通过索引访问元素的时间复杂度为 ,其底层通过 起始地址 + i * 元素大小快速定位。
- 通过对索引进行模运算来模拟环状遍历过程。
- 操作特性:
- 访问速度快:读写特定位置的元素极其高效。
- 删除开销大:数组元素在内存中紧密排列。在约瑟夫环中剔除成员后,为了保持内存连续,通常需要移动该位置之后的所有元素。
- 动态扩展的代价:
- 如果使用类似 Python
list的动态数组,当人数超出预设容量时,系统需分配2倍大小的新空间并复制原数据。 - 这一过程的时间复杂度为 。
- 如果使用类似 Python
1.2 链表实现
链表通过指针将独立节点 连接成顺序集合,更加适合模拟动态变化的过程。
- 实现原理:
- 每个节点包含数据和指向下一个节点的引用。
- 循环单向链表 是最佳选择,因为它将尾节点指向头节点,逻辑上天然形成一个“圆圈”。
- 优势分析:
- 高效删除:剔除成员时,只需修改前驱节点的指针指向,使其绕过被删除节点即可,无需在内存中移动其他数据。
- 动态性:链表不要求物理内存连续,可以根据需要随时增长或缩小,实现对内存的高效利用。
2 递归方案
递归提供了一种更抽象、更简洁的数学解法,其本质是函数调用自身 来解决问题。
2.1 编写递归的三步骤
递归思维能将复杂问题分解为更小且易于处理的部分:
- 基准情况:用于停止递归以防止死循环。对于约瑟夫环,当圈内只剩一人时即为终止。
- 公式:
- 递归情况:通过尝试更小的输入使问题规模变小。将规模为 的问题简化为 的相似子问题。
- 递推公式:
- 约束条件:即对输入进行的约束,如确保 ,防止意外输入导致程序崩溃。
2.2 运行机制:内存栈
递归的实现依赖于计算机底层的栈结构,是栈的一种经典应用。
- 栈帧:每次函数调用都会在内存栈 中创建一个栈帧,存储参数、局部变量 和返回地址。
- 控制流:通过
call指令将返回地址压栈并跳转;执行完毕后,通过ret指令弹出地址并返回。 - 指针管理:栈指针 (%rsp) 始终指向栈顶,帧指针 (%rbp) 指向当前活动记录的起始位置。
- 性能权衡:虽然递归代码易于编写,但频繁的压栈与出栈操作会带来额外的时间开销。此外,大规模递归存在栈溢出 的风险。
代码实现
1 迭代方案:基于循环单向链表的实现
链表通过指针逻辑可以天然地构建“环”形结构,无需像数组那样进行复杂的索引模运算。
1.1 节点定义 (Node Definition)
首先定义链表的基础单元——单向节点:
class SinglyNode:
def __init__(self, value):
self.value = value
self.next = None # 指向下一个节点的引用
1.2 逻辑模拟代码
通过构建首尾相连的循环单向链表 来模拟约瑟夫环:
def josephus_linked_list(n, k):
# Step 1: 构建循环单向链表
head = SinglyNode(1)
prev = head
for i in range(2, n + 1):
prev.next = SinglyNode(i)
prev = prev.next
prev.next = head # 将尾节点指向头节点,形成物理上的“圆圈”
# Step 2: 模拟报数并剔除过程
current = head
while current.next != current: # 当链表中不止一个节点时继续循环
# 寻找报数第 k 个人的前驱节点 (移动 k-2 步)
for _ in range(k - 2):
current = current.next
# 剔除节点:修改指针指向,绕过第 k 个节点
eliminated = current.next
current.next = eliminated.next
# 从被剔除者的下一个人开始重新计数
current = current.next
return current.value # 返回最后剩下的幸存者编号
2 递归方案:基于数学递推的实现
递归方案直接应用数学推导出的递推关系:
2.1递归实现代码
def josephus_recursive(n, k):
# Step 1: 基准情况 (Base Case)
# 只有一个人时,其在索引(从0开始)中的位置是 0
if n == 1:
return 0
# Step 2 & 3: 递归调用与逻辑简化
# 递推公式:f(n, k) = (f(n-1, k) + k) mod n
else:
return (josephus_recursive(n - 1, k) + k) % n
# 注意:由于此数学模型基于 0 索引计算,若编号为 1 至 n,结果需 +1
# survivor_id = josephus_recursive(n, k) + 1
分析与总结
1 算法对比
递归与迭代范式的对比如下表所示:
| 维度 | 递归 | 迭代 | 备注 |
|---|---|---|---|
| 空间效率 | 否 (N) | 是 (Y) | 迭代不需要额外的栈内存支出。 |
| 时间效率 | 否 (N) | 是 (Y) | 递归涉及频繁的压栈与出栈 操作,效率较低。 |
| 编程难度 | 低 (Easy) | 高 (Hard) | 递归尤其适用于问题可分解为相似子问题的场景(如约瑟夫环的数学递推)。 |
| 安全性 | 否 (N) | 是 (Y) | 处理大规模数据时,递归面临栈溢出 的风险。 |
2 递归的使用准则
基于性能与开发的平衡,建议:
- 推荐使用递归的情况:
- 当问题能够轻易分解为相似的子问题时。
- 当需要快速获得解决方案(代码编写快)而非追求极致效率时。
- 在递归中使用备忘录 (Memorization) 机制(如动态规划)时。
- 应当避免递归的情况:
- 当算法的时间效率和空间复杂度是首要考量因素时。
- 在内存受限的环境(如嵌入式处理器)中运行程序时,因为递归会消耗更多内存。