约瑟夫问题

约瑟夫问题,
是一个计算机科学和数学中的问题,
在计算机编程的算法中,
类似问题又称为约瑟夫环,
又称“丢手绢问题”

(来自百度百科词条:约瑟夫问题)。

约瑟夫问题定义

约瑟夫问题是个有名的问题:
N个人围成一圈,从第一个开始报数,
第M个将被杀掉,最后剩下一个,
其余人都将被杀掉。例如N=6,M=5,
被杀掉的顺序是:5,4,6,2,3。

约瑟夫问题分析

这个问题我遇到过几次了,
时间跨度相当长,
回顾一下当时的心路历程,
也算我入CSDN一年的纪念文吧。毕竟,
从当初的不会做,后续陆陆自己写出来多种解法。
同时这也是我用markdown写的第一篇文章ω

第一次遇到约瑟夫问题,
应该是大一上学期举行的程序设计新生赛,
那时的我刚开始上《程序设计基础》课,
连C语言中数组的概念都没搞清楚,
试图解决此题自然是不行的,
但是当时看榜做出这道题的人还挺多的,
同是新生怎么差距那么大呢?
当时的题目大概数据范围是最多到20个人,
当我后面知道他们是一个个手算打表,
一个个情况用if else输出 😃

好吧,那时我才知道,
这种比赛与其称作是程序设计竞赛,
不如算法竞赛叫的直观,
要灵活的解决问题,
而不是以为可以通过计算机强大的计算能力,
就去忽略了问题的本质。

通过新生赛的教训,我对编程的理解也有了提升,
伴随着C语言课上完,对语法逐渐熟悉,加上我对编程的兴趣,
又接触到了我们学校的OJ系统,开始在上面做老师推荐的题单
(其实就是一本通基础篇的题目),再次遇到了这个问题


2037:【例5.4】约瑟夫问题

在这里我就把题目整个放出来

题目描述

N 个人围成一圈,从第一个人开始报数,
数到M的人出圈;再由下一个人开始报数,
数到M的人出圈;…输出依次出圈的人的编号。

输入

输入N和M。

输出

输出一行,依次出圈的人的编号。

输入样例

8 5

输出样例

5 2 8 7 1 4 6 3

提示

【数据范围】

对于所有数据,2≤N,M≤1000。


虽然说当时学完了C语言,但毕竟基本都是学的语法,
没有接触到什么思维题,
所以说这题看了半天也不会,
网上一搜,说这道题要用链表,
于是很SAD这道题就被我跳过了

大一下学期,我选了一门ACM相关的课程,
在介绍STL的一节课上这道题又被当做了vector的例题,
虽然说题目改动成了输出最后一个出圈人的编号,
但思路都是一样的,
那时候我已经看出这个题不用什么链表,
就是一个模拟
自己想出了两个解法。
这两个代码跟我最近写的链表的解法码风还是有很大区别的,
足以看出我在这半年内的变化哈哈。
并且我们老师还说,
如果一个学生能够独立地做出这个题,
那他就有参加ACM比赛的潜力,笑。

解法一:【模拟】标记数组

因为我当时写的码风不太规范,也没有注释,所以写这篇文章时
我再写了一遍

初学者代码,大家图一乐,请看下下面代码

#include<stdio.h>
int main()
{
    int n,m,i,start,end,t,s;
    int a[1001],flag[1001]={0};
    scanf("%d %d",&n,&m);
    start=0;
    for (i=0;i<n;i++)
        a[i]=i+1;
    for (s=0;s<n;s++)
    {
        t=0;
        for (i=start; ;i++)
        {
            if (flag[i%n]==0)
            t++;
            if (t==m)
            break;
        }
        i%=n;
        //printf("%d\n",i);
        flag[i]=1;
        printf("%d ",a[i]);
        if (s<n-1)
        {
            start=(i+1)%n;
            for (i=start; ;i++)
            {
                if (flag[i%n]==0)
                break;
                else
                start++;
            }
            start%=n;
        }
	}
	return 0;
}

我们为了模拟出圈的过程,这里看作他们这个圈的大小始终是m,
但是出圈的人在报数过程中要跳过

  • 维护一个出圈人数变量out和一个报数计数器cnt
  • 使用一个标记数组,初始值每个人都在圈内,
  • 若报数报到m且这个人对应的编号没有出圈,
    将该编号打上标记并给out变量加一,再清零报数计数。
    并输出编号
  • 使用now代表当前遍历到的编号,无论其是否在圈内,
    都要继续往下报数,now+1,由于这是一个环,当到达n号
    位置时,要重新从1开始计数,利用取模操作可以完成
  • 当全部出圈即out=n时,跳出循环,注意内层循环的判断
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e5 + 10;
#define endl '\n'
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    bool flag[n + 1];                  //创建标记数组标记出圈状态
    memset(flag, false, sizeof(flag)); //在栈区创建要初始化为false
    int cnt = 0, out = 0, now = 1;     // cnt用于报数计数,out统计出圈人数,now是当前学生编号
    while (out < n)
    {
        while (cnt < m && out < n)
        {
            if (!flag[now]) //已经出圈的元素就不用报数了
            {
                cnt++;
                if (cnt == m)
                {
                    cout << now << " ";
                    flag[now] = true; //打上出圈标记
                    out++;
                    cnt = 0; //报数计数清零
                }
            }
            now++;
            if (now > n) //当前学生编号大于n成环重新从1开始
                now %= n;
        }
    }
    return 0;
}

解法二:【模拟】移动数组

上一个解法中我们并没有实际改变圈的大小,
因为我们并没有建立圈的数据结构,
仅是通过题目中数据有序递增这一特点来推断当前编号,
也就没办法让一个元素在循环过程中真正出圈,
通过标记数组来模拟出圈过程。
所以说解法一是圈不动人动,
解法二和解法三是圈和人都动。

  • 在这个解法中,
    我们建立一个数组存储圈,
    当一个编号需要出圈后,
    我们把该编号的后续元素向前移一位,
    直到数组为空。
  • 我们用一个变量start来表示报数起点的下标,
    报数到m要出圈的那个数的下标为(start+m-1)%len,
    输出其下标对应的编号,
  • 然后从这个下标开始到数组尾部数组元素往前移一位,
    需要正序更新元素,同时这里的数组大小由于该元素已经出圈,
    长度减一,最后更新起点下标为当前报数的下标
  • 外层循环表示出圈的次数,
    当圈内人数为0时跳出循环

这是我当时的写法,还是具有一定的可读性的,
当然使用vector更为方便。
不过Python List相当于C++中的vector,
这里就另外给一个python的代码。
c

#include <iostream>
using namespace std;
int main()
{
    int n, m;
    while (cin >> n >> m)
    {
        if (n == 0 && m == 0)
            break;
        int i, j, start = 0, len;
        int a[n];
        len = n;
        for (i = 0; i < n; i++)
            a[i] = i + 1;
        for (i = 0; i < n; i++)
        {
            printf("%d ", a[(start + m - 1) % len]);
            for (j = (start + m - 1) % len; j < len - 1; j++)
                a[j] = a[j + 1];
            start = (start + m - 1) % len;
            len--;
        }
    }

    return 0;
}

python

n,m=map(int,input().split())
circle=[]
for i in range(n):
    circle.append(i+1)
print("")
t=0
i=-1
while len(circle)>=1:
    t+=1
    if i>=len(circle)-1:
        i=0
    else:
        i+=1
    if t==m:
        print(circle[i],end=' ')
        circle.remove(circle[i])
        t=0
        i-=1

解法三:【链表】模拟链表

  • 为了方便, 这里用数组模拟链表。
  • 我们知道这是一个环,所以可以用环形链表来模拟
  • 当计数器到m需要出队该元素时,需要从链表中删除该元素
  • 删除元素需要使该元素的上一个元素指向该元素的下一个元素
    ,所以我们还需要一个变量来存储当前元素的前驱元素
  • 链表是由数据域和指针域两部分组成的,
    由于该问题中编号是从1开始依次递增的
    ,所以只需要一个数组记录指针域,
    存储该元素的下一个元素,
    通过一个变量now来表示当前元素,
    若需要访问下一个元素,
    更新now变成now的下一个元素,
    所以说数组中下标对应当前元素的编号,
    数组的值为下一个元素的编号

除了需要像之前的解法维护一个计数器变量,怎么判断跳出循环呢?
我们在出圈一个元素时同步将它的下一个元素置为0,
这样如果所有元素都已经出圈,最后一个元素会被循环多次,
它的下一个元素也会变成0,
但在环非空时是无论如何不会访问到下一个元素是0为元素的,
若出现了,跳出循环。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N = 1e5 + 10;
#define endl '\n'
int a[N];
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    //a数组是当前编号的下一个编号
    for (int i = 1; i < n; i++)
        a[i] = i + 1;
    a[n] = 1; //n元素的下一个元素是1号
    int cnt = 0, now = 1, prev = n; //同时创建prev保存前驱元素
    while (a[now])
    {
        // cout << prev << " " << now << " " << a[now] << endl;
        cnt++;
        if (cnt != m)
        {
            prev = now;
            now = a[now];
        }
        else
        {
            cout << now << " ";
            int nxt = a[now]; //需要临时变量存储下一个元素
            //如果该元素需要出队而前驱元素未出队,更新前驱元素的下一个元素
            if (a[prev]) 
                a[prev] = nxt;
            //从链表中移除当前元素
            a[now] = 0;
            prev = now; //如果m大于1不需要这句
            now = nxt;
            cnt = 0;
        }
    }
    return 0;
}

虽然约瑟夫问题解决的思路不是很难,
但在实际编写代码中,
需要对循环结构加以控制是不容易的,
只有通过自己的逐步调试,
才能掌握程序设计的方法和精髓,
真正地解决此类问题。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值