“约瑟夫”问题及若干变种
例1、约瑟夫问题(Josephus)
[问题描述]
M只猴子要选大王,选举办法如下:所有猴子按1…M编号围坐一圈,从第1号开始按顺序1,2,…,N报数,凡报到N的猴子退出到圈外,再从下一个猴子开始继续1~ N报数,如此循环,直到圈内只剩下一只猴子时,这只猴子就是大王。
M和N由键盘输入,1≤N,M≤10000,打印出最后剩下的那只猴子的编号。
例如,输入8 3,输出:7。
[问题分析1]
这个例题是由古罗马著名史学家Josephus提出的问题演变而来的,所以通常称为Josephus(约瑟夫)问题。
在确定程序设计方法之前首先来考虑如何组织数据,由于要记录m只猴子的状态,可利用含m个元素的数组monkey来实现。利用元素下标代表猴子的编号,元素的值表示猴子的状态,用monkey[k]=1表示第k只猴子仍在圈中,monkey[k]=0则表示第k只猴子已经出圈。
程序采用模拟选举过程的方法,设变量count表示计数器,开始报数前将count置为0,设变量current表示当前报数的猴子编号,初始时也置为0,设变量out记录出圈猴子数,初始时也置为0。每次报数都把monkey[current]的值加到count上,这样做的好处是直接避开了已出圈的猴子(因为它们对应的monkey[current]值为0),当count=n时,就对当前报数的猴子作出圈处理,即:monkey[current]:=0,count:=0,out:=out+1。然后继续往下报数,直到圈中只剩一只猴子为止(即out=m-1)。参考程序如下:
program josephus1a {模拟法,用数组下标表示猴子的编号}
const maxm=10000;
var m,n,count,current,out,i:integer;
monkey:array [1..maxm] of integer;
begin
write('Input m,n:');
readln(m,n);
for i:=1 to m do monkey[i]:=1;
out:=0; count:=0; current:=0;
while out<m-1 do
begin
while count<n do
begin
if current<m then current:=current+1 else current:=1;
count:=count+monkey[current];
end;
monkey[current]:=0; out:=out+1; count:=0
end;
for i:=1 to m do
if monkey[i]=1 then writeln('The monkey king is no.',i);
readln
end.
[运行结果]下划线表示输入
Input m,n:8 3
The monkey king is no.7 {时间:0秒}
Input m,n:10000 1987
The monkey king is no.8544 {时间:3秒}
[反 思] 时间复杂度很大O(M*N),对于极限数据会超时。这已经是优化过的程序,大家可以去看未做任何优化的程序josephus1b.pas,这个程序的时间复杂度为O(M*N*K),K是一个不确定的系数,对应着程序中的repeat循环花费的时间。空间复杂度为O(M)。
program josephus1b;{模拟法,用数组下标表示猴子的编号}
const maxm=10000;
var m,n,count,current,out,i:integer;
monkey:array [1..maxm] of integer;
begin
write('Input m,n:');
readln(m,n);
for i:=1 to m do monkey[i]:=1;
out:=0; count:=1; current:=1;
while out<m-1 do
begin
while count<n do
begin
repeat{寻找圈上的下一只猴子}
current:=current+1;
if current=m+1 then current:=1
until monkey[current]=1;
count:=count+1
end;
monkey[current]:=0; out:=out+1; count:=0
end;
for i:=1 to m do
if monkey[i]=1 then writeln('The monkey king is no.',i);
readln
end.
[问题分析2]
在组织数据时,也可以考虑只记录仍在圈中的猴子的情况。用一个线性表按编号由小到大依次记录圈中所有猴子的编号,每当有猴子出圈时,即从线性表中删除对应元素,表中元素减少一个。程序中用变量rest表示圈中剩余的猴子数,即线性表中元素的总数。参考程序如下:
program josephus2a; {模拟法,用数组元素的值表示猴子的编号}
const maxm=10000;
var m,n,current,rest,i:integer;
monkey:array [1..maxm] of integer;
begin
write('Input m,n:');
readln(m,n);
for i:=1 to m do monkey[i]:=i;
rest:=m; current:=1;
while rest>1 do
begin
current:=(current + n - 1) mod rest;
if current=0 then current:=rest;
for i:=current to rest-1 do monkey[i]:=monkey[i+1];
rest:=rest-1
end;
writeln('The monkey king is no.',monkey[1]);
readln
end.
[运行结果]下划线表示输入
Input m,n:10000 1987
The monkey king is no.8544 {时间:0.8秒}
[反 思] 时间复杂度为O(M*K),但K远远小于N,是for循环花费的时间,速度教快。空间复杂度仍然为O(M)。
也可以用monkey[j]存放第j个猴子的后继编号。当第j个出圈时,只要把monkey[j]的值赋给它的前驱,这样就自然跳过了j,以后再也不会访问到它了,但是时间上反而不如前面的方法。程序如下:
program josephus2b; {模拟法,用数组元素的值表示后继猴子的编号}
const maxm=10000;
var m,n,current,rest,i:integer;
monkey:array [1..maxm] of integer;
begin
write('Input m,n:');
readln(m,n);
for i:=1 to m-1 do monkey[i]:=i+1;
monkey[m]:=1;current:=m;
rest:=m;
while rest>1 do
begin
for i:=1 to n-1 do current:=monkey[current];
monkey[current]:=monkey[monkey[current]];
rest:=rest-1
end;
writeln('The monkey king is no.',monkey[current]);
readln
end.
[问题分析3] 本题用单向循环链表做,模拟的更形象。时间复杂度为O(m*n),空间复杂度也是O(m),极限数据比方法2稍慢。程序如下:
program josephus3; {模拟法3,单向循环链表}
TYPE
point=^node;
node=record
data:longint;
next:point
end;
VAR m,n,s:longint; {s为计数器}
p,q,head:point;
BEGIN
readln(m,n);
new(head);q:=head;head^.data:=1;
for s:=2 to m do
begin
new(p);
p^.data:=s; {建一个结点,并且赋上数据域}
q^.next:=p;
q:=p; {把p接到q的后面,再把p作为新的q}
end;
q^.next:=head; {做成循环队列}
s:=1;q:=head; {从队头开始报数,计数器也设为1}
repeat
p:=q^.next;
s:=s+1;
if s mod n=0 then begin {报到了n}
q^.next:=p^.next; {p出圈}
dispose(p)
end
else q:=p;
until q^.next=q;
writeln('The monkey king is no.',q^.data);
readln
END.
[运行结果]下划线表示输入
Input m,n:10000 9873
The monkey king is no.8195
[问题分析4]
能不能不通过模拟而直接求出第k次出圈的猴子的编号呢?这就是递推法的思想。用递推来求约瑟夫问题的方案,主要是找到m个猴子选大王过程与m-1个猴子选大王过程之间的关系。假如m=5,n=3,有5个猴子:
1 |
2 |
3 |
4 |
5 |
图1 |
然后进行第一步选猴子,从第一个数起,3出圈,剩下4只猴子(图2,4被标上红色表示下一次从4数起):
1 |
2 |
|
4 |
5 |
图2 |
1 |
2 |
3 |
4 |
图3 |
接下来的选择过程与m=4,n=3(即四只猴子选大王,图3,从1数起)的情况非常相似。图2中的4相当于图3中的1,图2中的5相当于图3中的2……。以此类推,得到如下对应关系(左边一列是图2中的数字,右边一列是图3中的数字):
4←1 (注意:4-1=3 ,4=(1+3-1)mod 5 +1)
5←2 ( 5=(2+3-1)mod 5 +1)
1←3 ( 1=(3+3-1)mod 5 +1)
2←4 ( 2=(4+3-1)mod 5 +1)
仔细研究一下这些对应关系,便不难发现,从1数到4要数2、3、4三个数,从2数到5要数3、4、5三个数,从3数到1要数4、5、1三个数,从4数到2要数5、1、2三个数——都是三个数,而这个“三”是由第一个对应关系确定的:delta=4-1=3。
现在假设我们知道m’=4,n=3的方案(a[i])为3、2、4、1,那么按照上面介绍的对应关系, 我们就可以推出m=5,n=3的方案为b[i]=(a[i]+delta-1)mod m +1),即:得到的新序列为1、5、2、4,再在这个序列前加上第一个出队的n(=3)号猴子,就得到3、1、5、2、4,而这就是m=5,n=3的出圈序列。
现在推广到m、n的情况,设a(m,i)表示m个猴子选大王第i个出圈的猴子编号,则:
1、第一个出圈的猴子是a(m,1)=(n-1)mod m +1。注:一般情况下m>=n,则a(m,1)=n。但m<n时就需要调整了。
2、数第二遍的时候,从a(m,1)的下一个开始数起,即:从a(m,1)mod m+1数起,得到delta。
Delta = a(m,1) mod m + 1 – 1 = a(m,1) mod m。
3、剩下的出圈顺序可由递推关系得出:a(m,i)=(a(m-1,i) + delta - 1) mod m + 1,其中i>1。
参考程序如下:
program josephus4a; {递推法1}
const maxm=10000;
var a:array[1..maxm] of longint;
k, m,n,i,j:longint;
begin
write('please input m,n: ');
readln(m,n);
a[1]:=1;
for i:=2 to m do
begin
a[i]:=(n-1)mod i +1;
k:=(a[i] mod i +1)-1;
for j:=1 to i-1 do
a[j]:=(a[j]+k-1) mod i +1;
end;
writeln('The monkey king is no.',a[1]);
readln
end.
[反 思] 这样的递推实际效果并不好,时间复杂度没有降下来。
还可以这样考虑,设m个人从第s个人开始报数,报到n出圈,当剩余rest个人的时候,出圈者的编号应该为:(s+n-1) mod rest。举例来说,现在16个人,从第1个人开始报数,报到8出圈,则第1个出圈者的编号为:(1+8-1) mod 16 = 8,而继续报数,还是从第8个位置开始,所以第2个出圈者的编号就是:(8+8-1) mod 15 = 0,此时为特例(=0),出圈的人正好是最后一个编号,则出圈的人就是最后15。这个程序如下:
program josephus4b; {递推法2}
const maxn=10000;
var monkey:array[1..maxn] of longint;
m,n,rest,out,i:longint;
begin
write('input m,n:');
readln(m,n);
out:=1;
for i:=1 to m do monkey[i]:=i;
for rest:=m downto 2 do
begin
out:=(out+n-1) mod rest;
if out=0 then out:=rest;
for i:=out to rest-1 do monkey[i]:=monkey[i+1];
end;
writeln(monkey[1]);
readln
end.
[问题分析5] 以上两个递推的程序,之所以效率不好,是因为每次有一个元素出圈后,都要把后面的一些元素值进行调整。所以效率和模拟基本一样。可不可以不去调整呢?可以,这就是直接递推,方法就是[问题分析4]里的描述(红色字),时间复杂度为O(m),效率最好。
参考程序如下:
Program josephus5; {直接递推}
var king,delta,n,m,i,j:longint;
begin
readln(M,N);
king:=1;
for i:=2 to M do
begin
delta:=N mod i;
king:=(king+delta-1) mod i +1;
end;
writeln('The monkey king is no.',king);
readln
end.
[运行结果]下划线表示输入
Input m,n:10000 9873
The monkey king is no.8195 {时间:0秒}
例2、慈善的约瑟夫
[问题描述]
你一定听说过约瑟夫问题吧?!即从n个人中找出唯一的幸存者。现在老约瑟夫将组织一个皆大欢喜的新游戏,假设n个人站成一圈,从第1人开始交替的去掉游戏者,但只是暂时去掉(例如,首先去掉2),直到最后剩下唯一的幸存者为止。幸存者选出后,所有比幸存者号码高的人每人将得到1TK(一种货币),永久性的离开。其余剩下的人将重复以上的过程,比幸存者号码高的人每人将得到1TK后离开。一旦经过这样的过程后,人数不再减少,最后剩下的那些人将得到2TK。请你计算一下老约瑟夫一共要付出多少钱?
如图1,第一轮有5人,幸存者是3,所以4、5得到1TK后离开,下一轮幸存者仍然是3,因此没有人离开,所以每人得到2TK,总共要付出2+2*3=8TK。
[问题输入]
输入文件名:jose.in,输入文件包含一个整数,不超过32767。
[问题输出]
输出文件名:jose.out,输出文件包含一个整数,不超过65535,表示总共要付出的钱数(单位为TK)。
[样例输入与输出]
jose.in:
10
jose.out:
13
图1
[问题分析]
首先,很明显的一点是每个人都会得到1TK,只有最后的那些幸存者多得到了1TK,所以我们只要找出最后会幸存几个人便行了!假设经过m次后还剩final[m]个人,此时人数不再减少了,则问题的解应该为:final[m]+n。
那么,如何求final[m]呢?显然当第i次的final[i]=i时,人数就不会再减少了,此时的i即为m;否则,我们就需要对剩下的final[i]个人再进行报数出列操作。
设jose[i]表示i个人的圈报数后的幸存者编号,设报到k的人出去,则jose[i-1]可以理解为第一轮第一次报数,k出去后的状态。如下图左边的图(A),k出去后会从k+1继续报数,此时圈中有i-1个人,从k+1开始报数,编号如序列(1):
jose[i]:k+1,k+2,……, i , 1,2,……,k-1 序列(1)
我们可以人为地把这个圈逆时针转k个单位,即变成右图(B)的状态,此时报数的序列如序列(2):
jose[i-1]:1, 2, ……, i-k , i-k+1,i-k+2,……,i-1 序列(2)
观察两个序列,我们发现,除了加边框的两个数据外,其它所有数据都满足下列规律:jose[i]=(jose[i-1]+k) mod i。
对于这个式子,稍做调整,变成公式(1),就都满足了。
jose[i]:=(jose[i-1]+1) mod i + 1 公式(1)
至此我们就找到了问题的递推式,边界也很明显,jose[1]=1。然后我们顺推求出每个jose[i],直到某一次jose[i]=i,则final[i]:=i,否则final[i]:=final[jose[i]]。
[参考程序]
Program jsoe(input,output);
const max=32767; {假设最大顺推的次数}
var jose:array[1..max] of integer;
final:array[1..max] of integer;
i,n:integer;
begin
assign(input,'jose.in');reset(input);
readln(n);close(input);
jose[1]:=1;
final[1]:=1;
for i:=2 to max do {顺推}
begin
jose[i]:=(jose[i-1]+1) mod i + 1; {递推求jose[i]}
if jose[i]=I {经过本次报数,编号比i大的都已出列,
then final[i]:=i 所以本次幸存者的编号就是幸存人数}
else final[i]:=final[jose[i]] {对本次幸存者继续递推}
end;
assign(output,'jose.out');rewrite(output);
write(final[n]+n);close(output)
end.
作 业
1、约瑟夫问题变形1
现在改成从第P个开始,每隔M只报数,报到的退出,直到剩下一只为止。最后剩下的为猴王。问:猴王是原来的第几只猴子?如:
Input m,n:10000 1987 34
The monkey king is no.8577
2、约瑟夫问题变形2
现在请你输出所有猴子出圈的顺序(其实最后一个就是猴王)。
如输入:8 3
输出:3 6 1 5 2 8 4 7
3、约瑟夫问题变形3
输入m,n,k。输出第k个出圈的是几号?
4、约瑟夫问题变形4
输入m,n,k。输出第k个猴子是第几个出圈的?
5、约瑟夫问题变形5
给定m和最后一个出圈者的编号,求最小的N?
[分析]
通过以上问题的解决,对于约瑟夫问题已比较熟悉了,首先我们按方法四中的递推算法继续思考,不难推出一般情况,即I 个人按J 报数的情况,当第一个人出圈以后,对报数的人进行重新编号,新编号为X 的人原编号应该为(X+J-1)mod I+1, 举例:新编号为1 的人的原编号应该为J+1, 但是J 有可能为I,这样一来J+1 就有可能越界,因此原编号应该为(1+J-1) mod I+1 。
按上面所讨论的情况,p[I] 表示I 个人按J 报数最后一个出圈者的编号。可以发现I 个人按J 报数与I-1 个人按J 报数最后出圈者之间的关系。即:p[I]=(p[I-1]+J-1) mod I+1, 初始条件为:p[1]=1 。
[参考程序]
Program ex5;
const max=1000;
var p:array[1..max] of integer;
n,m,i,k:integer;
begin
write('m&k');
readln(m,k);{ 输入总人数和最后留下的人的编号}
n:=0;
repeat
inc(n);
p[1]:=1;
for i:=2 to m do { 计算按n 报数时最后出圈者编号}
p[i]:=(p[i-1]+n-1) mod i+1;
until p[m]=k;{ 直到初圈的人为k}
writeln(n);{ 输出结果}
end.
6、约瑟夫的新问题
源程序文件名 jsf.pas
可执行文件名 jsf.exe
输入文件名 jsf.in
输出文件名 jsf.out
时间限制 1秒
问题描述
将1~M这M个自然数按由小到大的顺序沿顺时针方向围成一圈。以S为起点,先沿顺时针方向数到第N个数就出圈,然后再从刚出圈的那个数的左边沿逆时针方向数到第K个数再出圈,再从刚出圈的那个数的右边沿顺时针方向数到第N个数就出圈,然后再从刚出圈的那个数的左边沿逆时针方向数到第K个数再出圈,……。这样按顺时针方向和逆时针方向不断出圈,直到全部数都出圈为止。
请打印先后出圈的数的序列。
输入格式
文件中共4行,每行为一个自然数,分别表示M,S,N,K。M不超过1000。
输出格式
仅1行,先后出圈的数的序列,每个数之间有1个空格。
样例输入(jsf.in)
8
1
3
2
样例输出(jsf.out)
3 1 5 2 7 4 6 8
样例解释
先从1开始沿顺时针方向数到3,所以3先出圈;再从2开始沿逆时针方向数到1,所以1出圈;再从2开始沿顺时针方向数到5,所以5出圈,再从4开始沿逆时针方向数到2,所以2出圈,……
[问题分析]
本题是对一般约瑟夫问题的扩展,只要稍加分析不难发现,本题可以套用一般约瑟夫问题的解法,无需作太大的改动就能完美地解决本题。而且本题的数据量较小(M≤1000),因此可以直接使用数组的方法来解决,速度也较快,能够符合问题的要求,如果问题的规模再扩大的话,就可能需要使用链表结构来解决才能符合要求。下面给出两种方法的参考程序。
[参考程序1]
对于具体实现,可以使用一个标志数组js来表示一个数的状态(在圈中还是已出圈),1表示这个数还在圈中,0表示这个数已出圈。每次从当前位置按照指定方向数,如果数到的数的标志数组元素是1,则应该记录,否则应该略过该数继续向下数,直到数到的圈中数的个数达到K或N,此时应输出当前的数并将其相应的标志数组元素置为0,如此反复,直到圈中所有的数均出圈为止。
程序中设一个变量total用于记录当前仍在圈中的数。过程cl作用为顺时针数n个数,并将数到的第n个数输出然后出圈。过程uncl用于逆时针数k个数,并将数到的第k个数输出然后出圈。程序首先将所有数的状态均为在圈中,然后不断按顺时针数一次(cl)、逆时针数一次(uncl)的顺序从圈中删数,直到圈中没有数为止。
Program Jsf1; {数组模拟}
Var m,n,k,s,i,total:integer;
js:array[1..1000]of 0..1;
Procedure cl(var start:integer); {顺时针数数出圈}
var sum:integer;
begin
sum:=0;
while sum<n do {当数到第n个数就出圈}
begin
if js[start]=0 then start:=start mod m+1 {跳过已出圈的数}
else begin
inc(sum);
start:=start mod m+1
end
end;
if total=m then write((start+m-2) mod m+1) {输出出圈的数}
else write(' ',(start+m-2) mod m+1);
js[(start+m-2) mod m+1]:=0; {将出圈数作上出圈标记}
start:=(start+m-3) mod m+1 {计算下一次数数的起点}
end;
Procedure uncl(var start:integer); {逆时针数数出圈}
var sum:integer;
begin
sum:=0;
while sum<k do {当数到第k个数出圈}
begin
if js[start]=0 then start:=(start+m-2) mod m+1 {跳过已出圈的数}
else begin
inc(sum);
start:=(start+m-2) mod m+1
end
end;
if total=m then write(start mod m+1) {输出出圈的数}
else write(' ',start mod m+1);
js[start mod m+1]:=0; {将出圈数作上出圈标记}
start:=(start mod m+1) mod m+1 {计算下一次数数的起点}
end;
Begin {main}
assign(input,'jsf.in'); reset(input);
assign(output,'jsf.out');rewrite(output);
readln(m);readln(s);readln(n);readln(k);
total:=m; {统计圈中的数的个数}
for i:=1 to m do js[i]:=1; {开始将各数设为在圈中}
while total<>0 do {从圈中删数}
begin
cl(s); {从当前起点顺时针数n个数}
dec(total); {圈中的数减少一个}
if total<>0 then begin
uncl(s); {从当前起点逆时针数k个数}
dec(total) {圈中的数减少一个}
end
end;
close(input);close(output)
End.
[参考程序2]
program jsf2; {双向循环链表模拟}
type
pnode=^node; {双向循环链表的指针类型}
node=record {双向循环链表的结点类型}
id:integer; {自然数}
next:array[0..1] of pnode; {顺时针方向和逆时针方向的后继指针}
end;
var
root ,tail,start:pnode; {双向循环链表的队首指针和队尾指针,起点数的结点指针}
n,i,s:integer; {n—连续自然数的个数,s—由s开始报数}
mk:array[0..1] of integer; {mk[0]—顺时针的步长值,mk[1]—逆时针的步长值}
procedure add_node(id:integer); {该过程将自然数id加入双向循环链表,
var p:pnode; 通过for i:=n downto 1 do add_node(i);便可构造双向循环链表}
begin
new(p); {为自然数id 申请内存}
p^.id:=id; {p结点加入双向循环链表}
p^.next[1]:=root;
p^.next[0]:=tail;
if root<>nil
then begin {设定双向循环链表的队首指针和队尾指针}
root^.next[0]:=p;
tail^.next[1]:=p
end
else root:=p;
tail:=p
end;
function get_dulist(s:integer;la:pnode):pnode; {计算初始结点,由于报数从s开始,
var p:pnode; 因此首先必须在以la为首结点的双向循环链表中计算出第s个结点的地址}
j:integer;
begin
p:=la;j:=1;
while (p^.next[0]<>la)and(j<s) do begin p:=p^.next[0];j:=j+1 end;
if j=s then get_dulist:=p else get_dulist:=nil
end;
function delete(flag:integer;node:pnode):pnode;
{队员出列,设当前开始报数的结点为node,报数的方向为flag(=0时顺时针,=1时逆时针)。
函数delete(flag, node)完成如何删去下一个数,并返回其flag方向相邻的结点指针}
var
p:pnode;
i:integer;
begin
p:=node; {沿flag方向计算下一个出列数的结点p}
for i:=1 to mk[flag] do p:=p^.next[flag];
p^.next[0]^.next[1]:=p^.next[1]; {从双向循环链表中删去结点p }
p^.next[1]^.next[0]:=p^.next[0];
delete:=p^.next[flag]; {返回p在flag方向相邻的结点指针}
write(p^.id,' '); {输出出列数}
dispose(p); {释放结点p }
end;
{ 我们通过调用n次delete函数,便可以完成出列顺序的计算。第一次调用时,方向数设为0,开始报数的结点设为start。以后每一次调用方向数取反,指针指向当前开始报数的结点。这样依次类推,直至所有数出列为止。}
begin {main}
assign(input,'jsf.in');
assign(output,'jsf.out');
reset(input);
rewrite(output);
readln(n); {读自然数的个数}
readln(s); {读起点数}
readln(mk[0]); {读顺时针的步长值}
readln(mk[1]); {读逆时针的步长值}
for i:=n downto 1 do add_node(i); {构造出双向循环链表}
start:=get_dulist(s,root); {计算起点数的结点指针}
i:=0; {由顺时针开始报数}
while n>1 do {若剩余数大于1,则循环}
begin
start:=delete(i,start);
{从start 结点出发,沿方向i删去下一个数,并返回其i方向相邻的结点指针}
i:=1-i;n:=n-1; {方向数取反,剩余数-1}
end;
writeln(start^.id); {输出最后一个出列数}
{dispose(root);} {释放该结点}
close(input);
close(output);
end.