约瑟夫环是一个数学的应用问题:已知n个人(以编号1,2,3...n分别表示)围坐在一张圆桌周围。从编号为k的人开始报数,数到m的那个人出列;他的下一个人又从1开始报数,数到m的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。
C代码如下(joseph.cpp):
#include<stdio.h>
#include<stdlib.h>
#include<malloc.h>
typedef struct _node
{
struct _node* next;
int number;
}node,*linklist;
linklist create(int n);
void joseph(linklist head, int k, int m);
int main()
{
linklist head;
int m, n, k;
printf("please input n:");
scanf("%d",&n);
printf("please input m:");
scanf("%d",&m);
printf("please input k:");
scanf("%d",&k);
head = create(n);
printf("the sequences of leaving the list are:");
joseph(head,k,m);
return 0;
}
linklist create(int n)
{
linklist head = (linklist)malloc(sizeof(node));
node *tail;
int i;
head->next = head;
head->number = 1;
tail = head;
for(i=2;i<=n;i++)
{
node *p = (node*)malloc(sizeof(node));
p->number = i;
p->next = tail->next;
tail->next = p;
tail = p;
}
return head;
}
void joseph(linklist head, int k, int m)
{
int j;
node *p;
node *q;
if(m == 1 && k == 1)
{
p = head;
while(p->next != head)
{
printf("%d ",p->number);
q = p->next;
free(p);
p = q;
}
printf("%d\n",p->number);
}
else if(m == 1 && k != 1)
{
p = head;
for(j=1; j<k-1; j++)
p = p->next;
while(head->next != head)
{
q = p->next;
p->next = q->next;
printf("%d ",q->number);
if(q == head)
head = q->next;
free(q);
}
printf("%d\n",head->number);
}
else
{
p = head;
for(j=1; j<k; j++)
p = p->next;
while(head->next != head)
{
for(j=1; j<m-1; j++)
p = p->next;
q = p->next;
p->next = q->next;
printf("%d ",q->number);
if(q == head)
head = q->next;
free(q);
p = p->next;
}
printf("%d\n",head->number);
}
}
需要特别注意m和k的值是否等于1。
几组测试用例结果如下:
1、m != 1,k != 1
2、m != 1,k == 1
3、m == 1,k != 1
4、m == 1,k == 1
上面程序中,之所以要分别讨论m==1和k==1的情况,是因为在单向循环链表中要想删除某一个结点,必须先找到该结点的前驱结点,然后更改相关指针域,使循环链表不断链,而m=1,k=1时,要想使循环链表不断链,必须先找到链表的尾结点,所以要分不同情况讨论。
鉴于此,想到使用双向循环链表,要想删除某一个结点,不需要找前驱结点,即使是删除第一个结点,也不需要找尾结点。
C代码如下所示(joseph2.cpp),可以看到代码逻辑简洁了不少:
#include<stdio.h>
#include<stdlib.h>
#include<malloc.h>
typedef struct _node
{
struct _node* prev;
struct _node* next;
int number;
}node,*linklist;
linklist create(int n);
void joseph(linklist head, int k, int m);
int main()
{
linklist head;
int m, n, k;
printf("please input n:");
scanf("%d",&n);
printf("please input m:");
scanf("%d",&m);
printf("please input k:");
scanf("%d",&k);
head = create(n);
printf("the sequences of leaving the list are:");
joseph(head,k,m);
return 0;
}
linklist create(int n)
{
linklist head = (linklist)malloc(sizeof(node));
node *tail;
int i;
head->next = head;
head->prev = head;
head->number = 1;
tail = head;
for(i=2;i<=n;i++)
{
node *p = (node*)malloc(sizeof(node));
p->number = i;
p->next = tail->next;
p->prev = tail;
tail->next = p;
tail = p;
head->prev = tail;
}
return head;
}
void joseph(linklist head, int k, int m)
{
int i;
node *p;
node *q;
p = head;
for(i=1; i<k; i++)//获取开始计数的结点
p = p->next;
while(head->next != head)
{
for(i=1; i<m; i++)
p = p->next;//获取每轮计数的第m个结点,即待删除结点
q = p->next;
q->prev = p->prev;
p->prev->next = q;
printf("%d ",p->number);
if(p == head)//如果删除的是第一个结点,则需要重新设置head指针
head = q;
free(p);
p = q;//删除一个结点之后,从该结点的下一个结点重新开始计数
}
printf("%d\n",head->number);
}
可以得到与第一种代码相同的结果:
如果能使用C++标准库中的list来模拟循环链表,那么逻辑更清晰,代码更简洁。
C++代码如下(joseph3.cpp):
#include<iostream>
#include<list>
using namespace std;
void joseph(int n, int m, int k);
int main()
{
int n,m,k;
cout<<"please input n:";
cin>>n;
cout<<"please input m:";
cin>>m;
cout<<"please inpur k:";
cin>>k;
cout<<"the sequences of leaving the list are:";
joseph(n,m,k);
return 0;
}
void joseph(int n, int m, int k)
{
list<int> numbers;
int i,j;
for(i=1; i<=n; i++)
numbers.push_back(i);
list<int>::iterator current = numbers.begin();
list<int>::iterator next;
for(i=1; i<k; i++)
{
++current;
if(current == numbers.end())
current = numbers.begin();
}
while(numbers.size()>1)
{
for(i=1; i<m; i++)
{
++current;
if(current == numbers.end())
current = numbers.begin();
/*
由于list本身并不是一个循环链表,所以每当到达
最后一个元素的下一个位置时,需要修改迭代器指向第一个元素
*/
}
next = ++current;
if(next == numbers.end())
next = numbers.begin();
--current;
cout<<*current<<" ";
numbers.erase(current);
current = next;
}
cout<<*current<<endl;
}
可以得到与上面两种代码相同的结果。
上面编写的解约瑟夫环的程序模拟了整个报数的过程,程序运行时间还可以接受,很快就可以出计算结果。可是,当参与的总人数n及出列值m非常大时,其运算速度就慢下来。例如,当n的值有上百万,m的值为几万时,到最后虽然只剩2个人,也需要循环几万次(m的数量)才能确定2个人中下一个出列的序号。显然,在这个程序的执行过程中,很多步骤都是进行重复无用的循环。那么,能不能设计出更有效率的程序呢?
在约瑟夫环中,如果只是需要求出最后的一个出列者最初的序号,就没有必要去模拟整个报数的过程。因此,为了追求效率,可以考虑从数学角度进行推算,找出规律然后再编写程序即可。
为了讨论方便,先根据原意将问题用数学语言进行描述。
问题:将编号为1~n这n个人进行圆形排列,按顺时针从1开始报数,报到m的人退出圆形队列,剩下的人继续从1开始报数,不断重复。求最后出列者最初在圆形队列中的编号。
下面首先列出0~n这n个人的原始编号如下:
1、2、3、……、m-2、m-1、m、m+1、m+2、……、n-2、n-1、n
第一个出列人的编号一定是m%n。例如,在41个人中,若报到3的人出列,则第一个出列人的编号一定是3%41=3,1人出列后的列表如下:
1、2、3、……、m-2、m-1、m+1、m+2、……、n-2、n-1、n
根据规则,当有人出列之后,下一个位置的人又从1开始报数,则以上列表可调整为以下形式(即以m+1位置开始,n之后再接上0、1、2……,形成环状):
m+1、m+2、……、n-2、n-1、n、1、2、3、……、m-2、m-1
按上面排列的顺序重新进行编号,可得到下面的对应关系:
1、 2、 3、 ……、n-2、n-1
m+1、m+2、m+3、……、m-2、m-1
即,将出列1人后的数据重新组织成了1~n-1的列表,继续求n–1个参与人员,按报数到m即出列,求解最后一个出列者最初在圆形队列中的编号。
通过一次处理,将问题的规模缩小了。即,对于n个人报数的问题,可以分解为先求解(n–1)个人报数的子问题;而对于(n–1)个人报数的子问题,又可分解为先求[(n–1)–1]人个报数的子问题,……。
问题中的规模最小时是什么情况?就是只有1个人时(n=1),报数到m的人出列,这时最后出列的是谁?当然只有编号为1这个人。因此,可设有以下函数:
F(1)= 1
那么,当n=2,报数到m的人出列,最后出列的人是谁?应该是只有一个人报数时得到的最后出列的序号加上m+1(因为已经有1个人出了队列,求F(n)时因为已经有n-1个人出了队列,所以需要加上n-1),可用公式表示为以下形式:
F(2)= F(1)+ m + 1
通过上面的算式计算时,F(2)的结果可能会超过n值(人数的总数)。例如,设n=2,m=3(即2个人,报数到3时就出列),则按上式计算得到的值是:
F(2)= F(1)+ 3 + 1 = 1 + 3 + 1 = 5
一共只有2人参与,编号为5的人显然没有。怎么办?由于是环状报数,因此当两个人报完数之后,又从编号为1的人开始接着报数。根据这个原理,即可对求得的值与总人数n进行模运算,然后再加上1,因为不是从0开始计数的,即:
F(2)= [F(1)+ m + 1] % n + 1 = [1 + 3 + 1]%2 + 1 = 2
即,n=2,m=3(即有2个人,报数到3的人出列)时,循环报数最后一个出列的人的编号为2(编号从1开始)。
根据上面的推导过程,可以很容易推导出,当n=3时的公式:
F(3)= [F(2)+ m + 2]%3 + 1
同理,也可以推导出参与人数为N时,最后出列人员编号的公式:
F(n)= [F(n-1)+ m + n - 1]%n + 1
其实,这就是一个递推公式,公式包含以下两个式子:
F(1)= 1; n=1
F(n)= [F(n-1)+ m + n - 1]%n + 1; n>1
有了这个递推公式,再来设计程序就很简单了。
使用递归方式的代码如下(joseph4.cpp):
#include<stdio.h>
#include<stdlib.h>
int joseph(int n, int m);
int main()
{
int n,m;
printf("please input n:");
scanf("%d",&n);
printf("please input m:");
scanf("%d",&m);
printf("the last number is: %d\n", joseph(n,m));
return 0;
}
int joseph(int n, int m)
{
if(n == 1)
return 1;
else
return (joseph(n-1,m)+m+n-1)%n + 1;
}
几组测试用例结果如下:
使用递归函数会占用计算机较多的内存,当递归层次太深时可能导致程序不能执行,因此,也可以将程序直接编写为以下的迭代形式。
joseph5.cpp:
#include<stdio.h>
#include<stdlib.h>
int joseph(int n, int m);
int main()
{
int n,m;
printf("please input n:");
scanf("%d",&n);
printf("please input m:");
scanf("%d",&m);
printf("the last number is: %d\n", joseph(n,m));
return 0;
}
int joseph(int n, int m)
{
int last = 1;//相当于F(1)
int i;
for(i=2; i<=n; i++)//一步一步求F(2)到F(n)
last = (last + m + i - 1)%i + 1;
return last;
}
也可以得到与上面相同的结果。