约瑟夫环(Josephus Problem)
问题描述
约瑟夫环是一个经典的数学与计算机问题:
n 个人围成一圈,从第 1 个人开始报数,报到 m 的人出列;然后从出列者的下一个人重新开始报数,报到 m 的人再出列......如此循环,直到所有人都出列。求最后剩下的那个人(或求出列顺序)。
这个问题源自历史传说:约瑟夫和他的同伴被罗马人围困,他们决定自杀而不被俘,于是围成一圈,每数到第 3 个人就杀掉,约瑟夫通过计算找到了存活的位置。
一、模拟法(链表 / 数组模拟)
最直观的思路:用一个循环链表(或数组标记)模拟整个过程。
- 时间复杂度:O(n·m)
- 空间复杂度:O(n)
- 优点:能直接得到完整的出列顺序,易于理解
Java 代码(数组模拟)
java
import java.util.ArrayList;
import java.util.List;
public class JosephusSimulation {
/**
* 模拟法求约瑟夫环出列顺序
* @param n 总人数(编号 1 ~ n)
* @param m 报数到 m 出列
* @return 出列顺序列表
*/
public static List<Integer> josephusOrder(int n, int m) {
List<Integer> result = new ArrayList<>();
boolean[] out = new boolean[n + 1]; // out[i] = true 表示第 i 人已出列
int count = 0; // 已出列人数
int index = 0; // 当前报数位置(0 表示在 1 号之前)
int call = 0; // 当前报数
while (count < n) {
index = index % n + 1; // 环形移动到下一个人(1~n)
if (!out[index]) {
call++;
if (call == m) {
out[index] = true;
result.add(index);
count++;
call = 0;
}
}
}
return result;
}
public static void main(String[] args) {
int n = 7, m = 3;
List<Integer> order = josephusOrder(n, m);
System.out.println("出列顺序: " + order);
System.out.println("最后剩下: " + order.get(order.size() - 1));
}
}
输出
出列顺序: [3, 6, 2, 7, 5, 1, 4]
最后剩下: 4
二、数学递推法(最优解)
如果只要求最后剩下的那个人的编号,可以用递推公式,时间复杂度 O(n),空间 O(1)。
递推思想
设 f(n, m) 表示 n 个人(编号 0 ~ n-1)报数到 m 时最后剩下的人的编号。
- 第一个人出列后,剩下 n-1 人,问题规模缩小。
- 出列者下标为
(m-1) % n,从它下一位重新编号,可得到递推关系:
f(1,m)=0 f(1, m) = 0 f(1,m)=0
f(n,m)=(f(n−1,m)+m) mod n f(n, m) = \big(f(n-1, m) + m\big) \bmod n f(n,m)=(f(n−1,m)+m)modn
最终结果 f(n, m) 是 0 基编号,若题目要求 1 基编号,加 1 即可。
Java 代码(迭代实现)
java
public class JosephusMath {
/**
* 数学递推法求最后剩下的人(1 基编号)
* @param n 总人数
* @param m 报数到 m 出列
* @return 最后剩下的人的编号(1 ~ n)
*/
public static int lastRemaining(int n, int m) {
int ans = 0; // f(1, m) = 0,0 基编号
for (int i = 2; i <= n; i++) {
ans = (ans + m) % i;
}
return ans + 1; // 转成 1 基编号
}
public static void main(String[] args) {
int n = 7, m = 3;
System.out.println("最后剩下: " + lastRemaining(n, m)); // 4
}
}
递归写法(便于理解)
java
public static int f(int n, int m) {
if (n == 1) return 0;
return (f(n - 1, m) + m) % n;
}
// 结果 = f(n, m) + 1
⚠️ 注意:n 很大时递归会栈溢出,建议用迭代。
三、两种方法对比
| 方法 | 时间复杂度 | 空间复杂度 | 能否得到出列顺序 |
|---|---|---|---|
| 模拟法 | O(n·m) | O(n) | ✅ 可以 |
| 数学递推 | O(n) | O(1) | ❌ 只能得最后一人 |
四、常见变体与注意事项
- 编号从 0 还是 1 开始:数学公式默认 0 基,输出时按需 +1。
- m = 1 的情况:每次都是当前人出列,最后剩下的是第 n 个人。
- n = 1:直接返回该人。
- 求第 k 个出列的人:可用模拟法;若 n 很大可用更高级的"跳跃删除"优化(如使用线段树 / 有序集合,O(n log n))。
- 大 n 优化 :当 m 远小于 n 时,数学递推中
ans + m可能连续多步不取模,可批量跳跃优化到接近 O(m log n) 或 O(n/m) 级别。
五、示例验证
n = 7, m = 3 时手动推演:
初始: 1 2 3 4 5 6 7
报3出列: 3 → 剩 1 2 4 5 6 7
从4报: 6 → 剩 1 2 4 5 7
从7报: 2 → 剩 1 4 5 7
从4报: 7 → 剩 1 4 5
从1报: 5 → 剩 1 4
从1报: 1 → 剩 4
最后剩下 4 ✅
与两种算法的输出一致。