深入浅出“约瑟夫环问题”
借鉴的博客:
约瑟夫环——公式法(递推公式)
约瑟夫环(数学高效率解法,很详细)
约瑟夫环问题(Josephus problem)是一道经典的算法、数学问题,原问题是这样的:
据说著名犹太历史学家 Josephus有过以下的故事:在罗马人占领乔塔帕特后,39 个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。
鉴于这个问题太过于暴力,并且不够广义,我们将其抽象化为以下的问题解决:
【问题描述】
有n个人围成一圈,按顺序从1到n编号。从第1个人开始报数,报数m的人退出圈子,下一个人从1开始重新报数,报数m的人退出圈子。如此循环,直到留下最后一个人。问留下来的人的编号。
【输入形式】
输入一行由两个整数组成,n表示参与游戏的玩家人数,m表示最大报数(即每次报到这个数的人退出圈子)
【输出形式】
输出一行为一个整数a,表示最后剩下的人最初的编号。
【输入样例】
5 3
【输出样例】
4
简单分析一下这类问题,其实这类问题需要我们设计算法解决的,也是最难办到的,就是下面的两个主要的问题:
- 怎么样把排好的序号(代表每个参与游戏的人)连接成一个“环”处理。
- 怎么样在报数过程中处理前面报数后退出圈子的人,即空位序号。
有了这两个问题,相信很多人都非常容易想到用链表来解决,组织一个单项循环的链表,就能够模拟整个游戏的过程,思路非常清晰且容易理解:创建n个节点,从第一个节点开始,每m个节点删除一个节点,直到最后节点的指针域指向自己(即剩下最后一个玩家),读出这个玩家最初的编号,游戏完成。
但是这种算法一旦遇到n非常大的时候,就很难在短时间内给出答案。
在接触到网络上的数学方法之前,我拿到这个问题就浮现的算法是通过数组实现:
用数组来思考这个问题有几个好处,首先,数组的下标就是已经排好序的一组序列,其次,数组相对于抽象的数学方法来说更加直观但又不需要复杂的数据结构那样需求量的解决时间:
- 用0表示玩家出局,用1表示玩家在场,游戏开始前先将n个元素全部赋值为1,从下标为0的元素开始,每m个元素将一个元素赋值为0。
- 到了数组的末端,将溢出玩家编号的数组下标对当前的玩家人数取模(即**+1取模**),就能够实现一个循环数组,即“环”。
判断并打印每一次赋值为0的元素(编号为下标+1),同时根据数组的值选择跳开值已经为0的元素,这样就能得到每一轮出局的玩家编号,思路也还算比较容易理解,但是想要实现这个算法,需要的代码量非常的大,而且编写过程中很容易出错,且遇到n非常大时,同样需要很长的时间给出结果。
这个问题最容易的解法,应该是数学递推公式,但是这个算法有点难理解,我花了不少的时间才差不多能搞清楚。
为了方便说明这个算法,我们不妨先讨论一个一般的情况,即由键盘输入n,这n个人围成一个环,按顺序编号,从第一个人开始从1报数,报到3的人出列,直到最后剩下一个人,求这个人最初的编号。
话不多说,先上代码:
#include <iostream>
using namespace std;
int main()
{
int n, i, p = 0;
cin>>n;
for(i=2; i<=n; i++)
{
p = (p+3)%i;
}
cout<<"Last No. is:"<<p+1<<endl;
}
怎么样,是不是很惊讶,你冥思苦想的问题,用数学递推公式就可以把代码简化到短短几行,这就是强大算法的力量。
当然,这个递推想要把它弄明白并不简单,我们仍然要从一个特例入手,不妨假设我们输入的n = 10,即有10个参与游戏的玩家,那么游戏开始时,他们的编号依次为(由于需要用到取模运算形成一个“环”所以我们的编号需要从0开始,之后输出的时候注意+1就行了):
0 1 2 3 4 5 6 7 8 9
第一轮过后,编号为3的玩家出局,即剩下的玩家为:
0 1 3 4 5 6 7 8 9
下一轮报数是由编号为4的玩开始的,即4号玩家会报1,5号玩家报2,6号玩家出局,以此类推,我们可以得到剩下9位玩家新的编号。
7 8 0 1 (2) 3 4 5 6
同样的,下一轮报数是由上一轮编号为4的玩家开始报的,我们又可以得到剩下8位玩家新的编号。
4 5 6 7 0 1 2 3
仔细观察不难发现,第n轮剩下玩家的编号可以由第(n+1)轮玩家的编号推出来,即:
旧编号 = (新编号+报数的最大值)%旧编号对应的剩余玩家人数
也可以把它理解为:新编号向前移动3位再通过取模形成环即可得到上一轮的旧编号。
如果能够把这个递推原理理解清楚,那么我们就快要把这道题目想明白了:
幸运的是,我们很容易知道最后一轮玩家的编号,即获胜者的编号在最后一轮的时候一定是0,因为此时只剩下他一个人在圈内,再通过上述的递推关系式,我们就能够得到它最初即第一轮的编号了。
另外,这个问题我们也可以倒过来思考,即构造一个递归函数,以第一轮的出局的玩家编号为出口,依次推出后来每一轮出局的玩家编号。又一次让我们感到幸运的是,第一轮出局的玩家也非常好找。
int Josephus(int sum, int max, int n)
/*sum表示剩余的玩家总数,max表示最大报数值,n表示轮次*/
{
if(n == 1)
return (sum+max-1)%sum;
/* -1 是为了使编号从0开始*/
else
return (Josephus(int (sum-1), int max, int (n-1))%sum;
}
下面是完整的代码(数组解法和递归函数解法)
/*约瑟夫环——数组解法*/
#include <cstdio>
#include <cstring>
#define N 10000
int main()
{
int i, sum, n, counter = 0;/*sum表示玩家总数,n表示轮次(同时也是剩余玩家数)*/
int num[N];
scanf("%d",&sum);
n = sum;
memset(num, 1, n*sizeof(int));
for(i=0; ; i=(i+1)%sum)/*控制数组进入循环,到最后一个人之后第一个人即为第一个人,下标为0*/
/*这里还可以用另一种表达式控制循环:i == n-1 ? i=0 : i++,效果是一样的*/
{
if(num[i])
{
counter++;
}
if(counter == 3)
{
num[i] = 0;
counter = 0;
n -= 1;
}
if(n == 1)
break;
}
for(i=0; i<sum; i++)
{
if(num[i])
printf("%d",i+1);
}
return 0;
}
/*约瑟夫环——递归函数解法*/
#include <iostream>
#define N 1000
using namespace std;
int Josephus(int sum, int max, int n)
/*sum表示剩余的玩家总数,max表示最大报数值,n表示轮次*/
{
if(n == 1)
return (sum+max-1)%sum;
/* -1 是为了使编号从0开始*/
else
return (Josephus((sum-1), max, (n-1))+max)%sum;
}
int main()
{
int sum, n;
cin>>sum;
n = sum;
cout<<Josephus(sum, 3, n)+1;
return 0;
}