众所周知,约瑟夫环问题可以模拟解决,但本文不打算细讲模拟的方法,只会为了文章完整性起见提一下思路。本文的重点是介绍数学推导过程以及利用树状数组的解法,或者说主要是为了解决下面的变式问题,只是原版约瑟夫的推导过程确有帮助,故一并介绍
为了更好的阅读体验,请参见我的博客
约瑟夫问题
0,1,···,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字(删除后从下一个数字开始计数)。求出这个圆圈里剩下的最后一个数字。
例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。
原题参见LeetCode
原始思路
以上是约瑟夫环问题一种比较经典的描述,思考一下即可以想见两种解法:
- 最容易想到的自然是循环链表直接模拟,删除掉数字即删除链表节点,时间复杂度为 O ( m n ) O(mn) O(mn)
- 稍稍优化一下思路,用求余的方法,可以直接确定下一个该删除的位置,防止m过大导致空转很多圈的情况,但是时间复杂度仍然为 O ( n 2 ) O(n^2) O(n2)
不过既然已经求余,那么不妨思考一下,能否再进一步,直接用数学方法解决这个问题呢?
数学推导
约瑟夫问题每次删除一个人,也即问题的规模每次减小一,这提示我们使用递归的思路去解决这个问题。
然而问题在于,一个规模为 n n n的约瑟夫问题中是对 0 0 0~ n − 1 n-1 n−1进行操作,但是当我们删除一个数字之后,剩余的数字未必是 0 0 0~ n − 2 n-2 n−2,也就是说,我们要解决的主要是问题规模变小之后的数字编号对应问题。我们需要在规模为 n − 1 n-1 n−1问题的数字,和规模为 n n n的问题的剩余数字之间建立一个一一映射,从而将 n − 1 n-1 n−1问题的答案转化到 n n n问题去。
以上是问题的核心思路,下面介绍详细的推导过程。
不妨假设我们在 0 0 0~ n − 1 n-1 n−1中第一个删除的数字为 k k k,那么为了进行对应,我们先对 0 0 0~ n − 1 n-1 n−1进行重排,如下图所示

这相当于把已经删除的数字 k k k丢到最后去,不用考虑它,然后按照正常的操作顺序,我们下面应该对 k + 1 k+1 k+1~ k − 1 k-1 k−1进行操作。这即是一个规模为 n − 1 n-1 n−1的问题。我们建立一一映射关系如下图所示

该映射关系作为数学公式的表达如下:
假设 x n x_n xn是上面的数字, x n − 1 x_{n-1} xn−1是下面的数字,那么有 x n = ( x n − 1 + ( k + 1 ) ) m o d n x_n=(x_{n-1}+(k+1))\ mod\ n xn=(xn−1+(k+1)) mod n。根据上图,容易验证公式是成立的。
这时候设想一下,如果有了
n
−
1
n-1
n−1规模下的约瑟夫问题的答案
a
n
s
n
−
1
ans_{n-1}
ansn−1,利用上面的映射关系,不就可以把
a
n
s
n
−
1
ans_{n-1}
ansn−1映射过去,得到
a
n
s
n
ans_n
ansn了吗!由于
k
k
k是规模为
n
n
n的问题中第一个删除的数,所以显然应该有
k
=
(
m
−
1
)
m
o
d
n
k=(m-1)\ mod\ n
k=(m−1) mod n。综合起来整理一下,得到最终的公式:
a
n
s
n
=
(
a
n
s
n
−
1
+
m
m
o
d
n
)
m
o
d
n
=
(
a
n
s
n
−
1
+
m
)
m
o
d
n
ans_n=(ans_{n-1}+m\ mod\ n)\ mod\ n=(ans_{n-1}+m)\ mod\ n
ansn=(ansn−1+m mod n) mod n=(ansn−1+m) mod n
最后考虑一下边界条件,即
n
=
1
n=1
n=1时,显然答案是数字
0
0
0。
至此,我们已经完成了约瑟夫问题的数学公式推导。下面看一下代码实现(代码这么短有啥好看的):
class Solution {
public:
// 递归求解的函数
int solve(int n, int m) {
if (n == 1) return 0; // 边界条件
else return (solve(n - 1, m) + m) % n; // 公式
}
int lastRemaining(int n, int m) {
return solve(n, m);
}
};
也可以直接采用递推的写法:
class Solution {
public:
int lastRemaining(int n, int m) {
int ans = 0;
for (int i = 2; i <= n; i++) { // i即约瑟夫问题的规模
ans = (ans + m) % i;
}
return ans;
}
};
有一点值得注意,无论是递推还是递归求解约瑟夫问题,迭代的数字并不代表删除数字的顺序,而是代表着某个规模的约瑟夫问题的答案(比如
i=n-2
时,求出来的答案并不是倒数第二个被删除的数,而是代表规模为n-2的约瑟夫问题的答案)如果是一些变种约瑟夫(比如让你求倒数三个被删除的数字),就要注意分清这种概念
树状数组
关于树状数组的写法,放在下面的变种约瑟夫里一起介绍。
线型变向约瑟夫
在这万物复苏的春天,“春樱对决”比赛开始啦!晴明有N个式神,他想从这N个式神里选一个来当这次活动海报的主角。因为想当主角的式神非常多,于是晴明就定下了这样一个选拔的规矩:
将N个式神从左到右排成一排,编号为1~N。从第1个式神开始进行1~M的正向报数,报到的第M个式神出列,再从下一个式神开始继续1到M报数、出列。如果按照某个方向报数到尾部,那么再反方向继续报数。如此进行下去,直到剩下一个式神,而这个式神就是本次“春樱对决”海报的主角。
第一个来到这个寮的两面佛渴望能成为主角,毕竟这是他唯一可能成为主角的时刻了。于是佛佛求你编个程序帮助他实现这个愿望,告诉他要想当主角,他的编号应该是多少。
数据范围:N≤105,M≤109
(ACMOJ · 春樱对决 (sjtu.edu.cn))(没有SJTUOJ账号可能看不见呢)
原始思路
提前声明一下,这个“线型变向约瑟夫”的名字是我乱起的,毕竟本来这个题目背景(阴阳师?)就很奇怪。不过题目大意很清楚了,就是把约瑟夫问题的循环,变成了在一条链上两个方向来回跑。由此,我们仍然可以想到一些朴素解法:
- 用双链表直接模拟,时间复杂度 O ( m n ) O(mn) O(mn)
- 用循环链表,把除了头尾元素的部分复制一份接到尾的后面,再首尾相接构造循环链表,这样便可以转化成我们熟悉的问题,采用取余的方法,将时间复杂度降低到 O ( n 2 ) O(n^2) O(n2)
不过朴素方法仍然不是我们的重点,下面介绍数学方法。
数学推导
我们延续原版约瑟夫的思路,希望能够通过减小问题规模进行递归。
但是这次还有报数方向问题,应该是不能通过很直接的数学公式一行解决了。我们不妨把方向当做每一轮的已知量,加入递归函数传递的变量中,记为 d i r dir dir。另外,由于从循环链表变成线性链,每个位置的地位也不再等同,于是我们也传递每轮开始报数的位置,设为 s t st st。另外,为了求解的方便,我们仍然使用 0 0 0~ n − 1 n-1 n−1而非原问题中的 1 1 1~ n n n。下面开始求解。
记剩余人数为 n n n(即前文所述问题规模为 n n n)时初始位置为 s t n st_n stn,初始方向为 d i r n dir_n dirn(为了叙述的方便,下面将剩余人数为 n n n的问题简记为问题 n n n)。
我们首先要确定剩余人数为 n − 1 n-1 n−1时的报数方向 d i r n − 1 dir_{n-1} dirn−1。方向的改变和什么有关呢?自然是和我们跑过了几个转折点(即头尾节点)有关(经过奇数次就转向,偶数次就不转向)。为了方便,我们可以先将 0 0 0~ n − 1 n-1 n−1进行复制变成一个无限长链便于想象,如下图所示

我们已经将正常的折返等价成了上图的长链,也就是说在上图的链中按一个方向(不妨向右)跑就相当于原题的折返跑。只要落在图中蓝色的部分,原始方向都应该是向右;反之,落在红色部分原始方向就应该向左。
以初始方向向右为例,为了统计的方便,我们不妨将初始位置暂且看作从头(即数字0处)出发,那么跑过的路程就是 s = s t n + ( m − 1 ) s=st_n+(m-1) s=stn+(m−1),然后我们判断 [ s n − 1 ] = [ s t n + m − 1 n − 1 ] [\frac{s}{n-1}]=[\frac{st_n+m-1}{n-1}] [n−1s]=[n−1stn+m−1](这里[]是高斯函数)的奇偶性,即可知道跑完之后落在红色还是蓝色区域,从而知道方向。
初始方向向左也类似(这时初始 s t st st应该落在红区),只不过路程 s = ( n − 1 − s t n ) + ( m − 1 ) s=(n-1-st_n)+(m-1) s=(n−1−stn)+(m−1)(这里暂且看作出发位置是 n − 1 n-1 n−1)。
综合一下,有
s
=
{
s
t
n
+
m
−
1
,
d
i
r
n
=
R
i
g
h
t
n
−
s
t
n
+
m
−
2
,
d
i
r
n
=
L
e
f
t
s=\left\{\begin{matrix}st_n+m-1,dir_n=Right\\n-st_n+m-2,dir_n=Left \end{matrix}\right.
s={stn+m−1,dirn=Rightn−stn+m−2,dirn=Left
d i r n − 1 = { d i r n 的 反 向 , [ s n − 1 ] 为 奇 数 d i r n , [ s n − 1 ] 为 偶 数 dir_{n-1}=\left\{\begin{matrix}dir_n的反向,[\frac{s}{n-1}]为奇数\\dir_n,[\frac{s}{n-1}]为偶数\end{matrix}\right. dirn−1={dirn的反向,[n−1s]为奇数dirn,[n−1s]为偶数
有了上面的铺垫,我们接下来求解 s t n − 1 st_{n-1} stn−1就比较简单了。
我们先求出从
s
t
n
st_n
stn出发,移动
m
m
m个之后到达的位置,并记为
k
k
k。仍然看上面的图,如果落在蓝区,那么显然有
k
=
s
m
o
d
(
n
−
1
)
k=s\ mod\ (n-1)
k=s mod (n−1);如果落在红区,就是
k
=
n
−
1
−
(
s
m
o
d
(
n
−
1
)
)
k=n-1-(s\ mod\ (n-1))
k=n−1−(s mod (n−1))。综合一下,就有
k
=
{
s
m
o
d
(
n
−
1
)
,
d
i
r
n
−
1
=
R
i
g
h
t
n
−
1
−
(
s
m
o
d
(
n
−
1
)
)
,
d
i
r
n
−
1
=
L
e
f
t
k=\left\{\begin{matrix}s\ mod\ (n-1),dir_{n-1}=Right\\n-1-(s\ mod\ (n-1)),dir_{n-1}=Left \end{matrix}\right.
k={s mod (n−1),dirn−1=Rightn−1−(s mod (n−1)),dirn−1=Left
仅仅知道
k
k
k还不够,因为
k
k
k只是问题
n
n
n下的数字编号,而非问题
n
−
1
n-1
n−1下的,所以我们还缺一步。记得吗?原版的约瑟夫中还有一个建立一一映射的过程。当然,由于是单链而非环状,我们不可能再进行重排,但是需要删除的数,即
k
k
k,仍然要舍弃掉,并将剩下的数建立映射关系,如下图所示

假设 x n x_n xn是上面的数字, x n − 1 x_{n-1} xn−1是下面的数字,那么用数学关系表达就是 x n = { x n − 1 , x n − 1 ≤ k − 1 x n − 1 + 1 , x n − 1 ≥ k x_n=\left\{\begin{matrix} x_{n-1},x_{n-1}\leq k-1\\ x_{n-1}+1,x_{n-1}\geq k \end{matrix}\right. xn={xn−1,xn−1≤k−1xn−1+1,xn−1≥k
由此,我们便可以通过 k k k得到 s t n − 1 st_{n-1} stn−1了。如果 d i r n − 1 = R i g h t dir_{n-1}=Right dirn−1=Right,删除数字 k k k之后,下一个开始位置是问题 n n n中的 k + 1 k+1 k+1,代入公式得到对应的问题 n − 1 n-1 n−1中的数字编号为 k k k。同理,如果 d i r n − 1 = L e f t dir_{n-1}=Left dirn−1=Left,删除数字 k k k之后,下一个开始位置是问题 n n n中的 k − 1 k-1 k−1,代入公式得到对应的问题 n − 1 n-1 n−1中的数字编号为 k − 1 k-1 k−1。
这里不用担心k+1或k-1后会超出0~n的范围,因为头尾位置的方向都是向内的
将这些过程综合一下,就有
s
t
n
−
1
=
{
k
,
d
i
r
n
−
1
=
R
i
g
h
t
k
−
1
,
d
i
r
n
−
1
=
L
e
f
t
st_{n-1}=\left\{\begin{matrix}k,dir_{n-1}=Right\\k-1,dir_{n-1}=Left \end{matrix}\right.
stn−1={k,dirn−1=Rightk−1,dirn−1=Left
到这里,我们已经根据
s
t
n
st_n
stn和
d
i
r
n
dir_n
dirn求出了
s
t
n
−
1
st_{n-1}
stn−1和
d
i
r
n
−
1
dir_{n-1}
dirn−1,即成功地缩小了问题的规模。只要将问题
n
−
1
n-1
n−1的答案映射到问题
n
n
n的数字编号即可,映射公式已经推导过
a
n
s
n
=
{
a
n
s
n
−
1
,
a
n
s
n
−
1
≤
k
−
1
a
n
s
n
−
1
+
1
,
a
n
s
n
−
1
≥
k
ans_n=\left\{\begin{matrix} ans_{n-1},ans_{n-1}\leq k-1\\ ans_{n-1}+1,ans_{n-1}\geq k \end{matrix}\right.
ansn={ansn−1,ansn−1≤k−1ansn−1+1,ansn−1≥k
最后加上同样的边界条件,即
n
=
1
n=1
n=1时,显然答案是数字
0
0
0。
至此,我们完成了这个问题的数学推导。
下面贴出代码(这代码真没啥好看的,写完我自己都看不懂):
代码仅供参考,请不要盲目抄袭代码来完成作业!!!
#include <iostream>
using namespace std;
// dir=0代表右,dir=1代表左,st是开始报数的人
int solve(int n, int m, int st, int dir) {
if (n == 1) return 0;
int k = dir ? (n - 1 - st + m - 1) : (st + m - 1); // 这里的k是推导过程表述的路程s
int ndir = ((k / (n - 1)) & 1) ? (dir ^ 1) : dir; // s / (n-1) 是奇数就反向
k = (ndir ? (n - 1 - k % (n - 1)) : k % (n - 1)); // 这里的k即推导过程的k
int nst = ndir ? k - 1 : k; // 求出st_n-1
int ret = solve(n - 1, m, nst, ndir); // n-1问题的答案
return (ret >= k) ? ret + 1 : ret; // 进行映射
}
int main() {
int n, m;
scanf("%d%d", &n, &m);
printf("%d", solve(n, m, 0, 0) + 1);
return 0;
}
树状数组
有时间补上`(*>﹏<*)′