约瑟夫问题

约瑟夫问题,或称“约瑟夫环”,又名“丢手绢问题”。偶然碰到了这么一个问题,又了解到了它的来龙去脉,顿时十分感兴趣。在思考了一段时间后,我觉得有必要写下此文,纪念和传播这道经典的算法题,同时也附带我个人一些浅薄的思考和理解,与诸君分享。

这道算法题有着深刻的时代背景,它起源于一个非常惨烈的故事:

据说著名犹太历史学家 Josephus有过以下的故事:在罗马人占领乔塔帕特后,39 个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式:41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。然而Josephus 和他的朋友并不想遵从。首先从一个人开始,越过k-2个人(因为第一个人已经被越过),并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。问题是一开始要站在什么地方才能避免自杀?Josephus要他的朋友先假装遵从,他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏。
17世纪的法国数学家加斯帕在《数目的游戏问题》中讲了这样一个故事:15个教徒和15个非教徒在深海上遇险,必须将一半的人投入海中,其余的人才能幸免于难,于是想了一个办法:30个人围成一圆圈,从第一个人开始依次报数,每数到第九个人就将他扔入大海,如此循环进行直到仅余15个人为止。问怎样排法,才能使每次投入大海的都是非教徒。
——摘自约瑟夫问题_百度百科

当n,m数据量很小的时候,我们可以用循环链表模拟约瑟夫环的过程。当模拟到人数等于1的时候,输出剩下的人的序号即可。
具体解法这种方法往往实现起来比较简单,而且也很容易理解。但是时间复杂度却是很糟糕的,达到了O(nm),这样的话,其实在n,m比较大的时候(nm达到10^8或者更大),那么要得出结果往往需要耗费很长的时间,但是我们可以运用一点数学上的技巧,将最后结果推导出来。

为了简化出列的过程:
首先我们把这n个人的序号编号从0~n-1(理由很简单,由于m是可能大于n的,而当m大于等于n时,那么第一个出列的人编号是m%n,而m%n是可能等于0的,这样编号的话能够简化后续出列的过程),当数到m-1的那个人出列,因此我们编号完成之后,开始分析出列的过程:
第一次出列:
一开始的时候,所有人的编号排成序列的模式即为:
0,1,2,3,4,5...n-2,n-1
那么第一次出列的人的编号则是(m-1)%n1,那么在第一个人出列之后,从他的下一个人又开始从0开始报数,为了方便我们设k1 = m%n1(n1为当前序列的总人数)那么在第一个人出列之后,k1则是下一次新的编号序列的首位元素,那么我们得到的新的编号序列为:
k1,k1+1,k1+2,k1+3...n-2,n-1,0,1,2...k1-3,k1-2 (k1-1第一次已出列)
那么在这个新的序列中,第一个人依旧是从0开始报数,那么在这个新的序列中,每个人报的相应数字为:
0,1,2,3....n-2
那么第二次每个人报的相应数字与第一次时自己相应的编号对应起来的关系则为:
0 --> k1
1 --> k1+1
2 --> k1+2
...
n-2 ---> (k1+n-2)%n1(n1为当前序列的总人数,因为是循环的序列,k1+n-1可能大于总人数)
那么这时我们要解决的问题就是n-1个人的报数问题(即n-1阶约瑟夫环的问题)
可能以上过程你还是觉得不太清晰,那么我们重复以上过程,继续推导剩余的n-1个人的约瑟夫环的问题:
那么在这剩下的n-1个人中,我们也可以为了方便,将这n-1个人编号为:
0,1,2,3,4...n-2
那么此时出列的人的编号则是(m-1) % n2(n2为当前序列的总人数),同样的我们设k2 = m % n2,那么在这个人出列了以后,序列重排,重排后新的编号序列为:
k2,k2+1,k2+2,k2+3...n-2,n-1,0,1,2...k2-3,k2-2 (k2-1第一次已出列)
那么在这个新的序列中,第一个人依旧是从1开始报数,那么在这个新的序列中,每个人报的相应数字为:
1,2,3,4....n-2
那么这样的话是不是又把问题转化成了n-2阶约瑟夫环的问题呢?
后面的过程与前两次的过程一模一样,那么递归处理下去,直到最后只剩下一个人的时候,便可以直接得出结果
当我们得到一个人的时候(即一阶约瑟夫环问题)的结果,那么我们是否能通过一阶约瑟夫环问题的结果,推导出二阶约瑟夫环的结果呢?
借助上面的分析过程,我们知道,当在解决n阶约瑟夫环问题时,序号为k1的人出列后,剩下的n-1个人又重新组成了一个n-1阶的约瑟夫环,那么
假如得到了这个n-1阶约瑟夫环问题的结果为ans(即最后一个出列的人编号为ans),那么我们通过上述分析过程,可以知道,n阶约瑟夫环的结果
(ans + k)%n(n为当前序列的总人数),而k = m%n
则有:
n阶约瑟夫环的结果

(ans + m % n)%n,那么我们还可以将该式进行一下简单的化简:

当m<n时,易得上式可化简为:(ans + m)% n

而当m>=n时,那么上式则化简为:(ans % n + m%n%n)% n
即为:(ans % n + m%n)% n
而 (ans + m)% n = (ans % n + m%n)% n
因此得证
(ans + m % n)%n = (ans + m)% n
这样的话,我们就得到了递推公式,由于编号是从0开始的,那么我们可以令
f[1] = 0; //当一个人的时候,出队人员编号为0
f[n] = (f[n-1] + m)%n //m表示每次数到该数的人出列,n表示当前序列的总人数
而我们只需要得到第n次出列的结果即可,那么不需要另外声明数组保存数据,只需要直接一个for循环求得n阶约瑟夫环问题的结果即可
由于往往现实生活中编号是从1-n,那么我们把最后的结果加1即可。

#include <stdio.h>
int main()
{
    int n, m, i, s = 0;
    printf ("N M = ");
    scanf("%d%d", &n, &m);
    for (i = 2; i <= n; i++)
    {
        s = (s + m) % i;
        printf("%d\n",s);
    }
    printf ("\nThe winner is %d\n", s+1);
}

c++ 

#include <iostream>  
#include <cmath>  
#include<stdio.h>
#include <cstdio>  
#include <cstring>  
#include<algorithm> 
using namespace std;
int main()
{
    while (1)
    {
        int N;//人的总个数  
        int M;//间隔多少个人  

        cin >> N;
        cin >> M;
        int result = 0;//N=1情况  
        for (int i = 2; i <= N; i++)
        {
            result = (result + M) % i;//根据淘汰情况实时调整人数
        }
        cout << "最后自杀的人或者猴子是:" << result + 1 << endl;//result要加1  
    }
    return 0;
}
复制代码
 #include<iostream>
  using namespace std;
  
  int Joseph (int n,int m)
  {
  int i, s=0;
  for (i=2; i<=n; i++)
  s=(s+m)%i;
  return (s+1);
 }
 int main()
 {
    int m,n;
     cin>>n>>m;
     cout<<Joseph(n,m)<<endl;
     return 0;
 } 
方法三:递归

帅地被迫只能拿出我的递归大法,递归是思路是每次我们删除了某一个士兵之后,我们就对这些士兵重新编号,然后我们的难点就是找出删除前和删除后士兵编号的映射关系

如果你对递归不大懂,可以看我之前的递归文章:连刷半年题,告别递归,谈谈我的一些经验

我们定义递归函数 f(n,m) 的返回结果是存活士兵的编号,显然当 n = 1 时,f(n, m) = 1。假如我们能够找出 f(n,m) 和 f(n-1,m) 之间的关系的话,我们就可以用递归的方式来解决了。我们假设人员数为 n, 报数到 m 的人就自杀。则刚开始的编号为


1

m - 2

m - 1

m

m + 1

m + 2

n

进行了一次删除之后,删除了编号为 m 的节点。删除之后,就只剩下 n - 1 个节点了,删除前和删除之后的编号转换关系为:

删除前 — 删除后

… — …

m - 2 — n - 2

m - 1 — n - 1

m ---- 无(因为编号被删除了)

m + 1 — 1(因为下次就从这里报数了)

m + 2 ---- 2

… ---- …

新的环中只有 n - 1 个节点。且删除前编号为 m + 1, m + 2, m + 3 的节点成了删除后编号为 1, 2, 3 的节点。

假设 old 为删除之前的节点编号, new 为删除了一个节点之后的编号,则 old 与 new 之间的关系为 old = (new + m - 1) % n + 1。

有人可能看了会一脸懵逼?如果是这样,那么我建议你自己找几个例子模仿推导一下,估计就知道了

注:有些人可能会疑惑为什么不是 old = (new + m ) % n 呢?主要是因为编号是从 1 开始的,而不是从 0 开始的。如果 new + m == n的话,会导致最后的计算结果为 old = 0。所以 old = (new + m - 1) % n + 1.
这样,我们就得出 f(n, m) 与 f(n - 1, m)之间的关系了,而 f(1, m) = 1.所以我们可以采用递归的方式来做。代码如下:

int f(int n, int m){
    if(n == 1)   return n;
    return (f(n - 1, m) + m - 1) % n + 1;
}
  • 1
  • 2
  • 3
  • 4

我去,两行代码搞定,而且时间复杂度是 O(n),当然,空间复杂度还是 O(n),因为会递归会调用 n 次。

为了装逼,我必须用用一行代码来搞定,如下:

int f(int n, int m){
    return n == 1 ? n : (f(n - 1, m) + m - 1) % n + 1;
}
  • 1
  • 2
  • 3

卧槽,以后面试官让你手写约瑟夫问题,你就扔这一行代码给它。这里需要注意的是,如果 n 的数值太大,那么就不适合用递归了,注意怕递归太深,爆栈了

 

  • 21
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值