一文全解析约瑟夫环问题

  这个问题来源于犹太人约瑟夫经历过的故事,在罗马人占领乔塔帕特后,约瑟夫和他的朋友与39 个犹太人躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人时,该人就必须自杀,然后再由下一个人重新报数,直到所有人都自杀身亡为止。

      然而约瑟夫和他的朋友并不想遵从这个规则,于是,他们想出新的思路:从一个人开始,越过k-2个人(因为第一个人已经被越过),并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。

      问题是,给定了和,一开始要站在什么地方才能避免被处决?如果你是约瑟夫,你会站在什么样的位置呢?下面运用以下两个方面来解决这个问题。 

模拟法

循环单链表实现

      约瑟夫环问题的基本形式为:n个人围成一圈,从第一个开始报数,每报到m者将被杀掉,直至只剩一个人。

如:N=6,M=5

1     2     3     4     5     6

1     2     3     4            6

1     2     3                   6

1     2     3                  

1            3

1

      由此可以很容易想到使用循环单链表来实现。创建指针p,当指针移动m-1个位置后,就该删除下一个节点,由此类推,直至链表中只含一个节点。

代码实现

      用数组也可以实现暴力模拟约瑟夫环。但是,用模拟法有一个很明显的缺陷——时间复杂度高达O(nm),所以下面考虑优化算法。

 

数学法

 用递归法优化到O(n)复杂度

      在Donald E. Knuth的《具体数学》中,对m=2的情况使用了递归的解决方法,并推出了一个常数表达式,使得此种情况下,算法的复杂度为常量。同时,这种思路也可以应用于n>2的情况,但无法得出常数表达式,推广后的递归算法具体的思路如下:

      当n个人围成一圈并以m为步长第一次报数时,第m个人出列,此时就又组成了一个新的,人数为n-1的约瑟夫环,要求n个人的约瑟夫环问题的解,就依赖于求n-1个人的约瑟夫问题的解,要求n-2个人的约瑟夫问题的解,则依赖于求n-2个人的约瑟夫换问题的解,依次类推,直至求1个人的时候,该问题的解。

递推公式:f(N,M)=f((N-1,M)+M)%N

其中,f(N,M)表示N个人报数,每报到M时杀掉的那个人,最终胜利者的编号

推导过程

举例:11个人参与游戏,每报到3的人被杀掉

第一轮:从No.1开始报数,No.3被杀

第二轮:No.4从1开始报数,这时可以认为队伍的头是No.4,No.6被杀

……

第九轮:No.2从1开始报数,成为队伍的头,No.8被杀

第十轮:No.2从1开始报数,……No.2被杀

胜利者为No.7

假设1:当游戏中剩余11人时,我们知道胜利者为No.6。那么下一轮剩余10人时,胜利者为No.6。因为删掉No.3后,之后的人都往前移动了3位;

假设2:当游戏中剩余10人时,我们知道胜利者为No.3。那么下一轮剩余11人时,胜利者的编号是几?该问题可以看作假设1的逆过程,因此:f(11,3)=f(10,3)+3

为防止数组越界,对当前人数取模:

f(11,3)=(f(10,3)+3)%11

假设3:游戏中剩余N人,报到M者被杀,数组移动情况为:每杀一个人,下一个人成为头,相当于把数组向前移动M位。若已知剩余N-1人时胜利者下标为,则N个人时,就是往后移动M位。因此推导出递推公式:

f(N,M)=(f(N-1,M)+M)%N

代码实现

typedef long long LL;

LL kill(LL n,LL m)

{

 LL i,p=0;

 if(m==1) return n;

  else

{      

for(i=2;i<=n;i++)

{p=(p+m)%i;}

return p+1;}

}  

优化递推:复杂度降至O(m)

       观察以上代码中的p,p=(p+m)%i,当i比较大时,m远远小于i。因此队伍每次不止可以移动一个m位,可以一次移动x*m位来跳过x个i。当m远远小于n时,效率会更高。对于当前的p1,设:p1+x*m=i,当i+x>=n时,表示这次移动位数已经超过了n。令更新后的p2=p1+(n-i)*m,并且i=n来跳出本次循环。使用以上算法,运行速度将与游戏人数没有关系。

代码实现

 那么……还可以变得更简单吗?我们先来看这道题目

      问题为约瑟夫环问题的经典形式,但要注意到,在n可达10^12的情况下,再运用以上的递归法显然会超时。因此我们要重新考虑。

      假设初始编号为1,2,······,n,现在考虑一种新的编号方式。第一个人不会被踢掉,那么他的编号从n开始往后加1,变成n+1,然后第二个人编号变为n+2,直到第q个人,他被踢掉了。然后第q+1个人编号继续加1,变成了n+q,依次下去。考虑当前踢到的人编号为kq,那么此时已经踢掉了k个人,所以接下去的人新的编号为n+k(q-1)+1……

     所以编号为kq+d的人编号变成了n+k(q-1)+d,其中1<=d<q。直到最后,可以发现活下来的人编号为qn,问题是怎么根据这个编号推出他原来的编号?以n=10,q=3为例,下图就是每个人新的编号:

 

     令N=n+k(q-1)+d,那么他上一轮的编号是kq+d=kq+N-n-k(q-1)=k+N-n,因为k=(N-n-d)/(q-1)=[(N-n-1)/(q-1)],所以上一次编号可以写为[(N-n-1)/(q-1)]+N-n

     如果用D=qn+1-N代替N将会进一步简化算法:

算法伪代码如下:

D=1

while D<=(q-1)n:

D=k

Ans=qn+1-D  

其中k=Dq/(q-1),c++代码实现

      在这样优美而又简洁的算法下,问题便迎刃而解。

posted @ 2023-06-23 21:56  晨煦风清  阅读(1045)  评论(0编辑  收藏  举报