0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1996,日期见页头。两边不一致时信原站。
题目描述
n 个人围成一圈,从第一个人开始报数,数到 m 的人出列,再由下一个人重新从 1 开始报数,
数到 m 的人再出圈,依次类推,直到所有的人都出圈,请输出依次出圈人的编号。
注意:本题和《深入浅出-基础篇》上例题的表述稍有不同。
书上表述是给出淘汰 n-1 名小朋友,而该题是全部出圈。
输入格式:输入两个整数 n、m。
输出格式:输出一行 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第 ① 版:把递推原样搬过来(样例就挡住了)
// 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;}点「运行 ▶」看结果
那个递推之所以快,是因为它把出圈的过程整个跳过了: 它算的是「幸存者在最后一层的位置」,中间谁先谁后,它压根没经手。
⇒ 所以这一版不是「慢了」「差一点」,是零分 —— 一个更优的算法,用错了题。
★ 这件事值得单独记一笔,因为它和自学时的直觉正好相反: 「我学过更好的做法」在考场上不是优势,除非那个「更好」是针对这道题问的问题。 这道题问的是过程,而所有把过程压缩掉的优化,在这儿全部作废。
3第 ② 版:老实模拟 —— ★ 它就已经能 AC 了
拿一个 vector 当那个圈,谁出局就把谁删掉,删之前先打印。
和第 5 章那份 josephBrute.cpp 只差一行(出局的人要打出来):
// 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;}点「运行 ▶」看结果
pos = (pos + m - 1) % (int)circle.size();- 那个
-1:vector从 0 数起,而报数从 1 数起 —— 当前这个人自己就要报「1」, 所以只需要再往后走m - 1步。写成(pos + m)的话每次都会多走一个人。 % size要用「现在还剩几个人」,不是总人数n。- ★
erase之后pos不用动:后面的元素整体往前挪了一格,pos已经落在下一个该报数的人身上了。这里手贱写个pos++是很常见的错法。
m可以大于n(题目只保证两个都 ≤ 100)。n = 1, m = 100时圈里就一个人, 报到 100 出局的还是他 —— 取模写对了就自动对,没取模就是越界。m = 1:报到 1 就出局,也就是从 1 号开始一个一个出,输出正好是1 2 3 … n。 这是模拟题的照妖镜,写完第一件事就该拿它试。- 输出是一行,
n个数用空格隔开。行末多一个空格洛谷不计较,换行不能少。
4第 ③ 版:不删元素,改成打标记
另一种一样常见的写法:数组长度永远是 n,出局的人打个 dead 标记,
报数的时候遇到死人就跳过去、不算数。
它和第 ② 版输出逐字节相同,代价形状却完全不同:
第 ② 版的钱花在「erase 要把后面的元素整体挪一格」上,
第 ③ 版的钱花在「走过一格死人也得走」上。第 ⑥ 步会量出这句话到底值多少。
5第 ④ 版:树状数组 + 倍增(这道题不需要它)
把「活着」记成 1、「出局」记成 0,用第 38 章的树状数组维护前缀和,
那么「第 r 个活人是谁」就是「前缀和第一次到 r 的位置」—— 倍增一次 O(log n) 问出来:
出局者在活人里的名次: r = (k + m - 2) % left + 1 <- k 是这一轮谁先报「1」
删掉他之后,排在他后面的人自动顶上了他的名次,所以下一轮的 k 就是 r
⚠ 这道题用不着它(n ≤ 100)。它在这儿是为了回答下一步那个问题:
n 到十万、一百万的时候,谁还活着。
6★★★ 换尺子:这道题的「谁更快」不由 n 决定,由 m 决定
秒表在 n ≤ 100 上量不动,那就换尺子 —— 数为了找到并删掉下一个人一共动了多少下
(erase 挪一个元素算一下、走过一格算一下、树状数组循环一次算一下):
// 换一把尺子:三种写法各自「碰了多少个元素」//// 用法:./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 秒 |
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 章:估复杂度的时候,先问清楚哪些字母是变量。
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 ≤ 3、m = 1、m 远大于 n)。
第 ① 版则是 600 轮一轮都没对上。
- ★★★ 一个更优的算法,用错了题就是零分。 第 5 章把出圈过程优化没了, 而这道题要的就是那个过程 —— 先看清楚题目问的是「结果」还是「过程」。
- ★★★ 「谁更快」不写清主语就是错的。 同一个
n = 10⁵,标记版在m = 1时 是三个里最快的(0.01 秒),m = 100时是最慢的(0.91 秒)。 - ⚠ 两把尺子都会骗人。 次数差 419 倍、秒表只差 4 倍,
因为
memmove的一下和取模判断的一下根本不是同一个价钱。