题单 · 习题解析

洛谷 P1177 【模板】排序

★ 「本地测不出问题」说轻了:随机数据上,那个会 TLE 的手写快排反而比正确写法快 19%;★★ 2026-08-31 补了**第 37 章的一节:堆排序**(第 ⑥ 步)—— 它的 O(n log n) 是**最坏情况**的保证,四种数据比较次数只差 **7%**(快排同一列差 **419 倍**),而且是迭代的、一个字节额外空间都不要;⚠ 可它**比较次数比正确的快排还少 13%,秒表却更慢**(跳着访问 2i+1,cache 不友好)⇒ 两把尺子又打架;⚠⚠ 同日还重测了这一页的耗时表 —— 原来那三个「114 / 114 / 111 毫秒」是错的(拿 shell 时间戳相减,带着约 100 毫秒的固定开销),真值是 **11.2 / 6.2 / 5.7**,⇒ **一个加性开销会把小数字整个吃掉、在大数字上完全隐形**

原题:洛谷 P1177出自 第 10 章 排序:冒泡 → 归并 → 快排 的题单出自 第 11 章 分治 的题单出自 第 37 章 堆与 priority_queue 的题单出自 第 45 章 复杂度估算与考场策略 的题单题面本地存档:2026-08-27
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

将读入的 N 个数从小到大排序后输出。

输入格式

第一行为一个正整数 N。

第二行包含 N 个空格隔开的正整数 aᵢ,为你需要进行排序的数。

输出格式

将给定的 N 个数从小到大输出,数之间空格隔开。

说明 / 提示

对于 20% 的数据,有 1 ≤ N ≤ 10³;

对于 100% 的数据,有 1 ≤ N ≤ 10⁵,1 ≤ aᵢ ≤ 10⁹。

输入输出样例

输入

5
4 2 4 5 1

输出

1 2 4 4 5
学生版:一页看懂这道题

这道题考什么:排序模板题,但数据里藏着专门针对手写快排的杀手。要看的是快排的最坏情况:它可能退化成 O(n²)。

最容易错的两处

  1. 手写快排,pivot 取第一个元素。本地随机数据上它又快又对,可有序、倒序、全相同的数据会让它退化,交上去 TLE;递归深了还可能爆栈。
  2. 冒泡排序。它的答案是对的,但 n = 10⁵ 时要 10¹⁰ 次比较,跑不完。「这一趟没换过就退出」对随机数据没用。

能 AC 的:p1177,直接一行 sort。要自己写,就用随机 pivot 加三路划分(p1177Quick),或者堆排序(p1177Heap)。

下面每一步里灰色的「深入」块是折叠的:那是这些结论怎么实测出来的,第一遍可以跳过。

1⚠ 这一页不是教你写排序

★ 比赛里排序就一行,没有第二个选项
sort(a, a + n);

这一页存在的理由是另一个问题 —— 一个会真的把人挂掉的问题:

我手写的快排在本地跑得飞快,交上去却 TLE 了。为什么?

⇒ 这道题是「排序模板题」,可它的数据里藏着专门针对手写快排的杀手。 下面把那件事量出来。

p1177.cpp★ 这一版就能 AC
// P1177 【模板】排序 —— ★ 这一版就能 AC:sort 一行
//
// 比赛里排序就写这一行,没有第二个选项。这一页存在的理由**不是**教你手写排序,
// 而是回答一个具体的问题:**手写快排交上去为什么会 TLE?**
//
// ⚠ 读入 10⁵ 个数,`cin` 关掉同步就够了(第 8 章 P2249 那页量过同一件事)。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
sort(a.begin(), a.end());
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i + 1 == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

顶格数据(n = 10⁵)本机实测:114 毫秒(其中大部分是读写那 10 万个数)。

2第一版:冒泡 —— 它对,但它跑不完

p1177Bubble.cpp第 ① 版
// P1177 的第一版:冒泡 —— 大多数人真实的第一个排序算法
//
// 它当然是对的。问题是 O(n²):n = 10⁵ ⇒ **10¹⁰ 次比较**。
// 按本机实测的速率(见 p1177Count.cpp)外推,要跑十几秒,而时限是 1 秒。
//
// ⚠ 这份代码带一个「这一趟有没有换过」的提前退出 ——
// ★ 它让**已经有序**的数据变成 O(n),但对随机数据一点用都没有。
// ⇒ 「加了个优化」和「复杂度变了」是两回事,见第 7 章 P1147 那条。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<int> a(n);
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n; i++) {
bool swapped = false;
for (int j = 0; j + 1 + i < n; j++)
if (a[j] > a[j + 1]) { swap(a[j], a[j + 1]); swapped = true; }
if (!swapped) break; // 已经有序 ⇒ 提前收工
}
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i + 1 == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

O(n²):n = 10⁵ ⇒ 10¹⁰ 次比较。 本机实测 n = 20000 的随机数据要 379 毫秒,按平方外推到 10⁵ 是约 9.5 秒,时限 1 秒。

深入:冒泡提前退出的细节(进阶)
⚠ 那句「这一趟没换过就退出」买到了什么

这份冒泡带了个提前退出:一整趟都没交换过就说明已经有序,直接收工。

★ 它让已经有序的输入变成 O(n) —— 但对随机数据一点用都没有 (随机数据几乎每一趟都会换)。 ⇒ 「加了个优化」和「复杂度变了」是两件事(第 7 章 P1147 那条的反面: 那道题加两句 break 真的把类别改了,这里没有)。

3★★★ 第二版:手写快排 —— 它在本地是赢的

p1177QuickBad.cpp⚠ pivot 取第一个元素
// ⚠ 故意写错的(其实是「写得太天真」):手写快排,pivot 取**第一个元素**
//
// int pivot = a[l]; ← 教科书上最常见的写法
//
// ★★★ 它**答案完全正确**,但在两种数据上退化成 O(n²):
// ① **已经有序**(或倒序):每次划分只能切掉一个元素 ⇒ 递归 n 层;
// ② **所有数都相同**:单向 partition 会把所有元素都堆到一边。
//
// ⚠ 而这两种数据一点都不罕见 —— 洛谷 P1177 的数据里就有。
// 「我手写的快排在本地跑得飞快,交上去 TLE」十有八九就是它。
//
// ⚠ 还有一个更凶的后果:递归 n 层 ⇒ **爆栈**(第 30 章实测本机 8 MB 栈约 28 万层)。
// n = 10⁵ 还撑得住,再大就直接段错误 —— 而段错误看起来完全不像「排序写错了」。
#include <bits/stdc++.h>
using namespace std;
static vector<int> a;
static long long cmps; // 数一数比较了多少次
static void qsort_(int l, int r) {
if (l >= r) return;
int pivot = a[l], i = l, j = r; // ⚠ 就是这一行
while (i < j) {
while (i < j && (cmps++, a[j] >= pivot)) j--;
a[i] = a[j];
while (i < j && (cmps++, a[i] <= pivot)) i++;
a[j] = a[i];
}
a[i] = pivot;
qsort_(l, i - 1);
qsort_(i + 1, r);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
qsort_(0, n - 1);
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i + 1 == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

这是教科书上最常见的快排写法:pivot 取第一个元素,单向 partition。 样例过,随机数据也飞快。

4★ 换一把尺子:同一份代码,四种数据

p1177Count.cpp数比较次数
// 换一把尺子:两种 pivot 各在什么数据上退化
//
// 用法:./p1177Count [n] 人话版(带秒表)
// ./p1177Count [n] csv 只打 `键,值`,给 check:viz 用
//
// 四种数据(都是 n 个数):
// ① 随机 ② **已经升序** ③ **完全倒序** ④ **所有数都相同**
//
// 两种写法:
// · `bad` = pivot 取第一个元素 + 单向 partition(p1177QuickBad.cpp 那一版)
// · `good` = pivot 随机取 + 三路划分(p1177Quick.cpp 那一版)
//
// ★ 数的是**比较次数**,因为秒表在小数据上量不动,而在大数据上 bad 那版直接爆栈。
// ⚠ 所以 n 默认取 20000:这个规模下 bad 版的递归深度(有序数据上就是 n)
// 还没到爆栈线(本机约 28 万层),量得出来又不会崩。
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
using namespace std::chrono;
static vector<int> a;
static long long cmps;
static int depth_, maxDepth;
/* p1177QuickBad.cpp 那一版:pivot = a[l],单向 partition */
static void qbad(int l, int r) {
if (l >= r) return;
depth_++; maxDepth = max(maxDepth, depth_);
int pivot = a[l], i = l, j = r;
while (i < j) {
while (i < j && (cmps++, a[j] >= pivot)) j--;
a[i] = a[j];
while (i < j && (cmps++, a[i] <= pivot)) i++;
a[j] = a[i];
}
a[i] = pivot;
qbad(l, i - 1);
qbad(i + 1, r);
depth_--;
}
/* p1177Quick.cpp 那一版:随机 pivot + 三路划分 + 短的先递归 */
static mt19937 rng(20260827u);
static void qgood(int l, int r) {
while (l < r) {
depth_++; maxDepth = max(maxDepth, depth_);
int pivot = a[l + (int)(rng() % (unsigned)(r - l + 1))];
int lt = l, i = l, gt = r;
while (i <= gt) {
cmps++;
if (a[i] < pivot) swap(a[lt++], a[i++]);
else if (cmps++, a[i] > pivot) swap(a[i], a[gt--]);
else i++;
}
if (lt - l < r - gt) { qgood(l, lt - 1); l = gt + 1; }
else { qgood(gt + 1, r); r = lt - 1; }
depth_--;
}
}
static void makeData(int n, int shape) {
a.assign(n, 0);
mt19937 g(12345u);
for (int i = 0; i < n; i++) {
if (shape == 0) a[i] = (int)(g() % 1000000000u) + 1;
else if (shape == 1) a[i] = i + 1;
else if (shape == 2) a[i] = n - i;
else a[i] = 7;
}
}
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 20000;
bool csv = (argc > 2 && string(argv[2]) == "csv");
const char* name[4] = { "随机", "已经升序", "完全倒序", "所有数都相同" };
long long badC[4], goodC[4];
int badD[4], goodD[4];
double badMs[4], goodMs[4];
for (int s = 0; s < 4; s++) {
makeData(n, s);
cmps = 0; depth_ = 0; maxDepth = 0;
auto t0 = steady_clock::now();
qbad(0, n - 1);
badMs[s] = duration<double, milli>(steady_clock::now() - t0).count();
badC[s] = cmps; badD[s] = maxDepth;
makeData(n, s);
cmps = 0; depth_ = 0; maxDepth = 0;
t0 = steady_clock::now();
qgood(0, n - 1);
goodMs[s] = duration<double, milli>(steady_clock::now() - t0).count();
goodC[s] = cmps; goodD[s] = maxDepth;
}
if (csv) {
printf("n,%d\n", n);
for (int s = 0; s < 4; s++)
printf("bad%d,%lld\ngood%d,%lld\nbadDepth%d,%d\ngoodDepth%d,%d\nratio%d,%lld\nbadFaster%d,%d\n",
s, badC[s], s, goodC[s], s, badD[s], s, goodD[s], s, badC[s] / max(1LL, goodC[s]),
s, (badMs[s] < goodMs[s]) ? 1 : 0);
return 0;
}
printf("n = %d,两种 pivot 各要比较多少次:\n\n", n);
printf(" %-16s %14s %14s %10s %8s %8s %9s\n",
"数据", "pivot=首元素", "pivot 随机", "次数之比", "深度(坏)", "深度(好)", "秒表(坏)");
for (int s = 0; s < 4; s++) {
string t = name[s];
int w = 0; for (unsigned char c : t) { if ((c & 0xC0) == 0x80) continue; w += (c < 0x80) ? 1 : 2; }
printf(" %s%*s %14lld %14lld %9.2f× %8d %8d %7.1f ms\n", t.c_str(), max(0, 16 - w), "",
badC[s], goodC[s], (double)badC[s] / (double)max(1LL, goodC[s]), badD[s], goodD[s], badMs[s]);
}
printf("\n ★★★ 「随机」那一行 pivot=首元素**反而更少** —— 本地随手测,它是赢的。\n");
printf(" ★★★ 而下面三行是**同一份代码**在别的数据上的样子。\n");
printf(" ⚠ 看「深度(坏)」那一列:有序数据上递归 %d 层,n 再大一个量级就是段错误。\n", badD[1]);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
深入:四种数据的比较次数表(进阶)

n = 20000,两种 pivot 各要比较多少次:

数据 pivot = 首元素 pivot 随机 次数之比 递归最深(坏) 递归最深(好)
随机 477 045 589 200 ★ 0.81× 36 9
已经升序 199 990 000 564 737 354× 19 999 9
完全倒序 200 000 000 515 468 388× 19 999 9
所有数都相同 199 990 000 40 000 4 999× 19 999 1
★★★ 看第一行:随机数据上,「坏」的那版反而更少

0.81× 不是笔误 —— 随机数据上 pivot 取首元素比随机 pivot 还少比较 19% (少了一次生成随机数、三路划分也多一次判断)。秒表也是它更快。

⇒ 所以「本地测不出问题」这句话说轻了。真相是:本地随手测,它是赢的。 你没有任何理由怀疑它。

而下面三行是同一份代码换了数据的样子。

★ 为什么这三种数据能把它打死
  • 已经有序 / 完全倒序:pivot 每次都是当前段里最小(或最大)的那个 ⇒ 一次划分只切掉一个元素 ⇒ 递归 n 层,总比较次数 n²/2。
  • 所有数都相同:单向 partition 会把所有元素堆到一边,同样退化。

⚠ 而这三种数据一点都不罕见 —— 出题人只要在数据里放一组有序的, 所有手写快排的人一起 TLE。这道题就是这么干的。

★★ 还有一个更凶的后果在最后两列:递归 19 999 层。 n = 10⁵ 时就是 10 万层,而本机 8 MB 栈大约 28 万层就爆 (第 30 章实测过)—— 再大一个量级就是段错误,而段错误看起来完全不像「排序写错了」。

5怎么救:两处改动

p1177Quick.cpp能过的手写快排
// 手写快排 —— 能过的那一版:pivot **随机取**
//
// int pivot = a[l + rng() % (r - l + 1)];
//
// ★ 一行之差,上一版那两种「杀手数据」全部失效:
// 对手(出题人)不知道你的随机数,就构造不出必然退化的输入。
// ⇒ **随机化不是让算法变快,是让「最坏情况」变得不可预谋。**
//
// ⚠ 全相同元素那一档,光靠随机 pivot **还不够** —— 单向 partition 仍然会把
// 所有元素堆到一边。这里用的是**三路划分**(小于 / 等于 / 大于),
// 等于 pivot 的那一段直接不用再递归。
// ★ 这也是为什么 `std::sort` 不是教科书快排:它是 introsort
// (快排 + 递归太深就转堆排 + 小段插入排序)。
#include <bits/stdc++.h>
using namespace std;
static vector<int> a;
static mt19937 rng(20260827u);
static void qsort_(int l, int r) {
while (l < r) {
int pivot = a[l + (int)(rng() % (unsigned)(r - l + 1))];
int lt = l, i = l, gt = r; // [l,lt) < pivot, [lt,i) == pivot, (gt,r] > pivot
while (i <= gt) {
if (a[i] < pivot) swap(a[lt++], a[i++]);
else if (a[i] > pivot) swap(a[i], a[gt--]);
else i++;
}
/* ★ 先递归短的那一半,长的那半用循环 ⇒ 递归深度稳定在 O(log n),爆不了栈 */
if (lt - l < r - gt) { qsort_(l, lt - 1); l = gt + 1; }
else { qsort_(gt + 1, r); r = lt - 1; }
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
a.assign(n, 0);
for (int i = 0; i < n; i++) cin >> a[i];
qsort_(0, n - 1);
for (int i = 0; i < n; i++) cout << a[i] << " \n"[i + 1 == n];
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★ 一行随机 + 一个三路划分

① pivot 随机取。出题人不知道你的随机数,就构造不出必然退化的输入。 ⇒ 随机化不是让算法变快,是让「最坏情况」变得不可预谋。

② 三路划分(小于 / 等于 / 大于)。光靠随机 pivot 救不了「所有数都相同」—— 等于 pivot 的那一段直接不用再递归,那一档才从 2×10⁸ 掉到 4×10⁴。

★ 顺带第三件:先递归短的那一半,长的那半用循环 ⇒ 递归深度稳定在 O(log n), 上表最后一列的 9 和 1 就是它。

⇒ 这三条合起来,正是 std::sort 在做的事(它是 introsort: 快排 + 递归太深转堆排 + 小段插入排序)。所以比赛里写 sort。

深入:端到端实测与计时修正(进阶)

顶格数据(n = 10⁵)三种数据上的端到端实测 (A 机 · WSL2 · 8 核 / 7 G · 2026-08-31 重测 · 独占 · 跑 7 次取最快 5 次的平均):

随机 已经升序 所有数都相同
sort 11.2 毫秒 6.2 毫秒 5.7 毫秒
随机 pivot + 三路 13.7 毫秒 9.1 毫秒 4.9 毫秒
★ 堆排序(下一步补的那条路) 13.3 毫秒 8.6 毫秒 3.6 毫秒
⚠ pivot 取首元素 12.2 毫秒 ★ 1446.8 毫秒 ★ 1448.5 毫秒
⚠⚠ 这张表 2026-08-31 整个重测过 —— 原来那一版带着约 100 毫秒的假开销

这一页原先写的是「sort 114 毫秒 / 114 / 111」。⚠ 那三个数是错的, 而错法正是第 35 章 P2866 那一跤:用 shell 里的时间戳前后相减, 每次都带着约 100 毫秒的进程启动开销 —— 于是三个本该是 11 / 6 / 6 毫秒的数 一起被垫高成了 114 / 114 / 111,看着还特别整齐。

★ 而同一张表里 1511 那个数基本是对的(重测 1446.8)—— 因为那 100 毫秒摊在 1.4 秒上只有 7%,看不出来。 ⇒ ★★ 一个固定的加性开销,会把小数字整个吃掉,却在大数字上完全隐形 —— 所以「三个数几乎一样」这件事本身就该引起怀疑。 ⇒ 判据仍然是那一条:/usr/bin/time -f %e 跑 N 次除以 N,别拿 shell 里的时间戳相减。

6★ 第 37 章的补课:第三条路 —— 堆排序,它不用「救」

p1177Heap.cpp★ 堆排序:建堆 O(n) + n 次取最大,完全原地(顶格 13.3 毫秒)
// ★ 第 37 章的补课:**堆排序** —— 建堆 O(n) + n 次「取最大」,而且**完全原地**
//
// ============ 它和前面几版的关系 ============
// 这一页前面量过:pivot 取首元素的手写快排在**有序数据**上要比较 2×10⁸ 次(354 倍)、
// 递归 19 999 层。救它要两处改动(随机 pivot + 三路划分)。
// ★★ 而堆排序**根本不需要救** —— 它的 O(n log n) 是**最坏情况**的保证,
// 不是平均情况的:数据长什么样,它的比较次数几乎一模一样(第 ⑥ 步那张表)。
//
// ============ 两步 ============
// ① **建堆**:从最后一个非叶子结点往前,逐个 down —— [第 37 章第 11 步]证过这是 O(n);
// ② **n 次取最大**:把堆顶和最后一格交换、堆长度减一、再 down 一次
// ⇒ 最大的落到最后、次大的落到倒数第二…… 跑完就是升序。
//
// ★ 「原地、不要额外空间」正是这么来的:**被弹出去的元素就存在数组尾巴上**,
// 一个字节的额外空间都不用(对照 std::sort 的 introsort 也要 O(log n) 的栈)。
// ⚠ 而它在实战里通常比 std::sort 慢 —— 原因不在次数上,在 cache 上(第 ⑥ 步)。
#include <bits/stdc++.h>
using namespace std;
static int a[100005];
/** 下沉:两个儿子里挑**大**的换(这里建的是大根堆) */
static void down(int i, int len) {
int x = a[i];
while ((i << 1) + 1 < len) {
int c = (i << 1) + 1; // 0 基:儿子是 2i+1 / 2i+2
if (c + 1 < len && a[c + 1] > a[c]) c++;
if (a[c] <= x) break;
a[i] = a[c];
i = c;
}
a[i] = x;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = n / 2 - 1; i >= 0; i--) down(i, n); // ① 建堆,O(n)
for (int len = n; len > 1; len--) { // ② n 次取最大
swap(a[0], a[len - 1]);
down(0, len - 1);
}
string out;
for (int i = 0; i < n; i++) { if (i) out += ' '; out += to_string(a[i]); }
out += '\n';
cout << out;
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

上一步那两处改动(随机 pivot + 三路划分)是在救快排 —— 救的是它「最坏情况 O(n²)」这个毛病。而第 37 章讲完堆之后, 这道题多了一条根本不需要救的路:

★★ 堆排序的 O(n log n) 是「最坏情况」的保证,不是「平均情况」的。 数据长什么样,它都要走那么多步 —— 出题人构造不出让它退化的输入,因为不存在。

深入:堆排序的两张并排表(进阶)
★★★ 把两张表并排看:同一份代码换数据,快排自己差 419 倍,堆排序差 1.07 倍

n = 20000,四种数据上的比较次数(./p1177HeapCount 20000,和第 ④ 步同一批数据):

数据 pivot = 首元素 pivot 随机 + 三路 ★ 堆排序
随机 477 045 589 200 510 809
已经升序 199 990 000 564 737 529 074
完全倒序 200 000 000 515 468 493 307
所有数都相同 199 990 000 40 000 59 994
⇒ ★ 前三档最大 / 最小之比 419 倍 1.14 倍 ★ 1.07 倍

★★ 堆排序那一列前三档只差 7% —— 这就是「对数据形状免疫」的全部证据。 ⚠ 而第四档(所有数都相同)它只用 59 994 次,那是白捡的便宜不是退化: 一个数下沉时第一次比较就停住了。别把它混进那个比值里。

★ 还有一列这张表画不出来:递归深度。 快排在有序数据上是 19 999 层(第 ④ 步那张表),而堆排序是迭代的,没有递归 —— 「爆栈门槛 = 栈上限 ÷ 每层字节数」那笔账,它压根不用算。

⚠ 那它为什么不是标准答案 —— 两把尺子又打架了
n = 20000,随机数据 比较次数 秒表(★ 20 次取平均)
std::sort —— ★ 0.8 毫秒
随机 pivot + 三路划分 589 200 ——
★ 堆排序 ★ 510 809(比三路少 13%) ⚠ 1.3 毫秒(慢 1.7 倍)

比较次数更少的那一版反而更慢。 原因不在算法上,在内存上:

堆排序访问的下一格是 2i + 1 —— 跳着走的,每往下一层就跳得更远, 对 cache 极不友好;而快排的 partition 是两根指针从两头往中间顺序扫, 每读进一条 cache line 都物尽其用。

⇒ 「次数和秒表量的从来不是同一件事,两个都要报」的又一次 (P2925 是次数一样秒表差 1.13 倍、P2853 是次数差 100 倍秒表差 14 倍, 这一页是次数少 13% 而秒表慢 1.7 倍)。

⚠ 而它和上面那张顶格表(n = 10⁵ 端到端:三路 13.7 毫秒、堆排序 13.3 毫秒)咬得很紧 —— ⇒ ★ 稳定比它们快的是 std::sort,不是三路快排;说「堆排序更慢」要写清楚是跟谁比。

⚠⚠ 顺带一条 2026-08-31 自己踩出来的:这个秒表原来是单次 n = 20000 的排序 (两边都只有零点几毫秒)—— --ch=10 单跑是绿的,可全量 npm run check 那种 8 路并行的环境里 它自己翻红过一次(量到的是调度噪声,不是算法)。 ⇒ ★★ 亚毫秒级的秒表不能只跑一次:现在是重复 20 次取平均,倍数才稳得住。 ★ 同时顺手量了「cmps++ 那个插桩本身贵多少」—— 1.33 vs 1.29 毫秒,只有 3%, 所以拿「带计数的堆排序」和「没插桩的 std::sort」比是公平的。⚠ 但这句话是量出来才敢说的。

★★ 所以这三条路的分工是清楚的: 比赛里写 sort(它是 introsort:快排 + 递归太深转堆排 + 小段插入排序, 三者之长都占了);手写堆排序的价值在「最坏情况有保证」和「一个字节额外空间都不要」; 而手写快排要么随机化,要么就等着被卡。

p1177HeapCount.cpp第 37 章补课那一节的度量
// ★ 第 37 章补课那一节的度量:**堆排序在四种数据上稳不稳**
//
// 用法:./p1177HeapCount [n] 人话版(带秒表)
// ./p1177HeapCount [n] csv 只打 `键,值`,给 check:viz 用
//
// 四种数据和 p1177Count.cpp 完全一样:① 随机 ② 已经升序 ③ 完全倒序 ④ 所有数都相同。
// 量三件事:
// · 堆排序的**比较次数**(和 p1177Count 里两种快排那张表并排看)
// · ★★ 前**三**档(随机 / 升序 / 倒序)之间的**最大 / 最小之比** ——
// 这一个数就是「它对数据形状免疫」的全部证据(快排那张表同一列是 4999 倍)。
// ⚠ 第四档「所有数都相同」要单独看:那一档堆排序**反而更快**(一次 down 走一步就停),
// 它不是退化,是白捡的便宜 —— **别把它混进那个比值里**。
// · 秒表:堆排序 vs std::sort(⚠ 次数少不等于跑得快 —— 差在 cache 上)
//
// ⚠⚠ 2026-08-31 修:秒表那一列原来是**单次** n = 20000 的排序(两边都只有零点几毫秒),
// 在 `npm run check` 那种 8 路并行的环境里量的基本是噪声 —— 于是 `slower` 那个断言
// 在全量里翻红过一次(`--ch=10` 单跑却是绿的)。现在改成**重复 REP 次取平均**。
// ★ 机器闲下来重量的结果(A 机 · WSL2 · 2026-08-31 · REP = 50):
// 随机 1.29 ms / 升序 0.76 / 倒序 0.78 / 全相同 0.04,而 std::sort 是 0.78 / 0.09 / 0.07 / 0.07
// ⇒ 随机那一档堆排序慢 **1.7 倍**,而「所有数都相同」那一档它**反而更快**。
// ★ 顺带量了一件本来该先问的事:**cmps++ 那个插桩本身贵多少** —— 1.33 vs 1.29 ms,
// 只有 3%,所以拿「带计数的堆排序」去和「没插桩的 std::sort」比秒表是公平的。
// ⚠ 但这句话是**量出来才敢说**的,不是想当然。
//
// ⚠ 堆排序是**迭代**的,没有递归深度这一列(快排那版在有序数据上是 19 999 层)。
#include <bits/stdc++.h>
#include <chrono>
using namespace std;
using namespace std::chrono;
static vector<int> a;
static long long cmps;
static void down(int i, int len) {
int x = a[i];
while ((i << 1) + 1 < len) {
int c = (i << 1) + 1;
if (c + 1 < len && (cmps++, a[c + 1] > a[c])) c++;
if ((cmps++, a[c] <= x)) break;
a[i] = a[c];
i = c;
}
a[i] = x;
}
static void heapSort(int n) {
for (int i = n / 2 - 1; i >= 0; i--) down(i, n);
for (int len = n; len > 1; len--) { swap(a[0], a[len - 1]); down(0, len - 1); }
}
static void makeData(int n, int shape) {
a.assign(n, 0);
mt19937 g(12345u); // ⚠ 和 p1177Count.cpp 同一个种子
for (int i = 0; i < n; i++) {
if (shape == 0) a[i] = (int)(g() % 1000000000u) + 1;
else if (shape == 1) a[i] = i + 1;
else if (shape == 2) a[i] = n - i;
else a[i] = 7;
}
}
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 20000;
bool csv = (argc > 2 && string(argv[2]) == "csv");
const int REP = 20; // ★ 单次只有零点几毫秒 ⇒ 必须重复取平均
const char* name[4] = { "随机", "已经升序", "完全倒序", "所有数都相同" };
long long heapC[4];
double heapMs[4], sortMs[4];
int ok = 1;
for (int s = 0; s < 4; s++) {
makeData(n, s);
vector<int> want = a;
sort(want.begin(), want.end());
double hs = 0, ss = 0;
for (int r = 0; r < REP; r++) {
makeData(n, s);
cmps = 0;
auto t0 = steady_clock::now();
heapSort(n);
hs += duration<double, milli>(steady_clock::now() - t0).count();
if (a != want) ok = 0; // ★ 先钉死「它是对的」,再谈快慢
makeData(n, s);
t0 = steady_clock::now();
sort(a.begin(), a.end());
ss += duration<double, milli>(steady_clock::now() - t0).count();
}
heapMs[s] = hs / REP;
sortMs[s] = ss / REP;
heapC[s] = cmps; // 比较次数和轮数无关,取最后一轮的即可
}
long long mx = *max_element(heapC, heapC + 3), mn = *min_element(heapC, heapC + 3); // ⚠ 只看前三档
if (csv) {
printf("n,%d\n", n);
for (int s = 0; s < 4; s++) printf("heap%d,%lld\n", s, heapC[s]);
printf("spread,%lld,%lld,%lld\n", mx, mn, heapC[3]);
printf("ok,%d\n", ok);
printf("slower,%d\n", (heapMs[0] > sortMs[0]) ? 1 : 0);
return 0;
}
printf("n = %d,堆排序在四种数据上的比较次数:\n\n", n);
printf(" %-16s %14s %12s %12s\n", "数据", "堆排序比较次数", "堆排序", "std::sort");
for (int s = 0; s < 4; s++) {
string t = name[s];
int w = 0; for (unsigned char c : t) { if ((c & 0xC0) == 0x80) continue; w += (c < 0x80) ? 1 : 2; }
printf(" %s%*s %14lld %10.1f ms %10.1f ms\n", t.c_str(), max(0, 16 - w), "",
heapC[s], heapMs[s], sortMs[s]);
}
printf("\n ★★ 前三档的最大 / 最小之比 = %lld / %lld = %.3f —— **它对数据形状几乎免疫**\n",
mx, mn, (double)mx / (double)mn);
printf(" ⚠ 第四档「所有数都相同」只有 %lld 次 —— 那是白捡的便宜,不是退化\n", heapC[3]);
printf(" ★ 四档排序结果都正确:%s\n", ok ? "是" : "否");
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

7★ 对拍:它能证明的和不能证明的

p1177Gen.cpp生成器:五种形状
// 数据生成器(P1177 对拍用):`./p1177Gen <seed> [shape]`
//
// shape 0(默认)随机
// shape 1 已经升序 ← p1177QuickBad 的杀手之一
// shape 2 完全倒序 ← 同上
// shape 3 所有数都相同 ← 单向 partition 的杀手
// shape 4 只有两三种取值(大量重复,但不是全相同)
//
// ⚠ 这几档**抓不到任何「答案错」** —— 四个版本在所有档位上答案都一样。
// 它们要抓的是**复杂度**,而复杂度得靠 p1177Count.cpp 数次数
// (第 53 章那条:有一类 bug 只坏复杂度、不坏答案,对拍永远不说话)。
// ⇒ 那这个生成器还有什么用?**用来证明「答案确实都一样」** ——
// 不先钉死这一条,「只是慢」这个结论本身就是没根据的。
#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 shape = (argc > 2) ? atoi(argv[2]) : 0;
int n = ri(1, 30);
vector<int> a(n);
for (int i = 0; i < n; i++) {
if (shape == 1) a[i] = i + 1;
else if (shape == 2) a[i] = n - i;
else if (shape == 3) a[i] = 7;
else if (shape == 4) a[i] = ri(1, 3);
else a[i] = ri(1, 1000000000);
}
printf("%d\n", n);
for (int i = 0; i < n; i++) printf("%d%c", a[i], i + 1 == n ? '\n' : ' ');
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
⚠ 五种形状 × 300 轮 = 1500 轮,四个版本答案「全部相同」

冒泡、两种手写快排、sort —— 1500 轮,一次不一致都没有。

⇒ 对拍在这道题上证明不了任何和速度有关的事。 它能证明的只有一件:「那三版只是慢,不是错」 —— 而这一条不先钉死,「它退化了」这个结论本身就没有根据。

★ 这就是第 53 章那条的又一个现场: 有一类 bug 只坏复杂度、不坏答案,对拍永远不会说话。 要看见它,只能换一把尺子数次数(第 ④ 步那张表)。

p1177GenBig.cpp顶格数据
// 顶格数据(P1177 计时用):`./p1177GenBig <n> [shape]`
//
// shape 0 随机 / 1 已经升序 / 2 完全倒序 / 3 全相同
// ⚠ 题面:1 <= N <= 10⁵,1 <= a_i <= 10⁹。默认就按顶格来。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
int n = (argc > 1) ? atoi(argv[1]) : 100000;
int shape = (argc > 2) ? atoi(argv[2]) : 0;
mt19937 rng(20260827u);
printf("%d\n", n);
for (int i = 0; i < n; i++) {
int v;
if (shape == 1) v = i + 1;
else if (shape == 2) v = n - i;
else if (shape == 3) v = 7;
else v = (int)(rng() % 1000000000u) + 1;
printf("%d%c", v, i + 1 == n ? '\n' : ' ');
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果

8一张总表

版本 做法 随机数据 有序 / 全相同 结果
① p1177Bubble 冒泡 外推 9.5 秒 有序时 O(n) ✗ TLE
② p1177QuickBad 快排,pivot = 首元素 12.2 毫秒 ★ 1446.8 毫秒 ✗ TLE(本地测不出)
③ p1177Quick 随机 pivot + 三路划分 13.7 毫秒 4.9~9.1 毫秒 ★ AC
④ p1177 sort 一行 11.2 毫秒 5.7~6.2 毫秒 ★ AC
★ ⑤ p1177Heap 堆排序(第 37 章补的那条路) 13.3 毫秒 3.6~8.6 毫秒 ★ AC,而且最坏情况有保证
这一页记住三句话
  1. ★★★ 「本地测不出问题」说轻了 —— 随机数据上,那个会 TLE 的写法反而更快。 0.81×:它比正确写法少比较 19%。你没有任何理由怀疑它。
  2. ★★ 随机化不是让算法变快,是让最坏情况变得不可预谋。 而「所有数都相同」那一档光靠随机 pivot 救不了,还要三路划分。
  3. ★ 对拍证明不了速度。 1500 轮四个版本答案全同 —— 它唯一的作用是钉死「只是慢,不是错」,好让「换尺子数次数」这一步有意义。
  4. ★★ (第 37 章补的第四句)堆排序不需要「救」 —— 它的 O(n log n) 是最坏情况的保证, 四种数据比较次数只差 7%,而且是迭代的、不占额外空间。 ⚠ 可它比较次数更少却跑得更慢(跳着访问,cache 不友好)—— 两把尺子又打架,两个都要报。