目录
一、何为约瑟夫问题
-
约瑟夫问题,也称约瑟夫斯置换。在计算机编程的算法中,类似问题又称为约瑟夫环。又称“丢手绢问题”。
-
据说著名犹太历史学家 Josephus有过以下的故事:在罗马人占领乔塔帕特后,39 个犹太人与Josephus及他的朋友躲到一个洞中,39个犹太人决定宁愿死也不要被敌人抓到,于是决定了一个自杀方式,41个人排成一个圆圈,由第1个人开始报数,每报数到第3人该人就必须自杀,然后再由下一个重新报数,直到所有人都自杀身亡为止。然而Josephus 和他的朋友并不想遵从。首先从一个人开始,越过k-2个人(因为第一个人已经被越过),并杀掉第k个人。接着,再越过k-1个人,并杀掉第k个人。这个过程沿着圆圈一直进行,直到最终只剩下一个人留下,这个人就可以继续活着。问题是,给定了和,一开始要站在什么地方才能避免被处决?Josephus要他的朋友先假装遵从,他将朋友与自己安排在第16个与第31个位置,于是逃过了这场死亡游戏。
-
其实很好理解,n个人围成一圈,从第一个开始报数,第m个将被杀掉,最后剩下一个,其余人都将被杀掉。例如5个人,每次杀死第3个人,那么死亡的人数次序:3 -> 1 -> 5 -> 2 -> 4。每杀死一个人后,计数清零,并从下一个人开始循环计数直到满足3,如此往复。
1(1) 2(2) 3(3) 4(4) 5(5) --> 剩余队列编号 3 淘汰,对应原编号 3
1(1) 2(2) 3(4) 4(5) --> 剩余队列编号 1 淘汰,对应原编号 1
1(2) 2(4) 3(5) --> 剩余队列编号 3 淘汰,对应原编号 5
1(2) 2(4) --> 剩余队列编号 1 淘汰,对应原编号 2
1(4) --> 剩余队列编号 1 滔天,对应原编号 4
二、朴素法
首先,既然人数(n)固定,那么每杀死一个人n减1,直到n = 0时就结束了,所以我们需要一个最外层的循环来判断是否结束。其次,杀死次序(m)也是固定的,定义一个变量j记录次数,当j = m时,杀死当前的人(打印出当前人的位置),j从头开始计数,标记已经死亡的人或删除死亡的人。
朴素法思路很清晰,外循环判断是否所有人都死亡,内循环遍历所有存活的人,满足的元素被杀死,剩余人数减一。
2.1 朴素法-C语言
代码思路:
- 定义数组arr[n]记录所有存活的人,给每个人编号1~n,arr[i]即为编号,如果某人被杀死,arr[i] = -1。所以在遍历内循环时首先判断arr[i]是否为-1,否才执行。
- 总人数n,杀死次序m。
- 剩余人数num ,初始等于n,每杀死一个人num-1,直到num=0外循环结束,所有人被杀死(最后一个人活着),代码结束。
- 当前次序j,内循环每执行一次j+1,j = m时杀死人,j 重新赋值0。
代码:
#include <stdio.h>
#include <stdlib.h>
int main()
{
//约瑟夫问题
int n = 41;//人数
int m = 3;//第3个人杀死
int num = 0;//已经死亡的人
int j = 0;
int* arr = (int*)malloc(sizeof(int) * n);
if (arr == NULL)
return 0;
for (int i = 0; i < n; i++)//初始化数组arr
{
arr[i] = i + 1;
}
while (num < n)
{
for (int i = 0; i < n; i++)//遍历
{
if (arr[i] != -1)
{
j++;
if (j == 3)//死亡
{
printf("%d ", arr[i]);
arr[i] = -1;//标记死亡
j = 0;
num++;
}
}
}
}
return 0;
}
2.2 朴素法-C++
代码思路:
- 总体思路和C语言法一致,不同在于使用STL容器vector存储各元素,可以使用erase()方法删除死亡的元素,省去判断是否死亡的判断,效率提高。
- 注意erase方法返回值是下一个元素的迭代器;并且it++需判断是否为空。
代码:
#include <iostream>
using namespace std;
#include <stdio.h>
#include <string>
#include <sstream>
#include <algorithm>
#include <map>
#include <vector>
int main()
{
//约瑟夫问题
int n = 41;//人数
int m = 3;//第3个人杀死
int j = 0;
vector<int> arr(n);
for (int i = 0; i < n; i++)
{
arr[i] = i + 1;
}
while (n > 0)
{
for (auto it = arr.begin(); it != arr.end(); )//遍历
{
j++;
if (j == 3)//死亡
{
cout << *it << " ";
it = arr.erase(it);//删除当前元素,返回下一个元的迭代器
j = 0;
n--;
}
else
{
if (!arr.empty())//判断是否为空,不可盲目++
it++;
else
return 0;
}
}
}
return 0;
}
三、线段树法
显然直接暴力破解时间复杂度比较大,至少也是O(n)。那么有没有什么时间复杂度比较小的方式解决办法呢?切入正题,使用线段树解决约瑟夫问题。
3.1 何为线段树
- 线段树(Segment Tree)是一种二叉树,可视为树状数组的变种,1977 年由 Jon Louis Bentley 发明,用以存储区间或线段,并且允许快速查询结构内包含某一点的所有区间。线段树是一种用于维护区间信息的数据结构,可在O(logN)的时间复杂度内实现对数组的修改、查询。
- 线段树的单点查找非常迅速,而约瑟夫问题删除剩余队列第i个元素正好契合单点查找,所以采用线段树解决约瑟夫问题。
3.2 约瑟夫问题的线段树构建
(1)假设8个人,每次杀死第3个人。线段树构建方法:将一个区间化为为左右两个区间,然后递归处理,知道区间大小为1。
如上图,将8个人分区间递归建树,节点内的值是包含的叶子节点数目,区间[x:y]表示左子树和右子树的值,也可以认为是当前结点包含了区间内的所有值。可以看到,最后一排元素分别从左到右对应1~8数字,该特点和约瑟夫问题的剩余队列次序对应。
因此,对应线段树节点的表示方式:
typedef struct node_t
{
int left;
int right;
int count; //区间内的元素个数
}node_t;
(2)已经知道了线段树节点的建立,那么问题来了,如何用代码存储线段树?使用堆式建树,定义一个数组存储,各结点的数组下表如下图:
数组下表对应于树的层序遍历(广度优先搜素)。如果一个节点为a,那么它的左子节点为2a,右子节点为2a+1,父节点为a/2。因此可用移位操作符进行乘2操作加快速度。
代码如下:
#define L(a) ((a) << 1) //2*a,该节点的左子节点的数组下表
#define R(a) (((a) << 1) + 1) //2*a+1,该节点的右子节点的数组下表
#define MAXN 30001
node_t node[4 * MAXN];
注意:堆式建树的线段树的数组大小需要设为最大值的4倍。
(3)以上都是建树的初始化工作,理解了线段树的原理,就可以通过递归的方式建线段树了。
//以t为根节点,在区间[l, r]建立线段树
void build(int l, int r, int t)
{
node[t].left = l;
node[t].right = r;
node[t].count = r - l + 1;//长度
if (l == r)//左边等于右边,说明是同一节点,递归返回
return;
int mid = (l + r) / 2;
build(l, mid, L(t));//节点的左子节点
build(mid + 1, r, R(t));//节点的右子节点
}
建线段树过程,从根节点t出发,设定左子节点、右子节点和计数的值;当l==r时左右区间重合,说明已经是最后一个节点,递归返回。因为层序遍历,所以先递归左子树后递归右子树。
3.3 线段树的查询和修改
在前边我们已经建立了线段树,那么如何使用线段树解决约瑟夫问题呢?约瑟夫问题无非两步:第一,找到第m个要杀死的人,杀死并标记;第二,循环遍历至剩余一人。
3.3.1 删除结点(修改count)
因为我们在定义线段树的节点时定义了count(即线段树包含叶子节点的个数),所以可以利用count和数组位序i对比来查询。当我们执行杀死步骤时,只要在遍历时沿途将相关节点的count减一,即标记该元素死亡。因为叶子节点count皆为1,每次杀死一个叶子节点,count=0,就可以标记死亡了。这样有利于后面计算剩余的人数。
//t是根节点,i是剩余队列编号,即删除的元素编号
int tdelete(int t, int i)
{
node[t].count--;//每次递归进入时,当前节点元素少一
if (node[t].left == node[t].right)//当前结点是最后一个,左和右都相等,找到了
{
printf("%d ", node[t].left);//左边和右边都一样
return node[t].left;
}
if (node[L(t)].count >= i)//左子节点数大于i,往左子树寻找
{
return tdelete(L(t), i);
}
else //左子树人数不足,找右子树
{
return tdelete(R(t), i - node[L(t)].count);//因为此时:左子树count < i < 当前count,取i - 左子树count
}
}
以上代码用于"删除"某个元素,即标记某个元素的死亡。基本思路是利用线段树叶子节点和剩余队列i的对应关系,通过count和i比较,递归到需要删除的元素位序i,将其删除。
栗子:杀死第三个人,tdelete(1, 3);
- 第一次:根节点[1: 8],count-1(7),左子节点count=4 > 3,递归左子节点tdelete(L(t), 3)。
- 第二次:当前节点[1: 4],count-1(3),左子节点count=2 < 3,此时递归右子节点,tdelete(R(t), 3 - 2)。
- 第三次:当前节点[3: 4],count-1(1),左子节点count=1 = 1,递归左子节点,tdelete(L(t), 1)。
- 第四次:当前节点3,count-1(0),此时已经是叶子节点,node[t].left == node[t].right,打印出杀死的人3,返回值3。结束。
3.3.2 查询结点数(计算1~i的活人数)
在前面删除结点的同时,将相关节点的count做了相应的修改,这时候为了计算下一次删除,需要计算1~i的活人数。计算方法,利用建树时分割区间的方法,同样地判断中间值和i的关系来确实是往左子树还是右子树遍历。如题,总的区间[1: 8],要查找[1: 5]的活人数,直接[1: 4]的活人数count + 递归右子树即可。
//返回1~i的活人数
int get_count(int t, int i)
{
if (node[t].right <= i)
{
return node[t].count;
}
int mid = (node[t].left + node[t].right) / 2;
int s = 0;
if (i > mid)
{
s += node[L(t)].count;//必然包括左子树的所有
s += get_count(R(t), i);//再去右子树查找
}
else
{
s += get_count(L(t), i);
}
return s;
}
3.3.3 完整代码
前两步是关键步骤,完成关键的操作:删除结点和遍历。之后,只要在主函数中:
- 初始化数组和建立线段树
- 循环遍历n次
- 删除结点,并找到删除所在节点之前的活人数
- 做一些特殊情况判断:j值超出总数count时,取余操作。因为可能遍历到后边时,后面元素不够了,需要从尾部返回头部。
#include <iostream>
using namespace std;
#include <stdio.h>
#include <string>
#include <sstream>
#include <algorithm>
#include <map>
#include <vector>
#include <string.h>
#define L(a) ((a) << 1) //2*a,该节点的左子节点的数组下表
#define R(a) (((a) << 1) + 1) //2*a+1,该节点的右子节点的数组下表
#define MAXN 30001
typedef struct node_t
{
int left;
int right;
int count; //区间内的元素个数
}node_t;
node_t node[4 * MAXN];
void init()
{
memset(node, 0, sizeof(node));
}
//以t为根节点,在区间[l, r]建立线段树
void build(int l, int r, int t)
{
node[t].left = l;
node[t].right = r;
node[t].count = r - l + 1;//长度
if (l == r)//左边等于右边,说明是同一节点,递归返回
return;
int mid = (l + r) / 2;
build(l, mid, L(t));//节点的左子节点
build(mid + 1, r, R(t));//节点的右子节点
}
//t是根节点,i是剩余队列编号,即删除的元素编号
int tdelete(int t, int i)
{
node[t].count--;//每次递归进入时,当前节点元素少一
if (node[t].left == node[t].right)//当前结点是最后一个,左和右都相等,找到了
{
printf("%d ", node[t].left);//左边和右边都一样
return node[t].left;
}
if (node[L(t)].count >= i)//左子节点数大于i,往左子树寻找
{
return tdelete(L(t), i);
}
else //左子树人数不足,找右子树
{
return tdelete(R(t), i - node[L(t)].count);//因为此时:左子树count < i < 当前count,取i - 左子树count
}
}
//返回1~i的活人数
int get_count(int t, int i)
{
if (node[t].right <= i)
{
return node[t].count;
}
int mid = (node[t].left + node[t].right) / 2;
int s = 0;
if (i > mid)
{
s += node[L(t)].count;//必然包括左子树的所有
s += get_count(R(t), i);//再去右子树查找
}
else
{
s += get_count(L(t), i);
}
return s;
}
int main()
{
int n = 41;
int m = 3;
init();
build(1, n, 1);
int j = 0;//剩余队列的虚拟编号
for (int i = 1; i <= n; i++)
{
j += m;
if (j > node[1].count)//大于最大长度,取余。一般用于遍历到后面时元素长度已经不够的时候
{
j %= node[1].count;
}
if (j == 0)//重新赋值,可能从头开始
{
j = node[1].count;
}
int k = tdelete(1, j);
j = get_count(1, k);
}
return 0;
}