我第一次正经接触约瑟夫环,是在好几年前的一次面试里。面试官递给我一张白纸,说:n个人围成一圈,从第一个人开始报数,报到3的人出局,然后从下一个人重新从1报,最后剩下几号?我当时觉得这题太简单了,不就是个模拟吗,拿起笔就写循环链表。结果写着写着发现事情没那么简单——当n到一千、一万,甚至一亿的时候,模拟就完全跑不动了。而这恰恰是约瑟夫环最迷人的地方:一个看起来人畜无害的“报数出列”游戏,背后藏着一整条从暴力模拟、数据结构选型到数学优化的思维链路。
这篇文章我打算把约瑟夫环从里到外拆一遍:历史原型、数学模型、递推公式推导、四种代码实现选型、几个常考的变体,以及我实际写代码时踩过的一系列边界坑。无论你是刚学算法的学生、准备面试的候选人,还是纯粹想把一个经典问题吃透的工程师,这篇文章应该都能让你拿到一套“能直接抄作业”的完整方案。我们先从这个问题的本来面目聊起。
1. 约瑟夫环到底在问什么:从历史传说到现代表述
1.1 那个流传两千年的传说,和它背后的数学问题
约瑟夫环这个名字来自公元1世纪的犹太历史学家弗拉维乌斯·约瑟夫斯。传说在犹太战争期间,约瑟夫斯和40名士兵被罗马军队围困在一个山洞里,众人决定宁可自杀也不投降。但约瑟夫斯觉得自杀不是好主意,他提议大家围成一个圈,按某种规则轮流杀死相邻的人,最后剩下的人可以选择自杀或投降。传说他通过数学计算把自己和另一个同伴放在了正确的位置上,最终活了下来,向罗马军队投降。
这个传说的细节是不是完全符合史实,已经无从考证,但它留下的数学模型非常清晰且有魅力:n个人围成一圈,从某个人开始按固定步长报数,每报到k的人被“淘汰”,然后从下一个人重新开始,直到只剩一个人。问题是,最后幸存的那个人一开始站在第几个位置?
从算法角度说,这个问题的本质是:在一个循环迭代淘汰的规则下,如何高效预测最终幸存者的编号。它兼有离散数学的递归结构、算法设计中的复杂度权衡,以及工程实现中比较容易踩的各种边界陷阱,所以在计算机科学的面试和竞赛里长盛不衰。
1.2 抽象成数学模型:n、k、编号和幸存者
抛开传说,我们先把问题形式化。设总人数为n,报数步长为k,每个人有一个编号。编号有两种常见约定:从1开始(1-index)和从0开始(0-index)。这两种约定会导致最终答案相差1,很多人在一开始不统一约定,后面很容易算错,这点我会在第6章专门展开。
规则可以精确描述为:
- 初始有n个人,围成一个环。
- 从第1个位置开始报数,报数为1的人就是起点自己。
- 报到数字k的人退出环。
- 从被淘汰者的下一个位置重新从1开始报数。
- 重复上述过程,直到环中只剩一个人,这个人就是幸存者。
我们要求解的就是幸存者的编号。我用0-index表示时,记为f(n, k),范围是0到n-1;用1-index表示时,记为g(n, k),范围是1到n。两者的关系就是g(n, k) = f(n, k) + 1。
1.3 先用n=5、k=2手动跑一遍,建立直觉
光说规则太抽象,我们拿一个最小的例子手动模拟一遍。假设n=5(编号1到5),k=2,即“每隔一个人淘汰一个人”。
第一轮从1号开始报数:1号报1,2号报2,所以2号出列。剩余顺序为3、4、5、1,下一轮从3号开始报1。
第二轮从3号开始:3号报1,4号报2,所以4号出列。剩余顺序为5、1、3,下一轮从5号开始报1。
第三轮从5号开始:5号报1,1号报2,所以1号出列。剩余顺序为3、5,下一轮从3号开始报1。
第四轮从3号开始:3号报1,5号报2,所以5号出列。最后剩下3号。
所以n=5、k=2时,1-index的幸存者编号是3。这个简单例子是后面所有推导和代码的“验收标准”,我建议你在实现任何约瑟夫环代码时,都先用这一组参数验证输出是不是3。
2. 从暴力模拟到递推公式:编号映射才是破局关键
2.1 第一直觉:链表模拟为什么“能跑但跑不远”
最直观的做法就是真的用一个循环链表维护“还活着的人”,然后每轮移动k步删除一个节点。这个思路完全模拟了游戏过程,理解成本极低,代码也很容易写。我见过很多初学者第一次写约瑟夫环,用的都是这个方法。
但模拟的问题在复杂度上。每一轮要移动k步找到被删除的人,总共要删除n-1个人,所以时间复杂度是O(nk)。k很小的时候还好说,k如果和n一个量级,这就是O(n²),n稍微大到一万以上就开始吃力了。更糟糕的是,如果用数组模拟删除,每删一个元素还要搬运后续元素,复杂度更高。
这么说并不是要否定模拟。恰恰相反,我觉得任何人在写约瑟夫环时,都应该先把模拟版本跑通。它不仅是正确的基准答案,还能帮你验证后面所有优化算法的结果。真正的工程选择是:当n很大,或者需要反复求解不同k的结果时,必须换思路。
2.2 核心推导:删除一人后,编号发生了什么变化
从模拟到数学公式的关键一步,是理解“删掉一个人之后,剩下这些人的编号发生了什么变化”。
假设当前有n个人,编号为0到n-1(这里用0-index,因为取模运算最自然)。从0号开始报数,报到k-1的人出列。因为0号报1,所以出列的是编号为(k-1) % n的人。
这个人出列后,剩余n-1个人。原来的编号顺序从(k % n)开始,接下去是(k+1) % n,(k+2) % n,一直到(k-2) % n。我们把这n-1个人的顺序重新编号为0到n-2,也就是把“出列者的下一个位置”当作新的0号。
于是得到一个极重要的映射关系:在旧环里的编号old,和在新环里的编号new,满足:
old = (new + k) % n
这个式子的含义很朴素:新环里的0号对应旧环里的k号,新环里的1号对应旧环里的k+1号,后面依次错开k个位置。因为新环一共n-1个人,所以对应的是旧环n个人里的n-1个位置,取模把尾巴绕回开头。
2.3 递推公式落地:从f(1)一路滚到f(n)
有了映射关系,约瑟夫环问题的解就自然出来了。设f(i, k)表示规模为i个人、步长为k时,幸存者在“当前这i个人的0-index编号体系”中的位置。
对于规模为1的情况,环里只有编号0这个人,他必然幸存,所以f(1, k) = 0。
对于规模为i的情况,第一轮删掉一个人,剩下i-1个人。我们知道这i-1个人里的幸存者,在新编号下是f(i-1, k)。把它映射回旧编号,就得到:
f(i, k) = (f(i-1, k) + k) % i
这个递推公式是整个约瑟夫环问题的核心结论。它把规模n的问题不断缩小到n-1、n-2,最终缩到1,然后一路代回。更重要的是,它只需要O(1)的额外空间和O(n)的时间,比暴力模拟高出一个量级。
我们可以用n=5、k=2验证一遍:
- f(1, 2) = 0
- f(2, 2) = (0 + 2) % 2 = 0
- f(3, 2) = (0 + 2) % 3 = 2
- f(4, 2) = (2 + 2) % 4 = 0
- f(5, 2) = (0 + 2) % 5 = 2
所以0-index下幸存者编号是2,换算成1-index就是3号,和第1章手动跑的结论一致。
2.4 验证与边界:0-index和1-index的坑
我在推导过程中特意坚持用0-index,因为取模运算在0到i-1的整数上天然成立。如果直接用1-index,递推式会变成((g(i-1, k) + k - 1) % i) + 1,原理一样,但形式丑很多,也容易在取模边界上出错。
我建议的实践方式是:内部运算统一用0-index,得到结果后再加1转成1-index。也就是:
g(n, k) = f(n, k) + 1
这样的好处是,f的推导和代码都和数学定义严格对应,你不需要在每一步都纠结“这里要不要减一”。只有最后输出给用户时,才做一次转换。这个习惯能省掉大量bug。
3. 四种主流实现的对比:链表、数组、队列和迭代公式
3.1 循环链表:最贴合“围成一圈”语义的写法
如果你只想写一个逻辑上最自然的版本,循环链表是首选。每个节点代表一个人,节点首尾相连,删除一个节点只需要改两条指针。
我用Python写一个最小的循环链表实现,方便对照:
class Node: def __init__(self, value): self.value = value self.next = None def josephus_linked_list(n, k): head = Node(1) prev = head for i in range(2, n + 1): prev.next = Node(i) prev = prev.next prev.next = head # 首尾相连成环 cur = head prev = None while cur.next != cur: # 只剩一个节点时,cur.next == cur for _ in range(k - 1): prev = cur cur = cur.next prev.next = cur.next # 删除cur cur = cur.next return cur.value这个实现的循环结构完全模拟了“围成一圈”的语义,可读性很好。每次移动k-1步到达待删节点,删除后再从下一节点继续。要注意的是,创建链表时是按1到n的顺序串联的,所以初始的“从1号开始报数”是天然成立的。
不过这个版本的时间复杂度是O(nk),空间复杂度O(n)。当n和k都很小时它非常清晰,但当n达到百万量级,它就跑不动了。它最大的价值在于:它是验证其他优化算法正确性的“黄金基准”。
3.2 数组标记:思路最直白,性能最差
如果不想用指针,很多人会尝试用布尔数组标记每个位置是否已被淘汰,每次循环数够k个存活者:
def josephus_array(n, k): alive = [True] * n # 0-index left = n pos = 0 while left > 1: cnt = 0 while cnt < k: pos = (pos + 1) % n if alive[pos]: cnt += 1 alive[pos] = False left -= 1 return pos + 1这段代码的思路比链表更粗暴:用pos在0到n-1之间循环移动,遇到已经淘汰的位置就跳过,真正“报数”的只有活人。虽然省去了指针管理的麻烦,但每一轮都要线性地扫过很多已淘汰位置,整体复杂度相当高,大约是O(nk)加上大量无意义的扫描。空间上需要O(n)的布尔数组。
我个人的看法是,这个版本唯一的价值是教学演示,让初学者理解“怎么用线性结构模拟环形移动”。实际写算法题或者工程实现时,我不会用它。
3.3 队列旋转:用线性结构模拟环形的巧妙trick
如果你不想写链表,又不想在数组里做无意义的跳跃扫描,有第三个选择:用一个队列,把“报数”的过程转换成“队首出队、再入队”的旋转操作。
每轮从队首开始,把前k-1个人依次弹出并放到队尾,此时队首就是第k个人,直接弹出淘汰。重复直到队列长度为1。
在Python里,标准库collections.deque自带rotate方法,实现这个逻辑只要几行:
from collections import deque def josephus_queue(n, k): q = deque(range(1, n + 1)) while len(q) > 1: q.rotate(-(k - 1)) q.popleft() return q[0]rotate(-(k-1))的含义是:把队列左旋k-1个位置,让待出列的人移动到队首。这个写法非常优雅,代码量极少,而且真实模拟了“从下一个人重新报数”的规则。每次淘汰需要O(k)的旋转操作,总共淘汰n-1个人,所以时间复杂度也是O(nk),空间复杂度O(n)。
队列版本是我在代码可读性上最推荐的模拟实现。它不需要手动管理链表指针,也不会有“跳过死人”的无效扫描,语义清楚,不容易写错。如果面试中需要一个模拟解法做开局,我会优先写这个。
3.4 迭代公式:O(n)一行解,工程里的首选
前面推到的递推公式,写成迭代循环非常短。从i=2开始往上滚,每次用上一轮的幸存者位置算出当前规模下的幸存者位置:
def josephus_iter(n, k): ans = 0 # 0-index,n=1时的唯一幸存者 for i in range(2, n + 1): ans = (ans + k) % i return ans + 1 # 转成1-index就这么几行。时间复杂度O(n),空间复杂度O(1)。n无论是一万还是一亿,只要O(n)的循环能跑完,它都能快速给出结果。它是工程实现和算法竞赛里对固定步长约瑟夫环问题的标准解。
我在实际使用中会多做一个优化:当k远大于i时,ans + k可以取模到很小的值,可以用(ans + k) % i一步完成。当k特别大,比如k=10^9,i从2慢慢变大,取模效率依然是O(n),不用担心中间值溢出,因为Python的整数没有位数限制;但如果你在用C++或Java,注意ans + k可能超过int范围,需要转long long。这一点我会在第6章再提。
3.5 复杂度对比表:什么时候选哪种
我把四种实现在一个表里对比,方便你快速选型:
| 实现方式 | 时间复杂度 | 空间复杂度 | 代码量 | 适用场景 |
|---|---|---|---|---|
| 循环链表 | O(nk) | O(n) | 中等 | 教学演示,小规模可靠基准 |
| 数组标记 | O(nk)及以上 | O(n) | 少 | 仅教学理解环形移动 |
| 队列旋转 | O(nk) | O(n) | 最少 | 面试中的清晰模拟版本 |
| 迭代公式 | O(n) | O(1) | 最少 | n大、固定步长、需要高频计算 |
我的选型建议很明确:写题或写功能时优先用迭代公式;需要给面试官呈现思路演进时,先从队列模拟讲起,再引导到数学公式;只有在需要实际列出整个淘汰顺序,而不是只求最后一个幸存者时,才回到链表或队列模拟。
4. 变体与延伸:k变化的递推、完整出列顺序、k=2的位运算结论
4.1 每一轮k不同,递推式怎么改
经典的约瑟夫环假设每一轮报数步长k都是固定的。但真实场景里完全可能遇到“每一轮报数上限都不一样”的变体。比如第1轮报到k1就淘汰,第2轮报到k2就淘汰,依此类推。
这种情况下,暴力模拟依然可以做,每轮用不同的k移动。递推公式也可以改:如果当前规模是i,这一轮对应的步长是ki,那么递推式为:
f(i) = (f(i-1) + ki) % i
注意这里的下标顺序。如果最开始有n个人,第一轮淘汰对应的是i=n时的k_n,之后规模依次减小到n-1、n-2……所以要先把每轮的k按“从规模n到规模2”的顺序存好,然后从i=2开始逆推。
代码可以这样写:
def josephus_variable(n, ks): # ks[i] 表示当环里还剩 i+2 个人时对应的步长,下标从0开始 ans = 0 # 规模为1时的幸存者 for i in range(2, n + 1): k = ks[n - i] # 注意取法,ks[0]对应第一轮 ans = (ans + k) % i return ans + 1这个变体在面试里偶尔会出现,考察的就是你能不能真正理解公式里“上一轮幸存者在新编号中的位置”这一层含义,而不是死记硬背f(n,k)=(f(n-1,k)+k)%n。你只要理解了编号映射,把固定的k换成可变的k序列,答案自然就出来了。
4.2 完整出列顺序:怎么从O(n²)优化到O(nlogn)
有时候题目不只要最后一个幸存者,而是要求完整输出所有人的出列顺序。这时候递推公式就没法直接用,因为公式只保存了幸存者位置,没有保留每轮淘汰对象的信息。
最朴素的思路是继续用循环链表模拟,每次移动k-1步输出被删节点,复杂度O(nk)。如果n是10^5、k也是10^5,这就完全不可行。
一个更优的做法是使用树状数组或线段树维护“当前剩余人位置”的区间和,每次用“当前步长k对剩余人数取模”算出下一个要删的位置,再通过区间查询第k小找到真实编号。这样每一轮的时间复杂度是O(logn),整体是O(nlogn)。这个思路在算法竞赛里很常见,但实现起来比固定公式复杂一些,需要一个能维护前缀和的树状数组。
具体思路可以这样描述:假设当前剩余人数是m,上一轮删除后,下一轮起点在环中的位置是cur。那么下一轮要删除的位置,是从cur开始数第k个人(如果k % m == 0,就是第m个人)。在树状数组中,已删除的位置标记为0,未删除的位置标记为1。通过查询前缀和找到第target个未删除位置即可。删除后更新cur为那个位置的索引,继续下一轮。
这种优化我第一次接触时觉得有点绕,但只要理解了“树状数组上的区间和 = 剩余人数”这个映射,再配合一个二分查找找第k个存活位置,就不难写了。它也是我从“会背公式”进阶到“能用数据结构改造经典问题”的关键一步。
4.3 k=2时的二进制结论:一个漂亮的特例
当k=2时,约瑟夫环有一个非常漂亮的二进制规律,值得单独讲。
我用递推式算几组结果(0-index):
- n=1: f=0
- n=2: f=0
- n=3: f=2
- n=4: f=0
- n=5: f=2
- n=6: f=4
- n=7: f=6
- n=8: f=0
- n=9: f=2
- n=10: f=4
- n=11: f=6
- n=12: f=8
- n=13: f=10
观察这些结果,会发现一个规律:当n=2^m + l(其中0 ≤ l < 2^m)时,答案是2l。
换一种说法:把n写成二进制,去掉最高位的1,剩下的部分左移一位,得到的二进制数就是幸存者的0-index编号。
例如n=13,二进制是1101,去掉最高位得到101(也就是5),左移一位得到1010(也就是10),恰好就是f(13)=10。
如果转成1-index,就是2l + 1。
这个规律从二进制角度理解非常直观,因为k=2的淘汰过程本质上就是不断把环里奇数位的人淘汰掉,剩下的人重新编号后,编号相当于“除以2后的还原”。用位数和位移,一行代码就能算:
def josephus_k2(n): if n == 1: return 1 highest = 1 << (n.bit_length() - 1) l = n - highest return 2 * l + 1这个特例在竞赛题里偶尔会直接出现,甚至有些面试官会问“k=2时能不能不用循环,直接算”。如果你能把这个二进制规律讲清楚,对面试的表现会加分不少。
5. 这个“老古董”到底有什么用:从理论到应用的连接
5.1 约瑟夫环与轮转调度、淘汰策略的相似性
很多人学算法时都会问一句:这题除了面试和竞赛,真的有人用吗?说实话,约瑟夫环作为一个完整问题,在工业代码里直接出现的频率并不高。但它背后的“固定顺序周期循环+按条件淘汰”模型,在真实系统里到处都有影子。
典型的例子是操作系统的轮转调度。多个进程排成一个环形队列,时间片轮流分配,某个进程如果执行时间耗尽或者被判定退出,就会被移出队列,然后调度器从下一个进程继续。这和约瑟夫环的运行机制在结构上是同构的。另一个例子是某些共享资源的授权策略,多个请求方轮询获取锁,拿到资源的请求方在完成一次操作后被移出竞争集合,再看下一个。
缓存淘汰算法里也有类似思想。某些改进型的时钟淘汰算法(Clock算法)把缓存行排成环形,一个指针循环扫描,标记位为0的条目被淘汰,为1的条目被清标记后放行,继续找下一个。指针循环移动的方式和约瑟夫环的报数过程很像,只不过淘汰条件是标记位,而不是固定的步长k。
我举这些例子的意思是:不是让你真的在工程里写一个josephus函数,而是希望你看到“环形结构+周期动作+条件淘汰”这个通用模型。理解了模型,以后遇到类似的调度、淘汰、重试场景,你会比其他人更快地想到合适的数据结构和算法。
5.2 在面试和竞赛里,它考察的是哪种能力
面试官问约瑟夫环,本质上不是希望你背出一个公式,而是想看几件事。
第一是建模能力。你能不能把一段用自然语言描述的淘汰规则,准确地翻译成数学递推或数据结构操作。第二是复杂度意识。同样是解法,你会不会主动从O(nk)的模拟优化到O(n)的公式。第三是边界处理。n=1时怎么办,k比n大怎么办,编号从0开始还是从1开始,这些都是区分“背过答案”和“真正理解”的点。
竞赛里的约瑟夫环,更多会以变体形式出现,比如第4章提到的变化步长、输出完整出列顺序、或者把问题放到二维平面上做成动态规划。标准约瑟夫环本身只是个铺垫,变体才是拉开差距的地方。
5.3 什么时候别用公式:数据规模与维护成本的权衡
虽然迭代公式在时间和空间上都碾压模拟,但它有一个前提:你只关心最终幸存者编号,而不关心整个淘汰过程。如果你需要输出完整的出列顺序,或者需要在每轮淘汰时做额外的计算(比如累计被淘汰者的编号、统计每轮间隔),那么公式就没法直接给出过程信息,你还是得回到模拟类实现。
另一个很现实的考虑是代码可维护性。公式版本非常简洁,但它要求阅读者理解编号映射的递推逻辑。如果一段代码是给团队其他人长期维护的,有时候一个带清晰注释的队列版本,比一行高深莫测的公式更容易让后来者理解。我见过一些团队代码里有类似“f(n,k)=(f(n-1,k)+k)%n”这种一行式实现,没有注释根本看不懂。所以我的建议是:追求性能的地方用公式,并附上足够的注释;追求可读性且n不太大的地方,模拟版本也是合理选择。
6. 我在实战中踩过的坑:边界、取模、爆栈与数据规模
6.1 编号约定不一致:答案全错的最常见原因
我第一次用公式写约瑟夫环时,就踩过这个坑。脑子里想的是1-index,结果递推公式里f(1)=0被我理解成“1号幸存”,然后一路推下来,全错。后来把推导过程打印出来一个个对照,才发现问题出在起始编号上。
这里我给一个硬性建议:代码里所有中间计算都用0-index,只有当最终返回给调用方时,才做加一转换。如果有人给你一个接口,要求传入1-index的人数,那也先在函数内部减一处理。只要你坚持“内部0-index、输出1-index”这个约定,就不会搞混。
6.2 k % i == 0:取模边界到底该怎么处理
迭代公式里ans = (ans + k) % i。这里有一个很容易被忽略的小情况:如果ans + k刚好是i的整数倍,取模结果是0,代表幸存者在当前规模下会落到编号0的位置。这不是异常,是数学上正确的结果。
但如果你习惯性地以为取模结果“肯定不会等于0”,那就会出逻辑错误。尤其是在手动测试时,用一个能被k整除的n去验证,结果容易怀疑人生。比如n=6、k=3,用公式快速推一下:
- f(1)=0
- f(2)=(0+3)%2=1
- f(3)=(1+3)%3=1
- f(4)=(1+3)%4=0
- f(5)=(0+3)%5=3
- f(6)=(3+3)%6=0
所以0-index幸存者是0号,也就是1-index下的1号。手动模拟验证一下:1 2 3 4 5 6,k=3,出列顺序是3、6、4、2、5,最后剩1。正确。这个用例里取模多次出现0,正好是检验边界情况的好例子。
6.3 递归版别直接用:大n下的爆栈风险
迭代公式和递归公式在数学上是等价的,很多教程也会给递归写法:
def josephus_recursive(n, k): if n == 1: return 0 return (josephus_recursive(n - 1, k) + k) % n代码很简洁,但有一个现实问题:递归深度等于n。当n是10^5甚至10^6时,Python默认递归深度只有1000左右,直接栈溢出。即使你把递归深度调大,也会占用大量调用栈空间。
所以我的建议非常明确:工程实现一律用迭代版本。递归版本只用来演示数学定义,不要直接上生产环境。如果你确实想在代码里保留递归的清晰结构,可以设定一个阈值,n小的时候走递归,n大的时候走迭代,但没必要,迭代版本本身就不难懂。
6.4 测试用例设计:从n=1到n=10^7的验证清单
每次写完约瑟夫环代码,我建议至少跑这几个用例,能覆盖大多数边界问题:
- n=1,任意k:幸存者就是1号。这是最小边界,很多代码在这里就开始错。
- n=2,k=1:每轮报1就淘汰,第一轮淘汰1号,幸存者是2号。注意k=1的情况在模拟里很容易死循环。
- n=5,k=2:答案是3号,这是最经典的手算用例。
- n=6,k=3:答案是1号,用来检验取模出现0的场景。
- n=7,k=7:k等于n,考验取模和循环处理。
- n=1000000,k=1000000:用迭代公式验证性能,同时确认没有用递归导致爆栈。
这里特别说一下k=1。很多人会觉得k=1太简单了,不就是从头顺次淘汰吗,但如果用链表或队列模拟,k=1时移动步数是0,需要特别小心“for _ in range(k-1)”这种循环根本不会执行,得保证逻辑仍然正确。用迭代公式的话,f(i)=(f(i-1)+1)%i,结果就是n-1(0-index),转成1-index就是n号,完全正确。
最后再说两句我的习惯
回到开头那个面试场景。现在我遇到约瑟夫环类的问题,不会一上来就写公式,而是先快速确认两个边界:编号从1还是0开始,k是否可能大于n。然后在白板上先用伪代码描述“报数-淘汰-再报数”的过程,让面试官看到我理解题意,再随手推导一下编号映射,得出递推公式,最后给出迭代版本代码。这个流程既稳当,又能展示从模拟到优化的完整思维链路。
如果你只打算从这篇文章里带走一样东西,我希望是那个递推公式背后的“编号映射”思维,而不是公式本身。学会了这种“删掉一个元素后,重新编号,再从结果反推旧编号”的思路,你以后遇到各种变体约瑟夫环,甚至其他需要逆推的循环淘汰问题,都会觉得轻车熟路。最后再分享一个小技巧:动手写暴力模拟时,故意把n和k设得很小,用手算结果作为测试基准,用公式实现去对照,两边一致之后再把n放大。这个“小规模基准验证,大规模性能测试”的做法,能帮你省下大量调试时间。