跳转至

P1996 约瑟夫问题

题目描述

\(n\) 个人围成一圈,从第一个人开始报数,数到 \(m\) 的人出列,再由下一个人重新从 \(1\) 开始报数,数到 \(m\) 的人再出圈,依次类推,直到所有的人都出圈,请输出依次出圈人的编号。

注意:本题和《深入浅出-基础篇》上例题的表述稍有不同。书上表述是给出淘汰 \(n-1\) 名小朋友,而该题是全部出圈。

输入格式

输入两个整数 \(n,m\)

输出格式

输出一行 \(n\) 个整数,按顺序输出每个出圈人的编号。

输入输出样例 #1

输入 #1

10 3

输出 #1

3 6 9 2 7 1 8 5 10 4

说明/提示

\(1 \le m, n \le 100\)

📌 题目分析

给定数据:

n 个人围成一圈,报数到 m 出列。
数据范围:1 ≤ n, m ≤ 100

要求按顺序输出依次出列的人的编号。

👉 本质:

环形结构的遍历与动态删除

👉 类型:

经典模拟 / 数据结构:队列 (Queue) 

🧠 解题思路

1️⃣ 为什么“队列”是完美解法?

这道题最直观的动作就是“排队报数”。 遇到环形排队问题,C++ STL 中的 std::queue(队列)是终极杀器。 队列的特点是 “先进先出 (FIFO)”。我们可以利用它巧妙地模拟“围成一圈”的过程: * 没数到 m 的人:从队头出来(出队),直接跑到队尾重新排队(入队)。这完美等价于环形中“报数但没被淘汰,轮到下一圈”的操作。 * 数到 m 的人:直接从队头出来(出队),被淘汰,不再回到队伍里,并打印他的编号。

2️⃣ 核心模拟逻辑

  1. 初始化:先把 \(1\)\(n\)\(n\) 个人的编号按顺序压入队列 q 中。
  2. 开始报数:写一个 while (!q.empty()) 循环,只要队伍里还有人就不停。
  3. 循环数数:用一个 for 循环从 \(1\) 数到 \(m-1\)。在这个过程中,把队头的人 pop 掉,并立刻 push 到队尾。
  4. 无情淘汰:循环结束后,现在的队头就是刚好数到 \(m\) 的人。打印他的编号,然后只 poppush,他就永远地离开了这个圈。

3️⃣ 时间复杂度

队伍初始有 \(n\) 个人。每个人出列前,都需要经过 \(m-1\) 次的出队和入队操作。

总操作次数约为 O(n × m)

\(n, m \le 100\) 的数据范围内,最多只需要 \(10000\) 次队列操作,对于 C++ 来说几乎是 \(0\) 毫秒的降维打击。


💻 C++代码

#include <iostream>
#include <queue>

using namespace std;

int main() {
    // 优化输入输出流
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    queue<int> q;

    // 1. 初始化:所有人按顺序入队
    for (int i = 1; i <= n; i++) {
        q.push(i);
    }

    // 2. 模拟报数过程,直到队列为空
    while (!q.empty()) {
        // 让前面 m-1 个人安全过关,跑到队伍最后面去
        for (int i = 1; i < m; i++) {
            int safe_person = q.front();
            q.pop();
            q.push(safe_person);
        }

        // 现在的队头就是刚好报到 m 的人
        int doomed_person = q.front();
        cout << doomed_person << " "; // 打印被淘汰的人
        q.pop();                      // 彻底出队(不再 push 回去)
    }

    cout << "\n";
    return 0;
}

⚠️ 易错点

❌ 1. 误用原生数组引发的低效

很多新手初学时喜欢开一个布尔数组 bool vis[105] 标记这个人是不是出列了,然后用一个死循环在数组里不停地转圈,数够了 \(m\) 个没被标记的人就改标记。 这种写法代码极度臃肿,且会有大量无意义的无效遍历(因为要反复跳过已经出列的人)。学会用 STL queue 可以把逻辑精简到极致。

❌ 2. 报数循环的边界问题

题目要求是“数到 \(m\) 的人出列”。 这意味着前面需要被转移到队尾的安全人数是 \(m-1\) 个人。所以内部的 for 循环条件必须是 i < m(或者 i = 1; i <= m - 1)。如果写成了 i <= m,就会错杀下一个人。

❌ 3. 忘记队列的头尾概念

queue 中,只能查看队头 q.front() 和队尾 q.back()。出队是 q.pop(),入队是 q.push()。千万不要和 vector 或者 stack 的方法搞混了(比如错写成 q.top())。


🚀 一句话总结

安全的人从队头出队再跑到队尾,倒霉的人直接从队头出队被踢走,完美闭环!

🔥 补充:硬核知识点

约瑟夫问题的终极数学解法 (\(O(N)\) 递推):

虽然这道题要求输出完整的过程,必须老老实实模拟。但如果题目变态一点,把 \(n\) 放大到 \(10^7\),并且只问你最后一个幸存者是谁,用队列模拟绝对会 TLE(超时)。

这时候就需要用到图灵奖级别的数学公式了(动态规划推导): 设 \(f(n, m)\) 表示 \(n\) 个人报数,数到 \(m\) 出列时,最后幸存者的编号(从 0 开始)。 它有一个极其优美的递推公式: $\(f(n, m) = (f(n-1, m) + m) \% n\)$ 边界条件:\(f(1, m) = 0\)(只有 1 个人时,幸存者下标肯定是 0)。

代码实现极短:

int survivor = 0; // f(1, m) 的情况
for (int i = 2; i <= n; i++) {
    survivor = (survivor + m) % i;
}
// 最终答案加 1 转换回 1-indexed
cout << survivor + 1 << endl;
时间复杂度瞬间从 \(O(N \times M)\) 降维打击到 \(O(N)\),且空间复杂度只有 \(O(1)\)。这是算法竞赛中经常用来“秒杀”最后生还者问题的终极武器!