写在前面
本人是某大学数据科学与大数据技术专业再在读大二年级本科生。第一次写博客,写的可能比较乱,希望不要介意,内容上若有纰漏欢迎批评指针。
一、约瑟夫问题是什么?
Josephus问题:已知n个人(以编号1,2,3,…,代表)围坐成一圈。现在从编号为k的人开始报数,数到m的人出列;他的下一个人又从1开始报数,数到m的人又出列;依此重复下去,直到所有人全部出列。
例如:当n=8,m=4,k=3 时,出列顺序为 6,2,7,4,3,5,1,8。
我们采用线性表来模拟约瑟夫问题,每个人看作一个结点,围成一圈的每个人都有一个唯一编号,删除结点即视为出列。实际操作中采用的存储结构可以是顺序的也可以是链式的,但是都要考虑到一个问题:当前未出列的人中编号最后一个报数后,下一个要让当前表中的第一个人报数,即当前线性表的最后一个结点被访问后,应该访问其第一个结点,或者说最后一个结点的直接后继应该为第一个结点。以下是分别采用链式存储结构和顺序存储结构解决约瑟夫问题的算法。
二、解决方案
1.用循环链表
-
算法思想:
- 根据总人数n创建n个结点的循环链表模拟约瑟夫环,每个人为链表中的一个结点
- 工作指针从循环链表表头向后移动至第k个结点
- 从当前开始报数
- 若不满足出列条件,工作指针后移,继续报数
- 如满足出列条件,删除当前结点
-
算法描述:
/*-----解决约瑟夫问题-----*/ void Josephus(int n, int m, int k) { LinkList L; CreateList(L, n); LinkList nowPoint = L; //工作指针 当前节点 LinkList prePoint; //辅助指针 当前结点前驱 删除结点时用 for (int i = 0; i < k-1; ++i) { //从第k个开始数 prePoint = nowPoint; nowPoint = nowPoint->next; //工作指针后移至下一个结点 } for (int i = 0; i < n; ++i) { //每次1人出列,直至n个人全部出列 for (int j = 0; j < m-1; ++j){ //从当前位置开始报数 数到m-1 prePoint = nowPoint; nowPoint = nowPoint->next; } prePoint->next = nowPoint->next; //临时保存当前结点后继 cout << "编号 " << nowPoint->data << " 出列" << endl; delete nowPoint; //释放当前结点 报数为m的人出列 nowPoint = prePoint->next; //工作指针后移 } }
- 1.3算法分析:
- 正确性:测试用例如下图
- 正确性:测试用例如下图
- 时间复杂度:涉及尾插法创建循环链表O(n)、查找元素O(n)、删除元素O(1)等操作;解决约瑟夫问题中由于每次查找m个人,要查找n次,要移动mn次指针,算法时间复杂度是O(mn)。
- 空间复杂度:尾插法创建链表需要O(n)的空间;查找、删除原地工作空间复杂度是O(1);总体而言需要O(n)的空间。
- 健壮性:程序的输入n、m、k应该为整数,当输入的是浮点数是取其下整数,当输入是非法字符时,按0处理。当用户输入不是正实数时会要求重新输入。
2.用顺序表(数组)
- 1.1算法思想
- 根据问题规模,创建长度为n的顺序表,从第k开始报数
- 沿着顺序表报数,报数标志变量=m时,删除当前结点,后续结点前移,重置报数标志变量
- 重置报数起点,循环上一步,直至所有结点都被删除
- 1.2算法描述
/*-----解决约瑟夫问题---*/ void Josephus(int n, int m, int k) { List L; createList(L, n, m); //建表 m>0 1,2,3...n m<0 n,n-1,...1 // ↓↓适应m k 为可能至少有一个为负数的情况 if (m*k<0) k = n-abs(k)%n+1; m = abs(m) % n; k = abs(k) % n; // ↑↑如果没有上面这几句的话仅能解决m k均为正数的约瑟夫问题 int i = (k - 1) % L.length; //表头标记 int j = 0; //标记删除位置 初始为0 int t = 0; //辅助序号 用于配合删除结点 while (L.length) //循环直至所有人出列 { j = (i + j + m - 1) % L.length; //更新待删除结点位置 /* 更新待删除结点的原理 j = i + j; //根据起点位置向后移动j j = j + m - 1; //j向后移动至第m个人 j = j % L.length; //防止越界 这很关键 */ cout <<"编号 " << L.elem[j] << " 出列" << endl; for(int t=j; t<L.length-1; ++t) L.elem[t] = L.elem[t+1]; -- L.length; i = 0; //重置报数起点 } }
- 1.3算法分析
- 正确性:如下图
- 时间复杂度:涉及顺序表创建O(n)、查找O(1)、删除结点O(n)等操作;时间复杂度是O(n)。仅仅由输入总人数决定。
- 空间复杂度:创建长度为n的顺序表,后面的查找删除算法均在此空间进行,不需要额外的空间,因此空间复杂度为O(n)
- 健壮性:程序的输入n、m、k应该为整数,当输入的是浮点数是取其下整数,当输入是非法字符时,按0处理。其中n必须是正整数,因此当用户输入n不是正整数时会要求重新输入,m、k可以是任意整数。当输入出列周期m<0时,程序认为是反向报数。当输入报数起点k<0时,认为是从反向数第|k|个开始报数。
例如在n=8的情况下,即正序编号1 2 3 4 5 6 7 8:
①m=4,k=3意味着从3号开始向又报数,报到4的出列,6号最先出列
①m=4,k=-3 意味着从6号开始向右报数,报到4的出列,1号最先出列
②m=-4,k=3意味着从3号开始向左报数,报到4的出列,8号最先出列
③m=-4,k=-3意味着从6号开始向左报数,报到4的出列,3号最先出列
- 正确性:如下图
三、总结
问题涉及到线性表的基本操作包括查找与删除结点,顺序存储的方式在查找上占优势却在删除结点时因要移动元素而占劣势;链式在删除结点时因只需要修改指针而补救了查找需要“顺藤摸瓜”而消耗的大量时间。该问题逻辑上的人们是成环的,存储上可以选择成环或不成环,只不过当存储时选择不成环必然要在运算时进行一些操作使得算法符合原问题逻辑。单纯的顺序表,顺序表本身是不成环,但是这一点可以在运算上来弥补,每次到达尾结点是人工手动把工作指针移到表头(每轮报数起点)。而使用循环链表这种存储机制,直接省去了人工移动工作指针的麻烦。
附:完整源代码
#include <iostream>
using namespace std;
typedef int ElemType;
/*-----链表结点的存储结构定义-----*/
typedef struct LNode
{
ElemType data; //数据域
struct LNode *next; //指针域
}LNode, *LinkList;
/*-----尾插法建立单链表-----*/
void CreateList(LinkList &L, int n)
{
L = new LNode; //生成首结点
L->data = 1;
LinkList r = L; //尾指针指向首结点
for(int i = 1; i < n; ++i)
{
LinkList p = new LNode; //生成新结点
p->data = i+1;
p->next = NULL; //新结点*p为更新后的表尾结点
r->next = p; //尾结点后继更新为 新结点*p
r = p; //r指向尾结点
}
r->next = L;
}
/*-----解决约瑟夫问题-----*/
void Josephus(int n, int m, int k) {
LinkList L;
CreateList(L, n); //创建n个结点的循环链表链表
LinkList nowPoint = L; //工作指针 当前节点
LinkList prePoint; //辅助指针 配合删除结点
for (int i = 0; i < k-1; ++i) { //从第k个开始数
prePoint = nowPoint;
nowPoint = nowPoint->next; //工作指针后移至下一个结点
}
for (int i = 0; i < n; ++i) { //每次1人出列,直至n个人全部出列
for (int j = 0; j < m-1; ++j){ //从当前位置开始报数 数到m-1
prePoint = nowPoint;
nowPoint = nowPoint->next;
}
prePoint->next = nowPoint->next; //临时保存当前结点后继
cout << "编号 " << nowPoint->data << " 出列" << endl;
delete nowPoint; //释放当前结点 报数为m的人出列
nowPoint = prePoint->next; //工作指针后移
}
}
int main()
{
int n, m, k;
cout<<"请输入结点的个数:"<<endl;
cin >> n;
if (n<= 0) {
cout<<"请正确输入结点的个数:(正整数)"<<endl;
cin >> n;
}
cout<<"请输入报数周期是:"<<endl;
cin >> m;
if (m<= 0) {
cout<<"请正确输入报数周期:(正整数)"<<endl;
cin >> m;
}
cout<<"请输入从第几个数开始报数:"<<endl;
cin >> k;
if (k<= 0) {
cout<<"请正确输入报数起点:(正整数)"<<endl;
cin >> k;
}
Josephus(n, m, k);
return 0;
}
#include<iostream>
#include <cmath>
using namespace std;
typedef int ElemType;
/*------- 顺序表结构 -------*/
typedef struct
{
ElemType *elem; //指向数据元素的基地址
int length; //线性表当前长度
}List;
/*-----创建顺序表-----*/
void createList(List &L, int n, int m)
{
L.elem = new ElemType[n];
L.length = 0;
ElemType e;
if (m >= 0) //正序建立顺序表
for(int i=0; i<n; ++i) {
L.elem[i] = i+1;
++L.length;
}
else //周期m为负 逆序建表 实现逆序报数
for(int i=n-1; i>=0; --i) {
L.elem[i] = n-i;
++L.length;
}
}
/*-----解决约瑟夫问题---*/
void Josephus(int n, int m, int k)
{
List L;
createList(L, n, m); //建表 m>0 1,2,3...n m<0 n,n-1,...1
// ↓↓适应m k 为可能至少有一个为负数的情况
if (m*k<0)
k = n-abs(k)%n+1;
m = abs(m) % n;
k = abs(k) % n;
// ↑↑如果没有上面这几句的话仅能解决m k均为正数的约瑟夫问题
int i = (k - 1) % L.length; //表头标记
int j = 0; //标记删除位置 初始为0
int t = 0; //辅助序号 用于配合删除结点
while (L.length) //循环直至所有人出列
{
j = (i + j + m - 1) % L.length; //更新待删除结点位置
/* 更新待删除结点的原理
j = i + j; //根据起点位置向后移动j
j = j + m - 1; //j向后移动至第m个人
j = j % L.length; //防止越界 这很关键
*/
cout <<"编号 " << L.elem[j] << " 出列" << endl;
for(int t=j; t<L.length-1; ++t)
L.elem[t] = L.elem[t+1];
-- L.length;
i = 0; //重置报数起点
}
}
int main()
{
int n, m, k;
cout<<"请输入结点的个数:"<<endl;
cin >> n;
if (n <= 0) {
cout<<"请正确输入结点的个数:(正整数)"<<endl;
cin >> n;
}
cout<<"请输入报道报数周期是:"<<endl;
cin >> m;
cout<<"请输入从第几个数开始报数:"<<endl;
cin >> k;
Josephus(n, m, k);
return 0;
}