题单 · 习题解析

洛谷 P1996 约瑟夫问题

★★★ 本章原题,可本章的正解在这儿是零分 —— 那个递推快就快在「把出圈过程跳过了」,而题目要的正是过程

原题:洛谷 P1996出自 第 5 章 枚举与模拟 的题单题面本地存档:2026-08-26
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1996,日期见页头。两边不一致时信原站。

题目描述

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

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

输入格式:输入两个整数 nm

输出格式:输出一行 n 个整数,按顺序输出每个出圈人的编号。

数据范围1 ≤ m, n ≤ 100

输入输出样例

输入

10 3

输出

3 6 9 2 7 1 8 5 10 4 

10 个人、报到 3 出局。第一个出圈的是 3 号,最后一个(也就是幸存者)是 4 号。 上面那段输出是仓库里的 p1996.cpp 真跑出来的。

1先看清楚:它是本章原题,但本章的正解在这儿用不上

第 5 章后半场讲的就是约瑟夫,而且那一章的关键的一步 是「别去模拟那个圈」,最后落在三行递推上:

f(1) = 0
f(i) = (f(i-1) + m) % i        <- 幸存者的 0 基编号
答案 = f(n) + 1

O(n²) 变成 O(n),一个数组都不用开 —— 那一章就是这么收尾的。 而这道题恰好是它用不上的那道题。

2第 ① 版:把递推原样搬过来(样例就挡住了)

p1996Survivor.cpp第 ① 版:第 5 章那三行递推
样例要求 10 个数,它只打了 1 个 —— 而那 1 个(4 号)确实是正确的幸存者。
// P1996 的第 ① 版:把第 5 章那个「关键的一步」原样搬过来
//
// ★★★ 这一版是**对的算法,错的题**。
// 第 5 章后半场的全部力气都花在「不要模拟那个圈」上,最后得到三行递推:
// f(1) = 0; f(i) = (f(i-1) + m) % i; 答案 = f(n) + 1
// 它把 O(n²) 压成了 O(n),而且一个数组都不用开 —— 那一章是这么收尾的。
//
// 可 P1996 要的是**依次出圈的 n 个编号**,不是最后剩下的那一个。
// 而这个递推之所以快,恰恰是因为它**把出圈过程整个跳过了** ——
// 它算的是「幸存者在最后那一层的位置」,中间谁先谁后,它压根没经手。
//
// ⇒ 优化掉的正是这道题要的东西。样例一比就现形:要 10 个数,它只打 1 个。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
cin >> n >> m;
int f = 0; // f(1) = 0(0 基编号)
for (int i = 2; i <= n; i++) f = (f + m) % i;
cout << f + 1 << '\n'; // 换回 1 基
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 优化掉的,正是这道题要的东西

那个递推之所以快,是因为它把出圈的过程整个跳过了: 它算的是「幸存者在最后一层的位置」,中间谁先谁后,它压根没经手。

⇒ 所以这一版不是「慢了」「差一点」,是零分 —— 一个更优的算法,用错了题。

★ 这件事值得单独记一笔,因为它和自学时的直觉正好相反: 「我学过更好的做法」在考场上不是优势,除非那个「更好」是针对这道题问的问题。 这道题问的是过程,而所有把过程压缩掉的优化,在这儿全部作废。

3第 ② 版:老实模拟 —— ★ 它就已经能 AC 了

拿一个 vector 当那个圈,谁出局就把谁删掉,删之前先打印。 和第 5 章那份 josephBrute.cpp 只差一行(出局的人要打出来):

p1996.cpp第 ② 版:vector + erase(能 AC)
// P1996 约瑟夫问题(能 AC 的那一版):拿 vector 当那个圈,谁出局就删谁
//
// 和第 5 章 josephBrute.cpp 只差一处:出局的人**要打出来**,而且要打全 n 个。
// pos = (pos + m - 1) % size ← 那个 -1:当前这个人自己就要报「1」
// erase 之后 pos 正好落在下一个人身上(后面的元素整体往前挪了一格),
// 所以下一轮直接接着算,不用再 +1。
//
// n, m ≤ 100,这一版**已经足够 AC**(最多挪 100×100 = 一万个元素)。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
cin >> n >> m;
vector<int> circle(n);
iota(circle.begin(), circle.end(), 1); // 1, 2, …, n
int pos = 0;
while (!circle.empty()) {
pos = (pos + m - 1) % (int)circle.size();
cout << circle[pos] << ' ';
circle.erase(circle.begin() + pos); // ← 这一行是 O(size)
}
cout << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 三个下标坑,两个第 5 章讲过,第三个是这道题独有的
pos = (pos + m - 1) % (int)circle.size();
  1. 那个 -1vector 从 0 数起,而报数从 1 数起 —— 当前这个人自己就要报「1」, 所以只需要再往后走 m - 1 步。写成 (pos + m) 的话每次都会多走一个人。
  2. % size 要用「现在还剩几个人」,不是总人数 n
  3. erase 之后 pos 不用动:后面的元素整体往前挪了一格, pos 已经落在下一个该报数的人身上了。这里手贱写个 pos++ 是很常见的错法。
和算法无关、但真的会挂人的那一条
  • m 可以大于 n(题目只保证两个都 ≤ 100)。n = 1, m = 100 时圈里就一个人, 报到 100 出局的还是他 —— 取模写对了就自动对,没取模就是越界
  • m = 1:报到 1 就出局,也就是从 1 号开始一个一个出,输出正好是 1 2 3 … n。 这是模拟题的照妖镜,写完第一件事就该拿它试。
  • 输出是一行n 个数用空格隔开。行末多一个空格洛谷不计较,换行不能少

4第 ③ 版:不删元素,改成打标记

另一种一样常见的写法:数组长度永远是 n,出局的人打个 dead 标记, 报数的时候遇到死人就跳过去、不算数。

p1996Mark.cpp第 ③ 版:标记 + 跳过

它和第 ② 版输出逐字节相同,代价形状却完全不同: 第 ② 版的钱花在「erase 要把后面的元素整体挪一格」上, 第 ③ 版的钱花在「走过一格死人也得走」上。第 ⑥ 步会量出这句话到底值多少。

5第 ④ 版:树状数组 + 倍增(这道题不需要它)

把「活着」记成 1、「出局」记成 0,用第 38 章的树状数组维护前缀和, 那么「第 r 个活人是谁」就是「前缀和第一次到 r 的位置」—— 倍增一次 O(log n) 问出来:

出局者在活人里的名次: r = (k + m - 2) % left + 1      <- k 是这一轮谁先报「1」
删掉他之后,排在他后面的人自动顶上了他的名次,所以下一轮的 k 就是 r
p1996Fenwick.cpp第 ④ 版:树状数组,O(n log n)

这道题用不着它n ≤ 100)。它在这儿是为了回答下一步那个问题: n 到十万、一百万的时候,谁还活着。

6★★★ 换尺子:这道题的「谁更快」不由 n 决定,由 m 决定

秒表在 n ≤ 100 上量不动,那就换尺子 —— 数为了找到并删掉下一个人一共动了多少下erase 挪一个元素算一下、走过一格算一下、树状数组循环一次算一下):

p1996Count.cpp换一把尺子:三种写法各碰了多少下
默认量的是 n = 100000, m = 7。把第二个参数改成 1 和 100 各跑一次 —— 这一步的全部内容就在那两次里。
// 换一把尺子:三种写法各自「碰了多少个元素」
//
// 用法:./p1996Count <n> <m> 打三种写法碰的次数
// ./p1996Count <n> <m> csv 只打 `键,值`,给 check:viz 用
//
// 三把尺子量的是同一件事 —— **为了找到并删掉下一个人,一共动了多少下**:
// ② erase:每次 erase 要把后面的元素整体往前挪一格,挪几个就算几下
// ③ 标记 :每往后走一格(不管那格是死是活)算一下
// ④ 树状数组:树状数组里每循环一次算一下(add 和 kth 都算)
//
// ★ 这道题的规模(n ≤ 100)上三者根本分不出高下 —— 这也是正文要讲的一半。
// 把 n 拉大,形状才露出来:前两个是 O(n²),第三个是 O(n log n)。
#include <bits/stdc++.h>
using namespace std;
int n, m;
long long countErase() {
vector<int> c(n);
iota(c.begin(), c.end(), 1);
long long ops = 0;
int pos = 0;
while (!c.empty()) {
pos = (pos + m - 1) % (int)c.size();
ops += (long long)c.size() - pos - 1; // erase 要挪走的元素个数
c.erase(c.begin() + pos);
}
return ops;
}
long long countMark() {
vector<char> dead(n, 0);
long long ops = 0;
int cur = 0;
for (int left = n; left > 0; left--) {
int step = (m - 1) % left;
while (dead[cur]) { cur = (cur + 1) % n; ops++; }
for (int i = 0; i < step; i++)
do { cur = (cur + 1) % n; ops++; } while (dead[cur]);
dead[cur] = 1;
do { cur = (cur + 1) % n; ops++; } while (left > 1 && dead[cur]);
}
return ops;
}
vector<int> bit_;
long long bitOps = 0;
void add(int i, int v) { for (; i <= n; i += i & -i) { bit_[i] += v; bitOps++; } }
int kth(int r, int LOG) {
int p = 0;
for (int step = LOG; step > 0; step >>= 1) {
bitOps++;
if (p + step <= n && bit_[p + step] < r) { p += step; r -= bit_[p]; }
}
return p + 1;
}
long long countFenwick() {
bit_.assign(n + 1, 0);
bitOps = 0;
int LOG = 1;
while ((LOG << 1) <= n) LOG <<= 1;
for (int i = 1; i <= n; i++) add(i, 1);
int k = 1;
for (int left = n; left > 0; left--) {
int r = (k + m - 2) % left + 1;
int who = kth(r, LOG);
add(who, -1);
k = (r > left - 1) ? 1 : r;
}
return bitOps;
}
int main(int argc, char** argv) {
n = (argc > 1) ? atoi(argv[1]) : 10;
m = (argc > 2) ? atoi(argv[2]) : 3;
bool csv = (argc > 3 && string(argv[3]) == "csv");
long long a = countErase(), b = countMark(), c = countFenwick();
if (csv) {
printf("n,%d\nm,%d\nerase,%lld\nmark,%lld\nfenwick,%lld\n", n, m, a, b, c);
} else {
printf("n = %d, m = %d\n", n, m);
printf("② vector::erase 挪了 %lld 个元素\n", a);
printf("③ 标记 + 跳过 走了 %lld 格\n", b);
printf("④ 树状数组 循环了 %lld 次\n", c);
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

本机实测(B 机:原生 Ubuntu 7.0.0-30 / i5-10210U 8 线程 / 18 GB,2026-08-26,独占):

n = 100 000 erase 挪的元素 ③ 标记走的格子 ④ 树状数组循环
m = 1 4 999 950 000 100 000 3 456 000
m = 7 2 692 341 946 6 427 152 3 456 000
m = 100 2 512 355 562 73 003 365 3 456 000

秒表也是同一个说法(同机同日独占,输出都重定向掉):

n = 100 000 erase ③ 标记 ④ 树状数组
m = 1 0.69 秒 0.01 秒 0.02 秒
m = 7 0.32 秒 0.08 秒 0.02 秒
m = 100 0.33 秒 0.91 秒 0.02 秒
★★★ 同一个 n,标记版从「最快的」变成「最慢的」—— 变的只有 m

m = 1 时它是三个里最便宜的(正好 n 下,连树状数组都比不过它); m = 100秒表上它成了最慢的那个(0.91 秒),比 erase 版还慢。 碰的次数则涨了 730 倍(10 万 → 7300 万)。n 一个都没动。

⚠ 注意这两把尺子在这儿并不同意m = 100 那行,标记版碰的次数仍然只有 erase 版的三十四分之一,可秒表说它更慢。下一个提示框专门讲这件事。

三种写法的真实形状是:

② erase       : n^2 / 常数       <- 和 m 几乎无关
③ 标记 + 跳过 : m * n * ln(n)    <- ★ m 在分子上
④ 树状数组    : n * log(n)       <- 和 m 完全无关(三行 3 456 000 一模一样)

「哪种写法更快」这句话,没有主语就是错的。 这道题里被大多数人当成 「一个小常数」的 m,才是决定排名的那一位。 ★ 顺带一句给第 45 章估复杂度的时候,先问清楚哪些字母是变量。

⚠ 而这把尺子自己也有偏差 —— 和 P1219 那页正好反过来

m = 7 那一行:erase 碰了 26.9 亿下,标记版只碰了 642 万下,差 419 倍; 可秒表只差 4 倍(0.32 vs 0.08)。

因为 erase 那 26.9 亿下是 memmove —— 一整块连续内存往前搬, 每一下便宜到几乎不要钱;而标记版那 642 万下每一下都要判一次 dead[cur]、算一次取模。

P1219 那一页量到的是「次数一样、秒表差 18.7 倍」; 这一页量到的是它的反面:「次数差 419 倍、秒表只差 4 倍」。 两把尺子都会骗人,办法只有一个:两把都拿出来量,对不上就去看每一下到底在干什么。

7四个版本并排

版本 做法 样例 n ≤ 100 n = 10⁵ 最坏 能过吗
p1996Survivor 第 5 章的递推 只打 1 个数 ✗ 答非所问
p1996 vector + erase 0.69 秒(m = 1
p1996Mark 标记 + 跳过 0.91 秒(m = 100
p1996Fenwick 树状数组 + 倍增 0.02 秒

★ 后三版逐字节相同check:viz 每次跑 600 轮对拍 (300 轮随机 n, m ≤ 100,再加 300 轮专挑边界的 —— n ≤ 3m = 1m 远大于 n)。 第 ① 版则是 600 轮一轮都没对上

这一页记住三句话
  1. ★★★ 一个更优的算法,用错了题就是零分。 第 5 章把出圈过程优化没了, 而这道题要的就是那个过程 —— 先看清楚题目问的是「结果」还是「过程」。
  2. ★★★ 「谁更快」不写清主语就是错的。 同一个 n = 10⁵,标记版在 m = 1 时 是三个里最快的(0.01 秒),m = 100 时是最慢的(0.91 秒)。
  3. 两把尺子都会骗人。 次数差 419 倍、秒表只差 4 倍, 因为 memmove 的一下和取模判断的一下根本不是同一个价钱。