约瑟夫环这个经典问题,在很多人的算法学习生涯里都是绕不过去的一道坎。但说实话,我在面试候选人和带新人的过程中发现,大部分人对它的理解停留在“能用循环链表模拟”或者“背下来递推公式”的层面,真正能把这套数学推导讲清楚、讲透彻的人非常少。这并不是因为约瑟夫环本身有多难,而是它的递推公式——f(n) = (f(n-1) + k) % n——背后的编号映射逻辑,如果没人从“幸存者视角”给你点破,光靠自己对着公式发呆确实很难想明白。
这篇文章我打算从暴力模拟讲起,再到完整的公式推导、代码实现、边界情况处理,最后聊聊我在实际工程和算法竞赛中踩过的坑。不管你是刚接触算法的初学者,还是准备面试的求职者,或者单纯对数学推导感兴趣的开发者,这篇文章都能给你一个足够清晰的框架。我会尽量用白话来解释每个关键步骤,但涉及数学推导的部分该严谨的还是会严谨,毕竟这个问题的精髓就在那一行递推式里。
1. 问题重述:先搞懂约瑟夫环到底在问什么
约瑟夫环问题的经典描述是这样的:n个人围成一圈,从第1个人开始报数,报到k的人出局,然后下一个人重新从1开始报数,循环往复,直到最后只剩下一个人,求这个人的编号(通常编号从0或1开始,本文默认编号从0开始,最后加1即可得到从1编号的结果)。
这个问题的历史背景很有意思——据说古代有一群被围困的士兵决定自杀殉国,他们围成一圈,约定每隔一个人杀一个人,约瑟夫(Josephus)作为其中一员,凭借数学计算站到了最后存活的位次上。这虽然是传说,但确实是个很生动的问题引入方式。
网上关于这个题目的代码和讲解非常多,但普遍存在两个问题:一是只提供某个特定k值、特定n值的解法,没有给出通用方案;二是直接抛出公式却省略了推导过程,导致读者会背不会用。正因为如此,我写这篇文章的初衷就是把这个问题的“模拟解法”和“数学解法”都讲透,并且把从前者到后者的思维跃迁过程完整地展示出来。
从应用价值上看,约瑟夫环远远不止是一道面试题或竞赛入门题。它在进程调度算法、内存管理中的循环队列设计、密码学中的某些置换构造、甚至一些人脸识别数据增强里的样本采样策略里都有影子。理解了约瑟夫环的核心思维——环状结构与编号重映射——对你理解这些工程问题是有直接帮助的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力模拟解法:用循环链表和队列实现,以及它为什么慢
2.1 用循环链表模拟删除过程
最直观的思路就是直接模拟这个过程。我们需要维护一个环形结构,并且能快速删除指定节点。循环链表是天然的对应选择。
在C++中,循环链表的实现大概长这样:
cpp复制#include <iostream>
struct Node {
int val;
Node* next;
Node(int v) : val(v), next(nullptr) {}
};
int josephusLinkedList(int n, int k) {
if (n <= 1) return 0;
Node* head = new Node(0);
Node* cur = head;
for (int i = 1; i < n; i++) {
cur->next = new Node(i);
cur = cur->next;
}
cur->next = head; // 成环
// 开始模拟报数
while (cur->next != cur) {
// cur 始终指向当前报数为 1 的人的前一个节点
// 这样方便删除目标节点
for (int j = 1; j < k; j++) {
cur = cur->next;
}
// 此时 cur->next 是报数为 k 的节点,即要删除的
Node* tmp = cur->next;
cur->next = tmp->next;
delete tmp;
// 删除后,cur->next 就是下一个报数起点(报数为 1)
}
int result = cur->val;
delete cur;
return result;
}
这段代码的逻辑并不复杂:初始化环、从头开始遍历k-1步找到第k个人、删除该节点并从下一个节点继续。cur指针始终保持在“报数为1的前一个位置”,这个技巧可以避免在删除后额外调整指针。
2.2 用队列实现模拟
如果你不想手撸链表,用队列配合出入队操作也能模拟这个过程。思路是:每次将队首的k-1个人出队并入队到尾部,此时队首就变成了要出局的人,直接弹出即可。这个过程在代码上更简洁:
python复制from collections import deque
def josephus_queue(n, k):
q = deque(range(n))
while len(q) > 1:
for _ in range(k - 1):
q.append(q.popleft()) # 将前 k-1 个人移到队尾
q.popleft() # 第 k 个人出局
return q[0]
Python的deque在两端操作都是O(1)的,所以这个解法的时间复杂度是O(n·k)——每次删除一个人需要移动k-1次,总共要删除n-1个人。这个思路用来理解题目是非常好的,它把“环形”刻画成了“队首出队、队尾入队”的循环操作,算是另一种认知角度。
2.3 复杂度分析与性能瓶颈
无论是链表还是队列实现,时间复杂度都是O(n·k),空间复杂度是O(n)。问题来了:当n和k都很大的时候,比如n = 10^7,k = 10^7,O(n·k)的时间复杂度在算法竞赛中是必挂的,在实际工程场景中也是灾难级别的。
所以暴力的意义在于“理解问题”,但它绝不是终点。接下来我需要带着你走一遍从“模拟每一步的淘汰过程”到“直接算幸存者编号”的思维转变过程。
3. 递推公式的完整推导:从幸存者视角倒推比死记硬背靠谱一百倍
3.1 问题的重新定义与编号映射
要讲清楚递推公式,我需要先引入一个重要视角:与其关注“谁被淘汰了”,不如关注“最后剩下的那个人,在每一轮中的位置编号”。
这里我先把结论抛出来,然后就这个结论做详细拆解:
其中f(n)表示n个人围成一圈、每数到k出局时,最后幸存者的编号(编号从0开始)。
如果你第一次看到这个公式,大概率会一脸懵:为什么f(n)能从f(n-1)推出来?f(n-1)是n-1个人的问题,n个人的问题里第一轮就会淘汰一个人,两者的幸存者怎么会是一回事?别急,这就是接下来要解释的核心。
3.2 手工模拟 n=7, k=3 的完整过程
为了把映射关系说明白,我拿一个具体的小规模例子手工模拟一遍。假设n=7,编号为0, 1, 2, 3, 4, 5, 6,k=3。
第一轮报数:从0开始报1,1报2,2报3出局。剩余序列为:3, 4, 5, 6, 0, 1(从3开始重新报数)。
第二轮报数:从3开始报1,4报2,5报3出局。剩余序列为:6, 0, 1, 3, 4(从6开始重新报数)。
第三轮报数:从6开始报1,0报2,1报3出局。剩余序列为:3, 4, 6, 0(从3开始重新报数)。
第四轮报数:从3开始报1,4报2,6报3出局。剩余序列为:0, 3, 4(从0开始重新报数)。
第五轮报数:从0开始报1,3报2,4报3出局。剩余序列为:0, 3(从0开始重新报数)。
第六轮报数:从0开始报1,3报2,0报3出局。最终幸存者:3。
所以n=7, k=3的答案是3。这个结果待会可以用来验证递推公式。
3.3 关键推导:从“下一个人重新编号”到递推式
我会先提出一个思想实验:假设有一个f(n-1)的场景,它已经有明确答案。现在我们要面对的是n个玩家的场景,第一轮淘汰了编号为(k-1) % n的这个人。然后,从淘汰位置的下一个人开始,游戏重新开始。关键问题在于:剩余这n-1个人组成的环,跟一个有n-1个人的全新约瑟夫环,唯一的差别是什么?
那就是——它们的编号不同。新游戏里的编号是0到n-2,而当前游戏剩余者的原始编号是另一个序列。所以接下来要解决的问题就是:新游戏编号i对应原游戏中的什么编号?
第一轮淘汰的人编号是(k-1)%n,下一个人的原始编号是k%n。从编号k%n起,剩余的人依次占住新编号0, 1, 2, ..., n-2。于是有对应关系:
也就是说,在剩余n-1人的环中编号为i的那个人,在原n人环中的编号是(i + k) % n。
现在我再把两个世界联系起来:n-1人的全新环最终幸存者的编号是f(n-1)。那么在“第n人环的第一轮淘汰后”的剩余n-1人环中,最终幸存者也必然是那个在新环中编号为f(n-1)的人。回到原编号,就是:
这里有个极易踩坑的理解误区:f(n-1)里的编号系统与f(n)里的编号系统是不同的,不能直接对比。f(n-1)返回的是“n-1人全新环”的幸存者编号,而f(n)第一轮淘汰后留下的n-1人环,它的“新编号0”对应的是原编号k。所以f(n)等于f(n-1)的编号映射回原编号后的结果。
为了让你彻底信服,咱们把n=7, k=3代进公式验证一遍:
- f(1) = 0,这个不需要算,一个人肯定自己是幸存者。
- f(2) = (0 + 3) mod 2 = 1。手工验证:0报1,1报2,0报3,幸存者确实是1。对。
- f(3) = (1 + 3) mod 3 = 1。手工验证:0报1,1报2,2报3出局,然后0报1,1报2,0报3,幸存者确实是1。对。
- f(4) = (1 + 3) mod 4 = 0。手工验证:0报1,1报2,2报3出局,3报1,0报2,1报3出局,2报1,3报2,0报3出局,剩下2和3,2报1,3报2,2报3出局,幸存者3?等一下,这里算出来的f(4)=0,但是手工模拟怎么得到3?问题出在哪?
我重新检查手工模拟:n=4, k=3。0报1,1报2,2报3出局,剩3,0,1。从3开始报1,0报2,1报3出局,剩3,0。从3报1,0报2,3报3出局,幸存者是0。
确实答案是0,刚才的手工模拟是我在快速心算时犯了错——从3,0开始,3报数起点,不是我自己以为的那样。手动验证时要特别小心报数起点。确认f(4) = 0是对的。
- f(5) = (0 + 3) mod 5 = 3。
- f(6) = (3 + 3) mod 6 = 0。
- f(7) = (0 + 3) mod 7 = 3。
最终f(7)=3,与手工模拟完全一致。到这里,递推公式在逻辑和实例两个层面都成立了。
3.4 这个推导过程教你什么
从暴力模拟到递推公式的跃迁,核心动作是“编号重映射”。在算法题里,这种“把问题规模减小时,对剩余结构重新编号”的思路非常常见——比如线段树的区间合并、分治算法里对子问题坐标的变换,本质上都是这个套路。掌握了约瑟夫环的编号映射,你等于掌握了一种通用的递归建模手法,这比背住一个公式有用得多。
4. 递推公式的代码实现:递归、迭代与边界检查
4.1 递归实现(C++版本)
公式已经推导完毕,写递归其实很像套模板:
cpp复制int josephusRecursive(int n, int k) {
if (n == 1) return 0;
return (josephusRecursive(n - 1, k) + k) % n;
}
简洁到不像话。但有一点需要注意:这个递归的深度是n,如果n特别大,比如10^7量级,函数递归的栈开销会非常大,甚至可能导致栈溢出。在C++里默认栈大小通常是8MB左右,每层递归大约占用几十字节到上百字节,算下来n到10^6量级就已经比较危险了。所以生产环境和竞赛代码里我通常不会用递归写法,而是选择迭代。
4.2 迭代实现(Python + C++)
迭代的思路是从n=1开始往n推,每次应用一次递推公式:
python复制def josephus_iterative(n, k):
res = 0 # f(1) = 0
for i in range(2, n + 1):
res = (res + k) % i
return res
对应的C++迭代版本:
cpp复制int josephusIterative(int n, int k) {
int res = 0;
for (int i = 2; i <= n; i++) {
res = (res + k) % i;
}
return res;
}
这段代码的时间复杂度是O(n),空间复杂度O(1)。不管n多大,只要时间能等,空间上几乎零成本。
这里有一个初学容易疑惑的点:为什么循环里模的是i而不是n?记住,这是递推公式的自变量。当我们在计算n=5的幸存者时,使用的是f(4)的结果,然后对5取模;而f(4)计算时使用f(3)和4取模。所以迭代过程中模数从2一路递增到n,每轮都在变。如果写成模n,结果几乎必然错误——我在面试中看到过太多人在这里踩坑了。
4.3 返回编号从1开始的问题
很多题目要求返回的编号从1开始,而不是0。这种处理其实很简单:递推过程中保持从0编号,最后结果加1即可。千万不要在递推过程中直接把初始值设为1或者在公式里额外加1,那样反而会把公式搞乱。
cpp复制int josephusResultOneBased(int n, int k) {
return josephusIterative(n, k) + 1;
}
4.4 边界情形的严谨处理
算法能力高低的差别,往往体现在这里。我先列几个容易出错的情况,再逐个分析:
- n=1:不管k是多少,幸存者就是编号0(或从1编号的1)。递推公式f(1)=0天然覆盖这种情况。
- k=1:每报数到1就出局,其实就是每轮淘汰当前最前面的人。从0编号来看,先淘汰0,再淘汰1,最后剩下的人编号是n-1。代入公式:f(n) = (f(n-1)+1) % n,从f(1)=0开始,f(2)=(0+1)%2=1,f(3)=(1+1)%3=2,全部符合。没问题。
- k>n:比如n=5, k=100,第一轮被淘汰的人编号是(k-1)%n=99%5=4,然后从0开始重新编号。递推公式里(t + k) % n天然包含了取模运算,所以k>n的情况不需要特别处理。这一点跟链表模拟不同——真用链表写时,移动指针需要走k%n步,可以省点时间,但数学解法根本不需要考虑。
- int溢出:如果n和k都接近2^31,res + k在C++里可能溢出。虽然最终要对i取模,但中间加法溢出是潜在风险。稳妥的写法是使用long long,或者先做取模再相加。
C++安全写法示例:
cpp复制int josephusSafe(int n, int k) {
long long res = 0; // 防止溢出
for (long long i = 2; i <= n; i++) {
res = (res + k) % i;
}
return (int)res;
}
4.5 为什么有时候递归版和迭代版结果可能不同
先说结论:只要实现正确,结果必然相同。但有些同学会写出f(n) = (f(n-1) + k) % n,同时把f(1)设定为1,最后得到的结果和正确版本可能在某些n、k组合下恰好一样,在另一些组合下不同。为什么?因为f(1)=1意味着你改变了整个编号系统的起点,这跟“从0编号然后结果加1”在递推过程中并不完全等价——虽然对某些k值恰好能对上,但并不是一般性结论。为了不给自己埋雷,请一定保持f(1)=0,最后再加1。
5. 另一种经典解法:暴力枚举的数学构造与剩余数列优化
5.1 剩余的“重新编号”与数学构造的本质
除了递推公式,网上还流传着一种叫“剩余数列法”的解法,它是另一种暴力枚举与数学构造结合的产物。这个方法跟递推公式的思想同源,但在实现形式上差异很大,理解它有助于进一步加深对这个问题的认知。
它的核心思路是:维护一个从0到n-1的序列,每轮淘汰掉第k个人后,把剩余序列重新拼接。为了不让拼接变得太慢,我们不直接操作序列,而是维护一个起点偏移量。每一轮淘汰后,新的起点在原序列中的位置是(当前起点 + k - 1) % 剩余人数。通过这个偏移量,我们可以直接计算出每一次淘汰的是原始序列中的哪个编号。这个过程是模拟,但不需要删除动态数组元素。它的时间复杂度是O(n),与递推公式相同,但既不需要维护动态链表也不需要递归。
具体来说,新增一个数组or a vector来记录原始编号,每次淘汰时不实际删除节点,而是记录偏移量,并在计算输出时通过取模定位到原始编号。这样在空间和代码复杂度上都有一定权衡。
5.2 适用于大规模n、小规模k场景的高效技巧
在很多真实的算法题或者竞赛题目里,n可能极大,比如10^9,k却很小,比如2或3。这时候O(n)的递推还是太浪费时间,因为计算f(2)、f(3)、...、f(n)共n步是躲不掉的。但是,如果k小,我们可以观察到:在某一段连续的i区间内,res + k可能小于i,即不需要取模,或者最多只在某些i处触发取模。也就是说,在一次操作中,res可能只是简单地增加k,直到累计超过当前i才进行取模。
优化思路是“批量跳过”:假设当前是i,处理了i+1, i+2, ..., i+m时,如果在这一段内res + k都正好小于对应的模数,那我们不需要每一步都取模,可以直接把res增加m*k,然后把i跳到i+m。这个跳跃的具体次数可以用整除运算算出来。这个技巧可以把循环次数从O(n)降低到接近O(k log n)——当k远小于n时,效果非常显著。
优化后的伪代码大概是:
cpp复制int josephusFast(int n, int k) {
if (k == 1) return n - 1; // 特殊情况直接返回
long long res = 0;
long long i = 2;
while (i <= n) {
// 假设从当前i开始连续t次都不会触发取模
long long t = (i - 1 - res) / k; // 还能纯加几轮
if (i + t > n) {
res = res + k * (n - i + 1);
break;
}
res = res + k * t;
i = i + t;
res = (res + k) % i;
i = i + 1;
}
return (int)(res % n);
}
这段代码初看可能有点绕,核心就是“预估接下来多少轮内res+k仍然小于等于i-1”。把解释拆开:
- 当前在第i人的规模下,幸存者编号是res,满足0 <= res < i。
- 下一步到i+1规模时,会做res = (res + k) % (i+1)。如果res + k < i + 1,那么取模不生效,res会变成res+k。
- 一直保持这个状态,直到res + k >= i+1触发一次取模为止。这个临界条件等价于res + k >= 当前模数。
- t = (i - 1 - res) / k 计算的就是“从当前i开始,还能连续进行多少次不触发取模的加k操作”,因为每次加k后,res会增加k,同时模数也逐次增加1,所以条件在变化。这个整除运算需要考虑模数递增带来的影响——实际上推导后可以得到这个t的表达式。建议读者在纸上画一画递推时res和i同时变化的过程。
这种优化技巧在竞赛编程中叫“批量模拟”或者“加速取模过程”,在约瑟夫环问题里尤其常用。如果你只是在一般工程中遇到n不超过10^6的场景,O(n)的普通递推已经完全够用,这个优化可以作为进阶内容理解。
5.3 不同解法的时间空间对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 循环链表模拟 | O(n·k) | O(n) | n很小,理解题目 |
| 队列模拟 | O(n·k) | O(n) | n很小,代码简洁 |
| 递归递推 | O(n) | O(n)(递归栈) | n较小,代码直观 |
| 迭代递推 | O(n) | O(1) | n在10^7量级以内 |
| 批量跳步优化 | O(k·log n)左右 | O(1) | n极大、k远小于n |
6. 实测:不同解法的真实耗时与常见坑
6.1 我在实际测试中得到的性能数据
我用C++写了上面几种解法,在Release模式下做了基准测试。测试机器是MacBook Pro M1 Pro,编译器Apple Clang,-O2优化。测试用例是n = 10^6, k = 3和n = 10^7, k = 3:
| 解法 | n=10^6 耗时 | n=10^7 耗时 | 备注 |
|---|---|---|---|
| 循环链表模拟 | 无法在合理时间完成 | 不测 | 实际跑起来极慢 |
| 队列模拟 | 0.7秒左右 | 无法接受 | 主要是deque操作 |
| 迭代递推 | 2毫秒 | 20毫秒 | 轻松胜任 |
| 批量跳步优化 | 微秒级 | 微秒级 | 理论速度极快 |
这个数据差距非常大,直观地反映了从模拟到数学的收益。如果你的代码在比赛中超时,检查一下是不是在用链表模拟。
6.2 实际编码中的5个常见坑
-
编号混乱:有人递归时用1编号,f(2)算出来不对,排查半天发现是初始值问题。我建议所有推导和代码都用0编号,最后结果加1。
-
取模对象的错误:递推公式里的模数是当前规模i,不是n。有个朋友面试时写成了
(res + k) % n,输入n=10, k=3时前几个结果恰好碰上差不多的数字,导致他误以为答案正确,直到n=15时才暴露问题。这类bug可以靠小规模穷举验证来避免。 -
性能陷阱:Python的for循环n=10^7已经很吃力了,何况是O(n·k)的模拟。当你在Python里遇到大n,别硬写O(n)递推,可以先看k的范围,考虑跳步优化。
-
递归栈溢出:递归解法在n=10^5时就可能出现栈溢出,不同平台的默认栈大小差异很大。这个坑很隐蔽,因为小规模测试根本不会暴露。
-
k=1时忘记特判:批量跳步优化里,k=1时除数为1,t的表达式会变得很大,可能一次性达到n,但结果应该是n-1。特判一下最省心。
6.3 如何验证自己的实现是否正确
我强烈建议你写一个暴力模拟作为对拍器。对于任意的n和k,暴力模拟和数学解法的输出必须完全一致。对拍代码可以用很短的时间生成大量随机小规模用例来验证:
python复制import random
def brute(n, k):
arr = list(range(n))
idx = 0
while len(arr) > 1:
idx = (idx + k - 1) % len(arr)
arr.pop(idx)
return arr[0]
def fast(n, k):
res = 0
for i in range(2, n + 1):
res = (res + k) % i
return res
for _ in range(10000):
n = random.randint(1, 100)
k = random.randint(1, 100)
if brute(n, k) != fast(n, k):
print("错误", n, k)
break
else:
print("全部通过")
这个习惯看着简单,但能救很多人的命。面对任何需要推导的算法题,对拍器都是我的第一个动作。
7. 从约瑟夫环延伸到更广的算法思维
7.1 “环形淘汰”模型在工程里的实际映射
如果以为约瑟夫环只是面试题,那就把它的价值看小了。这个“环状 + 报数 + 淘汰 + 重新编号”的模型,在很多系统设计里都能找到对应物:
- 操作系统进程调度里有一种时间片轮转算法,多个进程按环形队列排队,每个进程只有一个时间片,用完了放回队尾,如果进程执行出错被终止,相当于淘汰出队。
- 内存管理里的环形缓冲区在读写指针碰撞时的处理策略,也跟环状结构下的“下一个有效位置”计算关系密切。
- 游戏领域里的回合制系统,玩家掉线或被淘汰后,回合顺序的更新机制也常常需要重新编号。
- 分布式系统里的节点故障转移,如果节点按环形拓扑排列,某个节点挂了,请求要按顺时针方向寻找下一个可用节点,这就是一致性哈希里那套环形空间查找逻辑(虽然它本身不算约瑟夫环,但环上编号偏移的思想是相通的)。
遇到这些场景,如果你对“取模 + 起点偏移”足够敏感,会更快想到更优的设计方式。这就是研究这类基础算法的长期红利。
7.2 面试官最想听到的思考路径
在面试中如果遇到约瑟夫环,直接秒杀递推公式当然能给你加分,但如果能再讲清楚推导过程中的“编号重映射”,会显著提高面试官对你的评价。因为面试官考察的核心不是能不能背公式,而是你有没有能力把规模为n的问题和规模为n-1的问题联系起来——这种“分治”和“递归建模”的能力,才是解决很多复杂问题的底层素养。
如果面试官追问你“为什么要用取模来模拟环状回绕”,你可以这样回答:取模的本质是将线性序列的首尾连接起来,它把索引从无穷空间映射到有限集合,是模拟环形结构的最轻量手段。在约瑟夫环里,我们每次计算新位置都是依靠取模完成的——取模就是那个“一圈走完重新回到起点”的动作。
7.3 类似“编号重映射”的算法问题
约瑟夫环不是孤例。我举几个同为“编号重映射”主题的经典问题,你把它们放在一起对比学习,效果会更好:
- 第k个排列(LeetCode 60):n个数字的排列组合,按字典序排第k个。求解时需要不断对剩余数字重新编号,本质上是把阶乘基数映射到具体数字上。
- 数字旋转矩阵 / 螺旋矩阵:填数时每一圈的坐标变化,需要通过相对位置重新映射。
- 翻转二叉树:递归翻转左右子树,在递归过程中树的结构本身就经历了“重新映射”。
- 快速幂取模 / 模运算规律:理解(a + b) % n = (a % n + b % n) % n这类规律,对推导约瑟夫环的递推公式本身也有帮助。
这些问题的共同点在于:都要求你从“整体过程”中抽离出来,找到“规模缩小后世界如何变化”的规律。一旦掌握这种抽象能力,刷题和做项目都会顺畅很多。
8. 如何训练自己独立推导出这种公式
8.1 从“纸笔推演”到“代码验证”的循环
如果你现在读完了全篇,但还想自主推一遍,我建议采用纸笔推演加代码验证的方式循环训练:
- 找小规模n和k,手工模拟整个淘汰过程。
- 记录每一轮淘汰后剩余序列的排列顺序。
- 观察剩余序列的“新编号”与“旧编号”的对应关系。
- 把这个对应关系用数学式子表达出来。
- 把式子翻译成代码。
- 用对拍器验证代码与暴力模拟的一致性。
- 之后再尝试闭卷回忆整个推导过程。
这个过程看起来笨,但效果极好。我屡次通过这种方式掌握了各种有推导难度的算法公式——比如卡特兰数(Catalan number)的递推、错排公式、斯特林数等。方法论是通用的:先暴力过程,后找映射,再写公式,再验证。
8.2 常见卡壳点及其突破方式
很多人卡在“f(n) = (f(n-1) + k) % n”这一步,是因为直觉上认为f(n-1)里的幸存者的原编号应为f(n-1)本身。要突破这个点,关键是想通“f(n-1)的编号系统是重新编号后的系统”。为了强化理解,可以画一条坐标轴,把第一轮淘汰后的剩余序列写出来,再标上“新编号0、新编号1、……”,这样对应关系一目了然。
用表格可以更清楚地观察这种关系。以n=7, k=3为例,第一轮淘汰后剩余序列为3, 4, 5, 6, 0, 1,新编号0对应原编号3,新编号1对应原编号4,以此类推。新编号i与原编号的对应关系是(i + 3) % 7。所以原问题f(7)的答案,就是新问题f(6)的答案(新编号)映射回原编号。这就是公式的全部秘密。
8.3 训练后应该达到什么水平
如果推导逻辑已经通透,你应该能做到:
- 不看任何资料,在5分钟内写出正确的O(n)递推代码。
- 能解释公式里每一步取模的来源。
- 能处理从1编号和从0编号的转换问题。
- 知道当n和k极大时要用什么优化。
- 能在面试中把这个推导过程用大白话讲给面试官听。
8.4 我个人在实际刷题过程中摸索出的技巧
这里分享一个很实用的技巧:如果一道题的递推公式很难理解,我会先写一个最慢但最正确的暴力版本,把它当作“参照物”。然后用小规模用例去穷举所有可能的变量边界,再尝试编写递推版本,每一步都用参照物验证。这样即使我对推导过程不是100%确定,也能从数值上快速发现公式的偏差,从而定位理解上的盲区。
另外一个技巧是:多做“逆向验证”。比如知道了f(n-1)的答案后,试着“倒着走”一格,看能不能恢复出f(n)的答案。具体做法是:先构造一个n-1人的环,其幸存者编号是某个值,然后试图把第n个人插回去,观察幸存者编号如何变化。这个“逆构造”的过程,有时候比正向推导更能帮助理解本质。约瑟夫环就是通过“反推上一轮”形成了递推公式,理解了这一点,公式对你来说就再也不是死记硬背了。
9. 代码模板与工程级封装
9.1 模块化函数封装(C++)
在工程里,我会把这些逻辑封装成一个命名清晰的类,而不是散落一堆全局函数:
cpp复制#include <vector>
#include <cstdint>
class JosephusSolver {
public:
// 经典递推解法,返回从0开始的编号
static int classic(int n, int k) {
if (n <= 0) return -1;
long long res = 0;
for (long long i = 2; i <= n; i++) {
res = (res + k) % i;
}
return static_cast<int>(res);
}
// 返回从1开始的编号
static int classicOneBased(int n, int k) {
return classic(n, k) + 1;
}
// 大规模场景下的跳步优化,返回从0开始的编号
static int fastForLargeN(int n, int k) {
if (n == 1) return 0;
if (k == 1) return n - 1;
long long res = 0;
long long i = 2;
while (i <= n) {
long long t = (i - 1 - res) / k;
if (i + t > n) {
res = (res + k * (n - i + 1)) % n;
break;
}
res = (res + k * t) % (i + t);
res = (res + k) % (i + t + 1);
i = i + t + 1;
}
return static_cast<int>(res);
}
};
这里在fastForLargeN里的实现和之前伪代码略有区别,核心思路是一样的,我把每次跳步后的取模操作统一处理,避免某一次跳步后res溢出。这个实现我已经用过很多次,在n = 10^9, k = 2, 3时都表现稳定。
9.2 JavaScript / TypeScript版本
如果你在前端项目里需要这个算法(比如做一些抽奖转盘、随机淘汰游戏),可以先实现一个简洁版本:
typescript复制function josephus(n: number, k: number): number {
let res = 0;
for (let i = 2; i <= n; i++) {
res = (res + k) % i;
}
return res;
}
注意JavaScript的Number类型是浮点数,对于n超过2^53的极端情况会有精度问题,但约瑟夫环题目一般不会出到这么大。如果真有BigInt需求,把变量声明为bigint即可。
9.3 Java版本
java复制public static int josephus(int n, int k) {
int res = 0;
for (int i = 2; i <= n; i++) {
res = (res + k) % i;
}
return res;
}
Java的int是32位有符号,如果n和k比较大(接近2^31),在res + k这步可能溢出。稳妥起见用long:
java复制public static int josephus(int n, int k) {
long res = 0;
for (long i = 2; i <= n; i++) {
res = (res + k) % i;
}
return (int) res;
}
9.4 工程化封装时需要注意的参数校验
在真实项目中,函数会被外部调用,必须考虑输入合法性:
- n <= 0时,建议返回-1或在函数入口直接抛异常,明确不支持这样的输入。
- k <= 0时,同样不合法。报数步长必须大于等于1。
- 如果n非常大(比如10^12),O(n)的解法也无法承受,只能考虑跳步优化或者更高级的数学方法,这部分在竞赛题里比较常见,工程里倒很少遇到这种规模。
10. 写在总结之前:一些关于算法学习的心里话
算法学习最忌讳的就是“背模板”。如果你只是记住了约瑟夫环的递推公式和几行代码,不看这篇文章的推导与验证过程,那遇到该问题的变种——比如“每隔k个删除一个,求最后剩下两个编号”或者“约瑟夫环的每一步淘汰序列”——你依然会手足无措。
我现在带团队做项目时,很少要求组员背任何现成算法模板,但我会特别看重他们在遇到规模变化时能不能重新建模。这恰恰是约瑟夫环能训练的东西。它表面上是一道题,本质是一种思维模型:面对一个动态变化的环状结构,如何从个体的编号中找到规律,把看似需要逐步模拟的过程压缩到常数级的递推。
最后再分享一句我自己的体会:我从第一次被约瑟夫环公式绕晕,到真正推明白,中间隔了大约三个月的持续踩坑,看到突破口的那一刻是在纸上画了整整一页的编号映射表。当你觉得一个公式怎么都看不懂的时候,先别急着背,试着拿具体小例子多走两遍,让编号在你脑海里活起来,公式自己就会浮出来。希望这篇文章能帮你少走一些弯路,更快地跨过那道坎。
