约瑟夫问题又称为约瑟夫环,约瑟夫问题有很多变种。本文就以几个经典的约瑟夫问题介绍其几种解法。


问题1:鲁智深吃馒头。据说,鲁智深一天中午匆匆来到开封府大相国寺,想蹭顿饭吃,当时大相国寺有99个和尚,只做了99个馒头。智清长老不愿得罪鲁智深,便把他安排在一个特定位置,之后对所有人说:从我开始报数(围成一圈),第5个人可以吃到馒头(并退下);退下的人的下一位开始从1报数,第5个人可以吃到馒头(并退下)……按此方法,所有和尚都吃到了馒头,唯独鲁智深没有吃上,请问他在哪个位置?


我们可以使用一个长度为101的数组,并把每一个元素都初始化为0(int a[101] = {0};)。用1~100表示99个和尚和鲁智深一共100个人的序号a[i]=0表示第i个人没吃到馒头,a[i]=1表示第i个人吃到了馒头。

其次,使用一个变量num存储馒头的个数(int num = 99;),馒头少一个则num–;。当num=0时认为馒头已经分完,此时让i从1遍历至100,若a[i]=0,则表示第i个人没有吃上馒头,i即为鲁智深的位置。

再看看题目的描述:每5个人吃一次馒头,即数组每过5个元素,就要让该元素为1。因为是围成一圈,因此到最后一个人要重新返回第1个人。且吃到馒头的人会退下,即下一次遍历时要跳过元素为1的元素。可以使用一个变量flag来记录过了多少个元素

基于此,我们可以大致整理出思路如下:

int a[101] = {0};int num = 99, flag = 0;for (int i=1; num>0; i++){if (a[i] == 0){flag++;if (flag == 5){a[i] = 1;num--;flag = 0;}}if (i == 100){i = 0;}}for (int i=1; i<=100; i++){if (a[i] == 0){printf("鲁智深在第%d个位置", i);break;}}

这种解决约瑟夫问题的方法称为筛法。我们在之前所讲的C语言丨筛法求素数(质数)一文中也介绍过这种方法。


问题2:n个骑士编号1、2、…、n,围坐在圆桌旁,编号为1的骑士从1开始报数,报到m的骑士出列,然后下一个位置再从1开始报数,找出最后留在圆桌旁的骑士编号。


对比上面的问题,其实就是把和尚和鲁智深换了个名字,换皮不换肉。因此,借用上面所说的筛法,同样能够很快地解决这个问题。我们来介绍另一种方法:

像上述算法一样,我们用1~n表示n个骑士的序号。不同的是,我们以1~n为结点的元素构造一个循环链表。定义指针p,p从指向第一个结点开始,骑士每报一个数相当于p沿循环链表前进1个结点,报到m的骑士相当于在循环链表中删除结点p。最终剩余的结点就是所求的解。

算法描述如下:

#include #include typedef struct node{int data;struct node *next;} CLinkList;int main(){int n, m;scanf("%d%d", &n, &m);//输入:骑士人数n和报到就出列的数字mCLinkList *R = (CLinkList *)malloc(sizeof(CLinkList)), *p, *s;p = R;for (int i=1; idata = i;s->next = NULL;p->next = s;p = p->next;}p->next = R->next;R = p;//构造一个循环链表,使其各结点值等于i(i=1,2,3,…,n),此时p指向最后一个结点nfor (int i=1; i<n; i++){for (int j=1; jnext;}//令p沿循环链表前进m-1个结点s = p->next;p->next = s->next;free(s);//删除结点p的下一个结点}printf("最后留在圆桌旁骑士的编号为%d", p->data);//输出return 0;//算法结束}

可以看到,这种算法的优点是把报过数的骑士从原来的跳过操作直接变为删除操作,节省了一部判断是否已经报数到m的操作;另外,因为是循环链表,因此也不需要判断是否到了数组的末尾。使用链表可以减少这两个判断,在一定程度上优化了算法,但同时也给思考增加了难度。


问题3:有N张纸牌,记为1,2,…,N。将它们牌面朝下垂直叠放在一起,应该怎样排放,才能使:从上面抽出的第一张牌是1,然后把该牌后面的两张牌依次插入牌叠的末尾,抽出面上一张,刚好是2;再依次把该牌后面的三张牌依次插入牌叠的末尾,抽出面上一张,刚好是3;如此继续下去直至抽到最后一张是N。


如果8张牌按照1、7、5、2、6、8、4、3的顺序排列,则刚好符合问题中N=8的情形。如图:

N=8时纸牌序列的变化情况
12345678
7677688
585587
24667
6388
874
45
3

同样地,我们用循环链表的方式来解决该问题,那么看牌面上的牌点即可看作读取线性表中的第i个数据元素,抽牌即可看作删除操作,插入牌叠的末尾即可看作插入操作

与前两个问题不同的是,每一次删除一个结点之后,再过m个结点后删除另一个结点,m会依次递增一;并且需要记录下删除结点的顺序。借助问题2,本问题可以表述如下:N个骑士编号1、2、…、N,围坐在圆桌旁,编号为1的骑士从1开始报数,报到1的骑士出列,然后下一个位置再从1开始报数,报到2的骑士出列,接着下一个位置再从1开始报数,报到3的骑士出列……记录下从编号为1到编号为N的骑士的出列的顺序。

因此,我们可以考虑将所求的纸牌序列存放在数组B中,以数组B的下标为结点构造一个循环链表。定义一个指针p,从p指向第一个结点开始。抽出面上那张牌的操作,相当于在循环链表中删除结点p。把i张牌放在末尾的操作,相当于p沿循环链表前进i个结点,并把i存放在数组B中某个位置,最终数组B中的纸牌序列就是所求的解。

算法描述如下:

#include #include typedef struct node{int data;struct node *next;} CLinkList;int main(){int N;scanf("%d", &N);//输入:纸牌的总数Nint B[N+1];//申请长度为N+1的一维数组B存放所求的纸牌序列CLinkList *R = (CLinkList *)malloc(sizeof(CLinkList)), *p, *s;p = R;for (int i=1; idata = i;s->next = NULL;p->next = s;p = p->next;}p->next = R->next;R = p;//构造一个循环链表,使其各结点值等于i(i=1,2,3,…,N),此时指针p指向最后一个结点NB[1] = 1;s = p->next;p->next = s->next;free(s);p = p->next;//令B[1]=1,删除头结点;令p指向后一个结点for (int i=2; i<N; i++){for (int j=1; jnext;}//令p沿循环链表前进i-1个结点B[p->next->data] = i;s = p->next;p->next = s->next;free(s);p = p->next;//删除结点p的下一个结点,令p指向再下一个结点}B[p->next->data] = N;printf("纸牌序列为:");for (int i=1; i<=N; i++){printf("%d、", B[i]);}//输出:一维数组Breturn 0;//算法结束}

约瑟夫问题经常出现在各种程序设计比赛中,其核心思想就是使用循环链表的知识,通过对循环链表的删除、遍历等操作,实现约瑟夫环的解决。


参考文献:

文益民 张瑞霞 李健 编著,数据结构与算法(第2版),清华大学出版社,P19-20,P36-37.