详解约瑟夫问题

  • Post author:
  • Post category:其他


                    算法集锦第一话:详解约瑟夫问题

约瑟夫问题(Josephus problem)是一个在是一个出现在计算机科学和数学中的问题。有时也称为约瑟夫斯置换。在计算机编程的算法中,类似问题又称为约瑟夫环。又称“丢手绢问题”。

如果有参加NOI/NOIP(现称CSP)的同学们,就会很清楚一个事实:约瑟夫问题绝对是你童年的阴影/

之一

/它以复杂,变幻莫测,算法简单/

复杂

/,容易/

艰巨

/记忆而著称。不过今天我们就来了解并学习几个简单的约瑟夫问题示例,扫清障碍!



模块一:约瑟夫问题简介

约瑟夫问题的来历也挺魔幻的(现今来讲理由也很魔幻😥😥),据说著名犹太历史学家 Josephus有过以下的故事:在罗马人占领乔塔帕特后,39 个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。然而Josephus 和他的朋友并不想遵从。首先从一个人开始,越过k-2个人(因为第一个人已经被越过),并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。问题是,给定了和,一开始要站在什么地方才能避免被处决?Josephus要他的朋友先假装遵从,他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏。 [1]

17世纪的法国数学家加斯帕在《数目的游戏问题》中讲了这样一个故事:15个教徒和15 个非教徒在深海上遇险,必须将一半的人投入海中,其余的人才能幸免于难,于是想了一个办法:30个人围成一圆圈,从第一个人开始依次报数,每数到第九个人就将他扔入大海,如此循环进行直到仅余15个人为止。问怎样排法,才能使每次投入大海的都是非教徒。

问题分析与算法设计

要说实话,约瑟夫问题并不难,但求解的方法很多;题目的变化形式也很多。这里给出一种实现方法。

题目中30个人围成一圈,因而启发我们用一个循环的链来表示,可以使用结构数组来构成一个循环链。结构中有两个成员,其一为指向下一个人的指针,以构成环形的链;其二为该人是否被扔下海的标记,为1表示还在船上。从第一个人开始对还未扔下海的人进行计数,每数到9时,将结构中的标记改为0,表示该人已被扔下海了。这样循环计数直到有15个人被扔下海为止。

由此看来,约瑟夫问题,可以转化为一个类似于余数或猫吃老鼠问题之类的。如果比较能理解这种做法的,我们来讲解一下法1:

以杀人问题(原来约瑟夫问题一直那么残暴🎃🎃)为例,N个人围成一圈,从第一个开始报数,第M个将被杀掉,最后剩下一个,其余人都将被杀掉。例如N=6,M=5,被杀掉的顺序是:5,4,6,2,3,1。

我们简单分析一下:

(1)由于对于每个人只有死和活两种状态,因此可以用布尔型数组标记每个人的状态,可用true表示死,false表示活。

(2)开始时每个人都是活的,所以数组初值全部赋为false。

(3)模拟杀人过程,直到所有人都被杀死为止。

如果你看懂了,就可以看下面的代码了:

学Pascal的同学看这个代码:

好吧没有代码块·····

var

a:array [1…20] of integer;

n,m,i,j,k,n1,m1:integer;

begin

readln(m,n);

for i:=1 to m do

a[i]:=i;

m1:=m;

n1:=1;

while m1>0 do

begin

j:=(n+n1-1-1) mod m1 +1;

n1:=j;

m1:=m1-1;

writeln(a[j]);

for k:=j to m1 do

a[k]:=a[k+1];

end;

end.

如果学C++的同学就看下面这个代码:

#include

using namespace std;

main()

{


bool a[101]={0};

int n,m,i,f=0,t=0,s=0;

cin>>n>>m;

do

{


++t;//逐个枚举圈中的所有位置

if(t>n)

t=1;//数组模拟环状,最后一个与第一个相连

if(!a[t])

s++;//第t个位置上有人则报数

if(s==m)//当前报的数是m

{


s=0;//计数器清零

cout<<t<<’ ‘;//输出被杀人编号

a[t]=1;//此处人已死,设置为空

f++;//死亡人数+1

}

}while(f!=n);//直到所有人都被杀死为止

}

看懂了吧?

可是如果你能坚持这么写,那么恭喜你,小酱敬你是条汉子(

A

)

这种做法好理解是好理解,可是无论是用链表实现还是用数组实现都有一个共同点:要模拟整个游戏过程,不仅程序写起来比较烦,而且时间复杂度高达

O(nm),当n,m非常大(例如上百万,上千万)的时候,几乎是没有办法在短时间内出结果的。我们注意到原问题仅仅是要求出最后的胜利者的序号,而不是要读者模拟整个过程。因此如果要追求效率,就要打破常规,实施一点数学策略(毕竟大家都入了OI坑)

因此,为了讨论方便,我们把问题转化一下,这样做并不影响题目原意。

我们知道第一个人(编号一定是(m-1)) 出列之后,剩下的n-1个人组成了一个 新的约瑟夫环(以编号为k=m mod n的人开始):

k ,k+1,k+2 … n-2,n-1,0,1,2,… k-2

并且从k开始报0。

我们把他们的编号做一下转换:

k –> 0

k+1 –> 1

k+2 –> 2





k-2 –> n-2

变换后就完完全全成为了(n-1)个人报数的子问题,假如我们知道这个子问题的解:例如x是最终的胜利者,那么根据上面这个表把这个x变回去不刚好就是n个人情况的解吗?!!变回去的公式很简单,相信大家都可以推出来:x’=(x+k) mod n

如何知道(n-1)个人报数的问题的解?对,只要知道(n-2)个人的解就行了。(n-2)个人的解呢?当然是先求(n-3)的情况 —- 这显然就是一个倒推问题!好了,思路出来了,下面写递推公式:

令f表示i个人玩游戏报m退出最后胜利者的编号,最后的结果自然是f[n]

递推公式

f[1]=0;

f[i]=(f[i-1]+m) mod i; (i>1)

有了这个公式,我们要做的就是从1-n顺序算出f的数值,最后结果是f[n]。因为实际生活中编号总是从1开始,我们输出f[n]+1。

如果你这个公式看懂了,那么恭喜你,你可以应付X-J的CSP考试了(小酱没参加过提高组),接下来就看代码吧:

/

PS:有同学可能有疑问:我是不是要保存每个f呢?其实不用,由于我们的程序是逐级递推的,不需要保存每个f,因此程序也是异常简单

/

Pascal有4种写法:

1号选手:

var

n,m,i,s,p:integer;

a:array[1…10000] of integer;

begin

read(n,m);//这步不用说了吧?

for i:=1 to n do

a[i]:=1;//先全部赋值1

p:=0;s:=0;//统计人数和报数字用的

repeat

for i:=1 to n do

begin

if a[i]=0

then continue; //用于等会排除出圈者

s:=s+a[i];//不断累加(报数字)

if s=m then//出圈者

begin

write(i,’ ‘);打印出圈者;

a[i]:=0;//明白刚才continue的意思了吧

p:=p+1;//人数减少一个;

s:=0;//重头报起.

end;

end;

until p=n;//直到人数到了

end.

2号选手:

var

a:array[1…100] of integer;

n,m,i,j,p:integer;

Begin

write(‘Input n,m:’);

readln(n,m);

for i:=1 to n do

a[i]:=i;

p:=1; {p用于记录报数的位置}

for i:=1 to n do

begin

j:=0; {j用于记录报到的人数}

while j<m do

begin

if a[p]<>0 then j:=j+1;

if p=n then p:=1 else p:=p+1; {处理边界情况}

end;

if p<>1

then begin write(a[p-1],’ ‘);a[p-1]:=0;end {处理边界情况}

else begin write(a[n],’ ‘);a[n]:=0;end;

end;

end.

3号选手:

var

a:array[1…100] of integer;

n,m,p,i,j:integer;

begin

readln(n,m);

for i:=1 to n-1 do

a[i]:=i+1;

a[n]:=1;

p:=n;

for i:=1 to n do

begin

for j:=1 to m-1 do

p:=a[p];

write(a[p],’ ‘);

a[p]:=a[a[p]];

end;

end.

4号选手:

var n,m,i,s:integer;

begin

write(‘N M =’);

read(n,m);

for i:=2 to n do

s:=(s+m) mod i;

writeln(s+1, );

end.

学Pascal的同学可以选择性记忆哦~

C++/C的写法有一种,如下:

#include

using namespace std;

const int m = 3;

int main()

{


int n, f = 0;

cin >> n;

for (int i = 1; i <= n; i++) f = (f + m) % i;

cout << f + 1 << endl;

}

这次为python争取到了一个名额,不过是3.x版的,学python的同学可以看:

#控制参数:

nums = 41

call = 3

#参数定义:

peoples = []

for _ in range(nums):

peoples.append(True)

result = []

num =1

#主逻辑

while(any(peoples)):

for index,people in enumerate(peoples):

if people:

if num == call:

peoples[index] = False

result.append(index+1)

print(index+1)#每轮的出局者

print(peoples)#每次的队列状态

num = 1

else:

num += 1

print(’-’* 25)

print(’\n总数为%d,报数为%d’ % (nums,call))

print(‘约瑟夫序列为:\n%s\n’ % result)

print(’-’* 25)

可以看到(推导出),这个算法的时间复杂度为O(n),相对于模拟算法已经有了很大的提高。算n,m等于一百万,一千万的情况不是问题了。可见,适当地运用数学策略,不仅可以让编程变得简单,而且往往会成倍地提高算法执行效率。

拓展一道例题:

约瑟夫问题10e100版(Pascal版)
描述 Description
n个人排成一圈。从某个人开始,按顺时针方向依次编号。从编号为1的人开始顺时针“一二一”报数,报到2的人退出圈子。这样不断循环下去,圈子里的人将不断减少。由于人的个数是有限的,因此最终会剩下一个人。试问最后剩下的人最开始的编号。
输入格式 Input Format
一个正整数n,表示人的个数。输入数据保证数字n不超过100位。
输出格式 Output Format
一个正整数。它表示经过“一二一”报数后最后剩下的人的编号。
样例输入 Sample Input
9
样例输出 Sample Output
3
时间限制 Time Limitation
各个测试点1s
注释 Hint
样例说明
当n=9时,退出圈子的人的编号依次为:
2 4 6 8 1 5 9 7
最后剩下的人编号为3
初见这道题,可能会想到模拟。可是数据实在太大啦!!
我们先拿手来算,可知n分别为12345678...时的结果是11313571...
有如下规律:从1到下一个1为一组,每一组中都是从1开始递增的奇数,且每组元素的个数分别为124...
这样就好弄了!!
大体思路如下:
①read(a)
②b:=1,c:=1{
   b为某一组的元素个数,c为累计所加到的数}while c<a do (b:=b*2,c:=b+c){
   超过目标时停止加数}
⑥c:=c-b{
   退到前一组}
⑦x:=a-c{
   算出目标为所在组的第几个元素}
⑧ans:=x*2-1{
   求出该元素}write(ans)
有了思路,再加上高精度就可以了。我写的代码比较烦琐,因为是先把上面的思路敲进去,再写过程,又把一些简单的过程合到主程序中了,所以有点乱,也有点烦琐。起提供思路的作用还是完全可以的吧~~~

直接上代码:

var

a,b,c:array[1…105]of longint;

la,lb,lc,i:longint;

s:string;

procedure incc;

var

i:integer;

begin

for i:=1 to 105 do

c:=c+b;

for i:=1 to 104 do

if c>9 then

begin

c:=c+cdiv10;

c:=c mod 10;

end;

end;

functioncxiaoa:boolean;

vari



版权声明:本文为Coduck原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。