约瑟夫问题解析附C/C++代码——使用线段树解决

一、何为约瑟夫问题

  • 约瑟夫问题,也称约瑟夫斯置换。在计算机编程的算法中,类似问题又称为约瑟夫环。又称“丢手绢问题”。

  • 据说著名犹太历史学家 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语言

代码思路:

  1. 定义数组arr[n]记录所有存活的人,给每个人编号1~n,arr[i]即为编号,如果某人被杀死,arr[i] = -1。所以在遍历内循环时首先判断arr[i]是否为-1,否才执行。
  2. 总人数n,杀死次序m。
  3. 剩余人数num ,初始等于n,每杀死一个人num-1,直到num=0外循环结束,所有人被杀死(最后一个人活着),代码结束。
  4. 当前次序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++

代码思路:

  1. 总体思路和C语言法一致,不同在于使用STL容器vector存储各元素,可以使用erase()方法删除死亡的元素,省去判断是否死亡的判断,效率提高。
  2. 注意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 何为线段树

  1. 线段树(Segment Tree)是一种二叉树,可视为树状数组的变种,1977 年由 Jon Louis Bentley 发明,用以存储区间或线段,并且允许快速查询结构内包含某一点的所有区间。线段树是一种用于维护区间信息的数据结构,可在O(logN)的时间复杂度内实现对数组的修改、查询。
  2. 线段树的单点查找非常迅速,而约瑟夫问题删除剩余队列第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;
    
}

四、参考文章

  • 6
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
好的,约瑟夫问题是一个经典的数学问题,可以通过使用链表来解决。下面是一个使用C++代码实现约瑟夫问题的设计思路: 1. 定义一个单向链表的结构体,包含两个成员变量:一个数据域和一个指向下一个节点的指针域。 ```c++ struct Node { int data; Node* next; }; ``` 2. 编写一个函数,用于创建一个含有n个节点的链表。在创建链表时,需要考虑每个节点的数据和下一个节点的指针。 ```c++ Node* createList(int n) { Node* head = new Node(); Node* p = head; for (int i = 1; i <= n; ++i) { Node* node = new Node(); node->data = i; p->next = node; p = p->next; } p->next = head->next; return head->next; } ``` 3. 编写一个函数,用于删除链表中的第m个节点。在删除节点时,需要修改前一个节点的指针,使其指向下一个节点。 ```c++ Node* removeNode(Node* head, int m) { Node* p = head; while (p->next != p) { for (int i = 1; i < m; ++i) { p = p->next; } Node* temp = p->next; p->next = temp->next; delete temp; p = p->next; } return p; } ``` 4. 在主函数中,调用上述函数,完成约瑟夫问题的求解。具体来说,我们可以先创建一个含有n个节点的链表,然后在循环中不断删除第m个节点,直到链表中只剩下一个节点为止。 ```c++ int main() { int n = 10; int m = 3; Node* head = createList(n); Node* result = removeNode(head, m); cout << "The last one is: " << result->data << endl; return 0; } ``` 这就是使用C++代码解决约瑟夫问题的基本思路。需要注意的是,在实际编写中,还需要考虑输入的合法性、内存的释放等问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

暗夜无风

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值