0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1923,日期见页头。两边不一致时信原站。
题目描述
输入 n 个数字 aᵢ,输出这些数字中第 k 小的数。最小的数是第 0 小。
请尽量不要使用 nth_element 来写本题,因为本题的重点在于练习分治算法。
输入格式
第一行有两个整数,分别表示 n 和 k。
第二行有 n 个整数,第 i 个数表示 aᵢ。
输出格式
一个整数,表示第 k 小的数。
说明 / 提示
对于 100% 的数据,1 ≤ aᵢ < 10⁹,1 ≤ n < 5 × 10⁶,0 ≤ k < n,且 n 为奇数。
输入输出样例
输入
5 1 4 3 2 1 5
输出
2
这道题考什么:快速选择求第 k 小(第 0 小是最小的数)。分治思路本身不难,难在输入很大,而且 k 是题面给的、不固定。
最容易错的三处
k从 1 数。题面写的是「最小的数是第 0 小」,把「第 k 小」的模板搬过来,写成a[k - 1]就错了。样例就能抓到:题面给 2,它给 1。- 默认
cin读 49.4 MB 的输入。顶格实测光读完就约 1032 毫秒,超时。关掉同步后约 156 毫秒,快读约 41 毫秒。要先把字节数算出来,再挑读法。 - 快读的缓冲区开小了。一次性
fread进一个 33.5 MB 的数组,装不下 49.4 MB 的输入,后面的数会读成 0。五个版本一起错,对拍全绿。要么把缓冲区算够,要么用「读完就续」的窗口。
能 AC 的:p1923(快读加随机 pivot 三路选择)。p1923Sort(快读加 sort)也能过。大小 k+1 的堆只是险过,别依赖它。
下面每一步里灰色的「深入」块是折叠的:那是这些结论怎么实测出来的,第一遍可以跳过。
1★ 这道题的三道坎,只有一道在算法上
n < 5 × 10⁶、aᵢ < 10⁹、时限 1 秒、内存 250 MB。
- ★ 输入 49.4 MB —— 全书最大的一次读入。第 ③ 步量了四种读法,分界线不在你以为的地方。
- ★★★
0 ≤ k < n——k是输入的一部分。 第 ④ 步会看到:有一种做法的耗时只跟着 k 变,在两个 k 上分别是全场最快和全场最慢。 - ★
n为奇数 —— 第 ⑥ 步专门造了一档违反它的数据,问一句「这条保证挡掉了什么」。
⇒ 算法本身(快速选择)第 12 章正文讲过了,这一页不重复。
// P1923 【深基9.例4】求第 k 小的数 —— 能 AC 的那一版//// 两件事都得做对,缺一个就挂:// ① ★ **读入**:n 到 5×10⁶、aᵢ 到 10⁹ ⇒ 输入大约 **48 MB**。// 这是全书最大的一次读入量,`cin`(哪怕关了同步)都吃不下 —— 见 p1923Read.cpp。// ② 快速选择:每次划分完只往**一边**走 ⇒ 期望 O(n)(工作量 n + n/2 + n/4 + … = 2n)。//// ⚠ 题面说「最小的数是第 **0** 小」—— k 是**从 0 开始**的。样例就能把这一位差抓出来。// ⚠ pivot 必须随机取,否则有序数据把它卡成 O(n²) ⇒ n = 5×10⁶ 时是 1.25×10¹³ 次。// ([第 10 章 P1177](/sol/p1177/) 整页都在说这件事,这里只是后果更重。)
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 5000005;static int a[MAXN];
/* ---- fread 快读:一次把整份输入吞进来 ---- *//* ---- fread 快读:4 MB 的窗口,读完就**续**(不是一次性把整份吞进来)---- ⚠ 一次性吞的写法必须保证缓冲区 >= 输入字节数,而这道题顶格是 49.4 MB —— 开小了会**静默**少读一截,见解析页第 ⑤ 步(我自己在这一页上踩的)。 */static char ibuf[1 << 22];static size_t ipos = 0, ilen = 0;static inline int gc() { if (ipos == ilen) { ilen = fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (!ilen) return -1; } return ibuf[ipos++];}static inline int readInt() { int c = gc(), x = 0; while (c != -1 && (c < '0' || c > '9')) c = gc(); for (; c >= '0' && c <= '9'; c = gc()) x = x * 10 + (c - '0'); return x;}
static mt19937 rng(20260827u);
/** a[l..r] 里第 k 小(k 从 0 数,相对整段而言的下标) */static int quickSelect(int l, int r, int k) { while (l < r) { int p = a[l + (int)(rng() % (unsigned)(r - l + 1))]; int i = l, j = r, m = l; // 三路划分:< p | == p | > p while (m <= j) { if (a[m] < p) swap(a[i++], a[m++]); else if (a[m] > p) swap(a[m], a[j--]); else m++; } if (k < i) r = i - 1; // 只往一边走 else if (k > j) l = j + 1; else return p; // 落在「等于 p」那一段里 } return a[l];}
int main() { int n = readInt(), k = readInt(); for (int i = 0; i < n; i++) a[i] = readInt(); printf("%d\n", quickSelect(0, n - 1, k)); // ★ k 从 0 数,不用减 1 return 0;}点「运行 ▶」看结果
顶格数据(n = 5 × 10⁶)本机实测:0.07 ~ 0.09 秒,而且不随 k 变。
2⚠ 先说样例就能抓到的那个:第 0 小
// P1923 ⚠ 错法一:把 k 当成「第 k 小,从 1 数」//// 题面第一句就写着「**最小的数是第 0 小**」,而绝大多数「第 k 小」的模板题是从 1 数的。// 顺手把模板搬过来,就会写成 `a[k - 1]`(或者 quickSelect(..., k - 1))。//// ★ 这个错**样例就抓得到**:样例是 `5 1 / 4 3 2 1 5`,排好序是 1 2 3 4 5,// 第 0 小是 1、第 1 小是 2 —— 题面给的答案是 **2**,而这一版给 1。// ⇒ [第 8 章 P2249](/sol/p2249/) 那条的又一次:**样例有时候比对拍还狠。**//// ⚠ 但它也有抓不到的时候:k = 0 那一档它会读到 a[-1],行为完全没定义 ——// 而那一档在题面里是**合法输入**(`0 <= k`)。
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 5000005;static int a[MAXN];
/* ---- fread 快读:4 MB 的窗口,读完就**续**(不是一次性把整份吞进来)---- ⚠ 一次性吞的写法必须保证缓冲区 >= 输入字节数,而这道题顶格是 49.4 MB —— 开小了会**静默**少读一截,见解析页第 ⑤ 步(我自己在这一页上踩的)。 */static char ibuf[1 << 22];static size_t ipos = 0, ilen = 0;static inline int gc() { if (ipos == ilen) { ilen = fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (!ilen) return -1; } return ibuf[ipos++];}static inline int readInt() { int c = gc(), x = 0; while (c != -1 && (c < '0' || c > '9')) c = gc(); for (; c >= '0' && c <= '9'; c = gc()) x = x * 10 + (c - '0'); return x;}
int main() { int n = readInt(), k = readInt(); for (int i = 0; i < n; i++) a[i] = readInt(); sort(a, a + n); printf("%d\n", a[k == 0 ? 0 : k - 1]); // ⚠ 就是这里(k = 0 时挡一下,免得越界) return 0;}点「运行 ▶」看结果
题面第一句就写着「最小的数是第 0 小」,而绝大多数「第 k 小」的模板题是从 1 数的。
顺手把模板搬过来就会写成 a[k - 1]。
样例是 5 1 / 4 3 2 1 5,排好序是 1 2 3 4 5:第 0 小是 1、第 1 小是 2 ——
题面给的答案是 2,而这一版给 1。
深入:四档对拍的抓获数(进阶)
样例就打死了它,对拍当然也抓得到。值得看的是最后一档只有 33 / 300 ——
那一档专门把 k 顶到两个端点(k = 0 或 k = n-1)。
k = 0 时这一版被我挡了一下(否则要读 a[-1]),于是那半档全过。
⇒ 又一次第 8 章 P2249 那条:样例有时候比对拍还狠。
3★ 第一道坎:49.4 MB 的输入
// P1923 的第一道门槛:把 5×10⁶ 个数读进来//// 用法:./p1923Read [n] 人话版(默认题面顶格 n = 5×10⁶)// ./p1923Read [n] csv 给 check:viz 用//// ★ 先算一笔账([第 10 章 P1271](/sol/p1271/) 那把尺子:**字节数**,不是数的个数):// n = 5×10⁶ 个数、每个 aᵢ < 10⁹ ⇒ 平均 9 位多 + 一个分隔符 ≈ **每个数 10 字节**// ⇒ 输入大约 **48 MB**。这是全书最大的一次读入。//// ⚠ 这份程序自己造数据写进临时文件,再 freopen 回来读 —— 不需要喂输入。// ⚠ 「同步开着」那一趟必须排最前面(关掉同步之后 cin 会预读,换文件会读到残渣)。//// ★★ 2026-09-01 改成「每一档跑 3 次取中位数」,理由是一次翻红换来的:// 默认 cin 本机独占实测 1032~1047 毫秒,而时限是 1000 —— **只赢 4%**。// 于是 `ms0 > 1000` 这个布尔量在 8 路并行的 check:viz 里被晃翻过一次(量到 ≤1000)。// ⇒ 硬规矩第 5 条那句「优先写倍数,秒数只当量级」在这儿是字面意思:// 断言钉的是**倍数**(默认 cin / 关同步 ≈ 6.4 倍,独占和满负载下都一样),// 以及「另外三档离时限还有 4 倍以上」,不再钉「它有没有跨过 1000 这条线」。
#include <bits/stdc++.h>#include <chrono>using namespace std;using namespace std::chrono;
static char path[64];
static void makeData(int nn) { snprintf(path, sizeof(path), "/tmp/p1923-bench-%d.txt", (int)getpid()); FILE* f = fopen(path, "w"); if (!f) { fprintf(stderr, "写不出临时文件\n"); exit(1); } mt19937 rng(20260827u); fprintf(f, "%d %d\n", nn, nn / 2); for (int i = 0; i < nn; i++) fprintf(f, "%u%c", (unsigned)(rng() % 999999999u) + 1, i + 1 == nn ? '\n' : ' '); fclose(f);}static void reopenIn() { if (!freopen(path, "r", stdin)) { fprintf(stderr, "读不回临时文件\n"); exit(1); } }
static char ibuf[1 << 22];static size_t ipos = 0, ilen = 0;static inline int gc() { if (ipos == ilen) { ilen = fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (!ilen) return EOF; } return ibuf[ipos++];}static inline int readIntFast() { int c = gc(), x = 0; while (c != EOF && (c < '0' || c > '9')) c = gc(); for (; c >= '0' && c <= '9'; c = gc()) x = x * 10 + (c - '0'); return x;}
int main(int argc, char** argv) { int n = (argc > 1) ? atoi(argv[1]) : 5000000; bool csv = (argc > 2 && string(argv[2]) == "csv"); makeData(n);
long long bytes = 0; { FILE* f = fopen(path, "rb"); fseek(f, 0, SEEK_END); bytes = ftell(f); fclose(f); } double mb = (double)bytes / 1048576.0;
const int REP = 3; // ★ 每一档跑 3 次取中位数(见文件顶上那段) double ms[4]; long long sum[4]; double rep[4][REP]; const char* NAME[4] = { "cin 默认(同步开着)", "cin 关同步", "scanf", "fread 快读" }; auto median3 = [](double* a) { double x = a[0], y = a[1], z = a[2]; return max(min(x, y), min(max(x, y), z)); };
for (int r = 0; r < REP; r++) { reopenIn(); auto t0 = steady_clock::now(); long long s = 0; int nn, kk; cin >> nn >> kk; for (int i = 0; i < n; i++) { int x; cin >> x; s += x; } rep[0][r] = duration<double, milli>(steady_clock::now() - t0).count(); sum[0] = s; }
ios::sync_with_stdio(false); cin.tie(nullptr); for (int r = 0; r < REP; r++) { reopenIn(); auto t0 = steady_clock::now(); long long s = 0; int nn, kk; cin >> nn >> kk; for (int i = 0; i < n; i++) { int x; cin >> x; s += x; } rep[1][r] = duration<double, milli>(steady_clock::now() - t0).count(); sum[1] = s; }
for (int r = 0; r < REP; r++) { reopenIn(); auto t0 = steady_clock::now(); long long s = 0; int nn, kk; if (scanf("%d %d", &nn, &kk) != 2) return 1; for (int i = 0; i < n; i++) { int x; if (scanf("%d", &x) != 1) return 1; s += x; } rep[2][r] = duration<double, milli>(steady_clock::now() - t0).count(); sum[2] = s; }
for (int r = 0; r < REP; r++) { reopenIn(); ipos = ilen = 0; auto t0 = steady_clock::now(); long long s = 0; readIntFast(); readIntFast(); for (int i = 0; i < n; i++) s += readIntFast(); rep[3][r] = duration<double, milli>(steady_clock::now() - t0).count(); sum[3] = s; }
for (int i = 0; i < 4; i++) ms[i] = median3(rep[i]);
remove(path); bool same = (sum[0] == sum[1] && sum[1] == sum[2] && sum[2] == sum[3]);
if (csv) { printf("n,%d\nbytesMB,%d\nsame,%d\n", n, (int)mb, same ? 1 : 0); for (int i = 0; i < 4; i++) printf("ms%d,%.0f\n", i, ms[i]); printf("cinOverLimit,%d\n", (ms[0] > 1000.0) ? 1 : 0); printf("syncOffOverLimit,%d\n", (ms[1] > 1000.0) ? 1 : 0); printf("scanfOverLimit,%d\n", (ms[2] > 1000.0) ? 1 : 0); printf("fastUnder200,%d\n", (ms[3] < 200.0) ? 1 : 0); printf("syncOffBeatsScanf,%d\n", (ms[1] < ms[2]) ? 1 : 0); printf("ratio,%d\n", (int)(ms[0] / ms[3])); /* ★ 这三个才是写进断言的:倍数、以及「另外三档离时限还有多远」 —— 见文件顶上那段 */ printf("cinOverSyncOff,%.1f\n", ms[0] / ms[1]); printf("cinEatsTimeLimit,%d\n", (ms[0] > 900.0) ? 1 : 0); printf("otherThreeMargin,%d\n", (int)(1000.0 / max(max(ms[1], ms[2]), ms[3]))); return 0; }
printf("n = %d,输入文件 %.1f MB(每个数约 %.1f 字节)\n\n", n, mb, (double)bytes / n); for (int i = 0; i < 4; i++) printf(" %-22s %8.0f 毫秒(3 次:%.0f / %.0f / %.0f)%s\n", NAME[i], ms[i], rep[i][0], rep[i][1], rep[i][2], (ms[i] > 1000.0) ? " ✗ 光读就超时了" : ""); printf("\n 四种读法的校验和%s。最慢 / 最快 = %.1f 倍。\n", same ? "完全一致" : "居然不一样!", ms[0] / max(0.001, ms[3])); printf(" ⇒ 时限 1 秒。★ **分界线正好落在「默认 cin」和「关掉同步」之间** ——\n"); printf(" 默认 cin 光读就超时,而关掉同步之后它反而比 scanf 还快。\n"); printf(" ⇒ 「要不要快读」不是一个是非题,是从四档里挑一档;挑之前先把字节数乘出来。\n"); return 0;}点「运行 ▶」看结果
先算账(第 10 章 P1271 那把尺子:字节数,不是数的个数):
5 × 10⁶ 个数 × 每个约 9.9 字节 = 47.1 MB。
本机顶格实测(只读,不算任何计算;A 机 · WSL2 · 2026-09-01 · 独占 · 每档 3 次取中位数):
| 读法 | 耗时 |
|---|---|
cin 默认(同步开着) |
★ 1032 毫秒 ✗ 光读就超时 |
cin 关同步 |
156 毫秒 |
scanf |
230 毫秒 |
fread 快读 |
★ 41 毫秒 |
深入:读入方式的计时细节(进阶)
这道题最慢和最快差 25 倍,但真正决定生死的只有第一行:
默认的 cin 光把输入读完就超时了。
⚠⚠ ★★ 而这句「超时」只赢 4%,这件事本身就是一条教训。
独占重测八次是 1032 ~ 1047 毫秒,而时限正好是 1000 ——
于是 check:viz 里那条「ms0 > 1000」的断言,在 8 路并行的全量闸门里被晃翻过一次
(那一趟量到 ≤ 1000,红灯,而页面上的数一个字没错)。
⇒ 现在断言钉的是倍数(默认 cin 比关同步慢 6.6 倍)和余量
(另外三档离时限还有 4.3 倍),不再钉「它有没有跨过 1 秒这条线」——
★ 这正是本书硬规矩第 5 条那句「优先写倍数,秒数只当量级」的字面意思。
⇒ ★★ 而对你做题的意义更直接:这一档在别的机器上完全可能落到线下面,
所以真正稳的结论不是「默认 cin 一定 TLE」,
而是「它比关同步慢六倍多,而你根本没有六倍可以浪费」。
⚠ 而两个常见说法在这里都不成立:
- 「
scanf比cin快」—— 关掉同步之后cin(156 毫秒)反而比scanf(230 毫秒)快。 ★ 这不是这道题的偶然:第 6 章 P2367 量的是 2×10⁷ 个数, 排序也是关同步 cin < scanf。 - 「这么大的数据必须手写快读」—— 也不对,关同步的
cin就够(156 毫秒,余量 6.4 倍)。
⇒ 「要不要读入优化」不是一个是非题,是从四档里挑一档 —— 挑之前先把字节数乘出来。
4★★★ 第二道坎:「谁最快」的主语是 k,而 k 是出题人给的
// P1923 第 ② 版:拿一个大小 k+1 的**大根堆**来求第 k 小//// 这是「求第 k 小」最常被推荐的做法,而且推荐语通常是这么写的:// 「只要 O(n log k),比排序的 O(n log n) 好。」//// ★ 而这句话**漏了主语**:`k` 是**输入的一部分**,你控制不了。// · k = 0 ⇒ 堆里只有一个元素,几乎就是一遍扫描,**四种做法里最快**;// · k = n/2 ⇒ ★ **它自己最慢的那一档**(比 k = 0 慢几十倍)。//// ⚠ 注意最慢的**不是** k = n-1:那时堆装得下整个数组,全程只有插入、// 没有「换掉堆顶再下沉」,反而比 k = n/2 快好几倍。// ⇒ **最坏的 k 不在端点上** —— 这一条是量出来的,不是推出来的(见 p1923Count.cpp)。//// ⇒ 题面写的是 `0 <= k < n`,也就是说**最坏的那个 k 一定会被出到**。
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 5000005;
/* ---- fread 快读:4 MB 的窗口,读完就**续**(不是一次性把整份吞进来)---- ⚠ 一次性吞的写法必须保证缓冲区 >= 输入字节数,而这道题顶格是 49.4 MB —— 开小了会**静默**少读一截,见解析页第 ⑤ 步(我自己在这一页上踩的)。 */static char ibuf[1 << 22];static size_t ipos = 0, ilen = 0;static inline int gc() { if (ipos == ilen) { ilen = fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (!ilen) return -1; } return ibuf[ipos++];}static inline int readInt() { int c = gc(), x = 0; while (c != -1 && (c < '0' || c > '9')) c = gc(); for (; c >= '0' && c <= '9'; c = gc()) x = x * 10 + (c - '0'); return x;}
static int heap_[MAXN]; // 手写大根堆,省掉 priority_queue 的开销
int main() { int n = readInt(), k = readInt(); int cap = k + 1, sz = 0; // 堆里始终保留「目前最小的 k+1 个」,堆顶是它们里最大的 for (int i = 0; i < n; i++) { int x = readInt(); if (sz < cap) { int c = sz++; // 上浮 heap_[c] = x; while (c > 0 && heap_[(c - 1) / 2] < heap_[c]) { swap(heap_[(c - 1) / 2], heap_[c]); c = (c - 1) / 2; } } else if (x < heap_[0]) { heap_[0] = x; // 换掉堆顶再下沉 int c = 0; while (true) { int l = 2 * c + 1, r = l + 1, big = c; if (l < sz && heap_[l] > heap_[big]) big = l; if (r < sz && heap_[r] > heap_[big]) big = r; if (big == c) break; swap(heap_[c], heap_[big]); c = big; } } } printf("%d\n", heap_[0]); // 堆顶就是第 k 小(k 从 0 数) return 0;}点「运行 ▶」看结果
「求第 k 小」最常被推荐的做法是维护一个大小 k+1 的大根堆,推荐语通常长这样:
只要
O(n log k),比排序的O(n log n)好。
// ★★★ 「谁更快」的主语是 k —— 而 k 是出题人给的//// 用法:./p1923Count [n] 人话版(默认 n = 10⁶)// ./p1923Count [n] csv 给 check:viz 用//// 同一份数组、同一台机器,只改一个东西:**要第几小**。四种做法各跑一遍。//// · 快速选择(随机 pivot + 三路):每次只往一边走 ⇒ 期望 O(n),**和 k 无关**;// · sort 之后取第 k 个:O(n log n),**也和 k 无关**;// · 大小 k+1 的大根堆:O(n log k) —— ★ **只有它跟着 k 变**;// · nth_element:库里的 introselect(题面让你别用,这里只当对照)。//// ⇒ 题面写的是 `0 <= k < n`,也就是说**最坏的那个 k 一定会被出到**。// 「O(n log k) 比 O(n log n) 好」这句话,在 k = n-1 那一档是句空话。
#include <bits/stdc++.h>#include <chrono>using namespace std;using namespace std::chrono;
static vector<int> base_, work;static long long qsOps = 0;
static mt19937 rng(20260827u);static int quickSelect(int l, int r, int k) { while (l < r) { int p = work[l + (int)(rng() % (unsigned)(r - l + 1))]; int i = l, j = r, m = l; while (m <= j) { qsOps++; if (work[m] < p) swap(work[i++], work[m++]); else if (work[m] > p) swap(work[m], work[j--]); else m++; } if (k < i) r = i - 1; else if (k > j) l = j + 1; else return p; } return work[l];}
static int heapKth(const vector<int>& a, int k) { int cap = k + 1; vector<int> h; h.reserve(cap); for (int x : a) { if ((int)h.size() < cap) { h.push_back(x); push_heap(h.begin(), h.end()); } else if (x < h.front()) { pop_heap(h.begin(), h.end()); h.back() = x; push_heap(h.begin(), h.end()); } } return h.front();}
int main(int argc, char** argv) { int n = (argc > 1) ? atoi(argv[1]) : 1000000; bool csv = (argc > 2 && string(argv[2]) == "csv");
base_.resize(n); { mt19937 g(20240612u); for (int i = 0; i < n; i++) base_[i] = (int)(g() % 999999999u) + 1; }
int ks[4] = { 0, n / 100, n / 2, n - 1 }; const char* kname[4] = { "k = 0", "k = n/100", "k = n/2", "k = n-1" }; double ms[4][4], raw[3][4][4]; int ans[4][4]; long long ops[4]; bool same = true;
/* ⚠⚠ 2026-09-05:这张表原来每格**只量一次**,于是「堆最慢的那一档是 k = n/2」 这条断言在 8 路并行的全量里被晃翻过一次(峰跑到了 k = n-1)。 独占重测五次那句话都是对的、而且 k = n/2 比 k = n-1 慢 2.8 倍 —— **余量不小, 翻它的是单次测量被调度打了一下**。⇒ 每格量 3 次取**中位数** ([第 278 条](/sol/p1177/) 那个改法;同一页的读入那条 2026-09-01 已经这么修过一次)。 ★ 而 ops 只取第一趟:quickSelect 用的是一个**一直往前走**的共享 rng, 重复跑会改掉那四个钉死的比较次数。 */ for (int rep = 0; rep < 3; rep++) { for (int t = 0; t < 4; t++) { int k = ks[t]; work = base_; qsOps = 0; auto t0 = steady_clock::now(); ans[t][0] = quickSelect(0, n - 1, k); raw[rep][t][0] = duration<double, milli>(steady_clock::now() - t0).count(); if (rep == 0) ops[t] = qsOps;
work = base_; t0 = steady_clock::now(); sort(work.begin(), work.end()); ans[t][1] = work[k]; raw[rep][t][1] = duration<double, milli>(steady_clock::now() - t0).count();
t0 = steady_clock::now(); ans[t][2] = heapKth(base_, k); raw[rep][t][2] = duration<double, milli>(steady_clock::now() - t0).count();
work = base_; t0 = steady_clock::now(); nth_element(work.begin(), work.begin() + k, work.end()); ans[t][3] = work[k]; raw[rep][t][3] = duration<double, milli>(steady_clock::now() - t0).count();
for (int i = 1; i < 4; i++) if (ans[t][i] != ans[t][0]) same = false; } } for (int t = 0; t < 4; t++) for (int i = 0; i < 4; i++) { double v[3] = { raw[0][t][i], raw[1][t][i], raw[2][t][i] }; sort(v, v + 3); ms[t][i] = v[1]; // ★ 中位数,不是平均 —— 一次离群不影响它 }
/* 结论量(不钉毫秒,钉「谁快谁慢」和倍数) */ int heapBestAt0 = 1; // k = 0 那一行,堆是四种里最快的 for (int i = 0; i < 4; i++) if (i != 2 && ms[0][2] > ms[0][i]) heapBestAt0 = 0; int heapPeak = 0; // 堆自己最慢的是哪一个 k for (int t = 1; t < 4; t++) if (ms[t][2] > ms[heapPeak][2]) heapPeak = t; int heapRatio = (int)(ms[2][2] / max(0.001, ms[0][2])); int qsSteady = 1; // 快速选择的比较次数四档都不到 4n for (int t = 0; t < 4; t++) if (ops[t] >= 4LL * n) qsSteady = 0; int heapSlowerThanQsAtMid = (ms[2][2] > ms[2][0] * 2) ? 1 : 0; /* ★ 「最慢的那一档不在端点上」赢多少:独占实测 k = n/2 比 k = n-1 慢约 2.8 倍,这里只钉 1.5 倍 */ int heapMidBeatsEnd = (ms[2][2] > ms[3][2] * 1.5) ? 1 : 0; int heapMidOverEndX10 = (int)(ms[2][2] / max(0.001, ms[3][2]) * 10);
if (csv) { printf("n,%d\nsame,%d\n", n, same ? 1 : 0); printf("heapBestAt0,%d\nheapPeakIdx,%d\nheapRatio,%d\nqsUnder4n,%d\nheapSlowerThanQsAtMid,%d\n", heapBestAt0, heapPeak, heapRatio, qsSteady, heapSlowerThanQsAtMid); printf("heapMidBeatsEnd,%d\nheapMidOverEndX10,%d\n", heapMidBeatsEnd, heapMidOverEndX10); for (int t = 0; t < 4; t++) printf("qsOps%d,%lld\n", t, ops[t]); return 0; }
printf("n = %d,同一份随机数组,只改「要第几小」:\n\n", n); printf(" %-10s %12s %12s %12s %12s\n", "", "快速选择", "sort", "大小k+1的堆", "nth_element"); printf(" %-10s %12s %12s %12s %12s\n", "----------", "------------", "------------", "------------", "------------"); for (int t = 0; t < 4; t++) { printf(" %-10s", kname[t]); for (int i = 0; i < 4; i++) printf(" %9.1f ms", ms[t][i]); printf("\n"); } printf("\n 四行四列的答案%s。\n", same ? "全部相同" : "居然不一样!"); printf("\n ★ 只看「堆」那一列:k = 0 时 %.1f 毫秒(**四种里最快**),%s 时 %.1f 毫秒 —— 差 %d 倍。\n", ms[0][2], kname[heapPeak], ms[heapPeak][2], heapRatio); printf(" ⚠ 而它最慢的那一档**不在端点上**:k = n-1 是 %.1f 毫秒,比 k = n/2 的 %.1f 还快" "(%.1f 倍;★ 每格 3 次取中位数)。\n", ms[3][2], ms[2][2], ms[2][2] / max(0.001, ms[3][2])); printf(" 而快速选择那一列几乎是一条直线:比较次数 %lld / %lld / %lld / %lld(都不到 4n)。\n", ops[0], ops[1], ops[2], ops[3]); printf("\n ⇒ 「O(n log k) 比 O(n log n) 好」这句话**没有主语就是错的** ——\n"); printf(" 主语是 k,而 k 写在输入里,题面保证 0 <= k < n。**最坏的那个 k 一定会被出到。**\n"); return 0;}点「运行 ▶」看结果
n = 10⁶,同一份随机数组,同一台机器,只改 k:
| 快速选择 | sort |
★ 大小 k+1 的堆 |
nth_element |
|
|---|---|---|---|---|
k = 0 |
2.7 ms | 56.8 ms | ★ 0.6 ms(全场最快) | 4.2 ms |
k = n/100 |
6.5 ms | 55.5 ms | 2.9 ms | 4.7 ms |
k = n/2 |
8.1 ms | 56.3 ms | ★ 33.8 ms(自己的最差档) | 6.1 ms |
k = n-1 |
5.4 ms | 54.7 ms | 10.6 ms | 4.8 ms |
⚠ 每一格是 3 次的中位数,不是量一次就抄下来 —— 2026-09-05 这张表里
「堆最慢的那一档是 k = n/2」那条断言在 8 路并行的全量里被晃翻过一次
(峰跑到了 k = n-1)。独占重测五次那句话都是对的、而且赢 3.1 倍
⇒ 翻它的不是余量,是「只量了一次」(第 37 章 P1177 那一跤的同一类)。
- 快速选择那一列几乎是一条直线(比较次数 1 169 699 / 2 641 728 / 3 296 281 / 2 586 341,
四档都不到
4n)——「每次只往一边走」这件事和k无关。 sort那一列更是一条直线:它压根不看k。- ★ 只有堆那一列在动,而且动得很凶:
k = 0时它是四种里最快的,k = n/2时它是自己最慢的。
⚠ 而且它最慢的那一档 不在端点上:k = n-1(10.6 ms)比 k = n/2(33.8 ms)还快 3.1 倍——
k = n-1 时堆装得下所有元素,全程只有插入、没有「换掉堆顶再下沉」。
深入:顶格端到端的三列数字(进阶)
顶格 n = 5 × 10⁶ 上端到端(本机实测):
k |
快速选择 | sort |
★ 堆 |
|---|---|---|---|
0 |
0.08 秒 | 0.35 秒 | ★ 0.03 秒 |
n/2 |
0.07 秒 | 0.35 秒 | ★ 0.50 秒 |
n-1 |
0.09 秒 | 0.35 秒 | 0.10 秒 |
⇒ 时限 1 秒:堆没有超时,但它的余量只剩 2 倍,而快速选择有 14 倍。
★★★ 「哪个算法更快」在这道题上没有唯一答案 —— 它取决于一个你控制不了的输入参数。
题面写的是 0 ≤ k < n,也就是说最坏的那个 k 一定会被出到。
⇒ 你只能按最坏的 k 选算法。
(第 10 章 P1177 说过「谁更快不写主语就是错的」,那里的主语是数据的形状; 这里的主语是题面的一个参数,而且它写在输入里,你连看都看不到。)
5⚠ 我自己在这一页上踩的:快读的缓冲区开小了
// ⚠ P1923 —— 我自己在写这一页时踩的那个坑,留一份现场//// 和 p1923.cpp 只差一处:快读用的是「**一次性把整份输入吞进一个静态数组**」的写法,// 而那个数组开了 `1 << 25` = **33 554 432 字节 ≈ 33.5 MB**。//// 这道题顶格的输入是 **49.4 MB**。// ⇒ fread 只吞进前 33.5 MB,剩下约 1.6×10⁶ 个数**根本没读到**,// 它们留在数组里是 0 —— 而 0 比题面允许的最小值(1)还小,于是排序后全挤在最前面。//// ★★★ 最要命的是它**一声不吭**:// · 没有报错、没有崩溃;// · 而我当时**五个版本用的是同一份错误的快读** ⇒ 五个版本一起错,// **对拍 0 次不一致**([第 9 章 P1182](/sol/p1182/) 那条:两边错得一样,对拍就是瞎的)。//// ⇒ 缓冲区够不够是一道算术题,尺子还是[第 10 章 P1271](/sol/p1271/) 那把:**字节数**。// 而更稳的写法是根本不去算它 —— 用「读完就续」的 4 MB 窗口(p1923.cpp 里那份)。
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 5000005;static int a[MAXN];
static char buf[1 << 25]; // ⚠ 33.5 MB,而输入有 49.4 MBstatic int bufLen = 0, bufPos = 0;static inline int readInt() { while (bufPos < bufLen && (buf[bufPos] < '0' || buf[bufPos] > '9')) bufPos++; int x = 0; while (bufPos < bufLen && buf[bufPos] >= '0' && buf[bufPos] <= '9') x = x * 10 + (buf[bufPos++] - '0'); return x;}
int main() { bufLen = (int)fread(buf, 1, sizeof(buf), stdin); int n = readInt(), k = readInt(); for (int i = 0; i < n; i++) a[i] = readInt(); sort(a, a + n); printf("%d\n", a[k]); return 0;}点「运行 ▶」看结果
写这一页的第一版时,五份代码共用同一段「一次性把整份输入 fread 进静态数组」的快读,
而那个数组开的是 1 << 25 = 33.5 MB。这道题顶格的输入是 49.4 MB。
后面约 1.6 × 10⁶ 个数根本没被读到,留在数组里是 0 ——
而 0 比题面允许的最小值 1 还小,排序后全挤在最前面。
没有报错、没有崩溃、没有越界。而当时五个版本用的是同一份错误的快读, 于是它们互相之间完全一致:对拍全绿。 (第 9 章 P1182 那条:两边错得一样,对拍就是瞎的。)
深入:缓冲区开小的错误答案对照(进阶)
真正的答案要拿正确的读法才看得见:
k |
正确 | ⚠ 缓冲区开小的那版 |
|---|---|---|
0 |
45 | ★ 0 |
2 500 000 |
463292518 | ✗ 227184204 |
2 940 527 |
558131568 | ✗ 349311757 |
4 999 999 |
999999859 | ★ ✓ 999999859(这一档它居然是对的) |
⇒ 四个 k 里错三个、对一个 —— 连「它到底对不对」都要挑对 k 才问得出来。
★ 两条改法,第二条更稳:
- 把缓冲区算够(尺子还是那把:字节数);
- ★ 根本不去算它 —— 用「读完就续」的 4 MB 窗口(
p1923.cpp里那份gc())。
6★★ 第三道坎:「n 为奇数」这条保证挡掉了什么?
// 数据生成器(P1923 对拍用):`./p1923Gen <seed> [level] [n]`//// level 0(默认)★ **照题面**:n 是**奇数**,aᵢ ∈ [1, 10⁹),k 随机// level 1 ★★ **n 取偶数** —— 故意**违反**题面那条「n 为奇数」的保证。// 这一档存在的唯一理由是:**验一验那条保证到底有没有用。**// level 2 已经升序 / 完全倒序 / 全部相同(专抓 pivot 取首元素那一版)// level 3 k 顶到两个端点(k = 0 或 k = n-1)+ 值域压到 1..3(大量重复)//// ★ 题面里的每一条保证都值得问一句「它挡掉了什么」:// · P1908 的「注意序列中可能有重复数字」是**情报** —— 它指的正是随机数据造不出来的那一档;// · 这道题的「n 为奇数」呢?level 1 就是用来回答这个问题的。// (答案在解析页第 ⑥ 步:五个版本在这一档上和照题面那一档**表现完全一样**。)
#include <bits/stdc++.h>using namespace std;static mt19937 rng;static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
int main(int argc, char** argv) { rng.seed((argc > 1) ? (unsigned)atoi(argv[1]) : 1); int level = (argc > 2) ? atoi(argv[2]) : 0;
int n = ri(1, 15); if (level == 1) { if (n % 2 == 1) n++; } // ★ 偶数(违反题面) else { if (n % 2 == 0) n++; } // 照题面:奇数 if (argc > 3) n = atoi(argv[3]);
int hi = (level == 3) ? 3 : (ri(0, 1) ? 20 : 999999999); vector<int> a(n); for (int i = 0; i < n; i++) a[i] = ri(1, hi); if (level == 2) { int m = ri(0, 2); if (m == 0) sort(a.begin(), a.end()); else if (m == 1) sort(a.begin(), a.end(), greater<int>()); else for (int i = 0; i < n; i++) a[i] = 7; }
int k = (level == 3) ? (ri(0, 1) ? 0 : n - 1) : ri(0, n - 1); printf("%d %d\n", n, k); for (int i = 0; i < n; i++) printf("%d%c", a[i], i + 1 == n ? '\n' : ' '); return 0;}点「运行 ▶」看结果
题面写着「且 n 为奇数」。上一页(P1908)刚说过
「题面里的『注意 / 可能 / 不保证』是出题人给的情报」——
那这一条呢?生成器的 level 1 就是专门违反它的:n 一律取偶数。
| 生成器 | 正解 | p1923Bad |
p1923Heap |
⚠ p1923One |
|---|---|---|---|---|
level 0 ★ 照题面(n 奇数) |
0 / 300 | 0 / 300 | 0 / 300 | 215 / 300 |
level 1 ★★ n 取偶数 |
0 / 300 | 0 / 300 | 0 / 300 | 223 / 300 |
| level 2 有序 / 倒序 / 全同 | 0 / 300 | 0 / 300 | 0 / 300 | 138 / 300 |
level 3 k 顶到端点 |
0 / 300 | 0 / 300 | 0 / 300 | 33 / 300 |
把 n 改成偶数之后,没有任何一版的行为发生变化(One 那 215 → 223 是随机涨落)。
⇒ 「n 为奇数」在这道题上是噪声:它一个错法都没挡掉,也一个错法都没指向。
把这一轮三道题并排看,题面里的约束正好凑齐三类:
| 类型 | 例子 | 它在干什么 | 怎么认出来 |
|---|---|---|---|
| 情报 | P1908「注意可能有重复数字」 | 指向随机数据造不出来的那一档 | 照着它造一档,抓获率从 0 跳到 251/300 |
| 命门 | P1226「p ≥ 2」「a ≥ 0」 |
挡死了三个常见错法 | 放宽它,那几个错法当场复活 |
| 噪声 | 这道题「n 为奇数」 |
什么都没做 | 违反它,没有任何一版的行为变化 |
⇒ 三类的判据是同一个动作:造一档违反它的数据,看有没有任何一版的行为变了。 这比「读题要仔细」有用得多 —— 它是个能跑的实验。
7★ 顺带:pivot 那条老坑,在这道题上重多少
// P1923 ⚠ 错法二:手写快速选择,但 pivot 取当前段的第一个元素//// 这条[第 10 章 P1177](/sol/p1177/) 整页都在说,这里只补一个数:**后果重多少**。// · P1177 的 n = 10⁵ ⇒ 退化后 5×10⁹ 次比较,端到端 1511 毫秒(时限 1 秒);// · 这道题的 n = 5×10⁶ ⇒ 退化后 **1.25×10¹³** 次 —— 差 **2500 倍**,// 而且递归深度也是 n 级别,栈先炸。//// ⚠ 它在**随机数据**上完全正常(和 P1177 一样:本地随手测,它甚至更快)——// 顶格随机数据上它和正解差不多,所以页面上量退化用的是**有序**数据,而且只量到// n = 2×10⁵ 再按平方外推:真喂顶格有序数据给它,要跑几个小时。
#include <bits/stdc++.h>using namespace std;
static const int MAXN = 5000005;static int a[MAXN];
/* ---- fread 快读:4 MB 的窗口,读完就**续**(不是一次性把整份吞进来)---- ⚠ 一次性吞的写法必须保证缓冲区 >= 输入字节数,而这道题顶格是 49.4 MB —— 开小了会**静默**少读一截,见解析页第 ⑤ 步(我自己在这一页上踩的)。 */static char ibuf[1 << 22];static size_t ipos = 0, ilen = 0;static inline int gc() { if (ipos == ilen) { ilen = fread(ibuf, 1, sizeof(ibuf), stdin); ipos = 0; if (!ilen) return -1; } return ibuf[ipos++];}static inline int readInt() { int c = gc(), x = 0; while (c != -1 && (c < '0' || c > '9')) c = gc(); for (; c >= '0' && c <= '9'; c = gc()) x = x * 10 + (c - '0'); return x;}
/** pivot = a[l],单向 partition —— 有序数据上一次只切掉一个元素 */static int quickSelectBad(int l, int r, int k) { while (l < r) { int p = a[l], i = l, j = r; while (i < j) { while (i < j && a[j] >= p) j--; a[i] = a[j]; while (i < j && a[i] <= p) i++; a[j] = a[i]; } a[i] = p; if (k < i) r = i - 1; else if (k > i) l = i + 1; else return p; } return a[l];}
int main() { int n = readInt(), k = readInt(); for (int i = 0; i < n; i++) a[i] = readInt(); printf("%d\n", quickSelectBad(0, n - 1, k)); return 0;}点「运行 ▶」看结果
这条第 10 章 P1177 整页都在说,这里只补一个数:后果重多少。
有序数据 n = 10⁵ |
n = 2 × 10⁵ |
顶格 n = 5 × 10⁶(按平方外推) |
|
|---|---|---|---|
| ⚠ pivot 取首元素 | 0.89 秒 | 3.61 秒 | ★ 约 2256 秒(38 分钟) |
| 快速选择(随机 pivot) | 0.00 秒 | 0.00 秒 | 0.09 秒 |
⚠ 而它在随机数据上完全正常 —— 顶格随机数据上它和正解一样是 0.07 秒。 四档对拍也是 0 / 1200:它只坏复杂度,不坏答案 (第 53 章那条:这类 bug 对拍永远不会说话)。
8一张总表
| 版本 | 做法 | 样例 | 1200 轮对拍 | 顶格(k = n/2) |
结果 |
|---|---|---|---|---|---|
① p1923One |
sort + a[k-1] |
✗ 1 | ✗ 抓 215/300 起 | — | ✗ WA |
② p1923Bad |
手写选择,pivot 取首 | ✓ | ★ 0 / 1200 | 有序数据外推 38 分钟 | ✗ TLE |
③ p1923Heap |
大小 k+1 的堆 |
✓ | ✓ | ★ 0.50 秒(余量 2 倍) | ⚠ 险过 |
④ p1923Sort |
快读 + sort |
✓ | ✓ | 0.35 秒 | ★ AC |
⑤ p1923 |
快读 + 随机 pivot 三路选择 | ✓ | ✓ | 0.07 秒 | ★ AC |
- ★★★ 「哪个算法更快」的主语,可能是一个你控制不了的输入参数。
大小
k+1的堆在k = 0时是全场最快(0.6 ms),在k = n/2时自己跟自己差 五十倍上下(本机 47 ~ 63); 而快速选择和sort对k完全不敏感。 题面写着0 ≤ k < n⇒ 最坏的那个k一定会被出到,你只能按它选。 - ★★★ 题面里的约束有三种:情报、命门、噪声 —— 而判据是同一个动作。
造一档违反它的数据,看有没有任何一版的行为变了。
这道题的「
n为奇数」是噪声(0 变化);同一天的 P1226「p ≥ 2」是命门(挡死三个错法)。 - ★★ 快读的缓冲区开小了,会一声不吭地少读一截 —— 而所有版本一起错,对拍全绿。
33.5 MB 的缓冲区吃 49.4 MB 的输入,四个
k里错三个。 ⇒ 要么把字节数算够,要么用「读完就续」的窗口根本不去算它。