约瑟夫问题描述
编号为1 到 N 的 N 个人围坐在一起形成一个圆圈,从编号为 1 的人开始依次报数(1,2,3…依次报),数到 m 的 人会被杀死出列,之后的人再从 1 开始报数。直到最后剩下一人,求这个人的编号。
解题思路
循环链表
对于这个问题,相信很多人首先想到的就是用循环链表。基本思路:
1. 首先构造一个循环链表,每个结点的初始值为1-n。
2. 然后根据m的值来进行删除,每到第m个结点就执行删除操作。
3. 最后剩下的一个结点的值即为所求。
实现代码:
#include<stdio.h>
#include<malloc.h>
typedef struct LNode{
int data;
struct LNode *next;
}LNode;
//初始化一个循环链表,data域为1-n
LNode *head,*rear;
LNode *Init(int n){
head = (LNode *)malloc(sizeof(LNode));
head->data = 1;
rear = head;
for(int i=2; i<=n; i++){
LNode *q = (LNode *)malloc(sizeof(LNode));
q->data = i;
rear->next = q;
rear = q;
}
rear->next = head;
return head;
}
//删除操作
void Delete(LNode *p){
LNode *q = p->next; //q为要删除的结点
p->next = q->next;
printf("%d ",q->data); //如果只需要输出最后一个人的编号,则去掉这句即可
free(q);
}
int main(){
int m,n;
scanf("%d%d",&n,&m);
head = Init(n);
LNode *p = head;
while(p->next != p){ //循环直到只剩下一个结点
for(int i=0; i<m-2; i++){ //用于找到报m-1的结点,然后执行删除操作
p = p->next;
}
Delete(p); //删除p->next
p = p->next;
}
printf("%d\n",p->data); //最后输出剩下的唯一一个编号
return 0;
}
对于循环链表的实现方式,其时间复杂度为O(m*n),空间复杂度为O(n)。
队列
对于队列,其解题思路与链表大致相同。其实很多人会想到用循环队列,其实没有必要,只需要用普通队列就可以实现。
基本思路:
1. 首先,将编号1-n按序入队,形成初始队列。
2. 对于每个编号,如果该编号不是当前循环的第m个,那么出队之后再次将其压入队列尾部,即出队后再入队,用queue来模拟循环队列的效果;否则,直接出队。
3. 队列中剩下的最后一个编号即为所求。
由于C++的STL中提供了队列模板可供使用,于是对于队列我采用了C++来编写,对于队列模板的基本用法,可以参考STL - queue的用法与实例。
实现代码:
#include<iostream>
#include<queue>
using namespace std;
queue<int> node;
//初始化队列
void Init(int n){
for(int i=1; i<=n; i++){
node.push(i);
}
}
//删除
void Delete(){
int temp = node.front();
cout<<temp<<" "; //如果只输出最后一个编号,那么去掉本句即可
node.pop();
}
int main(){
int n,m;
cin>>n>>m;
Init(n);
while(node.size()>1){
for(int i=0; i<m-1; i++){ //如果不是第m个编号,那么先出队,再加入队尾
int tmp = node.front();
node.pop();
node.push(tmp);
}
Delete(); //否则出队
}
cout<<node.front()<<endl; //输出最后一个编号
node.pop(); //队列清空
return 0;
}
对于队列的实现方式,其时间复杂度为O(m*n),空间复杂度为O(n)。
递归,用一行代码实现
其实这道题还可以用递归来解决。基本思路是每次我们删除了某一个人之后,我们就对剩下这些人重新编号。那么,解决这个问题最大的难点就变成了找出删除前和删除后人编号的映射关系。
为此,定义一个递归函数f(n, m),其返回结果是剩下的人的编号。显然,当n=1时,f(n, m) = 1。如果能找到f(n, m) 和 f(n-1, m)之间的关系,那么就可以用递归的方式来解决这个问题了。
初始值: 1,2,3,… ,m-2,m-1,m,m+1,m+2,… ,n
那么进行一次删除后,删除了编号为m的结点,然后对剩下的n-1个结点进行重新编号:
删除前old | 删除后new |
---|---|
… | … |
m-2 | n-2 |
m-1 | n-1 |
m | 被删除了 |
m+1 | 1(下次从这里开始报数) |
m+2 | 2 |
… | … |
n | n-m |
那么新的环中有n-1个结点,并且删除前编号为m-1,m-2的结点删除后编号变成了1,2。
如果假设删除之前的结点编号为old,删除了一个结点之后重新编号为new,由于本题设置的是编号从1开始,那么old与new之间的关系应为old = (new + m - 1)%n + 1(这里先+1再-1就是为了保证下标从1开始,如果new+m=n的话,会导致最后计算结果为old=0,然而对于这道题来说,由上表可知new=n-m是原来old=n的新编号,先+1再-1就是为了不让n变成0)。如果编号为从0开始,那么old与new之间的对应关系应为old = (new + m)%n。
实现代码:
int f(int n, int m){
return n=1? n : ( f(n-1,m) + m - 1 ) % n + 1;
}
对于递归的实现方式,其时间复杂度为O(n),空间复杂度为O(n)。
总结
对于约瑟夫环的问题,其本身并不难理解,实现也不困难,对于循环链表和队列的常规实现方式思路清晰,容易理解。另外,递归的方式最重要的就是找到相邻两次遍历删除前与删除后的编号之间的关系,属于数学归纳一类,需要注意编号是从0开始还是从1开始的。
对于这个问题,我只想到这几种方法,当然,还有最简单的数组,可以不断遍历数组,如果是第m个就将其存储元素设为-1,以此来解决这个问题,当然其时间复杂度还是O(m*n),如果有更快捷高效的方法,欢迎大家留言讨论。