算法集锦第一话:详解约瑟夫问题
约瑟夫问题(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分别为1,2,3,4,5,6,7,8...时的结果是1,1,3,1,3,5,7,1...
有如下规律:从1到下一个1为一组,每一组中都是从1开始递增的奇数,且每组元素的个数分别为1,2,4...
这样就好弄了!!
大体思路如下:
①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