第 12 讲

排序体系与下界分析(下)

上一讲把冒泡、选择、插入、希尔、堆、归并、快速、基数这八种排序逐个拆开讲透了。 但只认识八个算法,还远远谈不上「懂排序」。本讲要做三件事: 第一,把二十多种排序装进一个统一的分类框架里,看清它们各自继承了哪一族的「遗传基因」; 第二,把上一讲没讲的十来个算法补齐(折半插入、表插入、2-路插入、鸡尾酒、梳排序、 锦标赛、计数、桶、多路归并与败者树、原地归并); 第三,回答整个排序理论里最漂亮的那个问题——比较排序的下界为什么是 Ω(n log n), 以及基数排序、计数排序凭什么能绕过它。

预计 150 分钟 前置:第 11 讲八大排序、第 10 讲查找与判定树 关键词:五大体系 · 决策树 · 下界 Ω(n log n) · 稳定性 · 内省排序
本章导读(与上一讲的衔接)
  • 12.1 五大排序体系总览 —— 先用一张思维导图把 23 种排序归位,后面所有内容都挂在这张图上。
  • 12.2 插入类的三个变体 —— 折半插入、表插入、2-路插入。重点:为什么它们都没能突破 O(n²)。
  • 12.3 交换类扩展 —— 鸡尾酒排序、梳排序、奇偶排序,以及「交换类为什么普遍不稳定」。
  • 12.4 选择类补充 —— 锦标赛排序与树形选择排序,以及「堆为什么能取代胜者树」。
  • 12.5 非比较排序 —— 计数排序、桶排序、基数排序(LSD/MSD)。重点:计数排序为什么必须倒序放置。
  • 12.6 归并类扩展 —— 多路归并、胜者树 / 败者树、原地归并的手摇算法。这是外部排序的基石。
  • 12.7 工程实际用的排序 —— std::sort 的内省排序、stable_sort、Timsort,以及 qsort 为什么慢。
  • 12.8 本章理论重点:比较排序的下界 Ω(n log n) 的完整证明与决策树模型。
  • 12.9 稳定性深入剖析 —— 形式化定义、多关键字证明、15 个不稳定排序的反例数据。
  • 12.10 综合大对比表 + 选型决策流程图 + 外部排序专节(置换选择、败者树、最佳归并树)。
  • 12.11 工程视角:真实系统里的排序工程 —— Timsort 的 run 与归并栈、内省排序与 pdqsort、 并行与 MapReduce 的 shuffle、数据库里「省掉一次排序」的价值,以及「不要排序」的三种手段。
  • 12.12 本章小结、易错点清单、考点速记与 6 道自测题(答案折叠)。
关于上一讲的八种排序 冒泡、简单选择、直接插入、希尔、堆、归并、快速、基数这八种的实现代码与逐步图解已经在 第 11 讲 详细给过,本讲不再重复贴代码,只在 12.1 节的结论汇总表、12.10 节的综合大对比表里把它们和本章新增算法放到一起横向比较。 如果你对某一种的细节还记得不牢,请先回去复习上一讲,本讲默认你已经会手写它们。

12.1 五大排序体系总览:把 23 种排序归位

12.1.1 为什么要先分类

排序算法有几十种,如果只是把它们当成一堆互不相干的名字去背,考试时很容易「记住了却用不出来」。 但如果你知道每一个算法属于哪一族,很多事情会瞬间变得清晰:

所以我们按核心动作(而不是按时间复杂度)把排序分成五大体系。 判断一个算法属于哪一族,只需问一句:它靠什么把元素放到正确位置上?

插入类
靠「找一个位置插进去」。维持一个有序区,把新元素插到有序区的合适位置,其余元素后移。
交换类
靠「交换逆序的一对」。不额外开辟有序区,通过不断消除逆序对来收敛。
选择类
靠「选出最值再放到边界」。每一轮从无序区挑出最小(或最大)的元素,与边界交换或输出。
归并类
靠「合并两个有序段」。分治到长度为 1,再自底向上合并。
基数类
靠「按关键字的值域直接定位」。不做元素间比较,用下标或桶直接算出元素该去的位置。

12.1.2 五大体系分类思维导图

下图把本章涉及的 23 种排序按五大体系铺开,每类都标注了核心思想与代表算法。 图中用「★」标出的是本讲新讲的内容(上一讲已经展开过的用「·」标记)。 建议你把这张图截下来当复习提纲——它就是本讲的目录。

排 序 算 法 体 系 按「核心动作」划分五大族 ① 插入类 维持有序区,把新元素插进去 · 直接插入排序 O(n²) ★ 折半插入排序 · 希尔排序 O(n^1.3) ★ 表插入(静态链表) ★ 2-路插入排序 移动次数可优化,比较次数下界 O(n log n) 但总时间仍是 O(n²) ② 交换类 消除逆序对 · 冒泡排序 O(n²) · 快速排序 O(n log n) ★ 鸡尾酒(双向冒泡) ★ 梳排序 Comb Sort ★ 奇偶排序(砖块排序) 一族内部差距极大:冒泡 O(n²) vs 快排 O(n log n) 冒泡/鸡尾酒稳定,快排/梳排序不稳定 ③ 选择类 选最值,放到边界 · 简单选择排序 O(n²) · 堆排序 O(n log n) ★ 锦标赛排序 ★ 树形选择排序(胜者树) 建树 n−1 次比较,之后每次只需 log n 次重赛 堆 = 胜者树压进数组:额外空间 O(1) 整族不稳定(含堆排序) ④ 归并类 分治 + 合并有序段 · 2-路归并排序 O(n log n) ★ 多路归并(k-way) ★ 胜者树 / 败者树 ★ 原地归并 / 手摇算法 · std::stable_sort / Timsort 唯一能同时做到「稳定 + 最坏 O(n log n)」的一族 外部排序的主力:k 路归并减少 I/O ⑤ 基数类 不比较,按值域定位 ★ 计数排序 count sort ★ 桶排序 bucket sort · 基数排序 LSD(低位优先) ★ 基数排序 MSD(高位优先) O(n + k) 或 O(d(n + r)) 可以突破 Ω(n log n) 下界! 前提:关键字的值域可控 读图要点 ① 同一族内部可以有数量级差异:交换类既有 O(n²) 的冒泡,也有 O(n log n) 的快排;选择类既有 O(n²) 的简单选择,也有 O(n log n) 的堆排。 ② 「★」是本讲新增的 12 种:折半插入、表插入、2-路插入、鸡尾酒、梳排序、奇偶排序、锦标赛、树形选择、多路归并、胜者树/败者树、原地归并、计数/桶/MSD。 ③ 只有第 ⑤ 类(基数类)能突破 Ω(n log n):因为它不做元素间比较,下界证明的前提在它身上不成立——这是本章最核心的辨析点。 稳定性速记口诀: 稳定 = 插入类的直接插入/折半插入/表插入/2-路插入、冒泡、鸡尾酒、归并、计数、桶、基数(LSD) 不稳定: 希尔、简单选择、堆、快速、梳排序、奇偶排序、锦标赛、原地归并(手摇)、基数 MSD 口诀的由来:「跨越式交换 / 跨越式插入」必然破坏稳定性;「相邻交换 / 相邻插入 / 相等时取左段」则天然稳定。 记忆点:希尔、梳排序是「跨 gap」的插入/交换;简单选择、堆、锦标赛是「远距离换位」;快排是「远距离划分」;MSD 基数排序是「分桶递归」。
图 12-1 五大排序体系分类思维导图(★ 为本讲新增算法)

12.1.3 上一讲八种排序的结论汇总

在进入新内容之前,先把上一讲八种排序的结论压缩成一张表。 这张表不含实现细节,只保留「考试要写、面试要答」的那几个数字;完整的大对比表在 12.10 节。

算法体系平均最坏空间稳定一句话结论
冒泡排序交换类O(n²)O(n²)O(1)稳定相邻比较交换;可加 flag 提前结束,最好 O(n)
简单选择排序选择类O(n²)O(n²)O(1)不稳定比较次数固定 n(n−1)/2;移动次数最少(≤ n−1 次)
直接插入排序插入类O(n²)O(n²)O(1)稳定基本有序时接近 O(n);小规模数据的最优选择
希尔排序插入类约 O(n^1.3)O(n²)O(1)不稳定增量序列决定性能;跨越式插入破坏稳定性
堆排序选择类O(n log n)O(n log n)O(1)不稳定唯一「最坏 O(n log n) + O(1) 空间」的原地算法
归并排序归并类O(n log n)O(n log n)O(n)稳定唯一同时「稳定 + 最坏 O(n log n)」;代价是 O(n) 空间
快速排序交换类O(n log n)O(n²)O(log n)~O(n)不稳定平均最快(常数小、cache 友好),最坏会退化
基数排序基数类O(d(n+r))O(d(n+r))O(n+r)稳定
(LSD)
不比较;d 为位数、r 为基数;LSD 必须用稳定子过程
看表要看出「三组矛盾」
  • 快 vs 稳:最快的两种(快排、堆排)都不稳定,唯一稳定的 O(n log n) 是归并。
  • 省空间 vs 稳:想省空间就得原地交换(不稳定),想稳定就得开辅助数组。
  • 通用 vs 专用:基数类能到线性,但要求关键字是「可拆分的整数/定长串」且值域可控。
这三组矛盾解释了为什么工程上不存在「最好的排序」,只存在「最合适的组合」—— 这正是 12.7 节内省排序存在的理由。

12.2 插入类扩展:三个变体,一条死路

插入排序有两个可优化的量:比较次数(找插入位置要比较几次)和移动次数(腾位置要搬几个元素)。 直接插入排序两者都是 O(n²)。于是很自然地会问: 能不能分别把这两个量降下来? 本节三个算法正好对应三种尝试:折半插入优化比较、表插入优化移动、2-路插入同时优化两者。 但你会看到——它们都没能突破 O(n²),原因非常值得琢磨。

12.2.1 折半插入排序:比较次数降到 O(n log n),但时间还是 O(n²)

一句话本质:既然有序区已经排好,找插入位置就不必逐个比较,直接二分查找

直接插入排序在找位置时,是拿 A[i] 和有序区从右往左逐个比。 最坏情况下(新元素最小)要比 i 次,累计 1+2+…+(n−1) = O(n²)。 但有序区是有序的——有序就可以二分!用折半查找定位,每次定位只需 ⌈log₂ i⌉ 次比较,累计 Σ log₂ i = O(n log n)

折半插入 = 「折半查找定位」 + 「整体后移腾位」
比较次数 O(n log n) ↓   移动次数 O(n²) 不变   →   总时间复杂度仍为 O(n²)

为什么总时间还是 O(n²)?因为移动次数的瓶颈一点都没缓解。 插入 A[i] 时,为了腾出位置,下标 low..i-1 的所有元素都必须整体后移一格, 这是物理上必须搬动的数据,无法通过「更聪明的查找」省掉。 最坏情况(逆序数组)下,第 i 次插入要移动 i 个元素,累计移动 n(n−1)/2 = O(n²) 次。 一次移动和一次比较在计算模型里都算「一步基本操作」,所以数量级没变。

折半插入排序:A = [2, 5, 8, 11, 14, 6],现在要插入 A[5] = 6 2 5 8 11 14 6 有序区 lo=0 mid hi=4 i=5 待插入 第 1 次:mid=2,A[2]=8 > 6 → hi = mid−1 = 1 第 2 次:mid=0,A[0]=2 < 6 → lo = mid+1 = 1 第 3 次:mid=1,A[1]=5 < 6 → lo = mid+1 = 2 > hi,结束 定位完成:插入位置 low = 2,仅用 3 次比较(逐个比较要 5 次) 但是——腾位置必须物理搬移 3 个元素: 2 5 6 8 11 14 8、11、14 各后移一格 → 3 次移动(这部分一次都省不掉) 为什么总时间仍是 O(n²) 比较:Σ log₂i = O(n log n) ✅ 确实降了 移动:最坏 Σ i = n(n−1)/2 = O(n²) ❌ 一点没降 时间复杂度取「比较 + 移动」的较大者 → O(n²) 结论:折半插入只减少了「比较」这一项,是常数/对数级优化
图 12-2 折半插入排序的定位与移动:比较次数 O(n log n),移动次数仍 O(n²)
#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   折半插入排序(binary insertion sort)
   与直接插入排序的唯一区别:用折半查找确定插入位置 low,
   从而把「比较次数」从 O(n^2) 降到 O(n*log n)。
   但「移动次数」仍然是 O(n^2),所以总时间复杂度不变。
   稳定性:用 while (low <= high) 找「第一个大于 key 的位置」,
          相等元素不会被跨过,因此是稳定的。
   ============================================================ */
void binaryInsertionSort(vector<int>& a) {
    int n = a.size();
    for (int i = 1; i < n; ++i) {
        int key = a[i];
        int low = 0, high = i - 1;
        /* 折半查找:在 a[0..i-1] 中找第一个 > key 的位置 */
        while (low <= high) {
            int mid = low + ((high - low) >> 1);
            if (a[mid] > key) high = mid - 1;   /* 相等时走 else 分支,保证稳定 */
            else               low = mid + 1;
        }
        /* 此时 low 就是插入位置,把 a[low..i-1] 整体后移一格 */
        for (int j = i - 1; j >= low; --j) a[j + 1] = a[j];
        a[low] = key;
    }
}

/* 统计比较次数与移动次数,用于和直接插入排序对照 */
struct Stat { long long cmp, mov; };
Stat binaryInsertionCount(vector<int> a) {
    Stat s{0, 0};
    int n = a.size();
    for (int i = 1; i < n; ++i) {
        int key = a[i], low = 0, high = i - 1;
        while (low <= high) {
            ++s.cmp;
            int mid = low + ((high - low) >> 1);
            if (a[mid] > key) high = mid - 1; else low = mid + 1;
        }
        for (int j = i - 1; j >= low; --j) { a[j + 1] = a[j]; ++s.mov; }
        a[low] = key; ++s.mov;
    }
    return s;
}

/* 直接插入排序的计数版,用来对照 */
Stat directInsertionCount(vector<int> a) {
    Stat s{0, 0};
    int n = a.size();
    for (int i = 1; i < n; ++i) {
        int key = a[i], j = i - 1;
        while (j >= 0 && a[j] > key) { ++s.cmp; a[j + 1] = a[j]; ++s.mov; --j; }
        if (j >= 0) ++s.cmp;                    /* 最后一次「不满足条件」的比较也要计入 */
        a[j + 1] = key; ++s.mov;
    }
    return s;
}

int main() {
    vector<int> a = {5, 2, 9, 1, 7, 3, 8, 0, 6, 4};
    binaryInsertionSort(a);
    for (int x : a) cout << x << ' ';
    cout << "\n";                        // 0 1 2 3 4 5 6 7 8 9

    /* 最坏输入:完全逆序,观察两个计数 */
    vector<int> b = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
    Stat s = binaryInsertionCount(b);
    Stat d = directInsertionCount(b);
    cout << "n=10 逆序(折半插入):比较 " << s.cmp << " 次,移动 " << s.mov << " 次\n";
    cout << "n=10 逆序(直接插入):比较 " << d.cmp << " 次,移动 " << d.mov << " 次\n";
    /* 比较:19 对 45,约从 n^2/2 降到 n*log2(n) 量级;移动:两者都是 54 —— 一点没省!
       这就是「比较降了、时间没降」的实证:移动才是这个算法的时间瓶颈。 */
    return 0;
}
考点:折半插入的「比较次数」与「时间复杂度」是两个问题 考试里非常爱考这一句辨析:折半插入排序把比较次数降到了 O(n log n),但时间复杂度仍是 O(n²)。 答题时要补上一句「因为元素移动次数仍为 O(n²),而时间复杂度由比较与移动的总代价决定」。 另外注意:折半插入排序是稳定的(前提是折半时写成「找第一个大于 key 的位置」, 即 a[mid] > key 时往左,否则往右)。

12.2.2 表插入排序:用静态链表把移动次数降为 0

一句话本质:既然移动元素很贵,那就不移动元素——改用 next[] 数组串出一条有序链, 插入时只改两个指针。

数组的随机存取本来是优点,但在插入排序里恰恰成了负担:为了保持「物理下标顺序 = 逻辑顺序」, 每次插入都得搬家。表插入排序(list insertion sort)干脆放弃这个约束: 元素物理位置永远不动,另开一个 next[] 数组, 用 next[i] 表示「逻辑上下一个元素是谁」,形成一个静态链表

插入过程:先沿 next 链从表头开始逐个比较,找到第一个比新元素大的结点, 然后把新结点挂到它前面。整个过程只有两次指针赋值(next[pre] = i; next[i] = cur;), 一次元素移动都没有

表插入排序:数组 A[0..5] = [4, 2, 6, 1, 5, 3],下标 0 当表头哨兵 物理数组(永不动) 4 2 6 1 5 3 [0] [1] [2] [3] [4] [5] [6] next[] 链(只改这里) 3 5 6 0 1 2 4 逻辑顺序(沿 next 走一圈): 0 → 3(1) → 1(4) → 5(5) → 2(2)… 插入 A[5] = 3 时发生了什么? ① 沿链比较:哨兵 → 下标3(值1) → 下标1(值4),发现 4 > 3,应插在 3 号结点之后、1 号结点之前。 ② 只做两次赋值:next[5] = 1; next[3] = 5; → 元素移动次数 = 0 但比较次数没变:定位仍要从表头逐个走,最坏 Σ i = O(n²)。而且一旦用链表,随机存取 A[k] 就变成 O(n)。
图 12-3 表插入排序:元素物理位置不动,只重排 next[] 指针,移动次数降为 0
#include <iostream>
#include <vector>
#include <climits>
using namespace std;

/* ============================================================
   表插入排序(静态链表插入排序)
   · 元素物理位置永不改变,用 next[] 串成有序静态链表
   · 插入时只改两个指针 → 元素移动次数 = 0
   · 但定位仍需沿链逐个比较 → 比较次数仍为 O(n^2)
   · 失去了随机存取:按逻辑序号取第 k 个元素要 O(k)
   · 稳定性:新结点插在「第一个大于它的结点」之前,相等元素保持原有先后 → 稳定
   ============================================================ */
struct Node {
    int  data;
    int  next;      // 逻辑后继的下标,0 表示链表结束(下标 0 作哨兵,不存数据)
};

void listInsertionSort(vector<Node>& r) {
    int n = r.size() - 1;               // r[0] 是哨兵
    r[0].next = 0;                      // 初始空链表
    for (int i = 1; i <= n; ++i) {
        int pre = 0, cur = r[0].next;   // pre 是 cur 的前驱
        /* 沿链找第一个 data 大于 r[i].data 的结点 */
        while (cur != 0 && r[cur].data <= r[i].data) {
            pre = cur;
            cur = r[cur].next;
        }
        r[i].next = cur;                // 挂链:两步赋值,零元素移动
        r[pre].next = i;
    }
}

/* 沿 next 链输出有序序列(逻辑顺序) */
void printSorted(const vector<Node>& r) {
    for (int p = r[0].next; p != 0; p = r[p].next) cout << r[p].data << ' ';
    cout << "\n";
}

/* 把静态链表「重排」成物理有序:按逻辑顺序重新摆放元素,
   这样重排之后再按数组下标顺序输出就是有序的。这一步是 O(n^2) 的搬移,
   实际工程中通常不做,直接沿链遍历即可。 */
void rearrange(vector<Node>& r) {
    int n = r.size() - 1;
    vector<int> order;
    for (int p = r[0].next; p != 0; p = r[p].next) order.push_back(p);
    vector<Node> tmp(n + 1);
    tmp[0].data = INT_MIN;
    for (int k = 0; k < n; ++k) tmp[k + 1].data = r[order[k]].data;
    for (int k = 0; k <= n; ++k) { r[k].data = tmp[k].data; r[k].next = k + 1; }
    r[n].next = 0;
}

int main() {
    vector<int> a = {4, 2, 6, 1, 5, 3};
    vector<Node> r(a.size() + 1);
    r[0].data = INT_MIN;                // 哨兵:值设为最小,省掉边界判断
    for (int i = 0; i < (int)a.size(); ++i) r[i + 1].data = a[i];

    listInsertionSort(r);
    printSorted(r);                     // 1 2 3 4 5 6
    rearrange(r);
    printSorted(r);                     // 1 2 3 4 5 6(重排后物理也有序)
    return 0;
}
易错:表插入排序「省了移动」但赔上了两样东西
  1. 随机存取能力。数组最大的优势就是 A[k] 是 O(1)。一旦逻辑顺序由 next 决定, 「取第 k 个元素」就必须沿链走 k 步,退化为 O(n)。后续如果要折半查找、要按位访问,全都不成立了。
  2. 局部性(cache 友好度)。沿链访问的下标是跳着来的,CPU 缓存命中率远低于顺序扫描, 实测往往比「老老实实搬数组」还慢——这也是它只出现在教材里的原因。
所以:表插入排序是「移动次数 0、比较次数 O(n²)、空间多一个 next[]」, 总时间复杂度依然是 O(n²),它是一条走不通的优化路线。

12.2.3 2-路插入排序:移动次数降到约 n²/8

一句话本质:把辅助数组当成循环数组,以第一个元素为界,比它小的往前插、比它大的往后插, 于是「后移」被分摊到了两端,平均只需搬一半的元素。

直接插入排序里,每次插入都要把比新元素大的所有元素后移一位。如果新元素很小,就要搬一大片。 2-路插入(two-way insertion sort)的想法是:让数据从中间向两边长

  1. 开设与 A 等长的辅助数组 D,把 A[0] 放进 D[0], 用 firstfinal 两个指针标记 D 中已占用区间的两端
  2. A[i]:若 A[i] < D[0](比界值小),就往前(first 方向)插入; 否则往后(final 方向)插入。
  3. 关键是 D循环数组first 往前走到下标 0 之后会绕到 n−1, 所以两端的空间可以互相借用,不会溢出。
  4. 因为往两端插入,平均每次只需移动约一半的元素,总移动次数从 n²/2 降到约 n²/8
移动次数:直接插入 ≈ n²/2  →  2-路插入 ≈ n²/8(约为原来的 1/4)
比较次数与总时间复杂度仍然是 O(n²) —— 又是一次常数级优化
#include <iostream>
#include <vector>
#include <algorithm>
#include <random>
using namespace std;

/* ============================================================
   2-路插入排序(two-way insertion sort)
   · 开一个与待排数组等长的辅助数组 buf,从「两端向中间」使用:
       左段 buf[0..l) 放 < v[0] 的元素,按「递减」排列 —— 等价于不断向左端插入
       右段 buf[r..n) 放 >= v[0] 的元素,按「递增」排列 —— 等价于不断向右端插入
   · 移动量被摊到两端,比直接插入少很多(见下方实测数据)
   · 比较次数与总时间复杂度仍是 O(n^2),所以这属于「常数级优化」
   · 额外空间 O(n);相等元素一律走右段且用严格小于才交换 → 稳定
   实现要点:左段是递减的,写回时必须「从后往前」取值才能还原成递增 —— 
            这是本算法最容易写错的一步,写反了数组就完全乱掉。
   ============================================================ */
void twoWayInsertionSort(vector<int>& v, long long* movOut = nullptr) {
    int n = v.size();
    if (n < 2) { if (movOut) *movOut = 0; return; }
    vector<int> buf(n);
    long long mov = 0;
    int l = 0, r = n;                     // 左段 buf[0..l),右段 buf[r..n),中间是空闲区

    for (int i = 0; i < n; ++i) {
        int x = v[i];
        if (x < v[0]) {
            /* 左段:递减排列。新元素先放到左段右端,再逐格左移到正确位置 */
            int j = l;
            buf[j] = x; ++mov;
            while (j > 0 && buf[j - 1] < buf[j]) { swap(buf[j - 1], buf[j]); ++mov; --j; }
            ++l;
        } else {
            /* 右段:递增排列。新元素先放到右段左端,再逐格右移到正确位置。
               注意用「严格小于」才交换:相等元素不会互相跨过 → 稳定 */
            int j = r - 1;
            buf[j] = x; ++mov;
            while (j + 1 < n && buf[j + 1] < buf[j]) { swap(buf[j], buf[j + 1]); ++mov; ++j; }
            --r;
        }
    }
    /* 写回:左段递减 → 从 l-1 倒着取,正好是递增;右段本来就是递增,顺着取 */
    int k = 0;
    for (int i = l - 1; i >= 0; --i) v[k++] = buf[i];
    for (int i = r; i < n; ++i) v[k++] = buf[i];
    if (movOut) *movOut = mov;
}

/* 对照:直接插入排序的移动次数 */
long long directMoveCount(const vector<int>& src) {
    vector<int> a = src;
    long long mov = 0;
    for (int i = 1; i < (int)a.size(); ++i) {
        int key = a[i], j = i - 1;
        while (j >= 0 && a[j] > key) { a[j + 1] = a[j]; ++mov; --j; }
        a[j + 1] = key; ++mov;
    }
    return mov;
}

int main() {
    vector<int> a = {5, 2, 9, 1, 7, 3, 8, 0, 6, 4};
    twoWayInsertionSort(a);
    for (int x : a) cout << x << ' ';
    cout << "\n";                       // 0 1 2 3 4 5 6 7 8 9

    /* 正确性验证:随机数据(含大量重复值)批量比对 */
    mt19937 rng(7);
    int fails = 0;
    for (int test = 0; test < 20000; ++test) {
        int n = 1 + (int)(rng() % 80);
        int range = 1 + (int)(rng() % 25);      // 故意让值域很小,制造大量重复
        vector<int> v(n), w;
        for (int i = 0; i < n; ++i) v[i] = (int)(rng() % range);
        w = v;
        sort(w.begin(), w.end());
        twoWayInsertionSort(v);
        if (v != w) ++fails;
    }
    cout << "20000 组随机数据(含重复元素)验证:" << (fails == 0 ? "全部通过" : "有失败") << "\n";

    /* 移动次数对照:为什么说它只是「常数级优化」 */
    vector<int> b = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
    long long m2 = 0;
    vector<int> b2 = b;
    twoWayInsertionSort(b2, &m2);
    cout << "n=10 逆序:2-路插入移动 " << m2 << " 次,直接插入移动 "
         << directMoveCount(b) << " 次\n";

    /* 随机数据上的平均移动次数(n = 200,跑 200 次取平均) */
    double s2 = 0, s1 = 0;
    const int TRIALS = 200, N = 200;
    for (int t = 0; t < TRIALS; ++t) {
        vector<int> v(N);
        for (int i = 0; i < N; ++i) v[i] = (int)(rng() % 100000);
        long long mm = 0;
        vector<int> v2 = v;
        twoWayInsertionSort(v2, &mm);
        s2 += (double)mm;
        s1 += (double)directMoveCount(v);
    }
    cout << "n=200 随机数据平均移动次数:2-路插入 " << (long long)(s2 / TRIALS)
         << ",直接插入 " << (long long)(s1 / TRIALS)
         << "(n^2/2 = " << N * N / 2 << ")\n";
    cout << "→ 移动量确实少了很多,但仍是 O(n^2) 量级:所以总时间复杂度依然是 O(n^2)。\n";
    return 0;
}
本节的统一结论:三个变体都没能突破 O(n²) 把三个变体排在一起看,规律非常清楚:
  • 折半插入:比较 O(n log n) ✅,移动 O(n²) ❌ → 总时间 O(n²)
  • 表插入:移动 0 ✅,比较 O(n²) ❌ → 总时间 O(n²),还丢了随机存取
  • 2-路插入:移动 ≈ n²/8 ✅,比较 O(n²) ❌ → 总时间 O(n²)
为什么?因为「插入类」的根本瓶颈是元素之间的相对次序只能靠一次次的局部调整来确定, 每一次调整都只带来常数级的进展。要真正突破 O(n²), 要么引入分治(归并、快排),要么引入这种能 O(log n) 取最值的结构(堆排), 要么干脆不做比较(基数类)。这也正好呼应 12.8 节的下界理论: Ω(n log n)比较次数的下界,而插入类的修修补补连这个下界都没触及, 它们卡在了「移动」这一项上。

12.3 交换类扩展:鸡尾酒、梳排序与奇偶排序

交换类的共同特征:不开辟新的有序区,靠不断把逆序的相邻(或相隔 gap 的)元素对换掉来收敛。 冒泡是它的最朴素形态,快排是它的最强形态,中间还夹着几个很有意思的改良版。

12.3.1 鸡尾酒排序:一趟向右,一趟向左

一句话本质:冒泡每趟只能把最大值推到右端;鸡尾酒排序交替方向, 正反各来一趟,于是最大值和最小值能同时向两端就位。

回忆冒泡的退化场景:数组 [2,3,4,5,6,7,8,1]。 前面 7 个已经有序,只有最小值 1 在最右端。冒泡每一趟只能把元素向左挪一格(因为它是靠相邻交换推进的), 所以 1 要挪到最左端需要足足 7 趟。而鸡尾酒排序第 1 趟从左到右把 8 送到末尾, 第 2 趟从右到左扫描时,1 会被连续交换一路带到开头——2 趟搞定

数组 [2,3,4,5,6,7,8,1]第 1 趟后第 2 趟后第 3 趟后总计
普通冒泡(只向右推) [2,3,4,5,6,7,1,8] [2,3,4,5,6,1,7,8] [2,3,4,5,1,6,7,8] 每趟只前进一格… 7 趟
鸡尾酒排序(左右交替) [2,3,4,5,6,7,1,8](正向) [1,2,3,4,5,6,7,8](反向,一步到位) 2 趟

下面这个动画把两个算法放在同一组数据上并排跑,你可以直接对比它们的趟数、比较次数与交换次数:

#include <iostream>
#include <vector>
#include <algorithm>
#include <random>
using namespace std;

/* ============================================================
   鸡尾酒排序(cocktail sort / 双向冒泡排序)
   · 奇数趟从左到右(把最大值推到右端),偶数趟从右到左(把最小值推到左端)
   · 对「大部分有序,但最值落在错误一端」的数据,趟数可大幅减少
   · 最好 O(n)(本身有序时一趟就发现无交换);平均与最坏仍 O(n^2)
   · 空间 O(1);稳定(只交换严格逆序的相邻元素)
   下面这版用 lo / hi 收缩边界,一趟正向 + 一趟反向算「两趟」,
   但终止条件是「某一趟没有任何交换」,比死板地跑完 n-1 轮更早停。
   ============================================================ */
void cocktailSort(vector<int>& a) {
    int lo = 0, hi = (int)a.size() - 1;
    while (lo < hi) {
        bool sw = false;
        for (int i = lo; i < hi; ++i)                 /* 正向一趟 */
            if (a[i] > a[i + 1]) { swap(a[i], a[i + 1]); sw = true; }
        --hi;
        if (!sw) break;                               /* 一趟无交换 → 已整体有序 */
        sw = false;
        for (int i = hi; i > lo; --i)                 /* 反向一趟 */
            if (a[i - 1] > a[i]) { swap(a[i - 1], a[i]); sw = true; }
        ++lo;
        if (!sw) break;
    }
}

/* 返回趟数,用于和普通冒泡对比。
   「一趟」= 一次单向扫描(正向或反向各算一趟),这样和冒泡的趟数可以直接比较。 */
int cocktailPasses(const vector<int>& src, long long& cmps) {
    vector<int> a = src;
    int lo = 0, hi = (int)a.size() - 1, pass = 0;
    cmps = 0;
    while (lo < hi) {
        /* 正向一趟:把最大值送到 a[hi] */
        bool sw = false;
        for (int i = lo; i < hi; ++i) { ++cmps; if (a[i] > a[i + 1]) { swap(a[i], a[i + 1]); sw = true; } }
        ++pass; --hi;
        if (!sw) break;
        /* 反向一趟:把最小值送到 a[lo] —— 关键就是这一趟 */
        sw = false;
        for (int i = hi; i > lo; --i) { ++cmps; if (a[i - 1] > a[i]) { swap(a[i - 1], a[i]); sw = true; } }
        ++pass; ++lo;
        if (!sw) break;
    }
    return pass;
}

int bubblePasses(const vector<int>& src, long long& cmps) {
    vector<int> a = src;
    int n = a.size(), pass = 0;
    cmps = 0;
    for (int i = 0; i < n - 1; ++i) {
        bool swapped = false;
        for (int j = 0; j < n - 1 - i; ++j) { ++cmps; if (a[j] > a[j + 1]) { swap(a[j], a[j + 1]); swapped = true; } }
        ++pass;
        if (!swapped) break;
    }
    return pass;
}

int main() {
    vector<int> a = {2, 3, 4, 5, 6, 7, 8, 1};
    long long c1 = 0, c2 = 0;
    cout << "冒泡趟数   = " << bubblePasses(a, c1)   << ",比较 " << c1 << " 次\n";   // 7,比较 28 次
    cout << "鸡尾酒趟数 = " << cocktailPasses(a, c2) << ",比较 " << c2 << " 次\n";   // 3,比较 18 次
    /* 小口径说明(考点与实现要对得上):
       理论上"两趟就够"——正向一趟把 8 送到右端、反向一趟把 1 送到左端,
       这两趟共比较 7 + 6 = 13 次;
       但本实现还要再跑一趟"没有发生交换"的确认趟(5 次比较)才能确认已经有序,
       所以程序统计出来是 3 趟、18 次比较。
       考点的口径记住「鸡尾酒 2 趟 vs 冒泡 7 趟」即可;18 次是含确认趟的实现计数。 */

    vector<int> b = a;
    cocktailSort(b);
    for (int x : b) cout << x << ' ';
    cout << "\n";                     // 1 2 3 4 5 6 7 8

    /* 随机数据上验证正确性 */
    mt19937 rng(11);
    for (int t = 0; t < 200; ++t) {
        int n = 1 + (int)(rng() % 50);
        vector<int> v(n), w;
        for (int i = 0; i < n; ++i) v[i] = (int)(rng() % 15);
        w = v;
        sort(w.begin(), w.end());
        cocktailSort(v);
        if (v != w) { cout << "随机测试失败!\n"; return 1; }
    }
    cout << "200 组随机数据验证通过\n";
    return 0;
}
鸡尾酒排序的定位:一个「特定数据上的特效药」 它并不是「更快的冒泡」。在随机数据上,鸡尾酒排序的比较次数与冒泡同阶, 代码还更复杂;只有在「两端各有一个跑偏的元素」(也叫「乌龟问题」,turtle) 的数据上才有明显优势。
考试里它一般作为冒泡的改进出现在选择题中,要点是两个:双向交替最坏仍 O(n²)

12.3.2 梳排序:gap 递减的「粗排 + 收尾」

一句话本质:它是冒泡与希尔思想的私生子——用不断缩小的间隔 gap 做跨越式比较, 最后 gap = 1 时退化成标准冒泡来收尾。

冒泡慢在哪?慢在它只能比较相邻元素,一次交换最多消除一个逆序对, 而且一个「小元素在右端」的远距离逆序要挪很多趟。梳排序的做法是: 一开始用很大的 gap(比如 gap = n)去比较 A[i]A[i+gap], 这样一次交换就能让元素跨越很长的距离;然后让 gap 逐步缩小,做越来越细的调整。 「梳子」的比喻很贴切:先用大齿距把打结处梳开,再换小齿距理顺。

gap 初始为 n,每轮 gap = ⌊gap / 1.3⌋(不足 1 时取 1);
当 gap = 1 且某一轮没有任何交换发生时,排序结束。

为什么是除以 1.3 而不是除以 2?这是一个经验值(由 Lacey 与 Box 在 1991 年提出)。 关键在于:如果每轮 halving,当 n = 2^k 时 gap 序列会变成 n, n/2, n/4, …, 8, 4, 2, 1,这些 gap 有一个大于 1 的公约数 2, 导致每一轮都在比较同一批「互相之间原本就有序」的下标对,效率很差。 除以 1.3 得到的 gap 序列通常互质性更好,实测平均性能接近 O(n log n)

下面动图演示 gap 从 10 一路递减到 1 的完整过程,注意「相距 gap 个位置」的连线:

#include <iostream>
#include <vector>
using namespace std;

/* ============================================================
   梳排序(comb sort)
   · gap 从 n 开始,每轮 gap = floor(gap / 1.3),最后 gap = 1 时用冒泡收尾
   · 思想 = 冒泡的相邻比较 + 希尔的「大间隔先粗排」
   · 最坏 O(n^2),平均接近 O(n log n),空间 O(1)
   · 不稳定:gap > 1 时的交换跨越了中间元素,相等关键字的相对次序会被打乱
   ============================================================ */
void combSort(vector<int>& a) {
    int n = a.size();
    int gap = n;
    bool swapped = true;
    while (gap > 1 || swapped) {
        gap = (int)(gap / 1.3);
        if (gap < 1) gap = 1;
        swapped = false;
        for (int i = 0; i + gap < n; ++i) {
            if (a[i] > a[i + gap]) {
                swap(a[i], a[i + gap]);
                swapped = true;
            }
        }
    }
}

/* 打印 gap 序列,直观看到递减过程 */
void showGaps(int n) {
    int gap = n;
    cout << "gap 序列:" << gap;
    while (gap > 1) {
        gap = (int)(gap / 1.3);
        if (gap < 1) gap = 1;
        cout << " -> " << gap;
    }
    cout << "\n";
}

/* 对照实验:比较「除 1.3」与「除 2(halving)」在 n = 2^k 上的差别 */
long long combSwaps(const vector<int>& src, double divisor) {
    vector<int> a = src;
    int n = a.size(), gap = n;
    long long sw = 0;
    bool swapped = true;
    while (gap > 1 || swapped) {
        gap = (int)(gap / divisor);
        if (gap < 1) gap = 1;
        swapped = false;
        for (int i = 0; i + gap < n; ++i)
            if (a[i] > a[i + gap]) { swap(a[i], a[i + gap]); swapped = true; ++sw; }
    }
    return sw;
}

int main() {
    vector<int> a = {8, 4, 2, 1, 7, 3, 9, 5, 6, 0};
    showGaps(10);                  // 10 -> 7 -> 5 -> 3 -> 2 -> 1
    combSort(a);
    for (int x : a) cout << x << ' ';
    cout << "\n";                  // 0 1 2 3 4 5 6 7 8 9

    /* n = 16,构造一个偏斜输入,对比两种 gap 策略的交换次数 */
    vector<int> b;
    for (int i = 0; i < 16; ++i) b.push_back((i * 7 + 5) % 16);
    cout << "divisor 1.3 交换 " << combSwaps(b, 1.3)
         << " 次;divisor 2.0 交换 " << combSwaps(b, 2.0) << " 次\n";
    return 0;
}

12.3.3 奇偶排序(砖块排序):为并行而生的交换类

一句话本质:把所有相邻比较拆成「奇数对」和「偶数对」两组,两组交替执行; 同一组内部的比较互不干扰,因此可以完全并行。

奇偶排序(odd-even sort,又叫砖块排序 brick sort)的步骤是:

  1. 奇数阶段:比较并交换 (A[1],A[2]), (A[3],A[4]), (A[5],A[6]), …
  2. 偶数阶段:比较并交换 (A[0],A[1]), (A[2],A[3]), (A[4],A[5]), …
  3. 两个阶段交替进行,直到某一整轮(奇 + 偶)都没有发生交换。

它的时间复杂度是 O(n²),和冒泡一样差;但它有一个冒泡没有的性质: 奇偶排序是最简单的并行排序算法。因为在奇数阶段里,所有的比较对互不重叠, 在 GPU 或 SIMD 上可以一次性全部算完,于是并行时间复杂度降到 O(n) (需要 O(n) 个处理器)。这是「串行复杂度差但并行友好」的典型案例。 奇偶排序是稳定的(只交换严格逆序的相邻元素)。

考点:交换类的稳定性规律 判断一个交换类算法稳不稳定,只看一件事:它会不会交换「不相邻」的两个元素?
  • 只交换相邻元素 → 稳定。因为相等元素永远不会互相越过(条件是严格逆序 >)。 例:冒泡、鸡尾酒、奇偶排序。
  • 交换跨越了中间元素 → 不稳定。因为一个元素可能一次跳过若干个与它相等的元素。 例:快速排序(划分时 i、j 相距很远)、梳排序(gap > 1)、希尔排序(增量 > 1)。
这条规律可以直接推广到插入类:直接插入、折半插入是「相邻后移」→ 稳定; 希尔排序是「跨 gap 插入」→ 不稳定。

12.4 选择类补充:锦标赛排序与树形选择排序

选择类的基本动作是「从无序区选出最值」。简单选择排序的问题在于: 每一轮都要从头扫描一遍,上一轮辛苦比较出来的信息全部丢弃, 所以 n 轮下来是 O(n²) 次比较。 锦标赛排序的核心洞察就是:把比较结果存下来,下一轮就不用重比了。

12.4.1 一句话本质与比较次数分析

锦标赛排序 = 「用完全二叉树组织淘汰赛」 + 「取出冠军后只沿路径重赛」
建树一次 n − 1 次比较;之后每输出一个最小值只需 ⌈log₂ n⌉ 次比较

n 个元素放到完全二叉树的叶子上,两两比赛,小的升到父结点; 父结点继续两两比赛,直到根结点——根就是全局最小值。 这个「每个内部结点保存两个孩子中的胜者」的树,就叫胜者树(winner tree), 也叫树形选择排序

建树需要比较多少次?完全二叉树有 n 个叶子和 n−1 个内部结点, 每个内部结点对应一次比较,所以恰好是 n − 1 次—— 这和「线性扫描找最小值需要 n−1 次比较」完全一样,一点没省。 省的地方在后续

当我们取走根结点(最小值)后,需要找第二小的元素。注意—— 第二小的元素一定在「最小值走过的路径上输给它的那些对手」之中, 因为其它元素都是被这些对手直接或间接淘汰的。 这条路径的长度就是树高 ⌈log₂ n⌉。 所以我们只需把该叶子位置置为 +∞,然后沿着这条路径重赛(重新两两比较), 用 ⌈log₂ n⌉ 次比较就能得到新的最小值。

总比较次数 = (n − 1) + (n − 1)·⌈log₂ n⌉ = O(n log n)

12.4.2 胜者树的结构与重赛过程

下面这张图给出 A = [3,1,4,1,5,9,2,6] 的完整胜者树。 注意叶子上的下标标注:内部结点保存的是「赢家所在的叶子下标」而不是值本身, 这样重赛时才能顺着下标找到路径。

1 1 2 1 1 5 2 3 1 4 1 5 9 2 6 [0][1] [2][3] [4][5] [6][7] 叶子层 = 初始数据 绿色路径 = 最小值 1 的夺冠之路。取出它之后,把该叶置为 +∞,只需沿这条路径重赛 3 次即可得到新的最小值 1(另一个)。 如果改成「线性扫描找次小」,需要在剩下 7 个元素里再比 6 次;而重赛只要 3 次 —— 树高 log₂8 = 3 换来了这个加速。
图 12-4 胜者树(树形选择排序):A = [3,1,4,1,5,9,2,6] 的完整结构与冠军路径

下面的动画逐场比赛地建树,然后连续输出 4 个最小值,每次输出后都会演示「重赛」:

#include <iostream>
#include <vector>
#include <climits>
#include <random>
using namespace std;

/* ============================================================
   锦标赛排序(树形选择排序 / 胜者树)
   · 完全二叉树,叶子存原始数据,内部结点存「赢家所在的叶子下标」
   · 建树 n-1 次比较;每输出一个最小值,沿路径重赛 ceil(log2 n) 次
   · 总比较次数 O(n log n),额外空间 O(n)(2n 个结点的胜者树)
   · 不稳定:相等元素谁先被取走取决于树结构,相对次序会被打乱
   ============================================================ */
struct WinnerTree {
    int n;                      // 叶子个数(补齐到 2 的幂)
    vector<int> val;            // 参赛值,叶子位置为原始数据,其它为 INF
    vector<int> win;            // win[t] = 该子树胜者所在的叶子下标

    explicit WinnerTree(const vector<int>& a) {
        n = 1;
        while (n < (int)a.size()) n <<= 1;            // 补齐到 2 的幂,便于完全二叉树编号
        val.assign(2 * n, INT_MAX);
        for (int i = 0; i < (int)a.size(); ++i) val[n + i] = a[i];
        win.assign(2 * n, -1);
        for (int i = 0; i < n; ++i) win[n + i] = n + i;  // 叶子自己就是胜者
        build();
    }

    /* 自底向上建树:每个内部结点比较一次,共 n-1 次 */
    void build() {
        for (int t = n - 1; t >= 1; --t) {
            int L = win[2 * t], R = win[2 * t + 1];
            win[t] = (val[L] <= val[R]) ? L : R;
        }
    }

    /* 输出全部元素(非递减) */
    vector<int> sortAll() {
        vector<int> out;
        for (int k = 0; k < n; ++k) {
            int best = win[1];                            // 根结点给出最小值所在叶子
            if (val[best] == INT_MAX) break;              // 补齐出来的空位,跳过
            out.push_back(val[best]);
            val[best] = INT_MAX;                          // 该选手退赛
            /* 沿路径重赛:从叶子一路比到根 */
            for (int t = best / 2; t >= 1; t /= 2) {
                int L = win[2 * t], R = win[2 * t + 1];
                win[t] = (val[L] <= val[R]) ? L : R;
            }
        }
        return out;
    }
};

/* 统计比较次数,验证 n-1 + (n-1)*log2(n) 的量级 */
long long tournamentComparisons(const vector<int>& a, int& logn) {
    int n = 1;
    while (n < (int)a.size()) n <<= 1;
    logn = 0;
    for (int t = n; t > 1; t >>= 1) ++logn;          // logn = log2(n)
    vector<int> val(2 * n, INT_MAX), win(2 * n, -1);
    for (int i = 0; i < (int)a.size(); ++i) val[n + i] = a[i];
    for (int i = 0; i < n; ++i) win[n + i] = n + i;
    long long cmp = 0;
    for (int t = n - 1; t >= 1; --t) {                 // 建树:n-1 次比较
        int L = win[2 * t], R = win[2 * t + 1];
        ++cmp;
        win[t] = (val[L] <= val[R]) ? L : R;
    }
    int got = 0;
    while (got < (int)a.size()) {
        int best = win[1];
        if (val[best] == INT_MAX) break;
        ++got;
        val[best] = INT_MAX;
        for (int t = best / 2; t >= 1; t /= 2) {       // 重赛:最多 log2(n) 次比较
            int L = win[2 * t], R = win[2 * t + 1];
            ++cmp;
            win[t] = (val[L] <= val[R]) ? L : R;
        }
    }
    return cmp;
}

/* 对照:简单选择排序的比较次数(与数据无关,恒为 n(n-1)/2) */
long long selectionComparisons(int n) { return (long long)n * (n - 1) / 2; }

int main() {
    vector<int> a = {3, 1, 4, 1, 5, 9, 2, 6};
    WinnerTree wt(a);
    for (int x : wt.sortAll()) cout << x << ' ';
    cout << "\n";       // 1 1 2 3 4 5 6 9

    int logn = 0;
    long long cmp = tournamentComparisons(a, logn);
    cout << "n=8:胜者树比较 " << cmp << " 次(建树 " << (8 - 1)
         << " + 每次重赛 " << logn << " 层),简单选择排序需要 "
         << selectionComparisons(8) << " 次\n";

    /* 换一个更大的规模,差距才看得出来 */
    const int N = 1 << 14;                       // n = 16384
    vector<int> big(N);
    mt19937 rng(99);
    for (int i = 0; i < N; ++i) big[i] = (int)(rng() % 1000000);
    int lg2 = 0;
    long long c2 = tournamentComparisons(big, lg2);
    cout << "n=" << N << ":胜者树约 " << c2 << " 次,简单选择排序需要 "
         << selectionComparisons(N) << " 次(约 "
         << (double)selectionComparisons(N) / c2 << " 倍)\n";
    return 0;
}

12.4.3 为什么堆排序取代了胜者树

既然胜者树是 O(n log n),为什么我们平时讲、平时用的是堆排序而不是胜者树? 这是考试里非常经典的一道「比较题」,答案有三个层次:

对比维度胜者树(树形选择排序)堆排序
额外空间 需要 O(n):n 个叶结点 + n−1 个内部结点 = 约 2n 个存储单元 O(1):堆就长在原数组里,父子关系由下标 2i+1 / 2i+2 隐含,不需要任何额外结构
更新一次的代价 沿路径重赛,⌈log₂ n⌉比较(每层要和兄弟结点比一次,再决定父结点存谁) 从根向下筛选(sift down),≤ log₂ n比较 + 交换
数据结构归属 是「附加在数据之外的索引结构」,数据本身还在原数组里 数据与结构合二为一:原数组既是数据又是堆
稳定性 不稳定(谁先被取出取决于树形与补齐方式) 不稳定(根与末尾的远距离交换)
主要用途 外部排序的 k 路归并(用胜者树 / 败者树做选择器)、多路归并的通用构件 内存内排序的通用最坏保证;也是内省排序的兜底手段
一句话总结 堆 = 胜者树的数组压缩版。胜者树把「谁赢了」显式存在每个内部结点上,所以必须额外开 2n 的空间; 堆则发现「如果用完全二叉树编号,父子的下标关系本身就是结构」, 于是省掉了全部指针/索引开销。代价是堆在取最值时要把根与末尾交换再筛选, 而胜者树只需重赛——但在内存排序里,省下 O(n) 空间的价值远大于这点代价。 而在外部排序里,k 路归并的归并段本身就在磁盘上、不能搬进内存, 这时候败者树/胜者树就成了唯一选择——这就是 12.6 节的内容。

12.5 非比较排序:计数、桶与基数

前面所有算法都在做同一件事:比较两个元素的大小。 而这一节的三种算法一次元素间比较都不做(或只做常数次), 它们靠的是关键字的值域结构:既然关键字是 0~k 的整数,那我直接把「值」当下标用不就行了? 正因为它们绕开了「比较」这个模型,12.8 节的 Ω(n log n) 下界对它们不成立

12.5.1 计数排序:O(n + k) 与「必须稳定」的原因

一句话本质:统计每个值出现几次,用前缀和算出每个值应该占据的下标区间,然后倒序把元素放进输出数组。

计数排序(counting sort)要求关键字是范围可控的非负整数(设值域为 0..k−1)。 三步走:

  1. 统计频次cnt[v] 记录值 v 出现了多少次。这一步只看值,不看顺序。
  2. 求前缀和pre[v] = cnt[0] + cnt[1] + … + cnt[v], 含义是「值 ≤ v 的元素共有多少个」,也就是值 v 的元素应该落在 [0, pre[v]) 区间的最右端
  3. 倒序放置:从 i = n−1 开始往左遍历原数组,执行 out[--pre[A[i]]] = A[i]
计数排序三步走:A = [4, 1, 3, 4, 3, 2],值域 k = 5,n = 6 ① 原数组(a、b 标记用来观察相同关键字的先后) 4a 1 3a 4b 3b 2 下标 0 下标 5 cnt[v]:值 v 出现了几次 v=0 0 1 1 2 2 第一步 O(n) ② 前缀和 pre[v] = Σ cnt[0..v]:值 v 的元素应该落在 [0, pre[v]) 的最右端 0 1 2 4 6 pre[3] = 4 表示「≤3 的有 4 个」,所以 3 占 out[2..3];pre[4] = 6 表示 4 占 out[4..5] ③ 倒序放置(这是稳定性的关键):从 i = 5 往左走 i=5: 2 → out[1] i=4: 3b → out[3] i=3: 4b → out[5] i=2: 3a → out[2] i=1: 1 → out[0] i=0: 4a → out[4] 先处理靠后的 4b, 它占掉 4 的最后一个位置 out[5];于是靠前的 4a 只能落到 out[4]。 → 4a 仍在 4b 左边 ✅ 输出数组 out[](稳定!3a 在 3b 前、4a 在 4b 前) 1 2 3a 3b 4a 4b 如果把第 ③ 步改成正序(i 从 0 到 5),4a 会先抢到 out[5], 4b 只能退到 out[4] → 输出变成 4b、4a,稳定性被破坏 所以「倒序放置」不是优化技巧,而是稳定性的必要条件。
图 12-5 计数排序的三步:统计频次 → 求前缀和 → 倒序放置(附稳定性对比)

下面动图把这三步逐帧演示,并把「相同关键字的先后顺序」显式标记出来:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   计数排序(counting sort)—— 稳定版本
   前提:关键字是 [0, k) 范围内的整数
   时间:O(n + k)      空间:O(n + k)      稳定:是(靠倒序放置保证)
   ============================================================ */
vector<int> countingSort(const vector<int>& a, int k) {
    int n = a.size();
    vector<int> cnt(k, 0), out(n, 0);

    /* 第一步:统计每个值出现的次数,O(n) */
    for (int i = 0; i < n; ++i) ++cnt[a[i]];

    /* 第二步:前缀和。pre[v] 表示「值 <= v 的元素共有多少个」,
       也就是值 v 的元素在输出数组中的「末尾位置(开区间上界)」。O(k) */
    for (int v = 1; v < k; ++v) cnt[v] += cnt[v - 1];

    /* 第三步:倒序遍历原数组,先 --cnt[值] 再落位。
       倒序是稳定性的关键:最后出现的元素先占掉该值的最后一个空位,
       于是更靠前的同值元素只能放在它左边,相对次序得以保持。O(n) */
    for (int i = n - 1; i >= 0; --i) out[--cnt[a[i]]] = a[i];

    return out;
}

/* 反例演示:把第三步写成正序会怎样?(不稳定) */
vector<int> countingSortUnstable(const vector<int>& a, int k) {
    int n = a.size();
    vector<int> cnt(k, 0), pos(k, 0), out(n, 0);
    for (int i = 0; i < n; ++i) ++cnt[a[i]];
    pos[0] = 0;
    for (int v = 1; v < k; ++v) pos[v] = pos[v - 1] + cnt[v - 1];
    for (int i = 0; i < n; ++i) out[pos[a[i]]++] = a[i];   /* 正序 → 相同值被翻转 */
    return out;
}

int main() {
    /* 用「值 * 10 + 原始下标」编码,方便观察稳定性:
       例如 40 = 值 4 且原始下标 0,51 = 值 5 且原始下标 1 */
    vector<int> raw = {4, 1, 3, 4, 3, 2};
    vector<int> enc;
    for (int i = 0; i < (int)raw.size(); ++i) enc.push_back(raw[i] * 10 + i);

    vector<int> s1 = countingSort(enc, 60);
    cout << "稳定版  :";
    for (int x : s1) cout << "(" << x / 10 << ",idx" << x % 10 << ") ";
    cout << "\n";       /* (1,idx1) (2,idx5) (3,idx2) (3,idx4) (4,idx0) (4,idx3) —— 同值按原下标升序 */

    vector<int> s2 = countingSortUnstable(enc, 60);
    cout << "正序版  :";
    for (int x : s2) cout << "(" << x / 10 << ",idx" << x % 10 << ") ";
    cout << "\n";       /* (1,idx1) (2,idx5) (3,idx2) (3,idx4) …… 顺序被打乱 */

    vector<int> plain = countingSort(raw, 5);
    for (int x : plain) cout << x << ' ';
    cout << "\n";       // 1 2 3 3 4 4
    return 0;
}
为什么计数排序「必须」稳定?——最容易答错的一问 单看计数排序本身,稳定与否似乎「不影响正确性」:数组照样是有序的。 但计数排序真正的身份是基数排序的子过程。 基数排序 LSD 的做法是:先按最低位排一遍,再按次低位排一遍……最后按最高位排一遍。 这里有一个关键前提——按高位排序时,不能破坏低位已经排好的相对次序。 举个例子,两位数十进制数 21, 11
  • 先按个位排:21, 11(个位都是 1,保持原序)。
  • 再按十位排:如果子过程稳定,1 位的 11 会排在 2 位的 21 前面 → 结果 11, 21 ✅。
  • 如果子过程不稳定,十位排序时可能把 21 放到 11 前面 → 结果 21, 11 ❌,个位排好的成果被毁掉了。
所以「计数排序必须稳定」的真正理由是:它要作为基数排序的中间步骤, 而不是它单独使用时有什么额外好处。考试里如果只答「稳定更好」是拿不到分的。
计数排序的适用边界 时间 O(n + k)、空间 O(n + k)。只有 k = O(n) 时才是线性; 如果 k ≫ n(比如 10 个数、值域 10⁹),光是开 cnt 数组就要 4GB。 另外它只支持整数(或有离散小值域的关键字), 浮点数、字符串不能直接计数——字符串要先映射成整数(这正是基数排序做的事)。

12.5.2 桶排序:先粗分堆,再各自排好

一句话本质:把值域均匀切成 m 段(桶),把元素按值丢进对应的桶, 每个桶内部单独排序,最后按桶号顺序收集。

桶排序(bucket sort)和计数排序的关系很密切:计数排序是「一个值一个桶」的极端情形, 桶排序则是「一个区间一个桶」,因此它不要求关键字是整数,只要求能把值映射到桶号 (常见做法是 ⌊x · m⌋,其中 x ∈ [0,1))。

复杂度分析的关键在于数据分布。假设 n 个元素独立均匀地落在 [0,1), 开 n 个桶:

最坏情况:所有数据都挤在一个桶里(例如数据全部集中在 [0.9, 1.0)), 桶排序就退化成对一个长度为 n 的数组做插入排序 → O(n²)。 所以工程上桶排序往往配合「先抽样估计分布」使用。 桶排序是稳定的(前提:分桶时按原顺序追加、桶内用稳定排序)。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   桶排序(bucket sort)—— 适用于 [0,1) 上近似均匀分布的浮点数
   · 分桶 O(n) + 桶内插入排序 O(n)(均匀分布时)+ 收集 O(n) = O(n)
   · 最坏 O(n^2)(数据全部集中到同一个桶)
   · 额外空间 O(n + m);稳定(分桶按原序追加 + 桶内用插入排序)
   ============================================================ */
void bucketSort(vector<double>& a, int m) {
    int n = a.size();
    if (n < 2) return;
    vector<vector<double>> b(m);

    /* 分桶:按原数组顺序追加,保证桶内相对次序不变 → 稳定性的第一个条件 */
    for (int i = 0; i < n; ++i) {
        int k = (int)(a[i] * m);
        if (k >= m) k = m - 1;              /* 边界:a[i] 恰好等于 1.0 时兜底 */
        b[k].push_back(a[i]);
    }

    /* 桶内排序:插入排序常数小、且稳定 → 稳定性的第二个条件 */
    for (int k = 0; k < m; ++k) {
        for (int i = 1; i < (int)b[k].size(); ++i) {
            double key = b[k][i];
            int j = i - 1;
            while (j >= 0 && b[k][j] > key) { b[k][j + 1] = b[k][j]; --j; }
            b[k][j + 1] = key;
        }
    }

    /* 按桶号从小到大收集 */
    int idx = 0;
    for (int k = 0; k < m; ++k)
        for (double x : b[k]) a[idx++] = x;
}

/* 统计各桶元素个数,直观看到「均匀 vs 集中」的差别 */
void showBucketLoad(const vector<double>& a, int m) {
    vector<int> cnt(m, 0);
    for (double x : a) { int k = min((int)(x * m), m - 1); ++cnt[k]; }
    for (int k = 0; k < m; ++k) cout << "桶" << k << ":" << cnt[k] << "  ";
    cout << "\n";
}

int main() {
    vector<double> a = {0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.68, 0.33};
    showBucketLoad(a, 5);                  // 桶0:2 桶1:2 桶2:2 桶3:2 桶4:2 —— 很均匀
    bucketSort(a, 5);
    for (double x : a) cout << x << ' ';
    cout << "\n";                          // 0.12 0.17 0.21 0.26 0.33 0.39 0.68 0.72 0.78 0.94

    /* 反例:数据集中在 [0.9, 1.0) */
    vector<double> c;
    for (int i = 0; i < 10; ++i) c.push_back(0.9 + i * 0.009);
    showBucketLoad(c, 5);                  // 桶0:0 桶1:0 桶2:0 桶3:0 桶4:10 —— 全部挤在一个桶
    cout << "全部落进同一个桶 → 退化为对一个长度 n 的数组做插入排序 → O(n^2)\n";
    return 0;
}

12.5.3 基数排序的 LSD 与 MSD

基数排序(radix sort)在上一讲已经讲过 LSD(最低位优先,Least Significant Digit) 版本, 这里只补充两种方向的对照与结论,细节请回看第 11 讲。

对比项LSD(低位优先)MSD(高位优先)
处理顺序从最低位开始,逐位向高位做「分配 + 收集」从最高位开始,按最高位把序列分成若干子序列,再对每个子序列递归处理次高位
是否需要稳定子过程必须稳定,否则低位成果会被高位破坏不依赖稳定性(子序列之间已经由高位分开了)
实现方式通常用计数排序作为子过程,迭代实现,代码短通常用桶 + 递归(或显式栈)实现,代码复杂
能否提前结束不能,必须走完全部 d 位能:某一位上所有元素都相同就可以停止递归
适用场景定长关键字(定长整数、等长字符串、日期)变长关键字(字符串字典序排序),可以只比较前几位就分出胜负
稳定性稳定不稳定
时间复杂度O(d(n + r)),d 为位数、r 为基数与 d 有关,最坏仍是 O(d(n + r))
考点:基数排序为什么能突破 Ω(n log n)? 标准答案有两层:
  1. 下界证明的前提是「只允许比较」。Ω(n log n) 是从决策树模型推出来的, 决策树的每个内部结点必须是「一次两元素的比较」。基数排序不做这种比较, 它直接读关键字的某一位(值域信息),所以证明的前提不成立,下界自然不适用。
  2. 代价转移到了「值域」上。代价并没有凭空消失,而是变成了对关键字结构的假设: O(d(n+r)) 里的 r 是基数、d 是位数。 如果值域大得离谱(比如 64 位随机整数,d = 8, r = 256), 常数上就未必比快排快;而且它需要 O(n + r) 的额外空间。
一句话:下界只对「比较排序」成立,非比较排序用「空间换时间 + 利用值域结构」绕过了它。

12.6 归并类扩展:多路归并、胜者树/败者树与原地归并

归并类最朴素的形式是 2-路归并,它在上一讲已经详细讲过。本节要把归并这条线拉长: 往大处走,就是 k 路归并与外部排序(内存装不下时怎么办); 往小处走,就是原地归并(不想开 O(n) 辅助数组时怎么办)。

12.6.1 2-路归并的复习与「单趟 O(n)」结论

先明确一个后面反复要用的结论:把两个长度分别为 L、R 的有序段合并成一个有序段, 比较次数最多 L + R − 1 次,时间严格 O(L + R) = O(n)。 原因很简单:每做一次比较就至少有一个元素被「定下来」输出,除了最后一次之外不会有浪费。

下面的动画先演示标准 2-路归并(需要辅助数组),再演示原地归并的手摇算法:

12.6.2 多路归并:为什么外排序一定要用 k 路

一句话本质:外部排序的时间几乎全花在磁盘 I/O 上,所以要用「更大的归并路数 k」 来减少归并的趟数,从而减少读写磁盘的次数。

场景:一个 100GB 的文件要排序,内存只有 1GB。做法是:

  1. 置换选择 / 内部排序生成初始归并段:每次读进内存能装下的部分,排好序写回磁盘, 得到一个「归并段(run)」。100GB / 1GB = 100 个归并段。
  2. 多趟 k 路归并:把 k 个归并段合并成一个更大的归并段,反复进行直到只剩一个。

为什么 k 越大越省?设初始归并段有 m 个,做 k 路归并, 则归并趟数为 S = ⌈log_k m⌉,每趟都要把全部 n 条记录读一遍、写一遍, 所以总 I/O 次数 = 2n·⌈log_k m⌉(读 + 写各 n)。看这个式子:k 在底数上,趟数随 k 对数下降。

归并路数 k100 个归并段的趟数 ⌈log_k 100⌉总 I/O(相对)每选一个最小值需要比较几次(朴素扫描)
27 趟14n1 次
53 趟6n4 次
102 趟4n9 次
1001 趟2n99 次

但天下没有免费的午餐:k 路归并每一轮都要「从 k 个归并段的当前首元素中挑出最小的」。 朴素做法是线性扫描 k 个候选,每次输出花 O(k), 总共 O(nk) 次比较。当 k 很大(比如 100),这部分内部计算的开销就会盖过 I/O 的收益。 于是我们需要一个「从 k 个候选里 O(log k) 取最小」的结构——胜者树 / 败者树

工程上用的具体是败者树(loser tree),它是胜者树的改良版。为了让你看清两者的关系, 下面先讲清胜者树这条更直观的路线,再说明败者树做了什么改进。

12.6.3 胜者树与败者树:把「k 选 1」从 O(k) 降到 O(log k)

一句话本质:把 k 个归并段当前的首元素放到一棵完全二叉树的叶子上, 内部结点保存「这一小场比赛的结论」。选最小值只看根,取走之后只沿一条路径重赛, 于是每次更新只需 ⌈log₂ k⌉ 次比较。

两种树的差别只有一句话,但效果差别很大:

胜者树
内部结点保存胜者(较小者)的叶子编号。取走冠军后沿路径重赛时, 每一层必须重新访问两个孩子才能算出谁是胜者,所以每层要触及两个结点。
败者树
内部结点保存败者,冠军另外用 ls[0] 单独存放。 重赛时「赢家已经被带上来了」,所以每层只需拿当前赢家与结点上记着的那个败者比一次, 不必再去访问兄弟结点——路径上访问的结点数约为胜者树的一半。

两者都是 O(log k)比较,差别在常数(每层访问的结点数)。 下图给出败者树的结构与一次完整的重赛过程:

败者树(k = 5 路归并)结构示意:内部结点记「败者」,冠军从 ls[0] 另存 5 个归并段的当前首元素 段0: 12 段1: 7 段2: 3 段3: 25 段4: 9 败者7 败者12 败者9 12 7 3 25 段0 / 段1 的胜者 段2 / 段3 的胜者 冠军(最小值) 3(来自段2) ls[0] 重赛(输出 3 之后,段 2 的下一个元素是 15) ① 段2 的新首元素 15 与「父结点记录的败者 25」比 → 15 胜,25 继续留在该结点,15 上行; ② 15 再与「父结点记录的败者 9」比 → 9 胜,15 写入该结点,9 上行到根; ③ 9 与「根记录的败者 7」比 → 7 胜,9 写入根,7 成为新的冠军 ls[0]。全程只需 3 = ⌈log₂5⌉ 次比较,且不需要访问兄弟结点
图 12-6 败者树:内部结点记败者,冠军从 ls[0] 另存,重赛只需 ⌈log₂k⌉ 次比较

下面这份实现给出 k 路归并 + 胜者树选择器。选胜者树而不是败者树来写,是因为它更直观、 更容易一次写对;两者复杂度相同,把这份代码里的 recompute 改成 「只与结点记录的败者比较一次、不访问兄弟结点」,就得到败者树版本。

#include <iostream>
#include <vector>
#include <climits>
#include <algorithm>
#include <random>
using namespace std;

/* ============================================================
   k 路归并 + 胜者树选择器(外部排序的核心构件)
   · 问题:从 k 个有序归并段中反复挑出最小值。朴素做法每输出一个
     要扫描 k 个候选 → O(k);用树形结构可以压到 O(log k)。
   · 胜者树(winner tree):把 k 个归并段当前的首元素放在完全二叉树
     的叶子上,内部结点保存「两个孩子中的胜者(较小者)的叶子编号」。
     取最小值 = 读根结点 O(1);取走后只沿该叶子到根的路径重赛,
     路径长度 = ⌈log2 k⌉ → 单次更新 O(log k)。
   · 这就是外部排序所用「败者树」的同族结构。两者的复杂度都是
     O(log k);败者树的差别只在于把「败者」而不是「胜者」记在结点上,
     于是重赛时每层不必再去访问兄弟结点,路径上访问的结点少一半
     (见正文图 12-6 的对比说明)。
   · 实现要点:叶子编号从 K 开始(K = 把 k 向上补齐到 2 的幂),
     于是父结点 = t/2、左孩子 = 2t、右孩子 = 2t+1;
     补齐出来的空叶子值恒为 INT_MAX,永远当败者。
   ============================================================ */
struct WinnerTree {
    int k, K;                    // k = 归并路数;K = 补齐到 2 的幂
    vector<int> key;             // 叶子值,下标 K .. 2K-1
    vector<int> win;             // win[t] = 结点 t 子树中最小值的叶子编号;-1 表示该子树全空
    vector<int> owner;           // owner[leaf] = 该叶子属于哪一路

    explicit WinnerTree(const vector<vector<int>>& runs) {
        k = runs.size();
        K = 1;
        while (K < k) K <<= 1;                       // 补齐到 2 的幂
        key.assign(2 * K, INT_MAX);
        owner.assign(2 * K, -1);
        win.assign(K, -1);
        for (int i = 0; i < k; ++i)
            if (!runs[i].empty()) { key[K + i] = runs[i][0]; owner[K + i] = i; }
        for (int t = K - 1; t >= 1; --t) recompute(t);   // 自底向上建树
        win[0] = relink();
    }

    /* 取结点 node 子树的胜者;叶子若已取空(值为 INT_MAX)则返回 -1 */
    int kid(int node) const {
        if (node >= K) return (key[node] == INT_MAX) ? -1 : node;
        return win[node];
    }
    /* 用两个孩子重算结点 t:两边都空则 t 也空,否则取较小者 */
    void recompute(int t) {
        int a = kid(2 * t), b = kid(2 * t + 1);
        if (a < 0)       win[t] = b;
        else if (b < 0)  win[t] = a;
        else             win[t] = (key[a] <= key[b]) ? a : b;
    }
    int relink() { return (K == 1) ? kid(K) : win[1]; }

    int champion() const { return win[0]; }            // 全局最小值所在叶子;-1 表示全部取完

    /* 某个叶子的值被改动(或该路取空)之后,沿路径重赛:每层一次比较 */
    void update(int leaf) {
        for (int t = leaf / 2; t >= 1; t /= 2) recompute(t);
        win[0] = relink();
    }
};

/* 用胜者树做 k 路归并 */
vector<int> kWayMerge(const vector<vector<int>>& runs, long long& treeHeight) {
    int k = runs.size();
    treeHeight = 0;
    for (int t = k; t > 1; t >>= 1) ++treeHeight;      // 树高 = ⌈log2 k⌉
    vector<int> pos(k, 0), out;
    WinnerTree wt(runs);
    while (true) {
        int leaf = wt.champion();
        if (leaf < 0) break;                           // 全部取完
        int seg = wt.owner[leaf];
        out.push_back(wt.key[leaf]);
        ++pos[seg];
        /* 从该段读入下一条记录;段空了就置为 INF(等价于「该选手退赛」) */
        wt.key[leaf] = (pos[seg] < (int)runs[seg].size()) ? runs[seg][pos[seg]] : INT_MAX;
        wt.update(leaf);
    }
    return out;
}

/* 对照:朴素 k 路扫描,每输出一个元素要比较 O(k) 次 */
vector<int> naiveKWayMerge(const vector<vector<int>>& runs, long long& cmps) {
    int k = runs.size();
    vector<int> pos(k, 0), out;
    cmps = 0;
    while (true) {
        int best = -1;
        for (int i = 0; i < k; ++i) {
            if (pos[i] >= (int)runs[i].size()) continue;
            ++cmps;
            if (best < 0 || runs[i][pos[i]] < runs[best][pos[best]]) best = i;
        }
        if (best < 0) break;
        out.push_back(runs[best][pos[best]++]);
    }
    return out;
}

int main() {
    /* ---- 例 1:k = 5 的小例子,结果一目了然 ---- */
    vector<vector<int>> runs = {
        {12, 30, 44},
        {7,  18, 55},
        {3,  15, 21},
        {25, 27, 40},
        {9,  33, 36}
    };
    long long h1 = 0, c2 = 0;
    vector<int> r1 = kWayMerge(runs, h1);
    vector<int> r2 = naiveKWayMerge(runs, c2);
    for (int x : r1) cout << x << ' ';
    cout << "\n";        // 3 7 9 12 15 18 21 25 27 30 33 36 40 44 55
    cout << "两种实现结果一致:" << (r1 == r2 ? "是" : "否") << "\n";

    /* ---- 例 2:批量随机测试,验证正确性(含空段与大量重复值) ---- */
    mt19937 rng(11);
    int fails = 0;
    for (int t = 0; t < 20000; ++t) {
        int k = 1 + (int)(rng() % 24);
        vector<vector<int>> rs(k);
        for (int i = 0; i < k; ++i) {
            int len = (int)(rng() % 8);
            for (int j = 0; j < len; ++j) rs[i].push_back((int)(rng() % 50));
            sort(rs[i].begin(), rs[i].end());          // 每个归并段内部有序
        }
        long long hh = 0, cc = 0;
        if (kWayMerge(rs, hh) != naiveKWayMerge(rs, cc)) ++fails;
    }
    cout << "20000 组随机 k 路归并测试(k = 1..24,含空段):"
         << (fails == 0 ? "全部通过" : "有失败") << "\n";

    /* ---- 例 3:规模大一点,看 O(log k) 与 O(k) 的差距 ---- */
    const int K = 64, PER = 200;
    vector<vector<int>> big(K);
    for (int i = 0; i < K; ++i) {
        for (int j = 0; j < PER; ++j) big[i].push_back((int)(rng() % 100000));
        sort(big[i].begin(), big[i].end());
    }
    long long h2 = 0, d2 = 0;
    vector<int> m1 = kWayMerge(big, h2);
    vector<int> m2 = naiveKWayMerge(big, d2);
    cout << "k = " << K << "、每段 " << PER << " 条记录,共 " << m1.size() << " 条:\n";
    cout << "  胜者树  :每次重赛沿路径比较 " << h2 << " 次(⌈log2 " << K << "⌉),合计约 "
         << h2 * (long long)m1.size() << " 次\n";
    cout << "  朴素扫描:每轮最多 " << K << " 次,合计 " << d2 << " 次\n";
    cout << "  结果一致:" << (m1 == m2 ? "是" : "否") << "\n";
    cout << "→ k 越大,两者的差距越大:这就是外部排序必须用树形选择器的原因。\n";
    return 0;
}
败者树 vs 胜者树:一个字的差别,一半的代价 胜者树的内部结点存赢家:重赛时每一层都要重新读两个孩子、再比一次, 所以每层要触及两个结点。 败者树的内部结点存输家:因为「赢家已经在上一条路径上被带走了」, 所以每层只需要和结点上记着的那个败者比一次,路径上访问的结点数约为胜者树的一半。
结果:两种树都是 O(log k) 次比较,但败者树的常数更小,实现也同样简单。 这就是外部排序的实现里普遍采用败者树的原因。 (顺带回答 12.4.3 的悬念:内存排序用堆——因为它连 O(k) 的树都不要; 外排序用败者树——因为归并段不能搬进内存,树是必需的。)

12.6.4 原地归并:手摇算法(三次翻转)

一句话本质:归并之所以要 O(n) 辅助空间,是因为「把右边的元素插到左边元素之前」 必须腾地方。手摇算法发现:如果只是交换两个相邻的块,可以原地做——翻转三次就行。

问题描述:数组里有相邻两块 L = A[l..m-1]R = A[m..r], 想在不使用额外空间的前提下把它们整体换位(保持各自块内顺序),变成 R L

手摇算法 = reverse(L) → reverse(R) → reverse(L+R)
三次翻转,块内顺序复原、块间位置互换

举个具体例子,交换 L = [1,2]R = [3,4](合并成 [1,2,3,4]):

  1. 翻转 L:[2,1 | 3,4]
  2. 翻转 R:[2,1 | 4,3]
  3. 翻转整体:[3,4,1,2] ✅ 两块位置互换了,而且各自内部顺序也恢复了。
手摇算法(三次翻转)交换相邻两块:L = [1,2,3]、R = [4,5] 初始 1 2 3 4 5 L 块(长 3) R 块(长 2) ① reverse(L) 3 2 1 4 5 只翻转 L 内部(下标 l..m−1) ② reverse(R) 3 2 1 5 4 只翻转 R 内部(下标 m..r) ③ reverse(整段) 4 5 1 2 3 → [4,5 | 1,2,3] 两块位置互换 ✅ 为什么原地归并难,手摇解决了什么 标准归并:必须开 O(n) 辅助数组,把结果写到新数组再拷回。 原地归并的困难:右段元素要插到左段之前,只能靠搬移; 逐个插入的代价是 O(n) 次搬移 × O(n) 次插入 = O(n²)。 手摇的贡献:把「整体换位」变成 O(块长) 的翻转操作,空间 O(1)。 归并流程:反复用二分找边界 + 手摇交换,再对剩余部分递归。 代价:元素移动次数仍是 O(n²) 量级,且手摇交换的是整块 → 不稳定。 结论:O(1) 空间与 O(n) 时间不可兼得(这正是一种「时空权衡」)。
图 12-7 原地归并的手摇算法:三次翻转交换相邻两块,空间 O(1)
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   原地归并(手摇算法 / three reversals)
   · 用「三次翻转」原地交换相邻两块,从而避免 O(n) 辅助数组
   · 空间 O(1),但元素移动次数最坏 O(n^2)(比标准归并的 O(n) 差得多)
   · 不稳定:整块交换会打乱跨越两块的相等元素的相对次序
   ============================================================ */

/* 三次翻转:把 a[l..m-1] 与 a[m..r] 两块整体换位(保持各自块内顺序) */
void handShake(vector<int>& a, int l, int m, int r) {
    reverse(a.begin() + l, a.begin() + m);        /* ① 翻转左块 */
    reverse(a.begin() + m, a.begin() + r + 1);    /* ② 翻转右块 */
    reverse(a.begin() + l, a.begin() + r + 1);    /* ③ 翻转整段 */
}

/* 递归版原地归并:先找需要交换的边界,再用二分 + 手摇把块换位 */
void inplaceMergeRec(vector<int>& a, int l, int m, int r) {
    if (l >= m || m > r) return;
    int i = l, j = m;
    /* 跳过左块中已经比右块首元素小的前缀 */
    while (i < m && a[i] <= a[m]) ++i;
    /* 跳过右块中比 a[i] 小的前缀:这些元素最终要整体挪到左块前面 */
    while (j <= r && a[j] < a[i]) ++j;
    if (i == m) return;                            /* 左块整体小于右块 → 已就绪 */
    if (i == j - 1) { swap(a[i], a[m]); }          /* 退化情形:只剩一个元素要换,直接交换 */
    else          { handShake(a, i, m, j - 1); }   /* 两块整体换位 */
    /* 换位之后,a[i..i+(j-m)-1] 已经就位,剩下的两段递归处理 */
    int newMid = i + (j - m);
    inplaceMergeRec(a, newMid, j, r);
}

void inplaceMergeSort(vector<int>& a, int l, int r) {
    if (r - l < 1) return;
    int m = l + ((r - l) >> 1);
    inplaceMergeSort(a, l, m);
    inplaceMergeSort(a, m + 1, r);
    inplaceMergeRec(a, l, m + 1, r);
}

/* 对照:标准归并(需要 O(n) 辅助数组),用来验证原地版结果一致 */
void stdMergeSort(vector<int>& a, int l, int r, vector<int>& buf) {
    if (r - l < 1) return;
    int m = l + ((r - l) >> 1);
    stdMergeSort(a, l, m, buf);
    stdMergeSort(a, m + 1, r, buf);
    int i = l, j = m + 1, k = l;
    while (i <= m && j <= r) buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    while (i <= m) buf[k++] = a[i++];
    while (j <= r) buf[k++] = a[j++];
    for (int t = l; t <= r; ++t) a[t] = buf[t];
}

int main() {
    vector<int> a = {1, 4, 7, 2, 3, 9};
    handShake(a, 0, 3, 5);                     /* 交换 [1,4,7] 与 [2,3,9] */
    for (int x : a) cout << x << ' ';
    cout << "\n";                              /* 2 3 9 1 4 7 */

    vector<int> b = {8, 3, 5, 1, 9, 2, 7, 4, 6, 0};
    vector<int> c = b, buf(b.size());
    inplaceMergeSort(b, 0, b.size() - 1);
    stdMergeSort(c, 0, c.size() - 1, buf);
    for (int x : b) cout << x << ' ';
    cout << "\n";                              /* 0 1 2 3 4 5 6 7 8 9 */
    cout << "原地版与标准版结果一致:" << (b == c ? "是" : "否") << "\n";
    return 0;
}
原地归并值不值得用? 把两种归并放在一起比较:
方案时间空间稳定建议
标准 2-路归并O(n log n)O(n)稳定默认选择(std::stable_sort 就这样做)
原地归并(手摇)O(n²) 移动O(1)不稳定只在内存极度受限且数据量小时考虑
原地归并 + 二分找边界O(n log² n) 量级O(log n) 栈不稳定理论上有意义,工程上很少用
切记:原地归并是「用时间换空间」,不是「更快」。 这是回答「能不能不用辅助数组做归并」这类问题的标准口径。

12.7 工程实战:标准库到底用的是什么排序

学完理论,最有价值的一步是看看真正被千万行代码调用的排序长什么样。 你会发现标准库的做法和教科书完全不同——它不追求某一个算法的最优, 而是追求「在所有输入上都不会太差」。

12.7.1 std::sort 的内省排序(Introsort)

一句话本质:以快速排序为主干,用插入排序处理小区间,用堆排序兜底递归过深的情况, 三种算法各管一段,拼出「平均接近快排 + 最坏 O(n log n)」。

为什么非要这么拼?因为快排有两个天生的短板:

内省排序(Introsort)的决策流程 sort(first, last) 深度 ≤ 2⌊log₂n⌋ 深度 > 2⌊log₂n⌋ ① 快速排序(主干) 三数取中 / median-of-3 选枢轴 + Hoare 划分 区间长度 > 阈值(通常 16)? 是 → 继续递归划分;否 → 交给插入排序 ② 插入排序(小区间收尾) 长度 ≤ 16:插排常数最小、cache 友好、天然稳定 ③ 堆排序(兜底) 对该区间建堆 + 反复取最值,最坏 O(n log n) 不递归 → 深度不再增长 从而把最坏情况的 O(n²) 彻底堵死 三者的分工 快排:平均快、 常数小、cache 好 插排:小区间最优 堆排:最坏有保证 最终的复杂度:平均 O(n log n)(接近快排常数)、最坏 O(n log n)(由堆排保证)、额外空间 O(log n)(递归栈)。
图 12-8 内省排序的决策流程图:快排主干 + 小区间插排 + 深度超限转堆排

下面动图把「当前处在哪种模式」实时标出来,你可以看到深度超限时是如何切换到堆排的:

下面这份代码是一个可以直接编译运行的 introsort 实现,把三道保险都写全了:

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   自己实现 introsort(内省排序)= 快速排序 + 插入排序 + 堆排序
   竞赛写法:全局数组 + 自由函数,区间统一用 [lo, hi) 左闭右开
     · 主干:三数取中选枢轴的快速排序
     · 小区间(len <= 16):改用插入排序收尾
     · 递归深度 > 2*floor(log2(n)):改用堆排序,保证最坏 O(n log n)
   平均 O(n log n),最坏 O(n log n),空间 O(log n),不稳定
   数组大小依据:MAXN = 3e5 + 5,够放 3e5 个元素
   ============================================================ */

const int MAXN = 300005;
const int INSERTION_THRESHOLD = 16;

int a[MAXN];
int n = 0;

/* ---------- 插入排序:只处理 [lo, hi) ---------- */
void insertionSort(int lo, int hi) {
    for (int i = lo + 1; i < hi; ++i) {
        int key = a[i], j = i;
        while (j > lo && a[j - 1] > key) { a[j] = a[j - 1]; --j; }
        a[j] = key;
    }
}

/* ---------- 下沉:把 a[lo .. lo+len-1] 看成堆,调整下标 root ---------- */
void siftDown(int lo, int len, int root) {
    while (true) {
        int child = 2 * root + 1;
        if (child >= len) break;
        if (child + 1 < len && a[lo + child] < a[lo + child + 1]) ++child;   // 取较大的孩子
        if (a[lo + root] < a[lo + child]) {
            swap(a[lo + root], a[lo + child]);
            root = child;
        } else break;
    }
}

/* ---------- 堆排序:只处理 [lo, hi),最坏也是 O(n log n) ---------- */
void heapSort(int lo, int hi) {
    int len = hi - lo;
    for (int i = len / 2 - 1; i >= 0; --i) siftDown(lo, len, i);      // 建大顶堆
    for (int end = len - 1; end > 0; --end) {
        swap(a[lo], a[lo + end]);                                     // 最大值换到末尾
        siftDown(lo, end, 0);                                         // 剩余部分重新成堆
    }
}

/* ---------- 三数取中:把中位数换到 hi-2 位置并返回该下标 ---------- */
int medianOfThree(int lo, int hi) {          // [lo, hi)
    int mid = lo + (hi - lo) / 2;
    --hi;                                    // 指向最后一个有效元素
    if (a[mid] < a[lo]) swap(a[mid], a[lo]);
    if (a[hi] < a[lo])  swap(a[hi], a[lo]);
    if (a[hi] < a[mid]) swap(a[hi], a[mid]);
    /* 此时 a[lo] <= a[mid] <= a[hi],把 mid 换到 hi-1 当枢轴,a[hi] 留作右端哨兵 */
    swap(a[mid], a[hi - 1]);
    return hi - 1;
}

/* ---------- 核心:带深度限制的快排 ---------- */
void introsortLoop(int lo, int hi, int depthLimit) {   // [lo, hi)
    while (hi - lo > INSERTION_THRESHOLD) {
        if (depthLimit == 0) { heapSort(lo, hi); return; }   // ① 深度用尽 → 堆排兜底
        --depthLimit;
        int pivot = medianOfThree(lo, hi);                   // ② 三数取中
        int i = lo, j = hi - 1;
        while (true) {
            while (a[++i] < a[pivot]) {}
            while (a[pivot] < a[--j]) {}
            if (i < j) swap(a[i], a[j]); else break;
        }
        swap(a[i], a[pivot]);                                // 枢轴归位
        introsortLoop(i + 1, hi, depthLimit);                // 递归处理右半
        hi = i;                                              // 左半用循环继续处理(尾递归优化)
    }
    /* ③ 小区间直接退出,最后对整个区间做一次插入排序收尾 */
}

void introSort(int lo, int hi) {             // [lo, hi)
    if (hi - lo < 2) return;
    int depthLimit = 2 * (int)log2((double)(hi - lo));       // 2 * floor(log2 n)
    introsortLoop(lo, hi, depthLimit);
    insertionSort(lo, hi);                                   // 小区间收尾
}

int main() {
    n = 40;
    for (int i = 0; i < n; ++i) a[i] = (i * 17 + 5) % 40;
    introSort(0, n);
    for (int i = 0; i < n; ++i) printf("%d ", a[i]);
    printf("\n");

    /* 和标准库结果比对 */
    int b[40];
    for (int i = 0; i < n; ++i) b[i] = a[i];
    sort(b, b + n);
    bool same = true;
    for (int i = 0; i < n; ++i) if (a[i] != b[i]) same = false;
    printf("与 std::sort 结果一致:%s\n", same ? "是" : "否");

    /* 已经有序的输入(快排的经典最坏场景)也不再退化 */
    n = 100000;
    for (int i = 0; i < n; ++i) a[i] = i;
    introSort(0, n);
    printf("10 万条已排序数据 introsort 完成,首尾 = %d %d\n", a[0], a[n - 1]);
    return 0;
}
考点:为什么 introsort 能同时保证「平均快」和「最坏 O(n log n)」 标准答题模板(三段):
  1. 平均快来自快排:快排的常数因子最小、访问局部性最好,随机输入下是实测最快的 O(n log n) 算法。
  2. 最坏有保证来自堆排兜底:一旦递归深度超过 2⌊log₂ n⌋, 说明划分已经严重偏斜(否则深度必然是 O(log n)), 此时把该区间交给最坏 O(n log n) 的堆排,于是整体最坏不会超过 O(n log n)
  3. 常数更小来自插排收尾:小区间(≤ 16)用插排,省掉了大量递归调用与枢轴选择的固定开销。
补充一句会加分:三者都不稳定,所以 std::sort 也不稳定; 需要稳定时要用 std::stable_sort

12.7.2 std::stable_sort:稳定优先的归并实现

std::stable_sort 的实现是归并排序,但它比教科书的归并更讲究空间:

std::stable_sort:时间 O(n log n)(最坏 O(n log² n),当无法分配缓冲时);空间 O(n) 或原地;稳定
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
using namespace std;

/* ============================================================
   std::sort(不稳定)与 std::stable_sort(稳定)的行为对比
   场景:先按分数排名,分数相同的按「原顺序」保持先后 —— 这正是稳定性要解决的
   ============================================================ */
struct Student {
    string name;
    int    score;
};

int main() {
    vector<Student> v = {
        {"小明", 90}, {"小红", 85}, {"小刚", 90},
        {"小美", 85}, {"小强", 90}, {"小丽", 85}
    };

    /* ---- 1) std::sort:不稳定,同分学生的相对次序无法保证 ---- */
    vector<Student> a = v;
    std::sort(a.begin(), a.end(), [](const Student& x, const Student& y) {
        return x.score > y.score;            /* 只按分数比较 */
    });
    cout << "std::sort(不稳定):\n";
    for (auto& s : a) cout << "  " << s.name << " " << s.score << "\n";

    /* ---- 2) std::stable_sort:同分学生保持输入中的先后 ---- */
    vector<Student> b = v;
    std::stable_sort(b.begin(), b.end(), [](const Student& x, const Student& y) {
        return x.score > y.score;
    });
    cout << "std::stable_sort(稳定):\n";
    for (auto& s : b) cout << "  " << s.name << " " << s.score << "\n";

    /* ---- 3) 如果不允许用 stable_sort,可以自己「加次关键字」把不稳定排序变成稳定排序 ---- */
    vector<pair<Student, int>> c;
    for (int i = 0; i < (int)v.size(); ++i) c.push_back({v[i], i});   // 原始下标作为次关键字
    std::sort(c.begin(), c.end(), [](const pair<Student, int>& x, const pair<Student, int>& y) {
        if (x.first.score != y.first.score) return x.first.score > y.first.score;
        return x.second < y.second;                                   // 同分时按原下标升序 → 等价于稳定
    });
    cout << "自己加次关键字后的 std::sort:\n";
    for (auto& p : c) cout << "  " << p.first.name << " " << p.first.score << "\n";

    cout << "\n后两种结果应当完全一致(都等价于稳定排序)。\n";
    return 0;
}
实用技巧:用「加索引」把不稳定排序变稳定 std::sortstd::stable_sort 快,但前者不稳定。既要快又要稳定怎么办? 给每个元素附带它的原始下标作为「次关键字」:先比主关键字,主关键字相等时比下标。 这样任何两个元素的比较结果都是全序的(不会出现「相等」), 排序结果唯一确定,自然与稳定排序的结果一致。代价是每个元素多存一个下标字段, 比较也多做一次——比换成 stable_sort 通常更划算。

12.7.3 Timsort:为真实数据而生的排序

一句话本质:真实世界的数据不是随机的——它常常是「几段有序数据拼起来」。 Timsort 先扫描出这些天然有序的游程(run),再用归并把它们合并起来。

Timsort 由 Tim Peters 在 2002 年为 Python 设计,后来成为 Java(对象数组)、 Android、V8 等平台的标准排序。它的核心机制有三点:

  1. 识别游程(run)。从左往右扫描,找出一段已经非递减(或严格递减,递减就原地翻转成递增) 的连续片段。真实数据里这种 run 往往很长——比如一个已经按时间排序、又追加了几条新记录的表。
  2. 用插入排序把短 run 补到最小长度。如果某个 run 长度小于 minrun(32~64 之间, 由 n 计算得出),就用插入排序把它扩展到 minrun 长度。 因为对「已经基本有序」的片段做插入排序几乎是 O(长度) 的。
  3. 用归并合并 run,并用「折半插入 + 加洛普(gallop)模式」加速。 合并时如果发现某一方的元素连续赢了很多次,就切换到「二分查找批量搬运」的 gallop 模式, 一次搬一大段。
数据形态Timsort 的表现原因
完全有序O(n)整段就是一个 run,扫描一遍直接结束
几段有序拼接接近 O(n)run 很少,合并代价极低
少量元素乱序接近 O(n)run 很长,只有局部要插排修补
完全随机O(n log n)run 很短,退化为普通归并(常数比快排略大)
刻意构造的对手输入O(n log n)归并的天性:最坏也是 O(n log n),不会像快排那样退化
考点:Timsort 的三个关键词 run(游程)+ 插入排序扩展 minrun + 归并合并。 它稳定(归并家族的天性),最坏 O(n log n),最好 O(n), 额外空间 O(n)(合并缓冲区,但比朴素养归并小得多)。 考试里如果问「为什么 Python 的 sort 这么快」,标准答案就是: 它利用了真实数据中存在大量有序游程这一事实,把「已经有序」的部分直接识别出来并跳过。

12.7.4 为什么 C 的 qsort 常常比手写快排还慢

这是工程上一个非常经典的现象:同样是自己写的快排,用 std::sort 编译后飞快, 换成 qsort 却慢了好几倍。原因主要有四条,按重要性排序:

  1. 函数指针无法内联(最主要原因)。qsort 的签名是 void qsort(void* base, size_t n, size_t size, int (*cmp)(const void*, const void*))。 比较逻辑是通过函数指针传入的。编译器在编译 qsort 时必须假设这个指针随时可能指向任何函数, 因此无法把它内联展开——每一次比较都是一次真实的函数调用: 压栈、跳转、返回、寄存器保存与恢复。而 std::sort 是模板, 比较器作为模板参数传入,编译器可以把 operator< 或 lambda 完全内联, 一次比较可能只编译成一两条指令(比如 cmp %eax, %edx)。这个差距在 n 较大时通常是 2~5 倍
  2. void* 带来的间接访问与无法优化。qsort 只知道元素大小 size, 必须靠 memcpy 式的字节搬移来交换元素,无法像模板那样直接按类型读写, 编译器也没法把这种「运行时才知道大小的内存操作」优化成寄存器操作。
  3. 算法本身的差异。很多 qsort 实现是纯快排(部分实现加了小区间插排,但很少加深度兜底), 遇到偏斜输入会退化到 O(n²);而 std::sort 是 introsort, 最坏也是 O(n log n)
  4. 缺少针对性的枢轴策略。一些老实现用「取首元素」或「取中间元素」做枢轴, 对已排序输入极不友好;std::sort 用三数取中(部分实现还用 ninther 九数取中)。

下面这段代码把四条原因量化出来:在完全相同的输入上跑 std::sortqsortstable_sort 与手写快排, 你就能亲眼看到「函数指针 vs 编译期内联」的差距有多大。

#include <bits/stdc++.h>
using namespace std;
using Clock = chrono::steady_clock;

/* ============================================================
   排序性能对照实验:std::sort vs qsort vs stable_sort vs 手写快排
   竞赛写法:全局数组 + 自由函数,计时用 <chrono> 的 steady_clock
   结论预期:std::sort 通常最快;qsort 因为「函数指针无法内联」而明显偏慢
   (具体倍数与编译器、优化级别、数据类型有关,本程序只做量级演示)
   数组大小依据:N = 3e5,量级差别看得清,跑起来也就一两秒
   ============================================================ */

const int N = 300000;

int baseArr[N];        // 原始随机数据(只读,不参与排序)
int work[N];           // 每次计时前从这里复制一份,保证四种排序面对同一份数据

/* 把 baseArr 复制到 work(复制不计入计时) */
void copyBase() {
    for (int i = 0; i < N; ++i) work[i] = baseArr[i];
}

/* 两个时间点之间隔了多少毫秒 */
double msBetween(Clock::time_point t0, Clock::time_point t1) {
    return chrono::duration<double, milli>(t1 - t0).count();
}

/* ---- qsort 需要的 C 风格比较函数(函数指针 → 无法内联) ---- */
int cmpInt(const void *a, const void *b) {
    int x = *(const int *)a, y = *(const int *)b;
    return (x > y) - (x < y);
}

/* ---- 手写朴素快排:取首元素为枢轴。它无法应对已排序输入,
       所以下面用「比较次数计数器」代替跑完全程(否则 n = 20 万要等到天荒地老) ---- */
long long naiveQuickCmps = 0;

void naiveQuick(int lo, int hi, int depthCap) {
    if (lo >= hi) return;
    if (depthCap <= 0) return;                     // 演示用:到了极深就停手,避免爆栈
    int pivot = work[lo], i = lo, j = hi;
    while (i < j) {
        while (i < j) { ++naiveQuickCmps; if (work[j] < pivot) break; --j; }
        work[i] = work[j];
        while (i < j) { ++naiveQuickCmps; if (work[i] > pivot) break; ++i; }
        work[j] = work[i];
    }
    work[i] = pivot;
    naiveQuick(lo, i - 1, depthCap - 1);
    naiveQuick(i + 1, hi, depthCap - 1);
}

int main() {
    mt19937 rng(12345);
    for (int i = 0; i < N; ++i) baseArr[i] = (int)(rng() % 1000000);

    /* 四种方式:注意都在同一份随机数据上比较 */
    copyBase();
    Clock::time_point t0 = Clock::now();
    sort(work, work + N);                                  // ① std::sort(内省排序)
    Clock::time_point t1 = Clock::now();

    copyBase();
    Clock::time_point t2 = Clock::now();
    qsort(work, N, sizeof(int), cmpInt);                   // ② C 的 qsort:比较器是函数指针
    Clock::time_point t3 = Clock::now();

    copyBase();
    Clock::time_point t4 = Clock::now();
    stable_sort(work, work + N);                           // ③ std::stable_sort:稳定但有额外空间
    Clock::time_point t5 = Clock::now();

    copyBase();
    Clock::time_point t6 = Clock::now();
    naiveQuick(0, N - 1, 200);                             // ④ 手写朴素快排
    Clock::time_point t7 = Clock::now();

    double tStd = msBetween(t0, t1), tQs = msBetween(t2, t3);
    double tSt = msBetween(t4, t5), tNv = msBetween(t6, t7);

    printf("n = %d,随机数据(单位 ms,越小越好)\n", N);
    printf("  std::sort        : %.1f\n", tStd);
    printf("  qsort            : %.1f   (约为 std::sort 的 %.1f 倍)\n",
           tQs, tStd > 0 ? tQs / tStd : 0.0);
    printf("  std::stable_sort : %.1f   (稳定,代价是额外空间与常数)\n", tSt);
    printf("  手写朴素快排     : %.1f   (取首元素为枢轴)\n", tNv);

    /* ---- 最坏输入:已经完全有序。这里不跑朴素快排(那是 O(n^2)),
            而是直接数它的比较次数,用数字说明「退化」有多可怕 ---- */
    const int M = 4096;                            // 只统计规模 M 的比较次数,再外推
    for (int i = 0; i < M; ++i) work[i] = i;
    naiveQuickCmps = 0;
    naiveQuick(0, M - 1, M + 10);
    printf("\n已排序输入上「取首元素为枢轴」的朴素快排(n = %d):\n", M);
    printf("  比较次数 = %lld,而 n(n-1)/2 = %lld  → 完全退化为 O(n^2)\n",
           naiveQuickCmps, (long long)M * (M - 1) / 2);

    for (int i = 0; i < N; ++i) work[i] = i;       // n = 3e5 的已排序输入
    Clock::time_point t8 = Clock::now();
    sort(work, work + N);
    Clock::time_point t9 = Clock::now();
    printf("  而 std::sort(内省排序)在 n = %d 的已排序输入上只用 %.1f ms —— 三数取中 + 堆排兜底让它不退化\n",
           N, msBetween(t8, t9));
    printf("  结论:这就是内省排序存在的意义。\n");
    return 0;
}
结论与工程建议 C++ 里不要用 qsort需要排序就用 std::sort(要稳定用 std::stable_sort)。只有在「必须与 C 代码交互」或「元素类型是 POD 且比较器必须运行时决定」 时才考虑 qsort
这条结论也是「泛型编程(编译期多态)优于运行时多态」在性能上的经典例证: C++ 模板用编译期展开换来了内联机会,而 C 的函数指针付出了运行时间接调用的代价。

12.8 比较排序的下界:为什么至少需要 Ω(n log n)

这是本章、也常常是整个《数据结构》课程里最漂亮的一个证明。 它回答的问题看起来简单得可疑:「排序 n 个数,最少要比较多少次?」 而这个问题的答案,解释了为什么我们折腾了这么多算法,却始终跳不出 O(n log n) (除非放弃比较)。

12.8.1 决策树模型:把任何比较排序画成一棵树

先明确我们要证明的对象:比较排序(comparison sort)—— 指的是「算法在运行过程中,获取信息的唯一手段是比较两个元素的大小」。 它不允许读取元素的值本身(比如不能把元素当下标用),也不允许做哈希。 冒泡、插入、选择、希尔、堆、归并、快排都属于这一类。

现在关键的一步抽象:把算法的全部执行过程画成一棵二叉树。规则是:

这棵树就叫决策树(decision tree)。它有两个必须记住的性质:

① 决策树是二叉树(每个内部结点恰好两个孩子);
对算法的每一次具体运行,其比较次数 = 从根走到该叶子的路径长度
③ 算法在最坏情况下的比较次数 = 树的高度 h
为什么「每个结点恰好有两个孩子」很重要 因为比较只有两种结果。哪怕算法设计得再聪明, 它也无法在一次操作中获得「三选一」的信息——这正是二叉树性质的来源, 也是后面 2^h ≥ n! 这个不等式的根源。
(顺带一提:如果某次比较的两种结果会导致完全相同的后续行为,那两个分支可以合并, 但这只会让树更小、叶子更少,不会让证明失效——我们用的是「最多」这个上界。)

12.8.2 叶子数必须 ≥ n!:6 个排列一个都不能少

现在的推理链条只有三步,每一步都极其自然:

  1. n 个元素一共有 n! 种不同的排列(输入)。 比如 3 个元素有 3! = 6 种排列:abc, acb, bac, bca, cab, cba
  2. 排序算法必须能正确处理每一种输入。不同输入的正确输出是不同的: 输入 abc 应该输出 abc,输入 cba 应该输出 abc (注意——排序的输出是值的有序序列,但我们关心的是「哪个原始元素去了哪个位置」这个排列)。 因此算法至少需要 n! 种不同的「执行路径」来区分这 n! 种输入。
  3. 一棵高度为 h 的二叉树最多有 2^h 个叶子。(这是二叉树的基本性质: 第 k 层最多 2^k 个结点,所以叶子数 ≤ 2^h。)

把三步串起来:既然算法必须能区分 n! 种输入,就必须有至少 n! 个叶子; 而高度 h 的二叉树最多只有 2^h 个叶子,于是:

如果叶子数不够会怎样?那就意味着有两种不同的输入走到同一个叶子、得到同一个输出—— 而这两种输入的正确输出不同,所以算法必然在其中一种上给出错误答案。 这就是「叶子不够就一定有解被漏掉」的严格含义。

12.8.3 3 元素决策树:最少 3 次比较

拿最小的情况亲手验证一下。3 个元素 a, b, c 共有 3! = 6 种排列。 如果要证明「2 次比较够不够」,只需看:h = 2 的二叉树最多 2² = 4 个叶子,而我们需要 6 个——装不下。 所以 2 次比较必然不够,至少需要 3 次(2² = 4 < 6 ≤ 8 = 2³)。 下面这棵树同时也构造性地证明了 3 次就够了(所以 3 是最优的)。

<(真)≥(假) < < < < < < a < b ? b < c ? a < c ? a < c ? a < c ? b < c ? b < c ? a b c a c b b a c b c a c a b c b a a c b a b c 叶子 1叶子 2 叶子 3叶子 4 叶子 5叶子 6 重复(可与叶子 2 合并) 从这棵树读出的三个结论 ① 叶子数:3! = 6 种排列各有归属(最右两个虚线叶子是「补齐」出来的重复,合并后恰好 6 个)→ 叶子数 = n! 是不可少的 ② 树高:根到叶子恰好 3 条边 → 最坏情况需要 3 次比较;而 2^2 = 4 < 6,所以 2 次绝不可能 ③ 一般化:要放下 n! 个叶子,树高必须满足 2^h ≥ n!,两边取对数得 h ≥ log₂(n!)
图 12-9 3 元素决策树:6 个叶子、树高 3,印证 2^h ≥ n!(2² = 4 < 6 ≤ 8 = 2³)

下面这个动画从叶子往根逐步搭出这棵树,并走一条具体路径(输入 a=2, b=1, c=3):

考点:下界计算的三种问法
  • 「3 个元素排序至少需要几次比较?」答 3 次。推理:2² = 4 < 3! = 6 ≤ 2³ = 8, 所以 h ≥ 3。
  • 「5 个元素呢?」5! = 1202⁶ = 64 < 120 ≤ 128 = 2⁷,所以至少 7 次。
  • 「n 个元素的下界是多少?」⌈log₂(n!)⌉ = Ω(n log n)。 注意写答案时通常写「Ω(n log n) 次比较」,若要求具体整数则用 ⌈log₂(n!)⌉

12.8.4 用 Stirling 公式与积分放缩证明 log₂(n!) = Ω(n log n)

2^h ≥ n! 出发,两边取以 2 为底的对数(对数是单调递增的,不等号方向不变):

证法一:积分放缩(最适合手写答卷)

log₂ x 是单调递增函数,因此对每个 i ≥ 1log₂ i ≥ ∫_{i−1}^{i} log₂ x dx(在 [i−1, i] 上,被积函数处处 ≤ log₂ i)。 把 i = 1..n 加起来:

n ≥ 4n log₂ n − 1.4427n ≥ (1/2)·n log₂ n (因为此时 log₂ n ≥ 2.885)。所以

证法二:Stirling 公式(结论更精确)

Stirling 公式给出 n! ~ √(2πn) · (n/e)^n,即 n! = √(2πn)·(n/e)^n·(1 + O(1/n))。两边取以 2 为底的对数:

由于 log₂ e ≈ 1.4427 是常数,主项就是 n log₂ n,所以 log₂(n!) = n log₂ n − Θ(n) = Θ(n log n)。于是:

顺手记住几个数值(考试常拿来出小题)
nn!下界 ⌈log₂(n!)⌉用 2^h ≥ n! 验证实际最优比较次数
2212¹ = 2 ≥ 2 ✓1(一次比较即够)
3632² = 4 < 6 ≤ 8 = 2³ ✓3(已最优)
42452⁴ = 16 < 24 ≤ 32 = 2⁵ ✓5(已最优)
512072⁶ = 64 < 120 ≤ 128 = 2⁷ ✓7(已最优)
6720102⁹ = 512 < 720 ≤ 1024 = 2¹⁰ ✓10
75040132¹² = 4096 < 5040 ≤ 8192 = 2¹³ ✓16(信息论下界达不到)
840320162¹⁵ = 32768 < 40320 ≤ 65536 = 2¹⁶ ✓19
注意 n = 7 那一行:下界说「至少 13 次」,但已知最优算法需要 16 次—— 下界不一定是可达的。这正好说明 Ω 记号是「不可能更好」的证明,而不是「一定能做到」的构造。

12.8.5 平均情况的下界与信息论表述

上面的结论是最坏情况的。平均情况的下界同样成立,而且更强: 对决策树中所有叶子的深度按「每种输入等概率(各 1/n!)」求平均,得到平均比较次数。 可以证明(用「给定叶子数时,二叉树的外部路径长度最小值在完全平衡时取到」这一事实):

也就是说,即使按平均情况算,比较排序也至少需要 Ω(n log n) 次比较。 这一点很多人会记错(以为「平均下界比最坏下界松」)——实际上二者同阶: 因为决策树的叶子数就固定是 n!,一棵有 n! 个叶子的二叉树, 其平均深度必然至少是 log₂(n!)(平衡时最小)。

信息论的一般表述:把这个证明抽象出来,就是信息论里的一个标准论证:

要在 N 种等可能的结果中确定唯一一个,任何「每次只有 k 种可能输出」的判定过程
至少需要 ⌈log_k N⌉ 次判定。
排序问题里 N = n!(n! 种可能的输入排列),每次比较只有 k = 2 种结果,
所以下界是 ⌈log₂(n!)⌉ = Ω(n log n)

这个「信息论下界」的框架可以套用到很多问题上,是一把万能钥匙: 只要你能数出「可能的答案有多少种(N)」和「一次操作最多能提供多少信息(k)」, 就能立刻给出 Ω(log_k N) 的下界。例如:

问题可能结果数 N一次操作的信息量 k下界结论
比较排序 n 个数n!2(一次比较两种结果)⌈log₂(n!)⌉Ω(n log n),且归并/堆排可达
在 n 个数中找最大值n(谁是最大)2⌈log₂ n⌉下界是 ⌈log₂n⌉,但实际需要 n−1 次(锦标赛法证明 n−1 才是紧的)
在 n 个已排序数中查找n+1(n 个位置 + 不存在)3(<、=、>)⌈log₃(n+1)⌉折半查找 O(log₂ n) 已是最优阶
同时找最大和最小n(n−1)2约 ⌈1.5n⌉ 附近锦标赛配对法恰好 ⌈3n/2⌉ − 2 次,达到下界
易错:找最大值的下界是 ⌈log₂n⌉,但答案是 n−1,矛盾吗? 不矛盾。下界只是「不可能比它更少」的必要条件,未必可达。 找最大值的紧确下界需要额外论证:每个非最大元素都必须在某次比较中「输过」, 否则它就有可能是最大值;而一次比较最多让一个元素「输」, 所以要产生 n−1 个失败者,至少需要 n−1 次比较。 这个论证比信息论下界更强,得到的才是紧的下界。 考试时如果问「找最大值至少需要几次比较」,答案必须是 n−1,不能写 ⌈log₂n⌉。

12.8.6 为什么基数排序、计数排序能突破这个下界

这是整个下界理论里最常考的辨析点,一定要能脱口而出。

一句话答案 Ω(n log n) 的下界是对「比较排序」证明的,它的前提是「算法获取信息的唯一手段是比较」。 基数排序、计数排序不做元素之间的比较,而是直接利用关键字的「值域结构」(把值当下标、按位分桶), 所以证明的前提不成立,下界对它们无效。它们付出的代价是:需要对关键字做更强的假设 (必须是整数或可拆分的定长表示)、需要额外的 O(n + k) 空间, 并且复杂度里出现了值域 k 或位数 d——代价并没有消失,只是换了一种形式。

把三种算法放在决策树模型下对照,就能看清本质差别:

对比维度比较排序(快排/归并/堆排)计数排序基数排序(LSD)
决策树模型是否适用适用:每个结点是一次二值比较不适用:没有「比较结点」,只有「按下标取数」不适用:每一位是「按值分桶」,一次操作有 r 种结果
一次基本操作的输出种数 k2(真 / 假)k(值域,可直接索引)r(基数,分桶)
下界形式Ω(log₂(n!)) = Ω(n log n)无此下界无此下界
实际复杂度O(n log n)O(n + k)O(d(n + r))
对数据的额外要求只要元素可比较(最弱假设)必须是 [0,k) 的整数,且 k 不能太大必须能拆成 d 位、每位 r 种取值
额外空间O(1) ~ O(n)O(n + k)O(n + r)
稳定性归并稳定,快排/堆排不稳定稳定(倒序放置)LSD 稳定
什么时候反而更慢k ≫ n 时(比如 10 个数排 10⁹ 的值域)d 很大时(比如 64 位随机整数)
答题陷阱:不要说「基数排序比快排快」 正确的表述是:基数排序在「关键字可拆分为定长位串且值域可控」时能达到线性,此时才快于比较排序; 在通用场景下它并不比快排快dr 的常数惩罚 + O(n) 额外空间 + 缓存不友好)。 一句话总结:下界没有被推翻,只是被绕过了;绕过的门票是对数据结构的额外假设。

12.9 稳定性的深入剖析

12.9.1 形式化定义

「稳定」这个说法很口语化,先把它定义清楚:

稳定性的形式化定义 设待排序序列中有两个元素 ab,它们的关键字相等(key(a) = key(b)), 且在排序前的序列中 a 位于 b 之前
若排序后在结果序列中 a 仍然位于 b 之前(对任意这样的 a, b 都成立), 则称该排序算法是稳定的(stable);否则是不稳定的(unstable)
注意「任意」二字很关键:只要存在一对反例数据使次序颠倒,这个算法就是不稳定。

为什么会有「相等元素」这个概念?因为在真实数据里,元素很少只有一个字段。 一个学生记录有姓名、学号、分数;一次交易有金额、时间、流水号。 排序时我们指定的主关键字(比如分数)往往有重复值, 而重复值之间其实是有区别的——它们的区别就是「原来谁在前」。 稳定性要保住的正是这个区别。

12.9.2 什么场景下「必须」稳定

① 多关键字排序(最重要的场景)

要按「主关键字 + 次关键字」排序时,可以先按次关键字排一遍,再用稳定排序按主关键字排一遍。 第二次排序不会打乱第一次的成果,于是等价于按 (主要, 次要) 排序。 这个技巧在 12.9.3 会给出严格证明。

② 数据库 ORDER BY 多列

SQL 的 ORDER BY a, b 语义上要求「a 相同则按 b」。 如果数据库内部只对 a 做了一次不稳定排序, 同一 a 值内部的顺序就变成了「任意」,可能破坏「先按 b 排好」的前置步骤。

③ 保持「原顺序」的可解释性

比如日志按时间排序、表格按某列排序。用户期望「同分的记录保持原来的先后」, 否则每次点击排序按钮结果都在跳变,体验很差。

④ 归并外部排序

外部排序要分很多趟做 k 路归并,每趟归并都必须稳定, 否则前面几趟好不容易维持的次序会在某一趟被打乱(这一点与基数排序 LSD 的要求完全相同)。

12.9.3 用多关键字排序证明「稳定」的价值

下面这个命题值得完整写一遍,因为它同时说明了两件事:稳定性是什么、 以及「先次后主 + 稳定排序」为什么等价于「按组合关键字排序」。

命题:设元素有关键字对 (k₁, k₂),其中 k₁ 为主关键字。 若先用稳定排序按 k₂ 升序排列,再用稳定排序按 k₁ 升序排列, 则最终序列恰好是按 (k₁, k₂) 字典序升序排列。

证明:取最终序列中任意相邻的两个元素 a(在前)与 b(在后), 只需证明 (k₁(a), k₂(a)) ≤ (k₁(b), k₂(b))

  • 情形一:k₁(a) < k₁(b) 第二次排序按 k₁ 排,小者在前,所以 ab 前 ✅ 与假设一致。
  • 情形二:k₁(a) = k₁(b) 此时要看 k₂。因为第二次排序是稳定的, 且 ab 的关键字 k₁ 相等, 所以它们在第二次排序之前的相对次序被完整保留了下来, 即「在第一次排序(按 k₂)之后,a 就在 b 之前」。 而第一次排序是按 k₂ 升序的,排序后 ab 之前 意味着 k₂(a) ≤ k₂(b)(若 k₂(a) > k₂(b)b 必然在前,矛盾)✅。
  • 情形三:k₁(a) > k₁(b)不可能,因为第二次排序后大的 k₁ 不会在前面。

证毕。注意证明中「第二次排序稳定」这一条必不可少—— 如果第二次用的是不稳定的快排,情形二就断了:k₁ 相等时次序可能被任意打乱, 第一次按 k₂ 的努力全部作废。

反过来用:这就是「基数排序 LSD 为什么必须稳定」的同一个定理 基数排序 LSD 把「按数值排序」拆成「先按个位、再按十位、再按百位……」,每一位都是一次 「按某个关键字排序」。上面的命题说明:只要每一次子排序都是稳定的, 最终结果就等价于按 (百位, 十位, 个位) 的字典序排序, 而这就是按数值大小排序。所以基数排序的正确性完全依赖子过程的稳定性, 这也回扣了 12.5.1 节那个考点。

12.9.4 19 种排序的稳定性逐个分析与反例

背结论容易忘,看懂反例才记得牢。下面这张表给出每个不稳定排序的具体反例数据。 记号约定:5a 表示「值为 5、且在原序列中排在 5b 前面」的元素。

不稳定排序的反例数据(每个都是最小反例,记住它们就能随时自证) ① 简单选择排序:[5a, 5b, 2] 原始:[5a, 5b, 2] 第 1 轮:最小是 2(下标 2),与下标 0 的 5a 交换 → [2, 5b, 5a] 第 2 轮:剩下 [5b, 5a],已经有序,不动 结果:[2, 5b, 5a] —— 5a 与 5b 次序颠倒 ✗ 根源:交换的是「下标 0」与「最小元素下标 2」, 这是一次**跨越 5b** 的远距离交换, 5a 被扔到了 5b 的后面。 规律:只要「换位跨越了元素」,就不稳定。 ② 快速排序:[3a, 3b, 1](以首元素 3a 为枢轴) 原始:[3a, 3b, 1] 枢轴 pivot = 3a 从右往左找 < pivot 的元素:找到 1(下标 2) 把 1 换到最左 → [1, 3b, 3a],枢轴 3a 落到下标 2 结果:[1, 3b, 3a] —— 3a 与 3b 次序颠倒 ✗ 根源:Hoare 划分把元素从「最右」搬到「最左」, 3a 在被搬到末尾的途中跨过了 3b。 注意:即使改成「以末元素为枢轴」也仍然不稳定, 因为划分的跳跃性交换是快排的本质。 → 快排不可能通过小改实现稳定。 ③ 堆排序:[2a, 2b, 1](建大顶堆后取最值) 建大顶堆:[2a, 2b, 1](2a 是根,2b 是左孩子,1 是右孩子) 取根 2a 与末尾 1 交换 → [1, 2b, 2a],堆大小减 1,再筛选 剩余 [1, 2b] 调整后取出 2b,最后是 1 结果:[1, 2b, 2a] —— 2a 与 2b 次序颠倒 ✗ 根源:取最值时把「根」与「堆末尾」交换, 这两个位置相距很远;而且筛选过程中, 相等元素可能被换到任意一边(孩子选择用 < 还是 ≤ 都会破坏一种情形)。 → 堆排序的空间优势(O(1))正是它不稳定的代价。 补充:堆排序的比较次数固定,与初始序列无关,但移动次数与序列有关。
图 12-10 四个经典不稳定排序的最小反例(选择、快速、堆排序)
④ 希尔排序的不稳定:[2a, 2b, 1a, 1b],取增量 gap = 2 再 gap = 1 原始序列 2a 2b 1a 1b 按 gap = 2 分成两组:(2a, 1a) 与 (2b, 1b) gap = 2 组内插入排序后 1a 1b 2a 2b 注意:1a 与 2a 交换了位置 —— 这是一次跨越 2b 的交换 gap = 1 直接插入排序后 1a 1b 2a 2b 此时已基本有序,gap = 1 只做少量相邻调整,2a 仍在 2b 前面 这个例子说明什么? 希尔排序的不稳定性来自「gap > 1 时的组内插入」:不同组的元素被跨越式地互换,相等元素的相对次序可能被打乱。 要构造出「结果里 2a 跑到 2b 后面」的反例,只需让某次 gap > 1 的组内插入把 2a 搬到 2b 之后。不同增量序列下反例数据略有差异,但结论一致:希尔排序不稳定
图 12-11 希尔排序的不稳定:gap > 1 的跨组插入会打乱相等元素的相对次序
排序算法稳定?判据(为什么)最小反例 / 稳定原因
直接插入排序稳定只在严格逆序(>)时后移,相等元素不跨越相等时停住,新元素插在相等元素之后
折半插入排序稳定定位写成「找第一个 > key 的位置」,相等时插在后面实现细节决定;若写成 ≥ 就变成不稳定
表插入排序稳定沿链找「第一个 > 新元素的结点」再挂上去相等元素保持原链序
2-路插入排序稳定等于界值的元素固定往一侧插若把「等于」分到两侧则会不稳定(实现相关)
希尔排序不稳定gap > 1 时组内插入跨越了其它元素[2a,2b,1a,1b],gap=2 后 1a 跨过 2b
冒泡排序稳定只交换相邻且严格逆序的元素相等元素永不互换
鸡尾酒排序稳定同上,只是方向交替相邻交换
奇偶排序稳定同上,只是把比较对分成奇偶两组相邻交换
快速排序不稳定划分时 i、j 相距很远,交换具有跳跃性[3a,3b,1][1,3b,3a]
梳排序不稳定gap > 1 时比较相隔 gap 的元素并可能交换[2a,2b,1],gap=2 时 1 与 2a 交换
简单选择排序不稳定最小值与边界元素「远距离交换」[5a,5b,2][2,5b,5a]
堆排序不稳定根与堆末尾远距离交换,且筛选中相等元素可左右任选[2a,2b,1][1,2b,2a]
锦标赛排序不稳定重赛时相等元素谁晋级取决于树形与补齐方式相等时「取左」只保证当次,取走后另一个上升会插入前面
归并排序稳定合并时 L[i] <= R[j] 取左段,相等时左边优先这是归并代码里最容易写错、也最关键的一个等号
原地归并(手摇)不稳定整块交换会跨越两块的边界,跨越边界的相等元素次序被打乱[2a | 1, 2b] 手摇后 2a 会跑到 2b 后面
基数排序 LSD稳定每一位的分配与收集都保持原顺序子过程(计数排序)倒序放置保证稳定
基数排序 MSD不稳定按高位分桶后各子序列独立处理,桶间的次序无法保证实现相关;若每个桶内都用稳定排序并保持桶内原序则可稳定
计数排序稳定倒序放置:后出现的先占高位,先出现的留在低位改成正序扫描就会变成不稳定(见 12.5.1)
桶排序稳定分桶按原序追加 + 桶内用稳定排序(插入排序)两个条件缺一不可(桶内若用快排就不稳定)

12.9.5 如何把不稳定排序改成稳定

有三种实用手段,按推荐程度排序:

  1. 加「原始下标」作为次关键字(最实用)。 给每个元素附带它在原数组中的下标 idx,比较规则改成: 主关键字不等时比主关键字,相等时比 idx(小的在前)。 这样一来所有元素的比较结果都不相等(idx 唯一), 排序结果被唯一确定,而「idx 小者在前」正好就是稳定性的要求。
    代价:每个元素多一个字段,比较多一次分支。用 std::sort + 这个技巧, 通常比直接换 std::stable_sort 更快。
  2. 换一个稳定的算法。需要稳定又要 O(n log n),就用归并排序std::stable_sort、Timsort 都是这个路线)。 如果数据基本有序、规模不大,直接用插入排序或冒泡也行。
  3. 把排序过程「捆绑」成整体。(key, 原始序号) 打包成一个复合关键字, 整体参与比较与移动。这与第 1 种本质相同,只是实现上更彻底(比如包装成 structpair)。
考点:「加下标」为什么能变稳定? 因为稳定性问题的根源是「相等元素之间没有区分度」,算法可以对它们任意排列。 一旦加入唯一的 idx 作为次关键字,就不存在「相等」的元素对了—— 每一对元素都有确定的先后。此时任何正确的排序算法给出唯一的结果, 而这个结果恰好满足「原下标小者在前」,也就是稳定的定义。
注意:这个方法要求排序算法是正确的(对全序能排对),但不要求它本来稳定。

12.10 综合对比、选型决策与外部排序

12.10.1 排序算法综合大对比表

这张表是本讲的核心产出,把 22 种排序放在同一组维度下横向比较。 建议对照图 12-1 的五大体系一起看:同一族的算法,在「稳定性」「是否原地」上往往有一致的规律。

算法体系最好平均最坏空间 稳定比较?原地?比较 / 移动次数特点适用场景一句话记忆点
直接插入插入O(n)O(n²)O(n²)O(1) 比较与移动都随初始有序度变化;最坏各 n²/2小规模、基本有序、在线插入「抓扑克牌,往左插」
折半插入插入O(n log n)
(比较)
O(n²)O(n²)O(1) 比较 O(n log n);移动仍 O(n²)比较代价远高于移动代价的场合「找位置用二分,搬家还得一趟趟搬」
表插入插入O(n²)O(n²)O(n²)O(n) 移动 0;比较 O(n²);丢失随机存取元素极大、移动极贵的特殊场合「不搬元素,只改指针」
2-路插入插入O(n²)O(n²)O(n²)O(n) 移动约 n²/8(约 1/4)教学对比,说明常数优化「循环数组,两头长」
希尔排序插入O(n log n)≈O(n^1.3)O(n²)O(1) 不稳 比较与移动都依赖增量序列中等规模、内存受限、不需要稳定「先粗后细的插入排序」
冒泡排序交换O(n)O(n²)O(n²)O(1) 比较固定 n(n−1)/2(无 flag);移动 = 逆序对数教学、极小规模「相邻比较,大的冒上去」
鸡尾酒交换O(n)O(n²)O(n²)O(1) 对「最值在错误一端」的数据趟数大减两端有跑偏元素的近有序数据「冒泡来回走」
奇偶排序交换O(n)O(n²)O(n²)O(1) 比较次数同冒泡,但同组比较可并行GPU / SIMD / 并行硬件「奇偶两拨交替,天然并行」
快速排序交换O(n log n)O(n log n)O(n²)O(log n)~O(n) 不稳 平均约 1.39n log n 次比较;最坏 n²通用内存排序(工程首选)「选个枢轴,小的左大的右」
梳排序交换O(n log n)≈O(n log n)O(n²)O(1) 不稳 gap ÷1.3 递减;比冒泡少很多交换简单实现、不想写快排时「带齿距的冒泡」
简单选择选择O(n²)O(n²)O(n²)O(1) 不稳 比较固定 n(n−1)/2;移动最少 ≤ n−1移动代价极高(元素巨大)时「每轮挑最小,换到前面」
堆排序选择O(n log n)O(n log n)O(n log n)O(1) 不稳 比较次数与初始序列无关;移动约 n log n内存受限 + 需要最坏保证(含 top-k)「唯一最坏 O(n log n) 且 O(1) 空间」
锦标赛选择O(n log n)O(n log n)O(n log n)O(n) 不稳 建树 n−1 次;之后每次重赛 ⌈log₂n⌉ 次需要反复取最值的场合、外排序「胜者树,取走冠军只重赛一条路」
归并排序归并O(n log n)O(n log n)O(n log n)O(n) 比较次数稳定在 ⌈n log n⌉ 附近,与数据无关要求稳定、链表、外部排序「唯一稳定 + 最坏 O(n log n)」
原地归并归并O(n²) 移动O(n²) 移动O(n²) 移动O(1) 不稳 用 O(块长) 的翻转换取 O(1) 空间内存极度受限的小数据「三次翻转换位置」
多路归并 + 败者树归并O(n log k)O(n log k)O(n log k)O(k) 每输出一个元素 ⌈log₂k⌉ 次比较外部排序、k 个有序流合并「败者留在结点上,冠军单独存」
计数排序基数O(n+k)O(n+k)O(n+k)O(n+k) 零次元素比较;三次线性扫描小值域整数(评分、年龄、桶内排序)「值当下标,倒序放置才稳定」
桶排序基数O(n)O(n)O(n²)O(n+m)
(桶内比)
代价取决于数据分布;均匀时每桶 O(1)[0,1) 均匀分布、外部数据分块「均匀就线性,集中就平方」
基数排序 LSD基数O(d(n+r))O(d(n+r))O(d(n+r))O(n+r) d 趟,每趟 O(n+r);总 O(d(n+r))定长关键字(整数、日期、等长串)「低位优先,靠稳定性子过程累积」
基数排序 MSD基数O(d(n+r))O(d(n+r))O(d(n+r))O(n+r) 不稳 可提前结束;递归实现,桶数多时内存压力大变长字符串字典序「高位优先,分而治之」
内省排序
(std::sort)
混合O(n log n)O(n log n)O(n log n)O(log n) 不稳 快排常数 + 插排收尾 + 堆排兜底C++ 通用排序的默认答案「快排为主,插排收尾,堆排保底」
Timsort
(Python/Java)
混合O(n)O(n log n)O(n log n)O(n) 识别 run:有序段直接跳过,几乎零比较真实世界数据(大量局部有序)「先找有序段,再归并」

说明:比较? 列表示「是否属于比较排序」;原地? 列表示「空间是否为 O(1)」(或仅递归栈)。 「移动次数」在部分教材里按「元素赋值」计,在部分教材里按「交换」计,比较不同教材数据时要注意口径。

12.10.2 排序算法选择的决策流程

面试和实际开发里,比「背复杂度」更重要的是「拿到问题能立刻定位到该用哪个」。 下面这张流程图给出了一条最常用的决策路径: 数据量 → 是否要求稳定 → 数据分布 → 是否要求最坏保证 → 选定算法

要排序,怎么选? ① 数据量 n 有多大? 先看规模,规模决定要不要上高级算法 n ≤ 50 左右 n 较大(几千以上) 直接插入排序 / 折半插入排序 小规模时插排常数最小、稳定、代码短 实际标准库也是这么做的(小区间转插排) ② 要求稳定吗? 多关键字 / ORDER BY / 保持原序 → 要稳定 要稳定 不要求稳定 ③ 关键字是「可控值域的整数」吗? 值域 k 与 n 同阶、或能拆成定长位 → 可以走非比较路线 是(小值域整数) 否(通用可比较对象) 计数排序 / 桶排序 / 基数排序 LSD O(n+k) 或 O(d(n+r)),稳定,但需要 O(n+k) 额外空间 前提:k 不能远大于 n;必须能拆位 如果值域爆炸(如 10⁹),乖乖回去用比较排序 归并排序 / Timsort / std::stable_sort 稳定 + 最坏 O(n log n),代价是 O(n) 额外空间 链表排序、外部排序的首选(不需要随机存取) 若数据大量局部有序(日志、追加表),Timsort 更快 ④ 要最坏保证吗? 对抗性输入 / 实时系统 → 必须 O(n log n) 兜底 否则可以赌平均性能 要最坏保证 不需要 内省排序(std::sort)/ 堆排序 快排的实测速度 + 堆排的最坏保证;堆排额外空间 O(1) 快速排序(加三数取中) 平均最快,常数最小 ⑤ 别忘了这些「非算法」因素: 元素移动代价(大对象考虑指针排序)、内存是否够(够就归并,不够就堆排/快排)、缓存局部性(顺序访问优于跳跃访问)、能否并行(奇偶排序/并行归并)、数据是否在磁盘上(外部排序 + 败者树)。
图 12-12 排序算法选型决策流程图:数据量 → 稳定性 → 值域 → 最坏保证 → 选定算法

竞赛场景

  • 默认std::sortsort(a, a+n)),写起来最短、最坏有保证。
  • 要稳定:几乎不用 stable_sort(慢),改用「结构体里加 id 字段」的排序规则。
  • 值域小(≤10⁶ 量级):计数排序能省掉 log 的常数。
  • 要排名/去重:排序后加 unique,或直接上 nth_element 求第 k 小。
  • 只需要前 k 小:用 nth_element 或堆,别全排序。

工程场景

  • 通用std::sort;需要稳定语义就用「加下标」的写法。
  • 大对象:排序 vector<int> 下标(或 unique_ptr)而不是排对象本身。
  • 海量数据:外部排序(分块 + k 路归并 + 败者树)。
  • 多字段排序:优先「稳定排序 + 从次到主的顺序」,或直接写复合比较器。
  • 已有大量局部有序:Python 直接用 list.sort()(Timsort)即可。

教学 / 考试场景

  • 要能手写:插入、冒泡、选择、快速、归并、堆(这六个是核心)。
  • 要能推过程:希尔(增量序列)、堆(建堆与筛选)、快排(一趟划分)、归并(一趟结果)。
  • 要能算下界:决策树 + 2^h ≥ n!,n = 3/4/5 的结论要背下来。
  • 要能判稳定:记「跨越式交换/插入 → 不稳定」这一条规律。
  • 要能答辨析:折半插入为什么还是 O(n²)、基数排序为什么能突破下界。

12.10.3 外部排序专节:内存装不下的数据怎么排

一句话本质:外部排序的瓶颈不是 CPU 而是磁盘 I/O, 所以它的全部设计目标都是「让读写磁盘的次数尽量少」。

设文件有 n 条记录,内存一次只能装 m 条。经典的两阶段方案是:

  1. 阶段一:生成初始归并段。把文件分成 n/m 块,每块读进内存排序后写回磁盘, 得到 ⌈n/m⌉ 个有序的「归并段(run)」。
  2. 阶段二:多趟 k 路归并。每次把 k 个归并段合并成一个更长的归并段, 反复进行直到只剩一个。归并时用败者树从 k 个段的首元素里 O(log k) 取最小。

为什么用 k 路而不是 2 路?设初始归并段有 m 个,则归并趟数是 S = ⌈log_k m⌉;每趟要把 n 条记录完整读一遍、写一遍, 所以总 I/O 量 = 2n·⌈log_k m⌉。k 出现在底数上,所以增大 k 能显著减少趟数。

外部排序流程图:8 个初始归并段,用 4 路归并只需 2 趟(2 路归并要 3 趟) 无序大文件 n 条记录 阶段一:分块读入内存 每块内部排序(快排/堆排) 置换选择可让段长 ≈ 2m 8 个初始归并段(各自有序) R1 有序 R2 有序 R3 有序 R4 有序 R5 有序 R6 有序 R7 有序 R8 有序 第 1 趟:4 路归并(R1~R4) S1:长度 ≈ 4m 的有序段(由 R1~R4 合并而成) 第 2 趟:4 路归并(R5~R8) S2:长度 ≈ 4m 的有序段(由 R5~R8 合并而成) 第 3 趟:2 路归并(S1 + S2,不足 k 路也没关系) 最终有序文件(8m ≈ n 条记录) k 路 vs 2 路:为什么外排序一定要「多路」 · 2 路归并 8 个段需要 ⌈log₂8⌉ = 3 趟,总 I/O = 6n; · 4 路归并 8 个段需要 ⌈log₄8⌉ = 2 趟,总 I/O = 4n; · k 体现在对数的底数上,k 越大趟数越少、I/O 越省 但 k 增大会让「从 k 个段里选最小」的代价上升:朴素扫描 O(k),败者树 O(log k)。所以 k 通常取几十到几百,配合败者树使用。 置换选择(replacement selection) 目标:让初始归并段更长,从而减少归并趟数。 做法:内存里维护一个大小为 m 的最小堆① 从堆中输出最小值 x,同时读入下一条记录 y; ② 若 y ≥ x,则 y 属于当前归并段,插入堆继续; ③ 若 y < x,说明 y 比 x 小,它不可能出现在当前段的后   面,于是把 y 放进「待用区」,暂时不参与本轮输出; ④ 堆空时当前段结束,把待用区的 m 条记录重新建堆,开新段。 效果:段平均长度 = 2m(2 倍内存),最坏也有 m。
图 12-13 外部排序:置换选择生成初始归并段 + k 路归并(附败者树选择器)

置换选择为什么能生成长度约 2 倍内存的归并段

这是外部排序里最有趣的一个结论,很多人第一次看会觉得很神奇。 置换选择(replacement selection)的过程是:

  1. 先把 m 条记录读进内存,建成一个最小堆m 为内存能容纳的记录数)。
  2. 输出堆顶的最小值 x(写到当前归并段),然后从磁盘读入下一条记录 y
  3. 比较 y 与刚输出的 x
    • y ≥ xy 可以排在 x 后面,属于当前归并段,把它放进堆的根位置并向下筛选。
    • y < xyx 小,不可能出现在当前归并段里(当前段是递增输出的), 把它暂存到内存的「待用区」,本轮不参与输出。堆的有效大小减 1。
  4. 当堆中「有效元素」全部输出完,当前归并段结束。把待用区的记录重新建堆,开始下一个归并段。
为什么平均段长是 2m? 关键在于:新读入的记录 y 有大约一半的概率比当前输出的 x 大 (在随机数据下,y 落在「已输出部分之后」的概率约为 1/2), 于是它能继续留在当前段里。这样平均要读入约 2m 条记录, 才会积满 m 条「待用」记录、把堆耗尽。
所以平均段长是 2m用了 m 条记录的内存,却生成了 2m 条记录的初始归并段——凭空「多」出了一倍的有序数据。 这就是置换选择相比「直接内部排序分块」的优势:
  • 直接分块:段长 = m,n/m 个段。
  • 置换选择:平均段长 = 2m,n/(2m) 个段,段数减半, 于是归并趟数 ⌈log_k(n/2m)⌉ 也随之减少,I/O 更省。
注意:如果数据本身就是逆序的(最坏情况),每次新读入的 y 都小于 x, 置换选择退化到段长 = m,和直接分块一样,不会更差——这一点很让人放心。
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
#include <random>
using namespace std;

/* ============================================================
   置换选择(replacement selection)—— 外部排序生成初始归并段的算法
   · 内存里维护一个大小为 m 的最小堆
   · 输出堆顶最小值 x,同时读入下一条记录 y:
        y >= x  → y 属于当前归并段,插入堆
        y <  x  → y 放进「待用区」,本轮不输出
   · 堆中有效元素耗尽 → 当前段结束,把待用区重新建堆,开新段
   · 平均段长约 2m(随机数据),最坏退化为 m
   本程序用内存数组模拟磁盘文件,用来统计平均段长。
   ============================================================ */
struct RunInfo { vector<int> data; };

vector<RunInfo> replacementSelection(const vector<int>& file, int m, long long& reads) {
    vector<RunInfo> runs;
    int n = file.size();
    int next = 0;                                  // 下一条待读入记录在文件中的下标
    reads = 0;

    /* 初始:读入 m 条记录建最小堆 */
    vector<int> heap;
    for (int i = 0; i < m && next < n; ++i, ++next) { heap.push_back(file[next]); ++reads; }
    make_heap(heap.begin(), heap.end(), greater<int>());

    vector<int> frozen;                            // 待用区(属于下一个归并段)
    while (!heap.empty()) {
        RunInfo cur;
        while (!heap.empty()) {
            pop_heap(heap.begin(), heap.end(), greater<int>());
            int x = heap.back();
            heap.pop_back();
            cur.data.push_back(x);                 // 输出 x 到当前归并段

            if (next < n) {
                int y = file[next++];
                ++reads;
                if (y >= x) {
                    heap.push_back(y);             // 可以接在当前段后面 → 回到堆里
                    push_heap(heap.begin(), heap.end(), greater<int>());
                } else {
                    frozen.push_back(y);           // 比 x 小 → 只能等下一段
                }
            }
        }
        runs.push_back(cur);
        /* 当前段结束:把待用区重新建堆,开始下一段 */
        heap.swap(frozen);
        frozen.clear();
        make_heap(heap.begin(), heap.end(), greater<int>());
    }
    return runs;
}

/* 对照:直接分块 + 块内排序(段长固定为 m) */
vector<RunInfo> plainChunks(const vector<int>& file, int m) {
    vector<RunInfo> runs;
    for (int i = 0; i < (int)file.size(); i += m) {
        RunInfo r;
        int e = min((int)file.size(), i + m);
        r.data.assign(file.begin() + i, file.begin() + e);
        sort(r.data.begin(), r.data.end());
        runs.push_back(r);
    }
    return runs;
}

int main() {
    const int N = 1000, M = 50;                    // 1000 条记录,内存只能装 50 条
    vector<int> file(N);
    mt19937 rng(2024);
    for (int i = 0; i < N; ++i) file[i] = (int)(rng() % 100000);

    long long reads = 0;
    vector<RunInfo> r1 = replacementSelection(file, M, reads);
    int total = 0, minLen = N, maxLen = 0;
    for (auto& r : r1) {
        total += r.data.size();
        minLen = min(minLen, (int)r.data.size());
        maxLen = max(maxLen, (int)r.data.size());
        /* 顺便验证:每个归并段内部必须是有序的 */
        if (!is_sorted(r.data.begin(), r.data.end())) { cout << "错误:段内无序!\n"; return 1; }
    }
    cout << "置换选择:段数 = " << r1.size() << ",平均段长 = "
         << (double)total / r1.size() << "(最短 " << minLen << ",最长 " << maxLen << ")\n";
    cout << "理论预期:平均约 2m = " << 2 * M << ",段数约 n/(2m) = " << N / (2 * M) << "\n";

    vector<RunInfo> r2 = plainChunks(file, M);
    cout << "直接分块:段数 = " << r2.size() << ",平均段长 = " << M
         << "(固定)\n";
    cout << "→ 置换选择把段数从 " << r2.size() << " 降到 " << r1.size()
         << ",归并趟数随之减少,I/O 更省。\n";

    /* 最坏情况:逆序文件 */
    vector<int> desc(N);
    for (int i = 0; i < N; ++i) desc[i] = N - i;
    long long rd2 = 0;
    vector<RunInfo> r3 = replacementSelection(desc, M, rd2);
    cout << "逆序文件时:段数 = " << r3.size() << ",平均段长 = "
         << (double)N / r3.size() << "(退化为 m = " << M << ",不会更差)\n";
    return 0;
}

最佳归并树:用赫夫曼思想安排归并顺序

当归并段的长度不相等时(置换选择产生的段长通常不一样), 「先合并哪几个段」会影响总 I/O 量。这时候可以借用赫夫曼树(Huffman tree)的思想来安排。

设各归并段长度为 L₁, L₂, …, L_m,每次归并都要把这些记录读一遍写一遍, 所以总代价 ≈ 2 × Σ (Lᵢ × 该段参与的归并次数)。 这正好就是赫夫曼树里的「带权外部路径长度」:把段长当权值,构造赫夫曼树, 权值小的段先合并(路径长)、权值大的段后合并(路径短),总代价最小。

算例:5 个归并段,长度分别为 9、30、20、15、26(单位:万条记录),做 3 路归并。

朴素做法(按原顺序两两归并): 先合 9+30 → 39;再 39+20 → 59;再 59+15 → 74;再 74+26 → 100。 读写量 ≈ 2×(39 + 59 + 74 + 100) = 2×272 = 544。

最佳归并树做法:让短的段参与更多次归并。3 路归并要求段数满足 (m−1) mod (k−1) = 0,即 (5−1) mod 2 = 0 ✅ 无需补虚段。 按赫夫曼思想,先合并最短的三个:9+15+20 = 44;再 44+26+30 = 100。 读写量 ≈ 2×(44 + 100) = 2×144 = 288

结论:544 → 288,几乎省了一半 I/O。 这就是「最佳归并树」的价值:同样的数据、同样的内存,只是换了个归并顺序,I/O 就少了一半。 (注意:若段数不满足 (m−1) mod (k−1) = 0,需要补若干个长度为 0 的「虚段」凑够路数, 虚段在赫夫曼树上相当于权值为 0 的叶子,不影响总代价。)

12.11 工程视角:真实系统里的排序工程

12.7 节回答了「标准库用什么算法」,12.6 与 12.10.3 节回答了「内存装不下怎么排」,12.9 节讲透了稳定性。 但生产系统里的排序还有一批 12.7 没覆盖的问题:真实数据不是随机数据、有 64 个核却只快 4 倍、 一次不稳定排序会让分页出现重复行与丢失行。本节专补这些空白,最后给出一个反直觉的结论—— 最好的排序优化,往往是根本不排序。

12.11.1 真实数据不是随机的:run 与归并栈的完整机制

12.7.3 节点明了 Timsort 的三个关键词(run、插排扩展 minrun、归并合并)。这里补上「为什么」, 因为每个词背后都藏着一个工程决策。

为什么先找 run,而不是直接分治?因为快排、归并这类分治算法对任何输入都做 O(n log n) 次比较,它们看不见输入里已经存在的秩序。 而真实数据几乎总带着秩序:按时间生成的日志、按自增 ID 写入的表、两个已排序数据源的拼接。 Timsort 先把这些天然有序的连续片段原样识别出来,复杂度于是降到 n·log(run 数)

为什么降序必须写成「严格」递减?升序侧用非降序(a[i+1] >= a[i]), 降序侧用严格递减(a[i+1] < a[i])。若降序侧也允许相等,形如 [5, 5, 5] 的片段会被判成降序 run,反转后三个 5 的次序就颠倒了——稳定性当场丢失。 这与 12.9.4 节「跨越式交换必不稳定」是同一件事的两面。 minrun 则取 32~64:太小则 run 数暴涨,太大则插排的 O(k²) 抬头。

最关键的一点:run 不是随便合的,而是压进一个归并栈按不变量合。 这个栈就是第 03 讲讲的栈:每发现一个 run 就压栈,再检查栈顶三个 run 的长度 X, Y, ZZ 最新)是否满足 Z > Y + XY > X; 违反就把 Y 与「XZ 中较短的那个」合并——各 run 长度于是像斐波那契 一样增长,栈深只有 O(log n)

Timsort 的两阶段:切出天然 run(降序就地反转、过短用插排补齐),再用归并栈按不变量合并 ① run 发现:从左到右扫描一遍,切出 3 个天然 run 3 9 15 22 31 40 27 18 12 6 25 8 44 41 R1 非降序 len=5 R2 严格递减 len=5 R3 递减段 len=2 直接采用 就地反转 → [6,12,18,27,40] 短于 minRun(图示取 4)→ 插排补成 [8,25,41,44] 三个 run 依次压栈 ② 归并栈:每压入一个 run 就检查 Z > Y + X 与 Y > X,违反就合并相邻 run 栈状态①:刚压入 R3 检查 Y ≤ X + Z: 5 ≤ 5 + 4 = 9 → 违反,合并 Y 与 Z R1 len = 5 R2 len = 5 R3 len = 4 ← 栈底 ← 栈顶 栈状态②:R2 + R3 合成长度 9 再检查 Y ≤ X + Z: 5 ≤ 9 → 仍违反,继续合并 R1 len = 5 R2+R3 len = 9 一次归并搬运 9 个元素 栈状态③:全部合并完毕 栈里只剩一个 run 栈深始终不超过 O(log n) 全体有序 len = 14 本例只用 2 次归并 为什么「识别 run」能把复杂度压下去,却仍保得住最坏情况 · 快的原因:归并总代价 ≈ n · log₂(run 数)。本例 14 个元素只有 3 个 run;实测随机数据平均 run 长仅 62(16130 个 run),两段有序拼接则达 50 万(2 个 run)。 · 稳的原因:每个 run 长 ≥ minRun ≥ 16,故 run 数 r ≤ n/16;归并树高 ⌈log₂r⌉ ≤ log₂n,每层搬运量 ≤ n,所以最坏严格 O(n log n),不需要随机化枢轴 · 代价:需要 O(n) 归并缓冲区;随机数据上常数比快排大(实测慢约 1.5 倍);稳定性靠「严格递减才反转」+「归并时相等取左段」守住。
图 12-14 Timsort 的 run 发现与归并栈:降序 run 就地反转,过短 run 用插排补齐,栈按 Z > Y + X、Y > X 两条不变量合并

下面这份代码可直接编译运行,把三件事(run 发现、插排补齐 minrun、归并栈合并)都写全了, 并在四种形态的 100 万条数据上与 std::sort 正面对比。

下面这份代码是一个可以直接编译运行的简化版 Timsort, 把上面讲的三件事(run 发现、插排补齐 minrun、归并栈合并)都写全了, 并在四种不同形态的 100 万条数据上与 std::sort 正面对比。 注意它不是标准库的 Timsort——真实的 Timsort 还额外做了 gallop 模式、 临时缓冲区复用等大量优化,但结构上与这份代码完全一致。

#include <bits/stdc++.h>
using namespace std;

/* ============================================================
   简化版 Timsort:run 发现 + 插入排序扩展 + 归并栈
   ① 扫描天然升序 run;严格降序的 run 就地反转成升序;
   ② 长度不足 minRun 的 run,用二分插入排序补齐;
   ③ 用归并栈按两条不变量合并相邻 run,最后收尾合并。
   最坏 O(n log n)(run 长 >= 16,run 数 <= n/16),最好 O(n),空间 O(n)。
   数组依据:MAXN = 1000005,够放 100 万个元素。
   ============================================================ */

const int MAXN = 1000005;
const int STACK_MAX = 128;      /* 归并栈容量:n <= 1e6 时实际远用不到 40 */

int a[MAXN];                    /* 待排序数组 */
int src[MAXN];                  /* 原始数据副本,供两种排序各跑一遍 */
int buf[MAXN];                  /* 归并缓冲区 */

int runStart[STACK_MAX];        /* 归并栈:各 run 的起点 */
int runLen[STACK_MAX];          /* 归并栈:各 run 的长度 */
int runTop = 0;                 /* 栈中 run 的个数 */
int minRun = 16;                /* 最小 run 长度 */
int runCut = 0;                 /* 统计:本次切出的 run 总数 */

/* ---------- 二分插入:把 a[hi] 插进已有序的 a[lo, hi) ---------- */
void insertOne(int lo, int hi) {
    int key = a[hi];
    int l = lo, r = hi;
    while (l < r) {
        int mid = (l + r) >> 1;
        if (a[mid] <= key) l = mid + 1; else r = mid;
    }
    for (int i = hi; i > l; --i) a[i] = a[i - 1];
    a[l] = key;
}

/* ---------- ① run 发现:返回从 pos 起的 run 长度 ---------- */
int countRun(int pos, int n) {
    if (pos + 1 >= n) return 1;
    int hi = pos + 1;
    if (a[hi] < a[pos]) {                          /* 严格降序:反转为升序 */
        while (hi + 1 < n && a[hi + 1] < a[hi]) ++hi;
        reverse(a + pos, a + hi + 1);
    } else {                                       /* 非降序:一路吃到底 */
        while (hi + 1 < n && a[hi + 1] >= a[hi]) ++hi;
    }
    return hi - pos + 1;
}

/* ---------- ② 不足 minRun 的 run 用插入排序补齐 ---------- */
int nextRun(int pos, int n) {
    int len = countRun(pos, n);
    int want = minRun;
    if (want > n - pos) want = n - pos;
    if (len < want) {
        for (int i = pos + len; i < pos + want; ++i) insertOne(pos, i);
        len = want;
    }
    return len;
}

/* ---------- ③ 归并 [lo, mid) 与 [mid, hi):只搬较短的一侧,搬运量减半 ---------- */
void mergeRuns(int lo, int mid, int hi) {
    if (a[mid - 1] <= a[mid]) return;              /* 天然接得上就整段跳过 */
    int n1 = mid - lo, n2 = hi - mid;
    if (n1 <= n2) {
        for (int i = 0; i < n1; ++i) buf[i] = a[lo + i];
        int i = 0, j = mid, k = lo;
        while (i < n1 && j < hi) {
            if (a[j] < buf[i]) a[k++] = a[j++];    /* 相等取左段,保证稳定 */
            else               a[k++] = buf[i++];
        }
        while (i < n1) a[k++] = buf[i++];
    } else {
        for (int i = 0; i < n2; ++i) buf[i] = a[mid + i];
        int i = mid - 1, j = n2 - 1, k = hi - 1;
        while (i >= lo && j >= 0) {
            if (buf[j] < a[i]) a[k--] = a[i--];    /* 从后往前填,同样保持稳定 */
            else               a[k--] = buf[j--];
        }
        while (j >= 0) a[k--] = buf[j--];
    }
}

/* ---------- 合并栈中第 i 与第 i+1 个 run ---------- */
void mergeAt(int i) {
    int lo = runStart[i], mid = lo + runLen[i];
    int hi = runStart[i + 1] + runLen[i + 1];
    mergeRuns(lo, mid, hi);
    runStart[i] = lo;
    runLen[i]   = hi - lo;
    for (int k = i + 1; k + 1 < runTop; ++k) {     /* 后面的 run 整体前移一格 */
        runStart[k] = runStart[k + 1];
        runLen[k]   = runLen[k + 1];
    }
    --runTop;
}

/* ---------- 归并栈的两条不变量(Timsort 原版规则)----------
   栈自底向上记作 ... X, Y, Z(Z 是栈顶):① Z > Y + X  ② Y > X
   违反①合并 Y 与「X、Z 中较短的那个」,违反②合并 X 与 Y。
   各 run 长度于是像斐波那契一样增长,栈深只有 O(log n)。 */
void mergeCollapse() {
    while (runTop > 1) {
        int n = runTop - 2;
        if ((n > 0 && runLen[n - 1] <= runLen[n] + runLen[n + 1]) ||
            (n > 1 && runLen[n - 2] <= runLen[n] + runLen[n - 1])) {
            if (runLen[n - 1] < runLen[n + 1]) --n;
            mergeAt(n);
        } else if (runLen[n] <= runLen[n + 1]) {
            mergeAt(n);
        } else break;
    }
}

/* ---------- 主过程 ---------- */
void timSort(int n) {
    if (n < 2) return;
    int x = n, r = 0;                              /* minRun 取 n 的高位,落在 [16, 64] */
    while (x >= 64) { r |= (x & 1); x >>= 1; }
    minRun = x + r;
    if (minRun < 16) minRun = 16;

    runTop = 0; runCut = 0;
    int pos = 0;
    while (pos < n) {
        int len = nextRun(pos, n);
        runStart[runTop] = pos;
        runLen[runTop]   = len;
        ++runTop; ++runCut;
        pos += len;
        mergeCollapse();
    }
    while (runTop > 1) mergeAt(runTop - 2);        /* 收尾:把栈里剩下的全部合并 */
}

/* ============================================================
   数据生成、计时与对照实验
   ============================================================ */
unsigned long long seed = 88172645463325252ULL;
int rnd() {
    seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17;
    return (int)(seed >> 33);
}
double nowMs() {
    return (double)chrono::duration_cast<chrono::microseconds>(
               chrono::steady_clock::now().time_since_epoch()).count() / 1000.0;
}
void copyArr(int* dst, const int* s, int n) { for (int i = 0; i < n; ++i) dst[i] = s[i]; }

void genRandom(int n) { for (int i = 0; i < n; ++i) src[i] = rnd(); }
void genSorted(int n) { for (int i = 0; i < n; ++i) src[i] = i; }

/* 基本有序:整体升序,只有约 0.2% 的元素被随机交换过 */
void genNearlySorted(int n) {
    for (int i = 0; i < n; ++i) src[i] = i;
    int swaps = n / 500;
    for (int t = 0; t < swaps; ++t) {
        int i = rnd() % n, j = rnd() % n;
        int tmp = src[i]; src[i] = src[j]; src[j] = tmp;
    }
}

/* 分段有序:seg 段内部升序、段间随机 */
void genSegments(int n, int seg) {
    int len = n / seg;
    for (int s = 0; s < seg; ++s) {
        for (int i = 0; i < len; ++i) src[s * len + i] = rnd();
        sort(src + s * len, src + s * len + len);
    }
    for (int i = seg * len; i < n; ++i) src[i] = rnd();
}

/* 两段有序拼接:两个各自有序的数据源首尾相接 */
void genTwoRuns(int n) {
    int half = n / 2;
    for (int i = 0; i < half; ++i) src[i] = i * 2;
    for (int i = 0; i < n - half; ++i) src[half + i] = i * 2 + 1;
}

void report(const char* name, int n) {
    copyArr(a, src, n);
    double t0 = nowMs();
    timSort(n);
    double t1 = nowMs();
    int runs = runCut, mr = minRun;
    bool ok = true;
    for (int i = 1; i < n; ++i) if (a[i - 1] > a[i]) ok = false;

    copyArr(a, src, n);
    double t2 = nowMs();
    sort(a, a + n);
    double t3 = nowMs();

    printf("%s:简化 Timsort %8.2f ms | std::sort %8.2f ms | 切出 run %7d 个 | 平均 run 长 %8.1f | minRun %2d | %s\n",
           name, t1 - t0, t3 - t2, runs, (double)n / runs, mr, ok ? "有序 OK" : "排序错误");
}

int main() {
    int n = 1000000;
    printf("=== 简化版 Timsort 与 std::sort 对照(n = %d,单位 ms)===\n", n);

    genRandom(n);        report("完全随机", n);
    genTwoRuns(n);       report("两段拼接", n);
    genSegments(n, 256); report("分段有序", n);
    genNearlySorted(n);  report("基本有序", n);
    genSorted(n);        report("完全有序", n);
    return 0;
}

实测(同一台机器,多轮取量级):完全随机时切出 16130 个 run、平均 run 长仅 62, 简化版 Timsort 约 78 ms 对 std::sort 约 50 ms,反而慢约 1.5 倍两段有序拼接只有 2 个 run,约 1.4 ms 对 31 ms,快 20 倍以上完全有序只有 1 个 run,约 0.8 ms 对 5.2 ms。结论很干脆: Timsort 的优势完全来自「输入里有多少现成的秩序」。顺带注意 std::sort 排已有序数据只要随机数据的十分之一时间——它已经在检测输入模式

12.11.2 三段式设计的细节:内省排序为什么取 2⌊log₂ n⌋ 和 16

12.7.1 节已把内省排序(introsort)的三段结构讲透,这里只补三个「为什么恰好是这个数」, 因为工程实现里的魔数从来不是随手写的。

深度上限为什么是 2⌊log₂ n⌋ 而不是 ⌊log₂ n⌋ 因为「深度 ≤ log₂ n」只在每次划分都恰好对半时才成立;真实快排即使枢轴选得不错, 划分比例也常在 1:3 与 3:1 之间摆动,实际深度会明显大于 log₂ n。留一倍的含义是: 只要平均划分不劣于约 1:3,就永远不会触发堆排;一旦超过一倍,几乎只可能是遭遇了 刻意构造的对抗输入。取 2 倍就是「宁可偶尔多做一次不必要的堆排,也绝不让最坏滑向 O(n²)」。

内省排序真正的遗产不是算法,而是范式:把一个「平均快但最坏会崩」的算法、一个 「最坏有保证但平均慢」的算法、外加一个「小规模无敌」的算法拼起来,让它们各在自己最擅长的区间出场。 这个「混合算法」范式后来被复制到字符串匹配、正则引擎、凸包等几乎所有性能敏感领域。 它留下的洞也很清楚——只看递归深度,不看输入是否已经有序,这正是 pdqsort 要补的那一刀。

小区间阈值为什么是 16?因为 缓存行是 64 字节,正好装 16 个 int; 而插排的 O(k²) 与快排递归的固定开销恰好在 k ≈ 8~32 之间交叉, 这段区间实测总时间几乎持平,16 只是「曲线平缓区间里最好记的那个数」。

12.11.3 pdqsort 与 BlockQuicksort:跟分支预测较劲

先看一个反直觉的事实:现代快排的主要开销不是「比较次数」,而是「分支预测失败」。 现代 CPU 流水线有十几到二十级,一次预测失败要清空整条流水线,代价约 15~20 个时钟周期, 而一次比较本身只要 1 个周期。快排内层是两个各带「跳不跳出去」分支的 whilewhile (a[++i] < pivot) {}),在随机数据上分支结果几乎无法预测, 于是分支代价能超过比较代价一个数量级——这就解释了为什么「比较次数相同」的两个快排, 实测速度能差一倍。

无分支划分(branchless partition)怎么把分支去掉? BlockQuicksort(Edelkamp 与 Weiß,2016)把数组切成若干「块」(典型 64 个元素), 先成批算出每块里「哪些位置该往左、哪些该往右」,写成两个小缓冲区里的下标偏移, 再按这两串偏移做交换。关键在于交换循环里「下一个待交换位置」是从缓冲区读出来的, 没有依赖数据的分支,CPU 能靠条件传送(cmov)与乱序执行满速跑下去。

pdqsort = 内省排序 + 四把补刀。它全名 pattern-defeating quicksort,「战胜模式」指的就是专治让快排难堪的输入:

  1. 检测「已有序 / 逆序」并直接返回或反转。pdqsort 花 O(n) 扫一遍就收工, 而内省排序仍做满 n log n 次比较。这也是 第 11 讲快排那一节「已排序输入是最坏情况」的反面。
  2. 用 ninther(九数取中)代替三数取中。取样更多,坏枢轴概率更低。
  3. 大量重复键时切换成把「等于枢轴」的搬到中间的三路划分。 若键只有 3 种取值,普通二路快排要做 O(n²) 的活,三路划分一次就分完。
  4. 深度超限时先「换个随机枢轴再试一次」,还不行才转堆排序, 以免被对抗输入直接骗到常数更大的堆排上。

落地情况:Rust 标准库的 slice::sort_unstable 明确写着自己是 pattern-defeating quicksort; Boost.Sort 直接提供 boost::sort::pdqsort;libstdc++ 的 std::sort 长期是内省排序,近年也已向「检测已有序 + 分块划分」的方向改造——上一小节最后那组数据就是证据。

陷阱:无分支划分不是万灵药
  • pdqsort 仍然不稳定,要稳定结果就用 Timsort / stable_sort, 或按 12.7.2 节的技巧给比较键追加原始下标。
  • 元素比较代价高时收益锐减。若比较两个元素要解析字符串或调用虚函数, 瓶颈就从分支预测搬到了比较本身;此时真正该做的是抽出键单独排 (排 (key, index),而不是排大对象)。
  • 它依赖编译器愿意生成 cmov,优化等级低时可能反而更慢——这类优化必须实测。

12.11.4 多键排序的实现路线,与「不稳定」在生产里的真实代价

12.9.2 与 12.9.3 节讲清了「ORDER BY a, b 为什么需要稳定」及其证明。 这里往下走两步:工程上有哪几条实现路线,以及不稳定到底造成什么后果。

路线做法代价什么时候用它
A. 两遍稳定排序 先按次关键字 b 稳定排,再按主关键字 a 稳定排(12.9.3 节的定理保证正确) 两次完整排序 = 2·O(n log n) 排序键的组合在运行期才知道(例如用户点表头决定按哪几列排)
B. 一次比较多键 写一个比较器:a 不等先比 a,相等再比 b,仍相等再比 c…… 一次 O(n log n),但比较函数更贵(最坏走完整条比较链) 键的组合在查询编译期就固定;绝大多数数据库执行计划走这条
C. 根本不排序 若存在 (a, b) 联合索引,直接顺扫索引的有序叶子,天然就是 ORDER BY a, b 几乎为零:O(n) 顺序 I/O 查询能命中对应索引时——数据库里最划算的一条路

量级感受:1000 万行、每行 200 字节(约 2 GB)的数据走路线 B 约需 10⁷ × 24 ≈ 2.4×10⁸ 次比较,还要几百 MB 缓冲;路线 C 只是顺序扫一遍叶子链表。 加一个索引能把几十秒的查询变成几百毫秒,靠的就是把排序整个消灭掉。

不稳定的代价有四类,每一类都在生产里真实发生过:

三招标准解法,按性价比排序:

  1. 给排序键加「兜底唯一列」,把偏序补成全序。最常见的是在末尾追加主键: ORDER BY score DESC, id ASC。因为 id 唯一,比较结果不再相等, 排序结果就唯一确定了——代价只是每个元素多一次比较。
  2. 该用稳定排序就明确用。Java 的对象数组 Arrays.sort 用 Timsort、 C++ 用 std::stable_sort、Python 的 sorted 天然稳定。
  3. 分页改用游标分页(keyset pagination)。OFFSET 20 换成 WHERE (score, id) < (上一页末行的 score, id)

12.11.5 并行与分布式排序:Amdahl 定律与 MapReduce 的 shuffle

并行排序有一个绕不过去的结构性问题:前半段完美并行,后半段几乎无法并行。 典型做法是「分块各自排 + 归并」:把数组切成 p 块各交给一个核,这一步加速比接近 p; 问题出在归并——2 路并行归并是「两两配对、逐轮减半」,并行度指数衰减, 最后一轮只剩一个线程做 n 次比较。这就是 Amdahl 定律的教科书案例: 若某阶段占总时间比例为 s 且无法并行,则无论加多少核,加速比上限都是 1/s

阶段单核4 核(理想)16 核(理想)说明
分块排序(完美并行)6.0 s1.5 s0.375 s各块独立,加速比 = 核数
并行归并(并行度逐轮减半)4.0 s2.0 s1.2 s并行度衰减,远达不到核数倍
合计 / 实测加速比10.0 s / 1.0×3.5 s / 2.9×1.58 s / 6.3×核数翻 4 倍,加速比只涨 2.2 倍

核数翻 4 倍、加速比只涨 2.2 倍;若归并阶段完全无法并行(s = 0.4),上限就是 1/0.4 = 2.5 倍,再加核也没用。所以并行排序库都把主要精力花在 让归并阶段也并行上。

样本排序(sample sort)是经典答案:随机抽 p·k样本k 常取 100 以上) 并排序;等间隔取 p−1 个作为分裂点,把值域切成 p 段; 各线程按分裂点把自己的块分到 p 个桶,最后各排各的桶。因为分裂点来自随机样本, 每个桶的大小以极高概率落在 n/p 的常数倍以内,不会出现负载倾斜。

把视野放大到集群:MapReduce 的 shuffle 本质上就是一次分布式归并排序。逐阶段对应:

  1. map 端的局部排序。每个 map 把输出 (key, value) 攒在内存缓冲区里,满到某个比例 (Hadoop 默认 80%)就先按 key 排一次序(内部就是快排),再 spill 到本地磁盘; 多个 spill 文件最后被归并成一个「按 key 有序、且已按目标分区切好」的文件。 这就是 12.10.3 节的「生成初始归并段 + 多路归并」,只不过段落落在本地磁盘上。
  2. 分区(partition)。hash(key) mod R 或按 key 的值域范围把记录分给不同 reduce; 值域分区的边界靠抽样确定——这正是样本排序的思路,目的是让各 reduce 的 key 区间互不重叠。
  3. reduce 端的 k 路归并。每个 reduce 从所有 map 节点拉取(shuffle)属于自己的分区文件, 再做 k 路归并k 等于 map 数、可达几百上千。每一路本身已有序,归并一次即得全局有序流 ——这正是 12.6.3 节败者树存在的理由k 上千时朴素扫描每次要上千次比较, 而败者树(一棵完全二叉树,见第 07 讲)只要 ⌈log₂k⌉ ≈ 10 次。

代价在哪里?shuffle 的网络传输与磁盘落盘往往占整个作业总时间的一半以上。 所以 Hadoop / Spark 的调优手段——combiner 预聚合、map 端压缩、调大排序缓冲区—— 绝大多数是在减少 shuffle 的数据量或趟数:在分布式系统里,排序算法只是配角,数据搬运才是主角

12.11.6 外部排序的生产形态:参数到底怎么定

12.6.3 节讲了败者树结构,12.10.3 节讲了置换选择的「平均 2m」结论,这里补最后一个工程问题: 真实排序引擎怎么定 k、怎么定缓冲区、怎么跟硬件打交道。

k 取多少?这是一道「用内存换 I/O」的算术题。归并趟数是 S = ⌈log_k m⌉k 越大趟数越少,但每一路都要一块读缓冲区,所以 k 的上限是内存除以缓冲区大小。 生产实现里单路缓冲区常取 1~8 MB,于是 1 GB 内存可支撑 k = 128~1000——这解释了为什么 真实外排序的 k 常常是几百而不是教科书里的 2 或 4。

双缓冲与预读:让 CPU 和磁盘都不闲着。CPU 等磁盘说明 I/O 没跑满,磁盘等 CPU 说明 CPU 没跑满。 标准解法是给每一路配两块缓冲区(A/B):用 A 做归并的同时后台把下一块读进 B。 这就是第 04 讲队列与流水线思想在 I/O 上的复用。

硬件变了,最优参数也跟着变。机械硬盘(HDD)随机访问约 1 ms 一次、顺序吞吐 200 MB/s 量级, 所以「减少趟数、把随机 I/O 变成顺序 I/O」是压倒性目标,k 越大越好。而 NVMe SSD 随机访问只要几十微秒,随机 I/O 已经不贵,瓶颈开始转向 CPU:败者树的每次重赛、 每条记录的比较与搬移都要花时间,于是引擎反而会主动减小 k这就是工程视角与理论视角最大的不同。

12.11.7 排序在数据库里的真实地位:ORDER BY 只是冰山一角

换个角度:真正的数据系统里排序出现在哪些地方,以及为什么「省掉一次排序」比 「把排序写得更快」更值钱。

操作为什么需要排序不用排序时的替代方案
ORDER BY 语义直接要求有序输出 存在匹配索引时顺扫索引叶子(12.11.4 节路线 C)
GROUP BY 排好序后同键记录必然相邻,顺序扫一遍即可分组,只需 O(1) 的组状态 哈希分组:期望 O(n),但需常驻内存且输出无序
DISTINCT 排序后删掉相邻重复,顺带得到有序结果,还能落盘处理超大数据 哈希去重(取舍见 12.11.8 节)
JOIN(排序归并连接) 两表各按连接键排序后只需一趟归并O(n + m),且能处理不等值连接 与内存不足的大表 哈希连接:等值连接下常数更小,但只支持等值
窗口函数 OVER (PARTITION BY … ORDER BY …) 「分区」要靠排序把同一分区的行聚到一起,「窗口内有序」更是直接要求排序 通常没有替代方案,只能排
UNION(去重语义) 两个结果集合并去重,排序是标准实现路径 UNION ALL 不去重,完全不需要排序——这就是它更快的原因

索引本身就是「预先排好的序」。B+ 树的叶子按键值有序排列、叶子间还有链表,所以「按索引键顺序读」 等于「读一份已经排好序的数据」。这是查询优化器最重要的成本项之一:执行计划里出现 Using filesort(MySQL 的说法)就意味着真的要排序了——要分配排序缓冲、要比较, 数据量大了还要落盘做外部排序;而出现 Using index 或「索引有序扫描」则意味着 这次排序被省掉了

但「走索引省排序」有严格前提,工程上最容易踩三个坑:

12.11.8 「不要排序」往往是最好的优化

12.10.2 节的决策流程里有一句「只需要前 k 小:用 nth_element 或堆,别全排序」。 这句话值得单独展开,因为它是排序工程里回报率最高的一条经验:绝大多数真实需求其实 并不需要全序,只需要偏序、极值或去重;而「不需要全序」就意味着可以省掉一大截工作。

手段一:Top-K 用堆。要从 n 条数据里取最大的 k 条:

但堆不是无条件的赢家:k 接近 n 时,堆要做 O(n log n) 次下沉,反而比一次全排序更慢;经验判据是 k 远小于 n / log n 时才用堆。

手段二:第 k 小 / 中位数用快速选择(quickselect)。它就是快排「只递归有答案的那一边」的版本: 一次划分后若枢轴正好落在 k,答案就找到了;否则只在含第 k 小的那一半里继续找。 因为每次丢掉一半,比较次数的期望值是 3.4n 量级——线性,而不是 n log n。 仍以 n = 10⁸ 为例:全排序约 2.7×10⁹ 次比较,快速选择只要 3.4×10⁸ 左右, 大约快 8 倍。C++ 的 std::nth_element 就是它。

代价与陷阱:快速选择的最坏仍是 O(n²),实用实现必须配随机化枢轴;重复键多时还要三路划分。 若需要「前 k 小且有序」,正确组合是先用快速选择定位、再对小范围排序

手段三:去重时,排序去重与哈希去重怎么选?这是一个「理论复杂度骗人」的典型例子。

维度排序去重哈希去重
时间复杂度O(n log n) 次比较期望 O(n),最坏 O(n²)(冲突)
额外空间O(1)(原地)或 O(n)(归并)O(去重后条数),且必须常驻内存
内存访问模式以顺序访问为主,缓存与预读友好每步 2~3 次随机访问,缓存命中率极低
能否处理内存装不下的数据能:外部排序 + 相邻去重,天然支持落盘不能:哈希表必须常驻内存
输出是否有序有序(往往正是下游需要的)无序
抗攻击性不依赖哈希函数,行为确定可被构造的冲突键打成 O(n²)

经验法则:数据能全部装进内存、也不需要有序输出时用哈希去重; 反之(要落盘、要有序输出、要防冲突攻击)用排序去重。这里正是 第 10 讲哈希表那一节讲的「用空间换时间」在付账。 而当 n 达到 10⁸ 量级时,哈希去重虽然理论上是线性的, 却常常因为缓存未命中而并不比排序去重快。

12.11.9 工程选型对比表

把本节与 12.7 节讨论过的五种「生产级」排序放在一起比较。注意这张表不是12.10.1 节的 算法复杂度表:它比的是工程维度,而「是否稳定」「能否并行」「对真实数据的适应性」往往比复杂度更决定成败。

算法最坏复杂度是否稳定 对真实数据的适应性是否并行典型真实系统
Timsort O(n log n) 稳定 极强:专为「含大量有序 run」设计,最好可到 O(n);随机数据无优势 本身串行,可分块后并行归并 Python sorted、Java 对象数组、Android、V8 的 Array.prototype.sort
introsort O(n log n) 不稳定 一般:不识别已有序输入,排有序数组仍做满 n log n 串行 C++ std::sort 的经典实现、qsort 的高性能替代
pdqsort O(n log n) 不稳定 强:检测已有序 / 逆序 / 大量重复,用无分支分块划分降低分支预测失败 串行 Rust sort_unstable、Boost.Sort 的 pdqsort、libstdc++ 新版 std::sort
并行归并 / 样本排序 理想形式 O(n log n / p + n log p) 可保持稳定(相等时取编号小的块) 中等:分块阶段完美并行,归并阶段并行度逐轮减半(Amdahl) 是:多核 / NUMA TBB parallel_sort、GNU 并行模式、GPU 排序库
外部多路归并 2n·⌈log_k m⌉ 次 I/O(以 I/O 而非比较计) 稳定(相等时优先取段号小的段) 强:唯一能处理「内存装不下」的方案;置换选择把段长提到 2m 可并行归并,但受磁盘 / 网络带宽限制 数据库排序落盘、MapReduce shuffle、日志系统、sort(1) 命令(-S 调内存、-m 归并)
小结:工程选型的四步判断
  1. 先问「真的需要排序吗」。只要前 k 个 → 堆或 nth_element;只要第 k 小 / 中位数 → 快速选择;只要去重 → 哈希或排序去重;数据库里能走索引 → 让索引替你排。 这一步省下的往往比后面三步加起来还多。
  2. 再问「需要稳定吗」。需要 → stable_sort / Timsort,或按 12.7.2 节的技巧给比较键 追加原始下标;不需要 → 放心用 std::sort / sort_unstable。 无论选哪个,跨系统的排序都建议显式加上唯一兜底列
  3. 再问「数据是什么形态」。天然有序片段多 → Timsort 类;数据随机 → 快排家族;值域小 → 计数 / 基数排序(12.5 节);元素比较代价高 → 先抽键,排 (key, index) 再重排
  4. 最后问「装得下吗、有几个核」。装得下 → 内存排序;装不下 → 外部多路归并 + 置换选择 + 败者树, k 按「内存 ÷ 缓冲区」来定;多核 → 分块并行 + 样本排序。
排序的工程优化有四个层次——不排序 > 用对算法 > 用对参数 > 把常数写小,越靠前收益越大。

12.12 本章小结、易错点与自测题

12.12.1 必须记住的十件事

理论部分

  1. 五大体系:插入、交换、选择、归并、基数。判族看「核心动作」。
  2. 折半插入把比较降到 O(n log n),移动仍 O(n²),总时间仍 O(n²)
  3. 表插入把移动降为 0,但比较仍 O(n²),且丢失随机存取。
  4. 2-路插入把移动降到约 n²/8,仍是常数级优化,总时间 O(n²)。
  5. 决策树:内部结点 = 一次比较,叶子 = 一种输出排列;2^h ≥ n! → h = Ω(n log n)。

辨析与应用部分

  1. 3 个元素最少 3 次比较(2² = 4 < 6 ≤ 8 = 2³);5 个元素最少 7 次。
  2. 基数/计数排序能突破下界,因为它们不做元素间比较,而是利用关键字的值域结构
  3. 计数排序必须稳定(要当基数排序的子过程),靠倒序放置实现。
  4. 稳定性规律:跨越式交换/插入 → 不稳定;相邻交换/相邻插入/相等取左段 → 稳定。
  5. std::sort = 内省排序 = 快排 + 小区间插排 + 深度超限转堆排 → 平均快、最坏 O(n log n)。

12.12.2 易错点清单

易错 1:把「比较次数」当成「时间复杂度」 折半插入排序的比较次数是 O(n log n),但时间复杂度是 O(n²)。 时间复杂度衡量的是「比较 + 移动」的总代价,移动这一项没降下来,总数就降不下来。 同理,表插入排序「移动 0 次」也不等于「时间复杂度为 0」。
易错 2:以为「稳定的排序一定更慢」或「快的一定不稳定所以不能用」 稳定与快慢没有必然联系:归并排序既稳定又是 O(n log n)(代价是空间)。 而快排不稳定,但可以用「加原始下标做次关键字」把它变成稳定的效果—— 所以「需要稳定」绝不等于「不能用 std::sort」。
易错 3:认为「基数排序时间复杂度是 O(n),所以它在任何情况下都比快排快」 基数排序是 O(d(n+r))。当 d(位数)或 r(基数)很大时, 常数惩罚可能让它比快排慢得多;而且它额外需要 O(n+r) 空间。 正确表述是:在关键字可拆分为定长位串、值域可控时,它能达到线性。
易错 4:混淆「胜者树」与「败者树」 胜者树的内部结点存赢家;败者树的内部结点存输家、冠军另存 ls[0]。 败者树重赛时路径上访问的结点只有胜者树的一半,所以外排序实现里用败者树。 堆则是「连树都不要」的数组版本。
易错 5:把归并的稳定性写丢 归并排序的稳定性完全依赖一句话:合并时写 if (L[i] <= R[j]) 取 L; 而不是 <。 如果写成 <,相等时会去取右段元素,稳定性立刻丢失。 这是手写归并最容易扣分的地方。
易错 6:希尔排序的复杂度说成 O(n log n) 希尔排序的复杂度取决于增量序列,不能一概而论。 Hibbard 增量是 O(n^1.5),Sedgewick 增量可到 O(n^1.3) 左右, 而某些增量序列最坏仍是 O(n²)。考试中写「约 O(n^1.3),最坏 O(n²)」最稳。
易错 7:以为「外部排序的时间复杂度是 O(n log n)」 外部排序的主要成本是 I/O 次数,通常用「读写磁盘的次数」而不是「比较次数」来衡量: 总 I/O = 2n·⌈log_k m⌉。分析外部排序时如果只谈 CPU 复杂度,就没有抓住重点。
易错 8:桶排序说成「稳定且总是 O(n)」 桶排序的线性复杂度只在数据近似均匀分布时成立;数据集中时退化为 O(n²)。 稳定性也有前提:分桶必须按原顺序追加、桶内必须用稳定排序,两个条件缺一不可。

12.12.3 考点速记(note exam 合集)

考点清单(本章高频)
  1. 下界证明的完整链条:决策树 → 内部结点是二值比较 → 叶子 = 输出排列 → 叶子 ≥ n! → 2^h ≥ n!h ≥ log₂(n!) = Ω(n log n)。这条链每一步都要能自己写出来。
  2. n = 3/4/5 的最少比较次数:3、5、7。会算 ⌈log₂(n!)⌉
  3. 基数排序为什么能突破下界:不做比较 + 利用值域结构 + 付出值域/空间代价。
  4. 稳定性判断:给一组数据判断某个排序是否稳定;给一个中间状态判断是哪个算法(见自测题)。
  5. 计数排序的三步与倒序放置的必要性
  6. 折半插入的辨析:比较 O(n log n)、移动 O(n²)、总时间 O(n²)、稳定。
  7. 内省排序的三段组成与各自的理由
  8. 外部排序:为什么 k 路、败者树的作用、置换选择的 2m 结论、最佳归并树的赫夫曼思想。
  9. 「加下标变稳定」的原理:消除相等关系,使结果唯一。
  10. qsort 慢于 std::sort 的原因:函数指针无法内联(最主要)。

12.12.4 自测题(答案折叠)

1. 【决策树下界计算】分别求 n = 4、n = 6、n = 10 时,比较排序在最坏情况下至少需要多少次比较?

用公式 h ≥ ⌈log₂(n!)⌉

  • n = 44! = 242⁴ = 16 < 24 ≤ 32 = 2⁵,所以 h ≥ 5,至少 5 次。 (而且 5 次是可达的,5 个叶子用「先两两比较再插入」的策略即可做到。)
  • n = 66! = 7202⁹ = 512 < 720 ≤ 1024 = 2¹⁰,所以 h ≥ 10,至少 10 次。
  • n = 1010! = 36288002²¹ = 2097152 < 3628800 ≤ 4194304 = 2²², 所以 h ≥ 22,至少 22 次。

顺带记一下渐近值:log₂(n!) ≈ n log₂ n − 1.4427n + O(log n)。 当 n = 10 时:10 × 3.32 − 14.43 ≈ 18.8,加上低阶项后得到 22,量级一致。

2. 【多选】下列排序算法中,哪些是稳定的?(直接插入、折半插入、表插入、2-路插入、希尔、冒泡、鸡尾酒、奇偶排序、快速、梳排序、简单选择、堆、锦标赛、归并、原地归并、计数、桶、基数 LSD、基数 MSD)

稳定的有 11 个:

  • 插入类:直接插入 ✅、折半插入 ✅、表插入 ✅、2-路插入 ✅(这四个的稳定都依赖「相等时插在后面」的写法)
  • 交换类:冒泡 ✅、鸡尾酒 ✅、奇偶排序 ✅(共同点:只交换相邻元素)
  • 归并类:归并排序 ✅(合并时 L[i] <= R[j] 取左)
  • 基数类:计数排序 ✅、桶排序 ✅、基数排序 LSD ✅

不稳定的有 8 个:

  • 希尔(gap > 1 的跨组插入)、梳排序(gap > 1 的比较交换)、快速排序(跳跃式划分)
  • 简单选择(远距离换位)、堆排序(根与末尾交换)、锦标赛排序(重赛次序不定)
  • 原地归并(手摇整块交换跨越边界)、基数排序 MSD(按高位分桶后递归,桶间次序不定)

速记规律:「跨越式交换 / 跨越式插入 → 不稳定」。 注意表插入排序容易被人误判为不稳定(因为它用链表),实际上它沿链找「第一个大于新元素的结点」再插入, 相等元素保持原有链序,所以是稳定的。

3. 【中间状态判断】对序列 [15, 9, 20, 8, 30, 7] 完成某趟排序后得到 [9, 15, 8, 20, 7, 30]。这可能是哪种排序算法?为什么?

观察这组数据的变化:

  • 原来的相邻对:(15,9) 逆序 → 变成 (9,15)(20,8) 逆序 → 变成 (8,20)(30,7) 逆序 → 变成 (7,30)
  • 而跨对的组合 (9,20)(8,30) 等保持了原有的先后。

所以这最可能是「奇偶排序的偶数阶段」或「一趟奇数—偶数交替的冒泡」: 它恰好把下标 (0,1)、(2,3)、(4,5) 这三对相邻元素排好了序,而没有动其它关系。

排除其它算法:

  • 不可能是一趟冒泡:一趟冒泡从左到右依次比较相邻对并连锁交换, 结果应当是最大值 30 被推到最右端,且中间元素会被反复带动。 对 [15,9,20,8,30,7] 做一趟冒泡: [9,15,8,20,7,30] —— 咦,竟然完全一样!

所以更准确地说:这就是「一趟冒泡」的结果,同时也是「奇偶排序偶数阶段(下标 0-1、2-3、4-5)」的结果, 两者在这组数据上恰好一致(因为每一对相邻比较都只发生一次交换,且没有连锁效应)。

可以排除的算法:

  • 不是简单选择排序:一趟选择会把最小值 7 换到首位,结果应以 7 开头,而不是 [9,15,…]
  • 不是直接插入排序:插入排序的前 k+1 个元素必须已经有序。 此处前 2 个是 9,15(有序),前 3 个是 9,15,8(无序), 所以最多是「前 2 个有序」的中间态——但插入排序第 2 轮后应当是 [9,15,20,8,30,7](前 3 个有序), 与给定状态不符,因此不是插入排序的中间态。
  • 不是堆排序:堆排序一趟之后末尾应当是最大值 30 就位,而给定状态末尾确实是 30—— 但堆排序第一趟前需要先建堆,中间状态会呈现「大顶堆」的形状特征,而这里不满足。
  • 不是归并排序:归并的中间态应当是若干个长度相等的有序段拼接, 而给定状态是 [9,15 | 8,20 | 7,30]——这是长度 2 的有序段!所以它也可能是 2-路归并第一趟的结果。

结论:这道题的「标准答案」通常是冒泡排序(一趟)或 2-路归并(第一趟), 也可能是奇偶排序的一个阶段。判别这类题的关键是看「有序段的结构」: 长度 2 的有序段整齐排列 → 归并;最大值就位在最右 → 冒泡/堆排;最小值就位在最左 → 选择/插入。

4. 【证明题】用决策树模型证明:任何比较排序算法在最坏情况下至少需要 Ω(n log n) 次比较。

证明:

  1. 建立决策树。设算法 A 对 n 个元素进行排序。把 A 的所有可能执行过程表示为一棵二叉树 T: 每个内部结点对应 A 执行的一次比较(形如「a < b ?」), 结点的左孩子对应比较结果为真、右孩子对应为假;每个叶子对应 A 输出一个确定的排列。 由于 A 是正确的,每一个叶子上的排列都必须是「正确的排序结果」。
  2. 叶子数至少为 n!。n 个元素的输入一共有 n! 种排列。 对任意两种不同的输入排列,A 的正确输出(作为「哪个元素去了哪个位置」的置换)是不同的, 因此它们不可能终止于同一个叶子(否则该叶子对应的输出对其中一种输入必错)。 所以 T 至少有 n! 个叶子。
  3. 二叉树的高度限制叶子数。深度为 d 的二叉树每层最多 2^d 个结点, 故高度为 h 的二叉树最多有 2^h 个叶子。结合上一步:n! ≤ 2^h
  4. 取对数。两边取以 2 为底的对数,得 h ≥ log₂(n!)
  5. 估计 log₂(n!)。用积分放缩:因 log₂ x 单调递增, 对每个 i 有 log₂ i ≥ ∫_{i−1}^{i} log₂ x dx,求和得
    n ≥ 41.4427n ≤ (1/2) n log₂ n, 所以 log₂(n!) ≥ (1/2) n log₂ n = Ω(n log n)。 (也可直接用 Stirling 公式 n! ~ √(2πn)(n/e)^n 得到 log₂(n!) = n log₂ n − 1.4427n + O(log n) = Θ(n log n)。)
  6. 结论。算法 A 在最坏情况下的比较次数 = 树高 h ≥ log₂(n!) = Ω(n log n)。 由于 A 是任意的比较排序算法,命题成立。∎

补充说明:该证明的关键前提是「每次比较只有两种结果」,即信息论中「一次判定最多提供 1 bit 信息」。 基数排序、计数排序之所以不受此限,是因为它们的基本操作不是二值比较, 而是「按关键字的某一位/某个值直接定位」,一次操作可以有 k 种(或 r 种)结果, 相当于一次获得 log₂k bit 的信息——下界公式随之变成 ⌈log_k(n!)⌉,量级自然下降。

5. 【算法设计】给定 n 个 0~100 之间的整数,要求 O(n) 时间排序且保持稳定。请给出方案并说明为什么不能用快排。

方案:计数排序(值域 k = 101)。

  1. 开一个长度 101 的计数数组 cnt,扫描原数组统计频次:O(n)
  2. 求前缀和:cnt[v] += cnt[v−1],共 O(k) = O(101) = O(1)
  3. 倒序遍历原数组,执行 out[--cnt[a[i]]] = a[i]O(n)

总时间 O(n + k) = O(n)(因为 k = 101 是常数),额外空间 O(n + k), 且稳定(倒序放置保证)。

为什么不能用快排?

  • 快排是 O(n log n),达不到题目要求的 O(n);
  • 更根本的原因是:比较排序存在 Ω(n log n) 的下界(12.8 节已证), 所以任何基于比较的方法都不可能做到 O(n)。要突破这个下界,必须放弃比较, 转而利用关键字的「值域结构」——题目恰好给出了「0~100」这个极小的值域,正是为计数排序准备的。
  • 另外快排不稳定,也不满足题目的稳定性要求(当然可以用「加下标」的技巧补救,但仍达不到 O(n))。

如果值域改成 0~10⁹ 呢?计数排序的空间 O(k) 立刻爆炸(10⁹ 个 int ≈ 4GB), 此时应改用基数排序:把整数按十进制拆成若干位(或用 2⁸ = 256 进制拆成 4 位), 每一位用计数排序处理,时间 O(d(n+r)),空间降到 O(n + r)

6. 【综合分析】某系统要对 5000 万条用户记录按「注册时间」排序,每条记录约 200 字节,可用内存 512MB。请给出完整的排序方案与理由。

第一步:估算数据规模。

  • 数据总量 = 5000 万 × 200 B = 10 GB,远超内存,必须用外部排序
  • 内存 512 MB,其中可用于排序缓冲的按 400 MB 估算,能容纳的记录数 m ≈ 400MB / 200B = 200 万条

第二步:方案选型。

  1. 阶段一 · 生成初始归并段:用置换选择(replacement selection), 内存里维护一个 200 万条记录的最小堆(比较键为注册时间)。 平均可生成 2m = 400 万条 的归并段,于是归并段数 ≈ 5000万 / 400万 ≈ 13 个(若不使用置换选择而是直接分块,则段数约 25 个)。
  2. 阶段二 · k 路归并:取 k = 13(或稍大一些,一次把所有段都纳入), 则只需 1 趟归并即可完成,总 I/O ≈ 2n(读一遍 + 写一遍),这是最理想的情况。 13 路归并用败者树选择最小者,每次输出只需 ⌈log₂13⌉ = 4 次比较, 而不是扫描 13 个候选。
  3. 细节优化:每个归并段配一个读缓冲区(如 1 MB),归并时按块读写, 避免频繁的小 I/O;输出的记录先攒在写缓冲区里,满了再整块落盘。

第三步:为什么这些选择是对的。

  • 为什么不用单机内存排序?10 GB > 512 MB,物理上装不下; 如果用「边读边插」的做法,会产生大量随机 I/O,速度慢几个数量级。
  • 为什么用置换选择?它把段长从 m 提升到平均 2m,段数减半, 直接减少了归并趟数(本例中从 2 趟降到 1 趟),I/O 量也随之减半。
  • 为什么用败者树?k = 13 时朴素扫描每个输出要 13 次比较, 败者树只要 4 次。在 n = 5000 万的情况下,这部分 CPU 开销的差别很可观。
  • 如果段长差异很大怎么办?最佳归并树(赫夫曼思想)安排归并顺序: 短的段先合并、长的段后合并,可把总 I/O 再降低一大截(图 12-13 的算例里从 544 降到 288)。
  • 稳定性问题?若要求「注册时间相同的记录保持原有顺序」, 只需让每个归并段的内部排序稳定、并且 k 路归并时相等时优先取段号小的段,即可整体稳定。 另外可以在比较键里附加「原始文件偏移量」作为次关键字,一次性解决。

一句话总结:这就是「置换选择生成 2m 长的初始段 + k 路归并 + 败者树选择器 + 最佳归并树定顺序」的标准外部排序组合拳。

12.12.5 配套编程练习

练习任务提示 / 验收标准
练习 1实现折半插入排序,统计「比较次数」和「移动次数」,与直接插入排序对照逆序数据下:比较降为约 n log n,移动仍是 n(n−1)/2
练习 2用静态链表实现表插入排序,并统计元素移动次数移动次数应当恒为 0;再测一下「按逻辑序号取第 k 个元素」的耗时
练习 3构造一组数据,使鸡尾酒排序的趟数明显少于冒泡排序;再构造一组使二者相同前者形如「两端跑偏」,后者形如完全随机
练习 4实现梳排序,比较 gap ÷ 1.3 与 gap ÷ 2 在不同 n 上的交换次数n 为 2 的幂时 halving 明显更差
练习 5实现胜者树与败者树,比较二者在 k 路归并中「访问结点数」的差异败者树约为胜者树的一半
练习 6实现稳定版计数排序,并用「值 × 10 + 原下标」的编码验证稳定性把倒序改成正序后,输出中同值元素的下标不再升序
练习 7实现本章的 introsort,人为构造让快排退化的输入,验证它不会退化用「已排序数组 + 首元素枢轴」的经典反例,观察堆排兜底被触发
练习 8写程序验证 ⌈log₂(n!)⌉ 对 n = 3..10 的值,并与已知最优比较次数对比n = 7 时下界 13 但最优是 16,体会「下界未必可达」
练习 9模拟置换选择:用一个最小堆处理 1000 个随机数,内存限 50,统计平均段长平均段长应接近 2m = 100
练习 10实现最佳归并树的算例(9,30,20,15,26 三路归并),比较朴素顺序与赫夫曼顺序的读写量544 vs 288
洛谷 / 在线评测练习建议 在洛谷题库中搜索以下关键词即可找到对应题目(题号请以站内搜索为准,此处不列具体题号以免与站内编号不一致): 「排序 模板」(P1177 类模板题,用来对比不同排序的实际速度)、 「逆序对」(归并排序的经典应用,同时是「排序还能干什么」的最佳例子)、 「第 k 小」nth_element / 快速选择,体会「不需要全排序」)、 「瑞士轮」(归并思想在竞赛中的典型应用)、 「货仓选址」(排序后取中位数)、 「稳定排序」(练习多关键字排序与稳定性)。 第 14 讲洛谷题单会给出更完整的分层练习计划。
与下一讲的衔接 排序这一章到此结束。回头看,你已经拿到了算法设计里最重要的两块拼图: 「分治」(归并、快排)与「下界与信息论分析」(决策树)。 下一讲 第 13 讲 算法设计范式与动态规划 会把「范式」这个视角正式提出来: 分治、贪心、动态规划、回溯、分支限界各自解决什么形状的问题, 以及为什么「最优子结构 + 重叠子问题」是动态规划的入场券。 本章的归并排序将是理解「分治范式」的最好例子,而本章的下界分析则是你将来判断 「这个问题到底能不能做得更快」的第一把尺子。