第 11 讲

八大排序算法图解(上)

「把一堆数据排好序」是计算机里被做得最多的一件事:数据库的 ORDER BY、操作系统的进程调度、 搜索引擎的相关性排序,底层都是排序算法。本讲把冒泡、简单选择、直接插入、希尔、堆、归并、快速、基数 这八种经典排序一次讲透——每一种都有一句话本质、手推示例、可单步播放的动画、完整 C++ 实现, 以及「它到底稳不稳定、什么时候会退化」的真相。

预计 180 分钟 前置:第 07 讲树与二叉树(堆排序要用完全二叉树)、第 01 讲大 O 分析 关键词:稳定性 · 逆序对 · 分治 · 堆 · 增量序列 · 基数
本章导读
  • 11.1 排序的地基:稳定性的严格定义、内部排序与外部排序、评价指标,以及本章的统一约定。
  • 11.2–11.4 三种 O(n²) 的「笨办法」:冒泡、选择、插入。它们慢,但插入排序是工业级排序的基石, 必须吃透。
  • 11.5 希尔排序:给插入排序装上「缩小增量」的加速器。
  • 11.6 堆排序:本节最难也最值钱——建堆为什么是 O(n)、下标关系怎么推、Top-K 怎么套。
  • 11.7 归并排序:分治的教科书范例,顺手解决「统计逆序对」这个高频考点。
  • 11.8 快速排序:为什么它最快、为什么有序数据会把它打回 O(n²)、三路划分怎么救场。
  • 11.9 基数排序:不用比较也能排序,以及它和计数排序、桶排序的关系。
  • 11.10 八大算法综合对比表 + 选择决策流程图。
  • 11.11 工程视角 —— 生产系统到底怎么排序:标准库选的算法(introsort / Timsort / 双轴快排)、 小区间插入排序阈值、快排的复杂度攻击、稳定性与多键排序、100 GB 数据的外部排序、堆的真实身份是优先队列。
  • 11.12 易错点 + 考点清单 + 5 道自测题。
怎么用这一讲 每一节的结构完全一样:一句话本质 → 手推示例(静态图)→ 交互动画 → 完整 C++ 代码 → 复杂度与稳定性 → 易错点。 看动画时务必先自己在纸上推一遍再点播放,否则看的时候全都懂、合上电脑全都忘。八段动画合计约 900 帧, 建议按「单步」一步步走,边走边念出当前在比较哪两个元素。

11.1 排序的地基:概念、稳定性与评价指标

11.1.1 什么是排序

排序(sorting)的形式化定义是:给定含有 n 个记录的序列 {R1, R2, …, Rn},它们对应的关键字为 {K1, K2, …, Kn},排序就是要确定一种排列 p1, p2, …, pn,使得这些关键字满足 Kp1 ≤ Kp2 ≤ … ≤ Kpn(升序)或 (降序)。

这里有一个初学者常常忽略的细节:被排序的是「记录」,被比较的是「关键字」。一个学生记录可能同时有 学号、姓名、成绩三个字段,我们可以按学号排,也可以按成绩排。所以讨论排序时,永远要问一句: 「按哪个关键字排?」而一旦关键字相等,麻烦就来了——这就引出了本讲第一个、也是最容易被考倒的概念: 稳定性。

另一个必须分清的分类是内部排序(internal sorting)外部排序(external sorting)。 如果待排序的记录全部能装进内存,排序过程不需要访问外存,就叫内部排序;如果记录多到内存一次装不下, 排序过程中必须反复在内存与外存之间搬运数据块,就叫外部排序。本讲讲的八种全是内部排序, 但归并排序的思想可以直接升级成外排序的主力(11.7.5 会讲),这也是它虽然「额外空间 O(n)」却依然不可替代的原因。

11.1.2 稳定性:一个必须掰开揉碎的定义

稳定性(stability)的定义:假设待排序序列中有两个记录 RiRj,它们的关键字相等(Ki = Kj), 且在排序之前 Ri 排在 Rj 之前(即 i < j)。 如果排序之后 Ri 仍然排在 Rj 之前, 则称这个排序算法是稳定的;否则称它不稳定

请把这句话读三遍。它的关键在「关键字相等的两个记录,排序前后相对次序是否改变」—— 只有相等的时候才谈稳定性,关键字不相等时谁前谁后是排序本身决定的,谈不上稳不稳定。

为什么要在乎这个?看一个真实场景:

为什么稳定性很值钱 一张成绩单已经按「姓名」排好了序,现在要求再按「班级」排序,并且希望同班同学之间保持原来的姓名顺序。 如果用的排序算法是稳定的,那么按班级排完之后,同班内部姓名依然有序,一步到位; 如果算法不稳定,同班内部的姓名顺序就被打乱了,只能重新按「班级 + 姓名」双关键字排序。
换句话说:稳定排序可以「多趟排序、关键字从次要到主要依次进行」,这就是基数排序能成立的根本前提(见 11.9)。
排序前:两个关键字为 5 的记录,用 5a、5b 区分它们出现的先后 5a 3 5b 1 5a 出现在 5b 之前,这就是「原本的相对次序」 ✓ 稳定排序的结果:5a 仍在 5b 之前 1 3 5a 5b 冒泡、插入、归并、基数都属于这一类 ✗ 不稳定排序的结果:5a 与 5b 的相对次序被交换了 1 3 5b 5a 选择排序、希尔排序、堆排序、快速排序属于这一类 关键字相等 ≠ 位置可以随便换:只要相等的元素互相跨过了,算法就不稳定。
图 11-1 稳定性的含义:关键字相等的两个记录,排序前后相对次序是否保持不变

11.1.3 评价排序算法的四个维度

同一批数据,用不同的排序算法处理,代价可能差好几个数量级。我们主要看四个维度:

维度含义为什么要关心
时间复杂度比较次数 + 移动(交换)次数的数量级;要分最好 / 平均 / 最坏三种情况 最坏情况决定了系统的「最大延迟」,工程上比平均情况更重要
空间复杂度除输入数组外额外占用的辅助空间 O(1) 叫原地排序(in-place);归并排序的 O(n) 在内存紧张时是硬伤
稳定性见 11.1.2 多关键字排序、需要保留原始次序的业务场景
比较与移动次数同为 O(n²),系数可能相差一倍;移动代价远大于比较时(记录很大),移动次数最关键 选择排序的比较次数永远固定,但交换次数最多 n−1 次,这就是它在「记录巨大、交换昂贵」时的价值

这里要特别强调一句话:「时间复杂度相同」不等于「性能相同」。冒泡、选择、插入都是 O(n²), 但插入排序在基本有序的数据上接近 O(n),而选择排序无论数据长什么样都要做 n(n−1)/2 次比较。 堆排序、归并排序、快速排序都是 O(n log n),但快排的实测速度通常是堆排序的两三倍—— 因为常数不同、对 CPU 缓存的友好程度不同。所以「复杂度」是下限,不是全部

关于「比较次数下界」的一个预告 基于关键字比较的排序算法,最坏情况下至少需要 ⌈log2(n!)⌉ 次比较, 也就是 Ω(n log n)。这条下界告诉我们:堆排序、归并排序已经是「比较类排序」的最优量级了, 想再快就必须换思路——基数排序正是靠「不比较」绕开了这条下界。这个结论的严格证明(决策树模型) 放在第 12 讲,本讲先记住结论。

11.1.4 本章统一约定

升序
所有演示与代码一律按从小到大排列。降序只需把比较符号反过来,不重复讲。
0 基下标
数组下标从 0 开始,长度为 n 的数组下标范围是 [0, n−1]。 注意与国内教材常见的 1 基伪代码(A[1..n])换算。
就地排序
除特别说明外,算法直接修改传入的数组,额外空间尽量 O(1)。
区间写法
[l, r] 表示闭区间,两端都包含,长度为 r − l + 1A[l..r] 表示这一段子数组。
「一趟」
指算法外层循环执行一次所做的事。冒泡的一趟 = 从左到右扫一遍;选择的一趟 = 选出并安放一个最小值; 插入的一趟 = 安放一个元素。
逆序对
下标 i < jA[i] > A[j] 的一对元素。 逆序对的总数就是「这个序列离有序有多远」的度量,也是插入排序移动次数的下界(11.7.6 详解)。

11.2 冒泡排序:最直观,也最容易被低估

11.2.1 一句话本质与手推示例

冒泡排序 = 反复扫描未排序区间,相邻两两比较,逆序就交换,每轮把当前最大值「冒」到区间末尾

冒泡排序(bubble sort)是所有排序里最容易讲明白的一个。它的动作只有两个:比较相邻元素逆序就交换。由于每一轮从左到右扫描时,较大的元素总会被一路向右换过去, 所以一轮结束时,未排序区间里最大的那个元素一定站在了区间的最右端——就像水里的气泡浮到水面。

下面拿 A = [5, 2, 9, 1, 7, 3] 手工推第 1 轮(n = 6,未排序区间 [0, 5]):

  1. 比较 A[0]=5A[1]=2:5 > 2,交换 → [2, 5, 9, 1, 7, 3]
  2. 比较 A[1]=5A[2]=9:5 ≤ 9,不动 → [2, 5, 9, 1, 7, 3]
  3. 比较 A[2]=9A[3]=1:9 > 1,交换 → [2, 5, 1, 9, 7, 3]
  4. 比较 A[3]=9A[4]=7:9 > 7,交换 → [2, 5, 1, 7, 9, 3]
  5. 比较 A[4]=9A[5]=3:9 > 3,交换 → [2, 5, 1, 7, 3, 9]

第 1 轮结束,最大值 9 停在 A[5],它的位置再也不会变了。第 2 轮只在 [0, 4] 里扫描,把 7 送到 A[4]……如此重复 n−1 轮即可全部有序。 注意一个细节:每一轮只需要扫描到 n−1−轮数 为止, 因为后面那些位置已经是就位的最大值了,再比就是浪费。

第 1 轮扫描:最大值 9 像气泡一样一路向右浮到末尾 5 2 9 1 7 3 5 > 2 → 交换 2 5 9 1 7 3 5 ≤ 9 → 不动 2 5 1 7 3 9 9 一路换到末尾,位置就此确定(绿色) 优化:如果某一趟从头到尾没有发生任何交换,说明序列已经有序,可以直接收工 1 2 3 4 5 本趟 4 次比较全部「不交换」→ 立即结束,最好情况 O(n) 注意:只有带 swapped 标志的优化版才有 O(n) 的最好情况;不带标志的版本最好情况仍然是 O(n²)。
图 11-2 冒泡排序的一趟扫描与「无交换则提前结束」的优化

11.2.2 交互动画:看清每一次比较与交换

下面的动画用 A = [5, 2, 9, 1, 7, 3, 8, 4, 6, 0] 演示全过程。请重点盯住画面左上的 轮次 / 比较次数 / 交换次数 / swapped 标志四个数字,以及格子下方的 已就位区间标记——它是冒泡排序「每一轮确定一个最终位置」的直接证据。 观察一下 0 这个最小值:它一开始躺在最后一个位置,每一轮只能往前挪一格,这恰好说明了冒泡排序最坏情况下的「慢」。

看完动画后请回答两个问题:① 第 1 轮做了多少次比较?② 整个过程中 swapped 标志有没有变成过 false? 答案分别是 9 次和「没有」——因为 0 需要 9 轮才能挪到最前面,每一轮都至少发生了一次交换。 这也解释了为什么动画里没能演示出「提前结束」:想看到那个效果,得换成几乎有序的数据。

11.2.3 完整 C++ 实现(基础版 + 优化版 + 变体)

下面的代码给出了三个版本:bubbleBasic 是原始版本,bubbleSort 加了 swapped 标志,bubbleBackward 是从后往前扫描的变体 (它每轮把最小值送到最前面,效果等价)。三个版本都输出比较与交换次数,方便你对照动画里的数字。

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

/* ============================================================
   冒泡排序(升序)
   核心:反复扫描未排序区间,相邻两两比较,逆序就交换
   ============================================================ */

/* 版本 1:原始冒泡。无论数据是否有序,都要老老实实跑满 n-1 轮,
   比较次数恒为 n(n-1)/2,最好情况也是 O(n^2)。 */
void bubbleBasic(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    cmp = swp = 0;
    for (int i = 0; i < n - 1; ++i) {          // 一共 n-1 轮
        for (int j = 0; j < n - 1 - i; ++j) {  // 后面 i 个已经就位,不用再比
            ++cmp;
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
                ++swp;
            }
        }
    }
}

/* 版本 2:带 swapped 标志的优化版。
   某一趟从头到尾没有发生交换 → 说明序列已经有序,立即结束。
   于是「已经有序」的输入只需 1 趟 n-1 次比较,最好情况降为 O(n)。 */
void bubbleSort(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    cmp = swp = 0;
    for (int i = 0; i < n - 1; ++i) {
        bool swapped = false;                  // 本趟是否发生过交换
        for (int j = 0; j < n - 1 - i; ++j) {
            ++cmp;
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
                ++swp;
                swapped = true;
            }
        }
        if (!swapped) break;                   // 本趟无交换 → 提前收工
    }
}

/* 版本 3:从后往前扫描的变体。
   每轮把「未排序区间的最小值」冒到区间最前面,效果与版本 2 等价。
   有些教材用它来配合「正向 + 反向交替」的鸡尾酒排序(cocktail sort)。 */
void bubbleBackward(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    cmp = swp = 0;
    for (int i = 0; i < n - 1; ++i) {
        bool swapped = false;
        for (int j = n - 1; j > i; --j) {      // 从右往左
            ++cmp;
            if (a[j - 1] > a[j]) {
                swap(a[j - 1], a[j]);
                ++swp;
                swapped = true;
            }
        }
        if (!swapped) break;
    }
}

/* 顺带记录一个「本趟是否有序」的更强优化:
   记录最后发生交换的位置 lastSwap,下一趟只需扫到 lastSwap 即可,
   因为 lastSwap 之后的元素本趟已经确认有序。 */
void bubbleWithLastSwap(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    cmp = swp = 0;
    int bound = n - 1;                         // 本趟需要扫描的右边界
    while (bound > 0) {
        int lastSwap = 0;
        for (int j = 0; j < bound; ++j) {
            ++cmp;
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
                ++swp;
                lastSwap = j;                  // 记住最后一次交换的位置
            }
        }
        bound = lastSwap;                      // 后面都排好了,缩小边界
    }
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}

int main() {
    vector<int> raw = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};
    long long cmp = 0, swp = 0;

    vector<int> a = raw;
    bubbleSort(a, cmp, swp);
    cout << "优化版冒泡   : "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";   // 45 次比较、25 次交换

    a = raw;
    bubbleBasic(a, cmp, swp);
    cout << "原始版冒泡   : "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";   // 恒为 45 次比较

    /* 最好情况对比:已经有序的输入 */
    vector<int> sorted = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
    a = sorted; bubbleSort(a, cmp, swp);
    cout << "有序数据·优化版:比较 " << cmp << " 次,交换 " << swp << " 次\n"; // 9 次比较、0 次交换
    a = sorted; bubbleBasic(a, cmp, swp);
    cout << "有序数据·原始版:比较 " << cmp << " 次,交换 " << swp << " 次\n"; // 45 次比较

    /* 最坏情况:完全逆序 */
    vector<int> rev = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
    a = rev; bubbleSort(a, cmp, swp);
    cout << "逆序数据·优化版:比较 " << cmp << " 次,交换 " << swp << " 次\n"; // 45 次比较、45 次交换

    a = raw; bubbleBackward(a, cmp, swp);
    cout << "从后往前变体 : "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";

    a = raw; bubbleWithLastSwap(a, cmp, swp);
    cout << "lastSwap 优化: "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";
    return 0;
}

11.2.4 复杂度、稳定性与易错点

情况比较次数交换次数时间复杂度典型输入
最好n − 1(仅优化版)0O(n)(优化版)/ O(n²)(原始版)已经升序
平均约 n(n−1)/4约 n(n−1)/4O(n²)随机排列
最坏n(n−1)/2n(n−1)/2O(n²)完全逆序

空间复杂度 O(1)(只用了一个临时变量做交换),稳定——因为交换的条件写的是 a[j] > a[j+1]不是 :只有严格逆序才交换, 于是关键字相等的两个元素永远不会互相跨过,相对次序天然保持不变。

易错点:把 > 写成 ≥ 就不稳定了 很多同学随手写成 if (a[j] >= a[j+1]) swap(...),这一改,冒泡排序立刻变成不稳定: 对 [5a, 5b, 3],第一趟比较 5a 与 5b 时会因为「相等」而交换,把 5b 换到了 5a 前面, 相对次序被破坏。教科书上写 > 不是笔误,而是稳定性的保证。
易错点:内层循环的边界 内层写成 j < n - 1 会让每一轮都扫到底,虽然结果仍然正确(因为后面部分已有序, 不会再发生交换),但比较次数会固定成 (n−1)²,比最优的 n(n−1)/2 多出近一倍。 正确写法是 j < n - 1 - i
考点:冒泡排序的「中间状态」特征 考题常给一个序列问「这是哪种排序算法某一趟之后的结果」。冒泡排序的判别特征是: 末尾必定有若干个已经就位的最大值,且它们严格递增;同时序列中最大的那个数一定在它最终的位置上。 例:[2, 1, 4, 3, 5, 7, 9] 末尾的 7, 9 已就位,最大数 9 在最后 —— 大概率是冒泡的一趟结果。

11.3 简单选择排序:交换次数最少的 O(n²) 算法

11.3.1 一句话本质与手推示例

简单选择排序 = 每一轮在未排序区间里挑出最小值,与区间第一个位置交换一次,然后区间右缩一格

选择排序(selection sort)和冒泡排序的想法很接近,都是「每一轮确定一个元素的最终位置」, 但动作完全不同:冒泡是边走边换,一轮可能换很多次;选择排序是先看完整轮、记住最小值的下标, 最后只换一次。这个差别带来一个非常实用的性质:交换次数最多 n−1 次

A = [5, 2, 9, 1, 7, 3] 手推一遍:

  1. 第 1 轮:未排序区间 [0, 5]。先假设 minIdx = 0(值 5), 依次比较 A[1]=2(更小,minIdx = 1)、A[2]=9(不小)、 A[3]=1(更小,minIdx = 3)、A[4]=7A[5]=3(都不小)。 共比较 5 次,得到最小值下标 3。交换 A[0]A[3][1, 2, 9, 5, 7, 3]
  2. 第 2 轮:区间 [1, 5],最小值是 A[1] = 2,本来就在位,不交换
  3. 第 3 轮:区间 [2, 5],最小值是 A[5] = 3,交换 A[2]A[5][1, 2, 3, 5, 7, 9]
  4. 第 4 轮:区间 [3, 5],最小值 A[3] = 5 在位,不交换。
  5. 第 5 轮:区间 [4, 5],最小值 A[4] = 7 在位,不交换。排序完成。

比较次数:5 + 4 + 3 + 2 + 1 = 15 = n(n−1)/2,一次都没少;交换次数:只有 2 次。 这就是选择排序的画像——比较一点都不省,但搬运极其节省

11.3.2 交互动画

下面的动画用 A = [5, 2, 9, 1, 7, 3, 8, 4, 6, 0] 演示。请留意两个指针: 橙色 j 是扫描指针,蓝色 min 是「目前见过的最小值」。 特别注意 min 的移动方式——它是跳跃式的,而且每次移动都只改一个下标变量,数组一个元素都没动。

11.3.3 为什么「交换次数最多 n−1 次」很值钱

先明确「交换」和「比较」的代价差异。比较两个关键字通常只是读两个数、做一次减法; 而交换两个记录意味着要搬动整条记录(可能几百字节)。如果记录很大、或者交换涉及写磁盘, 一次交换的代价可能是几万次比较。在这种场景下,选择排序的「n−1 次交换上限」就成了硬指标。

把它和冒泡对比一下:同样是 n = 1000 的逆序数据,冒泡排序需要 n(n−1)/2 ≈ 50 万次交换,而选择排序最多 999 次。差 500 倍。 所以老教材里常有一句结论:「当记录本身很大、移动代价远高于比较代价时,选择排序反而优于冒泡排序。」

顺带记住:选择排序的比较次数是固定的 无论输入是「已经有序」还是「完全逆序」,选择排序的比较次数永远是 n(n−1)/2。 因为它必须把未排序区间整个看一遍,才能知道谁最小 —— 这一点和冒泡(有优化版 O(n) 最好情况)、 插入(最好 O(n))形成鲜明对比。考试里非常爱考这个「铁打的比较次数」。

11.3.4 不稳定性:一个反例讲透

选择排序不稳定。很多同学不理解:明明只是「选出最小值换到前面」,怎么会破坏次序?关键就在 「交换」是长距离的——它会把一个元素越过一大段,其中包括与它关键字相等的元素。

取序列 [5a, 5b, 2](5a 与 5b 关键字相同,5a 在 5b 前面)。 第 1 轮:最小值是 A[2] = 2,把 A[0]A[2] 交换, 得到 [2, 5b, 5a]。看,5a 被换到了 5b 后面,相对次序翻转了,算法不稳定。

再强调一遍它的机理:交换发生在「区间首」与「最小值所在位置」之间,这两个位置可能相隔很远, 中间夹着的相等元素就被无情地跨过了。归并排序之所以稳定,是因为它只做「相邻段的顺序搬运」, 从不长距离跳跃。

选择排序不稳定的经典反例:序列 [5a, 5b, 2] ① 初始状态 5a 5b 2 最小值是 2(下标 2) ② 交换 A[0] 与 A[2] 2 5b 5a 5a 被一步跨到了 5b 后面 → 次序翻转,不稳定 对比:插入排序遇到相等元素时「不移动」,所以它是稳定的 插入排序的条件写 a[j] > key(严格大于才右移),相等就停下 —— 相等的元素永远不会互相跨越。
图 11-3 选择排序不稳定的原因:长距离交换会跨越关键字相等的元素

11.3.5 完整 C++ 实现与复杂度

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

/* ============================================================
   简单选择排序(升序)
   每轮在未排序区间 [i, n-1] 中选出最小值,与 A[i] 交换
   比较次数恒为 n(n-1)/2;交换次数最多 n-1 次
   ============================================================ */
void selectionSort(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    cmp = swp = 0;
    for (int i = 0; i < n - 1; ++i) {
        int minIdx = i;                        // 先假设 A[i] 最小
        for (int j = i + 1; j < n; ++j) {
            ++cmp;
            if (a[j] < a[minIdx]) minIdx = j;  // 只记下标,不动数组
        }
        if (minIdx != i) {                     // 最小元素已在原位就不必交换
            swap(a[i], a[minIdx]);
            ++swp;
        }
    }
}

/* 变体:每一轮同时找最小值和最大值,从两端向中间收缩。
   比较次数仍是 O(n^2),但常数约为原来的一半(n/2 轮,每轮约 n 次比较)——
   这是「锦标赛」式的双向选择排序。 */
void selectionSortBidirectional(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    cmp = swp = 0;
    int lo = 0, hi = n - 1;
    while (lo < hi) {
        int minIdx = lo, maxIdx = lo;
        for (int j = lo; j <= hi; ++j) {
            ++cmp;
            if (a[j] < a[minIdx]) minIdx = j;
            ++cmp;
            if (a[j] > a[maxIdx]) maxIdx = j;
        }
        swap(a[lo], a[minIdx]); ++swp;
        /* 注意:如果最大值原本就在 lo 位置,它已经被换到 minIdx 去了,要修正下标 */
        if (maxIdx == lo) maxIdx = minIdx;
        swap(a[hi], a[maxIdx]); ++swp;
        ++lo; --hi;
    }
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}

int main() {
    vector<int> raw = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};
    long long cmp = 0, swp = 0;

    vector<int> a = raw;
    selectionSort(a, cmp, swp);
    cout << "选择排序     : "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";      // 45 次比较、7 次交换

    /* 已经有序的输入:比较次数一次都不会少! */
    vector<int> sorted = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    a = sorted; selectionSort(a, cmp, swp);
    cout << "有序数据     : 比较 " << cmp << " 次,交换 " << swp << " 次\n"; // 仍是 45 次比较

    a = raw; selectionSortBidirectional(a, cmp, swp);
    cout << "双向选择排序 : "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";

    /* 演示不稳定:用 (关键字, 原始编号) 的形式观察相等关键字的相对次序 */
    vector<pair<int, char>> rec = {{5, 'a'}, {5, 'b'}, {2, 'c'}};
    int n = rec.size();
    for (int i = 0; i < n - 1; ++i) {
        int minIdx = i;
        for (int j = i + 1; j < n; ++j)
            if (rec[j].first < rec[minIdx].first) minIdx = j;
        if (minIdx != i) swap(rec[i], rec[minIdx]);
    }
    cout << "不稳定验证   : ";
    for (auto& p : rec) cout << p.first << p.second << " ";   // 2c 5b 5a —— 次序被翻转
    cout << "\n";
    return 0;
}
指标说明
时间复杂度最好 = 平均 = 最坏 = O(n²)比较次数恒为 n(n−1)/2,与输入无关
比较次数n(n−1)/2固定值,不受数据分布影响
交换次数≤ n − 1每轮最多一次;所有 O(n²) 排序中最少
空间复杂度O(1)原地排序
稳定性不稳定长距离交换会跨越相等元素

11.4 直接插入排序:工业界最被低估的排序

11.4.1 一句话本质与手推示例

直接插入排序 = 把 A[i] 暂存为 key,在前面已经有序A[0..i−1] 中 从后往前找位置,比 key 大的统统右移一格,最后把 key 落进空出来的位置

插入排序(insertion sort)模仿的是人摸扑克牌的动作:左手是已经理好的牌,右手摸到一张新牌, 就从右往左比过去,找到该插的位置塞进去。它的动作只有两个:比较右移(不是交换!)。 代码短到只有 5 行,但它是 std::sortqsort 这类工业级排序在小数组上的收尾选择。

A = [5, 2, 9, 1, 7] 手推(| 左边是有序区):

  1. 初始:[5 | 2, 9, 1, 7],把 A[0] 单独视为有序区。
  2. i = 1,key = 2:与 A[0] = 5 比较,5 > 2 → 5 右移 → [_, 5 | 9, 1, 7], j 退到 −1(越界),把 key 写入 A[0][2, 5 | 9, 1, 7]。比较 1 次、移动 1 次。
  3. i = 2,key = 9:与 A[1] = 5 比较,5 ≤ 9 → 停,key 就地写入 A[2][2, 5, 9 | 1, 7]。比较 1 次、移动 0 次。
  4. i = 3,key = 1:与 9 比(9 > 1,9 右移)、与 5 比(右移)、与 2 比(右移),j 退到 −1, 写入 A[0][1, 2, 5, 9 | 7]。比较 3 次、移动 3 次。
  5. i = 4,key = 7:与 9 比(右移)、与 5 比(5 ≤ 7,停),写入 A[3][1, 2, 5, 7, 9]。比较 2 次、移动 1 次。

对比第 2 步和第 4 步,你会发现一个规律:如果一个元素本来就该待在原地,它只花 1 次比较; 而如果它需要跨越 k 个元素,就要花 k 次移动。所以总代价约等于「逆序对总数」—— 这正是插入排序「基本有序时接近 O(n)」的原因。

11.4.2 交互动画

动画里额外画了两个视觉元素:左上角紫色方块是暂存变量 key,数组中的虚线框是「坑」—— 也就是元素被搬走后空出来的位置。请特别注意:插入排序靠的是移动而不是交换, 每一次右移都会让坑往左挪一格,直到 key 落进最后的坑。

11.4.3 为什么「基本有序」时接近 O(n)

先把插入排序的代价算清楚。设 M 为总移动次数、C 为总比较次数。内层循环 while (a[j] > key) 每成功一次就移动一个元素,所以 移动次数 = 「需要跨过的元素个数」的总和。精确地说:

M = 序列中的逆序对总数  C = M + (n − 1) − (成功停下与越界的次数差) ≈ M + n

为什么 M 恰好等于逆序对总数?因为每移动一个元素 A[j]A[j+1], 就说明 A[j] > keyj 在 key 原来的位置之前——这正是一个逆序对; 而每个逆序对恰好会被处理一次。于是有三种情况:

这就解释了两件事:① 为什么插入排序是 O(n²) 家族里平均最快的;② 为什么它会成为「小区间收尾」的首选——当一个数组被快排切到只剩 16 个元素时, 这段小区间内部已经相当随机但规模极小,插入排序的低常数会碾压递归开销。

一个常被面试官追问的结论 插入排序的比较次数有一个下界:C ≥ M + (n − 1) − (成功停下的次数)。 更常用的结论是:「插入排序的比较次数 ≤ 移动次数 + n − 1」, 而移动次数等于逆序对数。所以只要序列的逆序对是 O(n),插入排序就是 O(n)。
这条结论的逆命题也成立:任何一个只允许「相邻交换」的排序算法,交换次数的下界就是逆序对总数

11.4.4 带哨兵的写法(1 基下标)

国内教材通常用 1 基下标,并在 A[0] 处放一个哨兵(sentinel): 把当前要插入的元素先存进 A[0],这样内层循环就可以省掉 j ≥ 1 这个边界判断—— 因为 A[0] 就是 key 自己,比较到它一定会停。这是一个典型的「用一点空间换掉一个判断」的技巧, 考试里经常考「哨兵的作用是什么」。

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

/* ============================================================
   直接插入排序(升序,0 基下标版本)
   把 A[i] 插入前面已经有序的 A[0..i-1] 中
   比较次数与移动次数都等于「逆序对数量」量级
   ============================================================ */
void insertionSort(vector<int>& a, long long& cmp, long long& mv) {
    int n = a.size();
    cmp = mv = 0;
    for (int i = 1; i < n; ++i) {
        int key = a[i];                        // 暂存待插入元素
        int j = i - 1;
        while (j >= 0 && a[j] > key) {         // 严格大于才右移 → 保证稳定
            ++cmp;
            a[j + 1] = a[j];                   // 元素右移一格(不是交换!)
            ++mv;
            --j;
        }
        if (j >= 0) ++cmp;                      // 最后一次比较(因 a[j] <= key 而停下)
        a[j + 1] = key;                        // 落进最后的空位
    }
}

/* ============================================================
   教材风格:1 基下标 + 哨兵 A[0]
   a[1..n] 存数据,a[0] 空出来当哨兵
   哨兵的作用:省掉内层循环的 j >= 1 边界判断
   ============================================================ */
void insertionSortSentinel(vector<int>& a, int n, long long& cmp, long long& mv) {
    cmp = mv = 0;
    for (int i = 2; i <= n; ++i) {
        if (a[i] >= a[i - 1]) { ++cmp; continue; }   // 小优化:已经有序就不必搬
        a[0] = a[i];                                // 把 key 放进哨兵位
        int j = i - 1;
        while (a[j] > a[0]) {                       // 不必判断 j >= 1:a[0] 一定不满足条件
            ++cmp;
            a[j + 1] = a[j];
            ++mv;
            --j;
        }
        ++cmp;
        a[j + 1] = a[0];                            // 把哨兵里的 key 放回正确位置
    }
}

/* ============================================================
   折半插入排序:用二分查找定位插入点
   比较次数从 O(n^2) 降到 O(n log n),但移动次数一点没少(仍是 O(n^2))
   ============================================================ */
void binaryInsertionSort(vector<int>& a, long long& cmp, long long& mv) {
    int n = a.size();
    cmp = mv = 0;
    for (int i = 1; i < n; ++i) {
        int key = a[i];
        int lo = 0, hi = i;                     // 在有序区 [0, i-1] 中找插入位置,区间用 [lo, hi)
        while (lo < hi) {
            int mid = lo + (hi - lo) / 2;
            ++cmp;
            if (a[mid] <= key) lo = mid + 1;    // 用 <= 保证即使相等也往右走 → 稳定
            else hi = mid;
        }
        /* 插入位置是 lo,把 [lo, i-1] 整体右移一格 */
        for (int j = i; j > lo; --j) { a[j] = a[j - 1]; ++mv; }
        a[lo] = key;
    }
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}

int main() {
    vector<int> raw = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};
    long long cmp = 0, mv = 0;

    vector<int> a = raw;
    insertionSort(a, cmp, mv);
    cout << "直接插入排序 : "; printArr(a);
    cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";      // 31 次比较、25 次移动

    vector<int> sorted = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    a = sorted; insertionSort(a, cmp, mv);
    cout << "基本有序     : 比较 " << cmp << " 次,移动 " << mv << " 次\n"; // 9 次比较、0 次移动 → O(n)

    vector<int> rev = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
    a = rev; insertionSort(a, cmp, mv);
    cout << "完全逆序     : 比较 " << cmp << " 次,移动 " << mv << " 次\n"; // 45 次比较、45 次移动 → O(n^2)

    /* 哨兵版:a[1..n] 存数据 */
    vector<int> b(raw.size() + 1, 0);
    for (size_t i = 0; i < raw.size(); ++i) b[i + 1] = raw[i];
    insertionSortSentinel(b, (int)raw.size(), cmp, mv);
    cout << "哨兵版       : ";
    for (size_t i = 1; i < b.size(); ++i) cout << b[i] << (i + 1 < b.size() ? " " : "");
    cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    a = raw; binaryInsertionSort(a, cmp, mv);
    cout << "折半插入排序 : "; printArr(a);
    cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";      // 比较次数明显下降
    return 0;
}

11.4.5 折半插入排序:省比较、不省移动

插入排序的「查找插入位置」是在一个有序区里做的,那为什么不用二分查找呢?当然可以,这就得到了 折半插入排序(binary insertion sort)。它把比较次数从 O(n²) 降到 O(n log n):每个元素用 ⌈log2 i⌉ 次比较定位,总共约 n log n 次比较。

但是!移动次数一次都没少,仍然是 O(n²)。因为找到位置之后,还是要老老实实把 A[lo..i−1] 整段右移一格。所以折半插入排序的总时间复杂度仍然是 O(n²), 只是把「比较」这一项的系数降下来了。结论:折半插入排序适用于「比较很贵、移动很便宜」的场景 (比如关键字是长字符串),而对普通整数几乎没有提升。

易错点:折半插入的边界与稳定性 ① 二分查找的区间写法有 [lo, hi][lo, hi) 两种,混用极易死循环或漏解。 上面的代码统一用左闭右开 [lo, hi),初始 lo = 0, hi = i, 结束时 lo 就是插入位置。
② 判断条件必须是 a[mid] <= key 而不是 <:相等时要继续往找, 这样才能保证 key 插在「所有相等元素之后」,维持稳定性。写成 < 会让相等元素插到前面,破坏稳定性。

11.4.6 稳定性与工业界的真实用法

插入排序是稳定的:内层循环的条件是 a[j] > key(严格大于), 遇到相等元素立即停下,key 就落在它右边,绝不会跨过去。这一点和冒泡一样,是靠「不写等号」换来的。

工业实现里插入排序有三个典型用法:

11.5 希尔排序:给插入排序装上加速器

11.5.1 一句话本质与手推示例

希尔排序 = 取一个较大的增量 gap,把下标相差 gap 的元素分成一组,组内做插入排序; 不断缩小 gap,最后 gap = 1 时整体做一次插入排序

希尔排序(Shell sort,1959 年由 Donald Shell 提出)又叫缩小增量排序(diminishing increment sort)。 它的出发点是一个观察:插入排序在「基本有序」的序列上极快,但在完全乱序的序列上很慢, 因为一个元素要挪到它该去的位置,只能一格一格地挪

希尔排序的想法是:既然一次只能挪一格太慢,那就先让元素能大跨度地移动。 取 gap = 5 时,A[0]A[5] 是同组的, 它们可以直接交换——一步跨越 5 个位置!等到大 gap 的几趟做完,序列已经「大的在后面、小的在前面」, 逆序对大幅减少;最后 gap = 1 的那一趟插入排序就非常轻松了。

A = [8, 9, 1, 7, 2, 3, 5, 4, 6, 0](n = 10)手推:

  1. gap = 5:分为 5 组 —— {A[0],A[5]} = {8,3}、{A[1],A[6]} = {9,5}、{A[2],A[7]} = {1,4}、 {A[3],A[8]} = {7,6}、{A[4],A[9]} = {2,0}。每组只有两个元素,各自排好,得到 [3, 5, 1, 6, 0, 8, 9, 4, 7, 2]。注意 0 一步就从下标 9 跨到了下标 4。
  2. gap = 2:分为 2 组,奇数下标一组、偶数下标一组,各自做插入排序,得到 [0, 2, 1, 4, 3, 5, 6, 7, 8, 9](详细过程见动画)。此时序列已经「大致有序」。
  3. gap = 1:整体做一次插入排序。因为只剩下少量逆序对,只要 4 次移动就完成了。

对比一下:如果直接用插入排序处理这个序列,需要 25 次左右的移动;希尔排序三趟加起来只有 19 次左右。 数据规模越大,这个差距越夸张。

原始序列 A = [8, 9, 1, 7, 2, 3, 5, 4, 6, 0],n = 10 8 9 1 7 2 3 5 4 6 0 gap = 5:同色为一组,颜色 = 下标 mod 5 同组元素下标相差 gap,交换一次可跨越 5 个位置 缩小增量:gap 依次取 5 → 2 → 1,每趟都让序列「更有序」一点 gap=5 后 3 5 1 6 0 8 9 4 7 2 gap=2 后 0 2 1 4 3 5 6 7 8 9 gap=1 后 0 1 2 3 4 5 6 7 8 9 最后一趟只需少量移动
图 11-4 希尔排序的缩小增量过程:gap 从 5 到 2 再到 1,序列逐步「基本有序」

11.5.2 交互动画:看清 gap 与分组

下面的动画用 A = [8, 9, 1, 7, 2, 3, 5, 4, 6, 0], 增量序列 5 → 2 → 1颜色相同的格子属于同一组(下标对 gap 取模相同), 格子下方还标出了组号。请重点观察:同一个组内的元素,虽然下标相隔很远,但组内是严格按插入排序处理的。

11.5.3 增量序列怎么选:复杂度的命门

希尔排序的时间复杂度完全取决于增量序列的选取,这也是它特别有意思的地方—— 同一份代码,换一个 gap 序列,复杂度可能从 O(n²) 变成 O(n1.3)。

增量序列取法最坏时间复杂度评价
Shell 原始序列 n/2, n/4, …, 1(每次折半) O(n²) 最好写、最好讲,教材默认。但当 n 是 2 的幂时效率最差,因为增量之间不互质, 小增量可能一直在做「无效功」
Hibbard 序列 2k − 1, …, 7, 3, 1 O(n1.5) 增量互质(都是奇数),避免了「同一个元素被反复搬来搬去」的退化
Sedgewick 序列 1, 5, 19, 41, 109, 209, … O(n4/3) 目前实践中最快的已知序列之一,需要预先算好或查表
Knuth 序列 3k+1 型:1, 4, 13, 40, 121, … 约 O(n1.5) 实现简单(h = h*3 + 1),工程上常用
取 1 个固定 gap 只有一趟 不一定能排好! 这是最经典的错误:如果最后一趟不是 gap = 1,序列可能根本没有排好序
易错点:最后一趟必须 gap = 1 希尔排序的分组排序是局部有序,组与组之间没有任何大小关系。只有当 gap = 1 时, 「所有元素都在同一组内」,这一趟跑完整个序列才真正有序。 所以任何合法的增量序列必须以 1 结尾。考试里如果给出一个「gap 只做到 2」的中间状态, 它一定还不是最终结果。

11.5.4 为什么希尔排序不稳定,又为什么比插入快

先回答「为什么不稳定」:希尔排序会把序列按 gap 分成多个组, 关键字相同的元素很可能被分到不同的组,而每个组是「独立」排序的—— 组与组之间的相对次序完全由「下标对 gap 取模」这个与关键字无关的规则决定。 一旦后续的趟把它们搬进同一个组,谁前谁后就可能和原来相反。

标准教材给的反例是 [49a, 38, 65, 97, 76, 13, 27, 49b](n = 8,两个 49), 增量序列取 5 → 3 → 1。我们完整推一遍:

  1. gap = 5:分组 {A[0],A[5]}={49a,13}、{A[1],A[6]}={38,27}、{A[2],A[7]}={65,49b}。 组内插入排序后得到 [13, 27, 49b, 97, 76, 49a, 38, 65]
  2. gap = 3:分组 {A[0],A[3],A[6]}、{A[1],A[4],A[7]}、{A[2],A[5]}。 注意 49b 在下标 2、49a 在下标 5,它们这一次被分到了同一组! 组内比较时 A[2] = 49b 与 key = 49a 满足 49b ≤ 49a(相等), 于是 49a 停在 49b 后面,不再往前移动。这一趟之后: [13, 27, 49b, 38, 65, 49a, 97, 76]
  3. gap = 1:普通插入排序收尾,得到 [13, 27, 38, 49b, 49a, 65, 97, 76]

最终结果里 49b 排在了 49a 的前面,而原始序列中 49a 在下标 0、49b 在下标 7 —— 相对次序被颠倒了,所以希尔排序不稳定

理解不稳定性的正确姿势 不要试图用「两三个元素」的小例子去凑反例 —— 那样很容易得出「看起来挺稳定的」这种错误结论。 要抓住本质:稳定的插入排序被限制在「组内」执行,而分组规则与关键字无关, 所以相等元素跨组时次序无法保证。反过来看归并排序:它虽然也在搬元素, 但每次合并都是「相邻两段」,相等时又强制取左段,所以稳定。

再回答「为什么比插入排序快」:两个原因。

还有个常被忽略的优点:希尔排序是原地排序,空间 O(1),且代码只是在插入排序外面加了一层 gap 循环。 在嵌入式或对递归过敏的场景,它比快排、归并更受欢迎。

11.5.5 完整 C++ 实现(三种增量序列对比)

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

/* ============================================================
   希尔排序(缩小增量排序)
   统一接口:gap 序列由参数传入,最后一趟必须 gap == 1
   ============================================================ */

/* 通用的「按给定增量序列做希尔排序」,并统计比较 / 移动次数 */
void shellSortWithGaps(vector<int>& a, const vector<int>& gaps,
                       long long& cmp, long long& mv) {
    int n = a.size();
    cmp = mv = 0;
    for (size_t gi = 0; gi < gaps.size(); ++gi) {
        int gap = gaps[gi];
        if (gap <= 0 || gap >= n) continue;
        for (int i = gap; i < n; ++i) {        // 对每一组做插入排序
            int key = a[i];
            int j = i - gap;
            while (j >= 0) {
                ++cmp;
                if (a[j] <= key) break;        // 组内已就位(写成 <= 而不是 < 也不会更稳定)
                a[j + gap] = a[j];             // 同组内右移 gap 格
                ++mv;
                j -= gap;
            }
            a[j + gap] = key;
        }
    }
}

/* ① 折半增量:n/2, n/4, ..., 1 —— 最经典,最坏 O(n^2) */
vector<int> gapsShell(int n) {
    vector<int> g;
    for (int h = n / 2; h > 0; h /= 2) g.push_back(h);
    if (g.empty() || g.back() != 1) g.push_back(1);
    return g;
}

/* ② Hibbard 增量:2^k - 1, ..., 7, 3, 1 —— 最坏 O(n^1.5) */
vector<int> gapsHibbard(int n) {
    vector<int> g;
    for (int h = 1; h < n; h = h * 2 + 1) g.push_back(h);   // 1,3,7,15,...
    reverse(g.begin(), g.end());                            // 从大到小
    return g;
}

/* ③ Sedgewick 增量:1, 5, 19, 41, 109, 209, ... —— 最坏约 O(n^(4/3)) */
vector<int> gapsSedgewick(int n) {
    vector<int> g;
    for (int k = 0; ; ++k) {
        int h;
        if (k % 2 == 0) { int t = 1; for (int i = 0; i < k / 2 + 1; ++i) t *= 2; h = 1 + 9 * (t - 1); }
        else            { int t = 1; for (int i = 0; i < (k + 1) / 2 + 1; ++i) t *= 2; h = 1 + 8 * t - 6 * (t / 2); }
        if (h >= n) break;
        g.push_back(h);
    }
    if (g.empty() || g.back() != 1) g.push_back(1);
    reverse(g.begin(), g.end());
    return g;
}

/* Knuth 增量:h = 3h + 1,即 1, 4, 13, 40, 121, ... —— 工程上最常用 */
vector<int> gapsKnuth(int n) {
    vector<int> g;
    int h = 1;
    while (h < n) { g.push_back(h); h = h * 3 + 1; }
    if (g.empty()) g.push_back(1);
    reverse(g.begin(), g.end());
    return g;
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}
static void printGaps(const char* name, const vector<int>& g) {
    cout << name << " 增量序列: ";
    for (size_t i = 0; i < g.size(); ++i) cout << g[i] << (i + 1 < g.size() ? " -> " : "");
    cout << "\n";
}

int main() {
    vector<int> raw = {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};
    long long cmp = 0, mv = 0;
    int n = raw.size();

    vector<int> g1 = gapsShell(n), g2 = gapsHibbard(n), g3 = gapsKnuth(n), g4 = gapsSedgewick(n);
    printGaps("折半    ", g1);
    printGaps("Hibbard ", g2);
    printGaps("Knuth   ", g3);
    printGaps("Sedgewick", g4);

    vector<int> a = raw; shellSortWithGaps(a, g1, cmp, mv);
    cout << "折半增量结果   : "; printArr(a); cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    a = raw; shellSortWithGaps(a, g2, cmp, mv);
    cout << "Hibbard 结果   : "; printArr(a); cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    a = raw; shellSortWithGaps(a, g3, cmp, mv);
    cout << "Knuth 结果     : "; printArr(a); cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    a = raw; shellSortWithGaps(a, g4, cmp, mv);
    cout << "Sedgewick 结果 : "; printArr(a); cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    /* 对照:用 gap=1 的「假希尔」——其实等价于直接插入排序 */
    vector<int> onlyOne; onlyOne.push_back(1);
    a = raw; shellSortWithGaps(a, onlyOne, cmp, mv);
    cout << "只有 gap=1     : "; printArr(a); cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    /* 大规模对比:希尔排序 vs 纯插入排序 */
    const int N = 20000;
    vector<int> big(N);
    for (int i = 0; i < N; ++i) big[i] = (i * 7919) % N;    // 伪随机排列
    a = big; shellSortWithGaps(a, g3, cmp, mv);
    bool ok1 = true;
    for (int i = 1; i < N; ++i) if (a[i - 1] > a[i]) ok1 = false;
    cout << "\nn = " << N << " 时 Knuth 增量希尔排序:比较 " << cmp << " 次,移动 " << mv
         << " 次,有序性校验 " << (ok1 ? "通过" : "失败") << "\n";
    return 0;
}
指标折半增量HibbardSedgewickKnuth (3h+1)
最坏时间O(n²)O(n^1.5)O(n^(4/3))约 O(n^1.5)
平均时间约 O(n^1.3)约 O(n^1.25)实测最快接近 Hibbard
空间O(1),原地排序
稳定性不稳定(分组会跨越相等元素)
实现难度★★★★★★★

11.6 堆排序:把数组当成一棵完全二叉树

11.6.1 完全二叉树的数组表示与下标关系

堆排序(heap sort)的全部魔法都建立在一句话上:一棵完全二叉树可以被一个数组「无指针」地表示出来。 因为完全二叉树的结点是从上到下、从左到右逐层填满的,没有任何「空洞」, 所以只要按层序把结点依次放进数组,父子关系就可以用纯算术算出来,一个指针都不用存。

9 i = 0(根) 7 8 1 2 6 2 3 5 3 4 5 6 4 1 0 ? 7 8 9 下标关系(0 基,n 为结点总数): 左孩子 = 2i + 1 右孩子 = 2i + 2 父结点 = (i − 1) / 2 (整除) 最后一个非叶结点 = n/2 − 1 = 4(下标 5、6、7、8、9 都是叶子)
图 11-5 完全二叉树的数组表示:父子下标可以用纯算术互相推导

三条关系必须背下来(0 基下标):

左孩子
left(i) = 2i + 1,越界(≥ n)说明没有左孩子。
右孩子
right(i) = 2i + 2,越界说明没有右孩子。
父结点
parent(i) = (i − 1) / 2(整数除法,向下取整)。
最后一个非叶结点
n/2 − 1(整除)。它的所有后继结点都是叶子,这个下标是建堆的起点。
叶子结点数
⌈n/2⌉非叶结点数 ⌊n/2⌋
易错点:1 基下标与 0 基下标的公式不同 很多考研教材用 A[1..n],此时关系是 left = 2iright = 2i+1parent = i/2。两种约定都对,但绝对不能混用。 本讲全部采用 0 基:2i+1 / 2i+2 / (i−1)/2。 做题时先看题目给的下标从几开始,再套公式。

11.6.2 大根堆与小根堆

大根堆(max-heap):一棵完全二叉树,且每个结点的关键字都 ≥ 它的两个孩子, 即 A[i] ≥ A[2i+1]A[i] ≥ A[2i+2]小根堆(min-heap)把不等号反过来。

三条必须记牢的性质:

11.6.3 建堆为什么是 O(n)(本节最硬的推导)

先看建堆的做法:从最后一个非叶结点 ⌊n/2⌋ − 1 开始,从后往前对每个结点执行一次 「下沉(sift down)」,直到根结点。为什么是从后往前,而不是从根开始? 因为下沉操作有一个前提:被下沉结点的左右子树本身必须已经是合法的堆。 叶子天然是堆,所以从后往前处理,轮到某个结点时它的左右子树必然已经调整好了。

很多同学第一次算都会算成 O(n log n):一共约 n/2 个非叶结点, 每个最多下沉 log n 层,乘起来不就是 O(n log n) 吗? 这个估计太粗糙了——它默认了「每个结点都下沉满 log n 层」,而事实上绝大多数结点离叶子很近,根本下沉不了几层。

精确的求法是这样的。我们按「高度」给结点分组:

  1. 定义结点的高度 h = 它到最远叶子的边数。一个高度为 h 的结点,最多下沉 h 层。
  2. 在完全二叉树中,高度为 h 的结点大约有 ⌈n / 2h+1⌉ 个—— 高度越大,这样的结点越少,而且是以 2 的幂次急剧减少。
  3. 于是总代价
    T(n) = Σh=0⌊log n⌋ ⌈n / 2h+1⌉ · O(h)  = O( n · Σh≥0 h / 2h+1 ) = O( n/2 · Σh≥0 h / 2h )
  4. 关键在最后那个级数。Σh≥0 h / 2h 是一个收敛的级数,其值为 2
    由 Σh≥0 xh = 1/(1−x) 两边求导得 Σh≥1 h·xh−1 = 1/(1−x)², 两边乘 x 得 Σh≥0 h·xh = x/(1−x)², 代入 x = 1/2 得到 (1/2)/(1/2)² = 2
  5. 所以 T(n) = O(n/2 × 2) = O(n)

用一句大白话总结:「深度大的结点很多但几乎不用下沉,需要下沉很多层的结点极少」, 两边的 trade-off 恰好让总和收敛到一个与 n 成正比的常数。这就是建堆只需要 O(n) 的原因, 也是堆排序「总复杂度 O(n log n),而不是 O(n log n) 建堆 + O(n log n) 排序 = 常数翻倍」的原因。

考点:为什么不写成 O(n log n) 考试里如果问「堆排序建堆的时间复杂度」,标准答案是 O(n),并且要能说出理由: 「按高度分层求和,Σ h/2h 收敛」。 如果回答 O(n log n),说明你用的是「每个结点都下沉满 log n 层」的粗估,会被扣分。
顺带记住一个对照:如果改成从根开始逐个「上浮(sift up)」插入建堆, 那才是真正的 O(n log n)——因为上浮到根的路径是「越深的结点越费劲」,与下沉刚好相反。

11.6.4 交互动画:数组 + 完全二叉树双视图

下面这个动画同时画出数组和它对应的完全二叉树,两边同步高亮,你可以直接看到下标关系。 动画分两个阶段:

11.6.5 完整 C++ 实现

代码分成三块:siftDown(把一个结点往下调整到位)、buildHeap(自底向上建堆)、 heapSort(交换 + 缩小 + 下沉)。请特别注意 siftDown 里的 sz 参数—— 它是「当前堆的有效规模」,排序阶段这个值会不断缩小,这正是「已排序区不参与堆调整」的实现方式。

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

/* ============================================================
   堆排序(升序 → 用大根堆)
   下标关系(0 基):左孩子 2i+1,右孩子 2i+2,父结点 (i-1)/2
   ============================================================ */

/* 下沉:把下标 p 的元素往下调整,使 [0, sz) 重新成为大根堆
   前提:p 的左右子树都已经是合法的大根堆 */
void siftDown(vector<int>& a, int p, int sz, long long& cmp, long long& swp) {
    while (true) {
        int l = 2 * p + 1, r = 2 * p + 2;
        if (l >= sz) return;                  // 没有孩子 → 已经是叶子,结束
        int bigger = l;                       // 先假设左孩子更大
        if (r < sz) {
            ++cmp;
            if (a[r] > a[l]) bigger = r;      // 两个孩子都在,挑大的
        }
        ++cmp;
        if (a[bigger] <= a[p]) return;        // 父结点不小于孩子 → 堆性质成立,结束
        swap(a[p], a[bigger]);
        ++swp;
        p = bigger;                           // 继续往下检查
    }
}

/* 建堆:从最后一个非叶结点 n/2-1 开始,倒序下沉。总时间 O(n) */
void buildHeap(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    for (int i = n / 2 - 1; i >= 0; --i) siftDown(a, i, n, cmp, swp);
}

/* 堆排序主流程:O(n) 建堆 + (n-1) 次「换顶 + 下沉」,每次 O(log n) */
void heapSort(vector<int>& a, long long& cmp, long long& swp) {
    int n = a.size();
    cmp = swp = 0;
    if (n < 2) return;
    buildHeap(a, cmp, swp);                    // 第一步:建大根堆
    for (int end = n - 1; end > 0; --end) {    // 第二步:反复取走堆顶
        swap(a[0], a[end]);                    // 最大值换到末尾,位置确定
        ++swp;
        siftDown(a, 0, end, cmp, swp);         // 堆规模缩到 end,新堆顶下沉
    }
}

/* 变体:小根堆版本(把比较符号反过来即可),用于降序或 Top-K */
void siftDownMin(vector<int>& a, int p, int sz) {
    while (true) {
        int l = 2 * p + 1, r = 2 * p + 2;
        if (l >= sz) return;
        int smaller = l;
        if (r < sz && a[r] < a[l]) smaller = r;
        if (a[smaller] >= a[p]) return;
        swap(a[p], a[smaller]);
        p = smaller;
    }
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}
static bool isMaxHeap(const vector<int>& a) {
    int n = a.size();
    for (int i = 0; 2 * i + 1 < n; ++i) {
        if (a[i] < a[2 * i + 1]) return false;
        if (2 * i + 2 < n && a[i] < a[2 * i + 2]) return false;
    }
    return true;
}

int main() {
    vector<int> raw = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};
    long long cmp = 0, swp = 0;

    /* 单独看建堆的结果 */
    vector<int> h = raw;
    cmp = swp = 0;
    buildHeap(h, cmp, swp);
    cout << "建堆结果     : "; printArr(h);
    cout << "  是大根堆?" << (isMaxHeap(h) ? "是" : "否")
         << " 堆顶(最大值)= " << h[0] << " 建堆比较 " << cmp << " 次\n";

    vector<int> a = raw;
    heapSort(a, cmp, swp);
    cout << "堆排序结果   : "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";

    /* 与「逐个上浮插入建堆」对比:后者是 O(n log n) */
    vector<int> up = raw;
    long long upCmp = 0;
    for (size_t i = 1; i < up.size(); ++i) {          // sift up 建堆
        int c = i;
        while (c > 0) {
            int par = (c - 1) / 2;
            ++upCmp;
            if (up[par] >= up[c]) break;
            swap(up[par], up[c]);
            c = par;
        }
    }
    cout << "上浮建堆结果 : "; printArr(up);
    cout << "  是大根堆?" << (isMaxHeap(up) ? "是" : "否") << " 比较 " << upCmp << " 次\n";

    /* 用堆的思路做降序:小根堆依次取出最小值 → 得到升序,再反转 */
    vector<int> d = raw;
    for (int i = (int)d.size() / 2 - 1; i >= 0; --i) siftDownMin(d, i, d.size());
    vector<int> desc;
    for (int end = (int)d.size() - 1; end >= 0; --end) {
        desc.push_back(d[0]);                      // 取出当前最小值
        swap(d[0], d[end]);
        siftDownMin(d, 0, end);
    }
    reverse(desc.begin(), desc.end());             // 反转 → 降序
    cout << "小根堆取降序 : "; printArr(desc); cout << "\n";
    return 0;
}

11.6.6 不稳定性:一个具体例子

堆排序不稳定,而且它的不稳定非常「隐蔽」——不是发生在下沉里,而是发生在「堆顶与末尾交换」那一步。

取序列 [2a, 2b, 1](两个关键字为 2 的记录)。建堆时: 最后一个非叶结点是 ⌊3/2⌋ − 1 = 0,对下标 0 下沉。 它的两个孩子是 A[1] = 2bA[2] = 1,较大的孩子是 A[1] = 2b; 比较 A[1] = 2b 与父结点 A[0] = 2a2b > 2a 不成立(相等), 所以不交换,堆已经是 [2a, 2b, 1]

排序阶段:end = 2,把 A[0]A[2] 交换 → [1, 2b, 2a]。看!2a 被一步换到了 2b 的后面,相对次序翻转。 虽然 [2a, 2b, 1] 这个例子最终排出来是 [1, 2b, 2a], 两个 2 的次序确实颠倒了,这就是堆排序不稳定的直接证据。

易错点:堆排序「看起来」很像选择排序 注意上面这个不稳定的成因,和选择排序(11.3.4)一模一样: 两者都会把「极值」与「区间的某个端点」做长距离交换,跨越中间的相等元素。 记住这个共性:凡是用「远距离交换」来安放极值的算法,几乎都不稳定

11.6.7 优先队列:堆在 STL 里的样子

堆在工程中最常见的身份不是「排序算法」,而是优先队列(priority queue)。 优先队列要求「每次取出的都是当前优先级最高的元素」,而堆恰好能在 O(log n) 内完成插入与取极值, 在 O(1) 内读到极值。C++ 提供了两套现成工具:

#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
#include <functional>
#include <string>
using namespace std;

/* ============================================================
   STL 里的堆:priority_queue 与 make_heap / push_heap / pop_heap / sort_heap
   记住一句话:STL 的堆全部是「大根堆」语义,要小根堆就自定义比较器
   ============================================================ */

struct Task {
    int priority;          // 优先级,越大越先做
    string name;
    /* 自定义比较器:返回 true 表示 lhs 的优先级「低于」rhs,即 rhs 先出队
       —— 注意这与 sort 的比较器方向相反,是新手最容易踩的坑 */
    bool operator<(const Task& o) const { return priority < o.priority; }
};

int main() {
    /* ---------- ① priority_queue:默认大根堆 ---------- */
    priority_queue<int> pq;
    for (int x : {5, 2, 9, 1, 7, 3}) pq.push(x);
    cout << "priority_queue 依次出队(大根堆): ";
    while (!pq.empty()) { cout << pq.top() << ' '; pq.pop(); }
    cout << "\n";                              // 9 7 5 3 2 1

    /* 小根堆:用 greater<int> 或者把元素取负 */
    priority_queue<int, vector<int>, greater<int>> minpq;
    for (int x : {5, 2, 9, 1, 7, 3}) minpq.push(x);
    cout << "priority_queue 小根堆       : ";
    while (!minpq.empty()) { cout << minpq.top() << ' '; minpq.pop(); }
    cout << "\n";                              // 1 2 3 5 7 9

    /* 自定义类型 */
    priority_queue<Task> tq;
    tq.push({3, "写作业"}); tq.push({9, "交论文"}); tq.push({6, "打游戏"});
    cout << "任务按优先级出队            : ";
    while (!tq.empty()) { cout << tq.top().name << "(" << tq.top().priority << ") "; tq.pop(); }
    cout << "\n";                              // 交论文(9) 打游戏(6) 写作业(3)

    /* ---------- ② 四个堆算法:直接操作 vector ---------- */
    vector<int> v = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};

    make_heap(v.begin(), v.end());               // O(n) 建堆
    cout << "make_heap 之后堆顶         : " << v.front() << "\n";   // 9

    v.push_back(100);                            // 先 push_back 进容器
    push_heap(v.begin(), v.end());               // 再 push_heap 调整,让它上浮到位
    cout << "push_heap(100) 之后堆顶    : " << v.front() << "\n";   // 100

    pop_heap(v.begin(), v.end());                // 把堆顶换到末尾,并对 [begin, end-1) 重新调整
    int top = v.back();                          // 堆顶还在容器里,只是被挪到了末尾
    v.pop_back();                                // 真正从容器里删掉
    cout << "pop_heap 取出的最大值      : " << top
         << ",新堆顶 " << v.front() << "\n";                     // 100,新堆顶 9

    make_heap(v.begin(), v.end());
    sort_heap(v.begin(), v.end());               // 反复 pop_heap,最终得到升序数组(堆结构被破坏)
    cout << "sort_heap 之后              : ";
    for (int x : v) cout << x << ' ';
    cout << "\n";                              // 0 1 2 3 4 5 6 7 8 9

    /* 注意:sort_heap 等价于把 make_heap + 反复 pop_heap 走完,
       它比 std::sort 慢(常数更大),实际排序请优先用 std::sort。 */
    return 0;
}
易错点:push_heap / pop_heap 的名字骗人 push_heap 不会帮你把元素塞进容器——你得先自己 v.push_back(x), 再调用 push_heap 让这个新元素上浮到位。 同理 pop_heap 不会帮你删除元素——它只是把堆顶换到 v.back(), 你必须再调用 v.pop_back() 才真正删掉。 另外,priority_queue 的比较器方向与 std::sort 相反sortless 得到升序,而 priority_queueless(默认) 得到的是大根堆(最大的先出)。

11.6.8 Top-K 问题:堆最漂亮的实战应用

问题:从 n 个数中找出最大的 k 个数(k ≪ n),要求时间尽量少、空间尽量小。

朴素思路:全部排序后取前 k 个,需要 O(n log n) 时间和 O(n) 空间—— 当 n 是 10 亿、k 只有 10 时,把 10 亿个数全排一遍纯属浪费。

堆的思路:维护一个大小为 k 的小根堆。遍历每个元素 x: 如果堆没满就放进去;如果 x > 堆顶(堆顶是这 k 个数里最小的), 就弹出堆顶、把 x 放进去。遍历结束后,堆里剩下的就是最大的 k 个数。

为什么求「最大的 k 个」要用小根堆? 这是最反直觉的一点,记住这个口诀:「求最大用最小堆,求最小用最大堆」
道理是:小根堆的堆顶是「当前这 k 个候选里最弱的那个」。 来了一个新元素,只要它比最弱的强,就有资格把最弱的挤掉 —— 堆顶就是那道「门槛」, 这道门槛越高,越能快速淘汰掉不合格的元素。
#include <iostream>
#include <vector>
#include <queue>
#include <algorithm>
#include <functional>
using namespace std;

/* ============================================================
   Top-K:从 n 个数里取最大的 k 个
   维护一个大小为 k 的小根堆,堆顶是当前候选集合里的最小值(门槛)
   时间 O(n log k),空间 O(k)
   ============================================================ */
vector<int> topK(const vector<int>& nums, int k) {
    if (k <= 0) return {};
    priority_queue<int, vector<int>, greater<int>> heap;   // 小根堆
    for (int x : nums) {
        if ((int)heap.size() < k) {
            heap.push(x);                     // 堆还没满,先放进来
        } else if (x > heap.top()) {
            heap.pop();                       // 比门槛大 → 淘汰门槛
            heap.push(x);
        }
        /* 否则 x 连门槛都比不过,直接丢弃,一次 O(1) 判断就淘汰了 */
    }
    vector<int> res;
    while (!heap.empty()) { res.push_back(heap.top()); heap.pop(); }
    sort(res.rbegin(), res.rend());           // 从大到小输出
    return res;
}

/* 对照实现 1:全部排序后取前 k 个 —— O(n log n) */
vector<int> topKSort(vector<int> nums, int k) {
    sort(nums.begin(), nums.end(), greater<int>());
    if ((int)nums.size() > k) nums.resize(k);
    return nums;
}

/* 对照实现 2:快速选择(quickselect)—— 平均 O(n),但会打乱原数组且最坏 O(n^2) */
int quickSelect(vector<int>& a, int l, int r, int k) {
    if (l == r) return a[l];
    int pivot = a[l + (r - l) / 2], i = l, j = r;
    while (i <= j) {
        while (a[i] > pivot) ++i;             // 这里找的是「第 k 大」,所以按降序划分
        while (a[j] < pivot) --j;
        if (i <= j) { swap(a[i], a[j]); ++i; --j; }
    }
    if (k <= j) return quickSelect(a, l, j, k);
    if (k >= i) return quickSelect(a, i, r, k);
    return a[k];
}

int main() {
    vector<int> nums;
    for (int i = 0; i < 100000; ++i) nums.push_back((i * 7919 + 13) % 100000);   // 伪随机

    int k = 5;
    vector<int> r1 = topK(nums, k);
    cout << "堆方法 Top-" << k << "     : ";
    for (int x : r1) cout << x << ' ';
    cout << "\n";

    vector<int> r2 = topKSort(nums, k);
    cout << "全排序 Top-" << k << "    : ";
    for (int x : r2) cout << x << ' ';
    cout << "\n";

    vector<int> tmp = nums;
    cout << "快速选择第 " << k << " 大 : " << quickSelect(tmp, 0, tmp.size() - 1, k - 1) << "\n";

    cout << "两种方法结果一致?" << (r1 == r2 ? "一致" : "不一致") << "\n";

    /* 流式场景:内存放不下全部数据时,堆方法是唯一可行的 */
    cout << "(堆方法只需 O(k) = " << k << " 个额外空间,可以边读边算)\n";
    return 0;
}

11.6.9 堆排序小结

指标说明
建堆时间O(n)自底向上 sift down,Σ h/2h 收敛
排序时间O(n log n)n−1 次「换顶 + 下沉」,每次 O(log n)
总时间O(n log n)(最好 = 平均 = 最坏)没有退化风险,这点比快排强
空间O(1)原地排序;但递归写法会有 O(log n) 栈
稳定性不稳定堆顶与末尾的长距离交换会跨越相等元素
实测速度比快排慢 2~3 倍元素跳跃访问,对 CPU 缓存不友好
最擅长的事优先队列、Top-K、动态中位数、Dijkstra 的取最小边

11.7 归并排序:分治的教科书范例

11.7.1 分治三步骤与二路归并

归并排序 = (把区间一分为二)+ (递归排好左右两半)+ (merge 两个有序段)

归并排序(merge sort)由冯·诺依曼在 1945 年提出,是最标准的分治(divide and conquer)算法。 它的三步骤是:

  1. 分解(Divide):把当前区间 [l, r] 从中间切成 [l, mid][mid+1, r]
  2. 解决(Conquer):递归地对左右两半排序。当区间只剩 1 个元素时,它天然有序,递归到底。
  3. 合并(Merge):把两个已经有序的子区间合并成一个有序区间。这是整个算法的灵魂。

归并排序之所以能做到 O(n log n),关键在一个漂亮的平衡:分解的层数是 log n (每次对半切),而每一层的合并总代价是 O(n)(每一层加起来正好处理 n 个元素)。 乘起来就是 O(n log n)。而且这个结论与数据分布无关—— 不管输入是随机的还是有序的,切分方式都一样,所以最好、平均、最坏都是 O(n log n)

二路归并的具体过程(双指针法)

合并两个有序段 A[l..mid]A[mid+1..r] 的方法叫双指针取小: 用 i 指向左段头、j 指向右段头,比较 A[i]A[j], 谁小就把它抄进临时数组 T,然后对应的指针和 T 的写指针 k 一起后移。 某一侧先取完时,另一侧剩下的元素整段抄过去即可(因为它们本来就有序)。

举例:合并左段 [2, 5, 9] 与右段 [1, 3, 8]

  1. 2 vs 1 → 取 1,T = [1],j 右移。
  2. 2 vs 3 → 取 2,T = [1, 2],i 右移。
  3. 5 vs 3 → 取 3,T = [1, 2, 3],j 右移。
  4. 5 vs 8 → 取 5,T = [1, 2, 3, 5],i 右移。
  5. 9 vs 8 → 取 8,T = [1, 2, 3, 5, 8],j 已用尽。
  6. 右段取完,左段剩下的 [9] 整段抄过去 → T = [1, 2, 3, 5, 8, 9]。
归并排序的递归分解树(以 n = 8 为例,自顶向下切分,自底向上合并) [0, 7] 5 2 9 1 7 3 8 4 [0, 3] 5 2 9 1 [4, 7] 7 3 8 4 [0,1] 5 2 [2,3] 9 1 [4,5] 7 3 [6,7] 8 4 5 2 9 1 3 8 4 合并方向(自底向上): ① 长度 1 的两两合并 → [2,5] [1,9] [3,7] [4,8] ② 长度 2 的两两合并 → [1,2,5,9] [3,4,7,8] ③ 长度 4 的合并 → [1,2,3,4,5,7,8,9] 每一层的合并总代价都是 O(n),一共 log₂n = 3 层,所以总时间 O(n log n)。 注意:分解是「自顶向下递归」,合并是「自底向上返回」——这正是递归调用的返回顺序。
图 11-6 归并排序的递归分解树:分层切分、逐层合并,每层代价 O(n)

11.7.2 交互动画:递归区间 + 双指针

下面的动画有三个看点: 画布上方的一排短横线表示当前递归路径上每一层的区间 (颜色最深的那个就是正在处理的区间); 中间一行是原数组 A, 蓝色是左段、橙色是右段、变灰表示「该元素已经被取走」; 下面一行是临时数组 T,以及 i / j / k 三个指针。请特别注意每次「相等时取左段」的那一步—— 它是归并排序稳定性的全部秘密。

11.7.3 递归版与自底向上(非递归)版

递归版写法最贴近分治的定义,但每一次递归调用都有函数栈开销,而且当 n 很大时递归深度 log n 虽然不大,但常数开销依然存在。工程上(尤其库实现)更常用自底向上(bottom-up)的迭代版: 先两两合并长度为 1 的段,再合并长度为 2 的段,再合并长度为 4 的段…… 外层只有一个 for (len = 1; len < n; len *= 2) 循环,没有任何递归。

两版的时间空间复杂度完全一样,选择哪一个取决于:

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

/* ============================================================
   归并排序(递归版,升序)
   时间 O(n log n)(最好 = 平均 = 最坏),空间 O(n),稳定
   ============================================================ */

/* 合并两个相邻有序段 A[l..mid] 与 A[mid+1..r],借助临时数组 tmp */
void mergeRange(vector<int>& a, int l, int mid, int r, vector<int>& tmp, long long& cmp, long long& mv) {
    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        ++cmp;
        if (a[i] <= a[j]) tmp[k++] = a[i++];   // <= :相等时取左段 → 保证稳定
        else               tmp[k++] = a[j++];
        ++mv;
    }
    while (i <= mid) { tmp[k++] = a[i++]; ++mv; }   // 左段剩余,整段搬
    while (j <= r)   { tmp[k++] = a[j++]; ++mv; }   // 右段剩余,整段搬
    for (int t = l; t <= r; ++t) a[t] = tmp[t];     // 写回原数组
}

void msort(vector<int>& a, int l, int r, vector<int>& tmp, long long& cmp, long long& mv) {
    if (l >= r) return;                        // 递归边界:0 或 1 个元素天然有序
    int mid = l + (r - l) / 2;                 // 这样写可以避免 l+r 溢出
    msort(a, l, mid, tmp, cmp, mv);
    msort(a, mid + 1, r, tmp, cmp, mv);
    mergeRange(a, l, mid, r, tmp, cmp, mv);
}

void mergeSort(vector<int>& a, long long& cmp, long long& mv) {
    int n = a.size();
    cmp = mv = 0;
    if (n < 2) return;
    vector<int> tmp(n);                        // 临时数组只分配一次,反复复用
    msort(a, 0, n - 1, tmp, cmp, mv);
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}

int main() {
    vector<int> raw = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};
    long long cmp = 0, mv = 0;

    vector<int> a = raw;
    mergeSort(a, cmp, mv);
    cout << "归并排序结果 : "; printArr(a);
    cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    /* 已经有序 / 完全逆序:比较次数都在 O(n log n) 量级,不会退化 */
    vector<int> sorted = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
    a = sorted; mergeSort(a, cmp, mv);
    cout << "有序数据     : 比较 " << cmp << " 次,移动 " << mv << " 次\n";
    vector<int> rev = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
    a = rev; mergeSort(a, cmp, mv);
    cout << "逆序数据     : 比较 " << cmp << " 次,移动 " << mv << " 次\n";

    /* 稳定性验证:用 (关键字, 编号) 观察相等关键字的相对次序 */
    vector<pair<int, char>> rec = {{5, 'a'}, {2, 'x'}, {5, 'b'}, {1, 'y'}, {5, 'c'}};
    int n = rec.size();
    vector<pair<int, char>> tmp(n);
    /* 手写一次自底向上归并,便于观察 */
    for (int len = 1; len < n; len *= 2) {
        for (int l = 0; l < n; l += 2 * len) {
            int mid = min(l + len, n) - 1, r = min(l + 2 * len, n) - 1;
            if (mid >= r) continue;
            int i = l, j = mid + 1, k = l;
            while (i <= mid && j <= r) tmp[k++] = (rec[i].first <= rec[j].first) ? rec[i++] : rec[j++];
            while (i <= mid) tmp[k++] = rec[i++];
            while (j <= r) tmp[k++] = rec[j++];
            for (int t = l; t <= r; ++t) rec[t] = tmp[t];
        }
    }
    cout << "稳定性验证   : ";
    for (auto& p : rec) cout << p.first << p.second << " ";   // 1y 2x 5a 5b 5c —— 5 的相对次序没变
    cout << "\n";
    return 0;
}
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   归并排序(自底向上 / 非递归版)
   外层只有一个 len = 1, 2, 4, 8, ... 的循环,没有任何递归调用
   优点:没有函数栈开销、没有栈溢出风险、便于改造成外排序
   ============================================================ */
void mergeSortIter(vector<int>& a, long long& cmp, long long& mv) {
    int n = a.size();
    cmp = mv = 0;
    if (n < 2) return;
    vector<int> tmp(n);

    for (int len = 1; len < n; len *= 2) {            // 当前有序段的长度
        for (int l = 0; l < n; l += 2 * len) {        // 每次合并相邻两段
            int mid = min(l + len, n) - 1;            // 左段 [l, mid]
            int r   = min(l + 2 * len, n) - 1;        // 右段 [mid+1, r]
            if (mid >= r) continue;                   // 右段不存在,本组无需合并
            int i = l, j = mid + 1, k = l;
            while (i <= mid && j <= r) {
                ++cmp;
                tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];   // 相等取左 → 稳定
                ++mv;
            }
            while (i <= mid) { tmp[k++] = a[i++]; ++mv; }
            while (j <= r)   { tmp[k++] = a[j++]; ++mv; }
            for (int t = l; t <= r; ++t) a[t] = tmp[t];
        }
    }
}

/* 实用增强:小区间改用插入排序,可以减少递归/循环开销
   (这也是 TimSort 的做法:短的 run 直接用插入排序补长) */
void insertionRange(vector<int>& a, int l, int r) {
    for (int i = l + 1; i <= r; ++i) {
        int key = a[i], j = i - 1;
        while (j >= l && a[j] > key) { a[j + 1] = a[j]; --j; }
        a[j + 1] = key;
    }
}
void mergeSortHybrid(vector<int>& a, long long& cmp, long long& mv, int threshold = 7) {
    int n = a.size();
    cmp = mv = 0;
    if (n < 2) return;
    vector<int> tmp(n);
    for (int l = 0; l < n; l += threshold) insertionRange(a, l, min(l + threshold, n) - 1);
    for (int len = threshold; len < n; len *= 2) {
        for (int l = 0; l < n; l += 2 * len) {
            int mid = min(l + len, n) - 1, r = min(l + 2 * len, n) - 1;
            if (mid >= r) continue;
            int i = l, j = mid + 1, k = l;
            while (i <= mid && j <= r) tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
            while (i <= mid) tmp[k++] = a[i++];
            while (j <= r) tmp[k++] = a[j++];
            for (int t = l; t <= r; ++t) a[t] = tmp[t];
        }
    }
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}
static bool isSorted(const vector<int>& a) {
    for (size_t i = 1; i < a.size(); ++i) if (a[i - 1] > a[i]) return false;
    return true;
}

int main() {
    vector<int> raw = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};
    long long cmp = 0, mv = 0;

    vector<int> a = raw;
    mergeSortIter(a, cmp, mv);
    cout << "迭代版归并   : "; printArr(a);
    cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    a = raw; mergeSortHybrid(a, cmp, mv);
    cout << "小区间插入版 : "; printArr(a);
    cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    /* 边界测试:n = 0..8 及 n = 1000 随机 */
    for (int n = 0; n <= 8; ++n) {
        vector<int> t(n);
        for (int i = 0; i < n; ++i) t[i] = (i * 5 + 3) % 7;
        mergeSortIter(t, cmp, mv);
        if (!isSorted(t)) { cout << "边界 n=" << n << " 失败\n"; return 1; }
    }
    vector<int> big(1000);
    for (int i = 0; i < 1000; ++i) big[i] = (i * 7919) % 1000;
    mergeSortIter(big, cmp, mv);
    cout << "n=1000 随机  : 有序性校验 " << (isSorted(big) ? "通过" : "失败") << "\n";
    return 0;
}

11.7.4 稳定性、空间代价与「为什么不用它排数组」

稳定性:归并排序是稳定的,因为合并时的判断写的是 a[i] <= a[j] 取左段。 这样一来,当左右两段出现相等的元素时,一定是原来在左边的那个先被放进结果数组, 相对次序得以保持。如果把等号去掉(写成 a[i] < a[j] 才取左边), 相等时会优先取右段,归并排序立刻变成不稳定——这个改动和冒泡里的 > vs >= 是同一类陷阱。

空间:O(n)。合并必须借助一个和原数组等长的临时数组。这一点让归并排序失去了「原地」的资格, 也是它在内存排序中不如快排流行的主要原因。能不能省掉这个数组?

n = 100 万时的真实对比 归并排序的移动次数约 n log n ≈ 2×107,而且每次移动都是「顺序写」—— 对 CPU 缓存与预取极其友好;快排虽然比较次数相近,但划分是来回跳跃的。 所以在外部排序(数据在磁盘上)场景里,归并排序是无可替代的: 磁盘最怕随机访问,而「顺序读块 → 归并 → 顺序写块」正是它的天然形态。

11.7.5 外排序:多路归并与败者树

当数据量大到内存装不下(比如 100 GB 的日志要排序,内存只有 4 GB),就必须用外部排序。 它的标准套路分两步:

  1. 生成初始归并段(run):把文件切成若干块,每块读进内存用内部排序(通常用快排或堆排)排好, 再写回磁盘。这样得到一个「内部有序、整体无序」的顺串集合。
  2. 多路归并:把这些有序顺串合并成一个整体有序的大文件。每次从 k 个顺串中各读一块进内存, 用k 路归并选出最小的写到输出缓冲区,缓冲区满了就写回磁盘。

多路归并带来一个新问题:k 路归并时,每次选最小要比较 k−1 次, 如果 k 很大(比如 100 路),比较开销会很吓人。解决办法是败者树(loser tree): 它是一棵完全二叉树,每个内部结点记录「刚刚比较中的败者」,胜者继续向上比。 这样选出全局最小值只需要 O(log k) 次比较,而且当某个顺串的当前元素被取走后, 只需沿着从叶子到根的路径重新比赛,也是 O(log k)

败者树 vs 堆 败者树本质上就是「专门为多路归并优化的堆」。为什么用败者树而不是直接用堆? 因为堆在弹出堆顶后要重新调整整棵树,而败者树只需要沿着一条路径更新; 并且败者树额外保存了「败者」信息,使得输出下一个元素时不必从根重新比。
外排序的总时间 = 内部排序时间 + 磁盘读写时间 + 归并比较时间, 而磁盘 I/O 通常是瓶颈,所以优化的核心是减少归并趟数(增大 k、增大内存缓冲区)。

11.7.6 重点应用:用归并排序求逆序对

逆序对(inversion)的定义:下标 i < jA[i] > A[j] 的一对元素。 逆序对总数是「序列离有序有多远」的度量。例如 [5, 2, 9, 1] 的逆序对有 (5,2)、(5,1)、(2,1)、(9,1),共 4 个。

暴力做法是双重循环枚举所有 (i, j),O(n²)。n = 105 就超时了。 归并排序的做法漂亮得多,核心观察只有一句话:

在合并 A[i..mid]A[mid+1..j..r] 时,若 A[i] > A[j], 则左段从 imid全部 (mid − i + 1) 个元素都与 A[j] 构成逆序对

为什么?因为左段已经有序,如果 A[i] > A[j],那么 A[i+1], A[i+2], …, A[mid] 只会更大,当然也都大于 A[j];同时它们在下标上都位于 j 之前,所以全都构成逆序对。 于是本来要一个个数的 (mid − i + 1) 个逆序对,被一次性统计完了。

这样做为什么不会重复计数?因为归并排序是按「归并段」分层的:每一对下标 (p, q) 会在唯一的一层被分开到左右两段,所以每对逆序对恰好被统计一次。 总复杂度与归并排序相同:O(n log n)

动画里每一帧左上角的「累计逆序对」就是答案。注意观察:这个计数器只在「取右段元素」时增加, 而且一次增加 mid − i + 1 个。

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

/* ============================================================
   用归并排序统计逆序对
   逆序对:i < j 但 a[i] > a[j]
   核心:合并时若 a[i] > a[j],则左段 [i, mid] 全部元素都与 a[j] 构成逆序对
   时间 O(n log n),空间 O(n)
   ============================================================ */
long long mergeCount(vector<int>& a, int l, int mid, int r, vector<int>& tmp) {
    int i = l, j = mid + 1, k = l;
    long long cnt = 0;
    while (i <= mid && j <= r) {
        if (a[i] <= a[j]) {
            tmp[k++] = a[i++];                  // 左边不大于右边,不产生逆序对
        } else {
            cnt += (mid - i + 1);               // 关键一步:一次性统计一整段
            tmp[k++] = a[j++];
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r)   tmp[k++] = a[j++];
    for (int t = l; t <= r; ++t) a[t] = tmp[t];
    return cnt;
}

long long msortCount(vector<int>& a, int l, int r, vector<int>& tmp) {
    if (l >= r) return 0;
    int mid = l + (r - l) / 2;
    long long cnt = 0;
    cnt += msortCount(a, l, mid, tmp);
    cnt += msortCount(a, mid + 1, r, tmp);
    cnt += mergeCount(a, l, mid, r, tmp);
    return cnt;
}

long long countInversions(vector<int> a) {     // 传值:不破坏调用者的数组
    int n = a.size();
    if (n < 2) return 0;
    vector<int> tmp(n);
    return msortCount(a, 0, n - 1, tmp);
}

/* 对照:暴力 O(n^2) 统计,只用于小数据验证 */
long long countInversionsBrute(const vector<int>& a) {
    long long cnt = 0;
    for (size_t i = 0; i < a.size(); ++i)
        for (size_t j = i + 1; j < a.size(); ++j)
            if (a[i] > a[j]) ++cnt;
    return cnt;
}

int main() {
    vector<int> a = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0};
    cout << "归并统计逆序对 : " << countInversions(a) << "\n";        // 25
    cout << "暴力统计逆序对 : " << countInversionsBrute(a) << "\n"; // 25

    vector<int> sorted = {1, 2, 3, 4, 5};
    cout << "完全有序       : " << countInversions(sorted) << "\n";  // 0

    vector<int> rev = {5, 4, 3, 2, 1};
    cout << "完全逆序       : " << countInversions(rev) << "\n";     // 10 = 5*4/2

    /* 大规模随机数据:归并 O(n log n) vs 暴力 O(n^2)(暴力这里只跑 n=3000) */
    vector<int> big(200000);
    for (int i = 0; i < 200000; ++i) big[i] = (i * 7919 + 13) % 200000;
    cout << "n=200000 逆序对: " << countInversions(big) << "(归并版,毫秒级完成)\n";

    vector<int> small(3000);
    for (int i = 0; i < 3000; ++i) small[i] = (i * 7919 + 13) % 3000;
    cout << "n=3000 两法一致性: "
         << (countInversions(small) == countInversionsBrute(small) ? "一致" : "不一致") << "\n";

    /* 提示:逆序对数量可能达到 n(n-1)/2,n = 200000 时约 2×10^10,
       必须用 long long(int 会溢出)—— 这是本类题目最常见的 WA 原因。 */
    return 0;
}
易错点:逆序对计数一定要用 long long 逆序对总数最大可以到 n(n−1)/2。当 n = 105 时约为 5×109已经超出 int 的表示范围(约 2.1×109)。 用 int 计数必然溢出、必然 WA。请一律使用 long long
另外注意 cnt += (mid - i + 1)mid - i + 1 是 int, 但加上去的时候会被提升为 long long,只要 cnt 本身是 long long 就没问题。
考点:逆序对的三个常见变式求逆序对总数:本节方法,O(n log n)。 ② 求每个元素前面比它大的个数:树状数组 / 归并都能做,注意离散化。 ③ 「最少交换次数使数组有序」:如果只允许交换相邻元素,答案就是逆序对总数 (因为每次相邻交换恰好消除一个逆序对)。如果允许任意交换,答案则是 n − 环的个数。 这三条经常在同一道题里连环考。

11.8 快速排序:实测最快的比较排序

11.8.1 一句话本质与两种划分写法

快速排序 = 选一个基准 pivot,一趟划分把它放到最终位置, 使得左边全部 ≤ 它、右边全部 ≥ 它,然后递归处理左右两半

快速排序(quick sort,Hoare 1960)和归并排序都是分治,但顺序刚好相反: 归并是「先切分、递归排好、最后合并」,快排是「先划分、让基准一步到位、再递归处理两边」。 快排不需要额外的合并步骤,所有工作都在原地完成,这是它比归并省空间的原因。

划分(partition)是快排的全部灵魂。它的目标只有一句话:

一趟划分结束后,A[p] 落在它最终该在的位置上, 且 A[l..p−1] 全部 ≤ A[p]A[p+1..r] 全部 ≥ A[p]

划分有两种主流写法,必须都能手写:

写法核心动作特点
挖坑法
(本讲动画采用)
把基准暂存起来,原地留下一个「坑」;j 从右往左找比基准小的填进左边的坑, i 从左往右找比基准大的填进右边的坑;相遇时把基准填进最后的坑 交换次数少(用「赋值」代替「交换」),图解直观,是大多数国内教材的讲法
Hoare 版
(原始论文版本)
ij 从两端相向扫描,i 停在 ≥ pivot 处、 j 停在 ≤ pivot 处,然后交换两者,直到两指针交叉 代码更短,平均交换次数比挖坑法略多但相差不大;返回的 j 不是基准的最终位置, 递归区间必须写成 [l, j][j+1, r],写错就死循环

下面是挖坑法一趟划分的完整过程(以 A = [5, 2, 9, 1, 7, 3, 8, 4, 6, 0] 为例,基准取 A[0] = 5):

挖坑法划分:pivot = A[0] = 5,i 从左、j 从右,相向填坑 ① 挖坑:把 5 暂存,下标 0 变成坑(虚线) 2 9 1 7 3 8 4 6 0 5 ← 基准暂存在这里 ② j 从右往左找到 0 < 5,填进左边的坑;坑转移到 j = 9 0 2 9 1 7 3 8 4 6 ③ i 从左往右找到 9 > 5,填进右边的坑;如此往复…… 0 2 4 1 3 8 7 6 9 ④ i 与 j 在下标 5 相遇,把基准 5 填进最后的坑 —— 一趟划分完成 0 2 4 1 3 5 8 7 6 9 左边 [0,2,4,1,3] 全部 ≤ 5 右边 [8,7,6,9] 全部 ≥ 5 关键认识:这一趟划分虽然没有把任何元素排到最终位置(除了基准 5),却把问题规模砍成了两半 —— 这就是分治的威力。
图 11-7 快速排序的挖坑法划分:基准归位,左右分区

11.8.2 交互动画:基准、双指针、递归区间

动画用 A = [5, 2, 9, 1, 7, 3, 8, 4, 6, 0, 11, 10]。屏幕上的紫色方块是当前基准, 蓝色 i 与橙色 j 是两个扫描指针,虚线框是待填的坑, 上方短横线是递归调用栈里各层的待排序区间。请特别留意每完成一次划分, 基准的位置就永久固定了 —— 画面上会有一格变成紫色「归位」状态。

11.8.3 基准选择的三种策略与各自的陷阱

快排的性能几乎完全由基准选得好不好决定。理想情况下基准应该接近中位数, 这样每次划分都能把区间砍成两半,递归深度 log n,总时间 O(n log n)。 如果基准每次都是极值,划分就变成「一边 0 个元素、一边 n−1 个元素」,递归深度退化成 n, 总时间变成 O(n²)

策略做法优点退化情形
取首元素 / 尾元素 pivot = A[l] 最简单,一行代码 数据已经有序或逆序时直接退化成 O(n²); 这也是最容易被出题人构造数据卡掉的写法
随机基准 swap(A[l], A[rand(l, r)]),再取 A[l] 期望复杂度 O(n log n),任何人都无法构造出固定卡你的数据 理论上仍有极小概率退化,但概率可忽略;依赖随机数生成器的开销
三数取中 A[l]、A[mid]、A[(l+r)/2] 三者的中位数作为基准 对「已经有序」「已经逆序」这两类最常见的真实数据免疫; 常数比随机化更小(没有 rand 调用) 对精心构造的「三数取中杀手序列」仍会退化(如 [1,2,…,n] 经过特定排列)
随机 + 三数取中 先随机抽 3 个位置,再取中位数 工程上的最优组合,std::sort 采用的思路

11.8.4 为什么「有序数据 + 端点基准」会退化成 O(n²)

这是快排最经典的考点,必须能把推导写出来。设输入是已经升序A = [1, 2, 3, …, n],基准取首元素 A[l]

以挖坑法为例,看第 1 趟划分(l = 0, r = n−1pivot = 1):

第 2 趟同理:对 [1, n−1] 划分,j 又要从右扫到底,做 n−2 次比较, 分出规模 n−2 的右子区间……依此类推。于是总比较次数为:

T(n) = (n − 1) + (n − 2) + (n − 3) + … + 1 = n(n − 1) / 2 = O(n²)

对应的递推式是:

T(n) = T(n − 1) + (n − 1), T(1) = T(0) = 0 ⟹ T(n) = n(n − 1)/2

这不只是「比较次数多」的问题,更严重的是递归深度变成了 n: 每层递归只减少一个元素,函数调用栈会压入 n 层,栈空间从 O(log n) 变成 O(n)。 当 n = 105 时,仅递归栈就可能吃掉几 MB, 在嵌入式或线程栈很小的环境里会直接栈溢出崩溃。所以「快排最坏 O(n²)」这句话里, 藏着的其实是两个坑:时间退化空间退化

易错点:手写快排千万不要取端点做基准 很多同学手写快排时顺手写 int pivot = a[l];,本地测几组随机数据都通过, 一交到 OJ 上就被「已经排好序」的数据卡成 TLE,甚至 RE(栈溢出)。 标准做法:三数取中 + 小区间插入排序,或者至少加一句 swap(a[l], a[l + rand() % (r - l + 1)]);

11.8.5 递归深度与工程优化四件套

工业界的快排实现(std::sort 的 introsort、Java 的 DualPivotQuicksort)都做了四件事:

  1. 小区间改用插入排序。当区间长度小于阈值(通常 8~16)时,递归的开销 (压栈、保护寄存器、函数调用)已经超过了排序本身。此时切到插入排序,实测能快 20%~30%。 std::sort 用的阈值是 16。
  2. 三数取中选基准,避免有序数据退化。
  3. 尾递归优化(消除尾递归)。快排有两个递归调用,把其中「较长的那一半」用循环处理、 只对「较短的一半」递归,可以把递归深度从 O(n) 压到 O(log n)。 这样即使遇到最坏情况,栈深度也只有 O(log n),不会栈溢出—— 虽然时间复杂度仍是 O(n²),但至少程序不会崩。完整可运行示例见下面的代码块。
  4. 三路划分处理大量重复元素(见下一节),以及深度超限时切换到堆排序 (introsort 的「intro」就是 introspective,一旦递归深度超过 2 log n 就改用堆排序, 保证最坏也是 O(n log n))。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   快速排序的尾递归优化(消除尾递归)
   把两个递归调用中的「长的一半」改成循环,只递归「短的一半」
   效果:递归深度从最坏 O(n) 降到 O(log n),杜绝栈溢出
   ============================================================ */

int partitionHole(vector<int>& a, int l, int r) {
    int pivot = a[l], i = l, j = r;            // 挖坑法
    while (i < j) {
        while (i < j && a[j] >= pivot) --j;
        if (i < j) a[i++] = a[j];
        while (i < j && a[i] <= pivot) ++i;
        if (i < j) a[j--] = a[i];
    }
    a[i] = pivot;
    return i;
}

int maxDepthPlain = 0, maxDepthTail = 0;

/* 普通双递归版:最坏情况下递归深度 = n */
void quickSortPlainD(vector<int>& a, int l, int r, int d) {
    if (d > maxDepthPlain) maxDepthPlain = d;
    if (l >= r) return;
    int p = partitionHole(a, l, r);
    quickSortPlainD(a, l, p - 1, d + 1);
    quickSortPlainD(a, p + 1, r, d + 1);
}

/* 尾递归优化版:只对较短的一半递归,较长的一半用 while 循环吃掉 */
void quickSortTailD(vector<int>& a, int l, int r, int d) {
    if (d > maxDepthTail) maxDepthTail = d;
    while (l < r) {
        int p = partitionHole(a, l, r);
        if (p - l < r - p) {                   // 左半更短 → 递归左边,循环处理右边
            quickSortTailD(a, l, p - 1, d + 1);
            l = p + 1;
        } else {                               // 右半更短 → 递归右边,循环处理左边
            quickSortTailD(a, p + 1, r, d + 1);
            r = p - 1;
        }
    }
}

static bool isSorted(const vector<int>& a) {
    for (size_t i = 1; i < a.size(); ++i) if (a[i - 1] > a[i]) return false;
    return true;
}

int main() {
    /* 最坏情况:完全升序的数据 + 取首元素为基准 */
    const int N = 1000;
    vector<int> asc(N);
    for (int i = 0; i < N; ++i) asc[i] = i;

    vector<int> a = asc;
    quickSortPlainD(a, 0, N - 1, 1);
    cout << "普通双递归版:最大递归深度 = " << maxDepthPlain
         << "(n = " << N << "),有序性 " << (isSorted(a) ? "通过" : "失败") << "\n";

    a = asc;
    quickSortTailD(a, 0, N - 1, 1);
    cout << "尾递归优化版:最大递归深度 = " << maxDepthTail
         << ",有序性 " << (isSorted(a) ? "通过" : "失败") << "\n";

    cout << "\n结论:两者排序结果完全一样,但尾递归优化把栈空间从 O(n) 压到了 O(log n)。\n";
    cout << "这就是为什么工程实现里绝不允许快排出现 O(n) 的递归深度。\n";
    return 0;
}

11.8.6 三路划分:荷兰国旗问题

普通的两路划分有一个致命弱点:当序列中存在大量与基准相等的元素时,它会把相等的元素 一股脑分到同一侧,导致划分极不平衡。极端情况:[5, 5, 5, 5, 5, 5, 5](全是同一个数), 取首元素为基准做两路划分,会把所有元素都分到「≥ pivot」的那一边,递归深度变成 n, 复杂度退化成 O(n²)。

解决方法是三路划分(three-way partition),也就是著名的荷兰国旗问题 (因为荷兰国旗恰好是红白蓝三条,正好对应「小于、等于、大于」三段)。它把区间分成三部分:

A[l..lt−1] < pivot | A[lt..gt] = pivot | A[gt+1..r] > pivot

划分完成后,中间那一段「等于 pivot」的元素整段都不用再递归了—— 它们的最终位置已经确定。于是全相同的序列只要一趟就排完,复杂度是 O(n)。 对于「大量重复关键字」的真实数据(比如按性别、按省份排序的成绩单),三路划分的提升是数量级的。

11.8.7 完整 C++ 实现(Hoare + 随机 + 三数取中 + 三路划分)

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

/* ============================================================
   快速排序:四种划分 / 基准策略的完整实现与对照
   ============================================================ */

/* ---------- ① 挖坑法划分(返回基准的最终下标) ---------- */
int partitionHole(vector<int>& a, int l, int r, long long& cmp, long long& mv) {
    int pivot = a[l];                          // 挖坑:a[l] 变成空位
    int i = l, j = r;
    while (i < j) {
        while (i < j && a[j] >= pivot) { --j; ++cmp; }   // 从右往左找比 pivot 小的
        if (i < j) { a[i++] = a[j]; ++mv; }              // 填进左边的坑
        while (i < j && a[i] <= pivot) { ++i; ++cmp; }   // 从左往右找比 pivot 大的
        if (i < j) { a[j--] = a[i]; ++mv; }              // 填进右边的坑
    }
    a[i] = pivot;                              // 基准归位
    return i;
}

/* ---------- ② Hoare 版划分(原始论文写法) ---------- */
/* 注意:Hoare 版返回的 j 只是「左右分界」,不是基准的最终位置!
   因此递归区间必须写成 [l, j] 与 [j+1, r],写成 [l, j-1] 会死循环 */
int partitionHoare(vector<int>& a, int l, int r, long long& cmp, long long& swp) {
    int pivot = a[l + (r - l) / 2];            // 取中间元素为基准,避免有序数据退化
    int i = l - 1, j = r + 1;
    while (true) {
        do { ++i; ++cmp; } while (a[i] < pivot);
        do { --j; ++cmp; } while (a[j] > pivot);
        if (i >= j) return j;
        swap(a[i], a[j]); ++swp;
    }
}

/* ---------- ③ 三数取中:把 l / mid / r 的中位数换到 a[l] ---------- */
void medianOfThreeToLeft(vector<int>& a, int l, int r) {
    int mid = l + (r - l) / 2;
    if (a[mid] < a[l]) swap(a[l], a[mid]);
    if (a[r]   < a[l]) swap(a[l], a[r]);
    if (a[r]   < a[mid]) swap(a[mid], a[r]);
    swap(a[l], a[mid]);                        // 现在 a[l] 是三者中的中位数
}

/* ---------- ④ 随机基准:随机选一个位置换到 a[l] ---------- */
void randomToLeft(vector<int>& a, int l, int r) {
    int k = l + rand() % (r - l + 1);
    swap(a[l], a[k]);
}

/* ---------- ⑤ 小区间插入排序 ---------- */
void insertionRange(vector<int>& a, int l, int r, long long& cmp, long long& mv) {
    for (int i = l + 1; i <= r; ++i) {
        int key = a[i], j = i - 1;
        while (j >= l) {
            ++cmp;
            if (a[j] <= key) break;
            a[j + 1] = a[j]; ++mv; --j;
        }
        a[j + 1] = key;
    }
}

const int INSERT_THRESHOLD = 16;               // 小区间阈值,与 std::sort 一致

/* 综合版快排:三数取中 + 小区间插入 + 短区间递归(控制栈深) */
void quickSort(vector<int>& a, int l, int r, long long& cmp, long long& mv) {
    while (l < r) {
        if (r - l + 1 <= INSERT_THRESHOLD) { insertionRange(a, l, r, cmp, mv); return; }
        medianOfThreeToLeft(a, l, r);          // 三数取中,避免有序退化
        int p = partitionHole(a, l, r, cmp, mv);
        /* 只对较短的一半递归,较长的一半用循环处理 → 栈深 O(log n) */
        if (p - l < r - p) { quickSort(a, l, p - 1, cmp, mv); l = p + 1; }
        else               { quickSort(a, p + 1, r, cmp, mv); r = p - 1; }
    }
}

/* 朴素版(首元素为基准,只用于演示退化,不要在生产代码里用) */
void quickSortNaive(vector<int>& a, int l, int r, long long& cmp, long long& mv) {
    if (l >= r) return;
    int p = partitionHole(a, l, r, cmp, mv);
    quickSortNaive(a, l, p - 1, cmp, mv);
    quickSortNaive(a, p + 1, r, cmp, mv);
}

/* Hoare 版快排(递归区间是 [l, j] 与 [j+1, r]) */
void quickSortHoare(vector<int>& a, int l, int r, long long& cmp, long long& swp) {
    if (l >= r) return;
    int j = partitionHoare(a, l, r, cmp, swp);
    quickSortHoare(a, l, j, cmp, swp);
    quickSortHoare(a, j + 1, r, cmp, swp);
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}
static bool isSorted(const vector<int>& a) {
    for (size_t i = 1; i < a.size(); ++i) if (a[i - 1] > a[i]) return false;
    return true;
}

int main() {
    vector<int> raw = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0, 11, 10};
    long long cmp = 0, mv = 0;

    vector<int> a = raw; quickSort(a, 0, a.size() - 1, cmp, mv);
    cout << "综合版快排   : "; printArr(a);
    cout << "  比较 " << cmp << " 次,移动 " << mv << " 次\n";

    a = raw; cmp = mv = 0; long long swp = 0;
    quickSortHoare(a, 0, a.size() - 1, cmp, swp);
    cout << "Hoare 版快排 : "; printArr(a);
    cout << "  比较 " << cmp << " 次,交换 " << swp << " 次\n";

    /* 有序数据 + 端点基准:退化的现场 */
    const int N = 2000;
    vector<int> asc(N);
    for (int i = 0; i < N; ++i) asc[i] = i;
    a = asc; cmp = mv = 0;
    quickSortNaive(a, 0, a.size() - 1, cmp, mv);
    cout << "\nn = " << N << " 升序 + 首元素基准(朴素版):比较 " << cmp << " 次";
    cout << "(n(n-1)/2 = " << (long long)N * (N - 1) / 2 << ")\n";

    a = asc; cmp = mv = 0;
    quickSort(a, 0, a.size() - 1, cmp, mv);
    cout << "n = " << N << " 升序 + 三数取中(综合版)  :比较 " << cmp << " 次,有序性校验 "
         << (isSorted(a) ? "通过" : "失败") << "\n";

    /* 大量重复元素:三路划分的主场(见下一个代码块) */
    cout << "\n提示:全部元素相同时,两路划分仍会退化成 O(n^2),请看三路划分的实现。\n";
    return 0;
}
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

/* ============================================================
   三路划分快速排序(荷兰国旗问题)
   把区间分成 < pivot、== pivot、> pivot 三段
   中间那一段整段跳过递归 —— 大量重复元素时的杀手锏
   ============================================================ */

/* 返回值通过引用传出:lt 是「等于区」的左端,gt 是「等于区」的右端
   划分后:a[l..lt-1] < pivot,a[lt..gt] == pivot,a[gt+1..r] > pivot */
void partition3Way(vector<int>& a, int l, int r, int& lt, int& gt, long long& cmp, long long& swp) {
    int pivot = a[l + (r - l) / 2];            // 取中间元素作基准,避免有序退化
    lt = l;                                    // lt 指向「小于区」的下一个空位
    gt = r;                                    // gt 指向「大于区」的前一个空位
    int i = l;
    while (i <= gt) {
        ++cmp;
        if (a[i] < pivot) {
            swap(a[lt], a[i]); ++swp;
            ++lt; ++i;
        } else if (a[i] > pivot) {
            swap(a[i], a[gt]); ++swp;
            --gt;                              // 注意:i 不前进!换过来的元素还没检查
        } else {
            ++i;                               // 等于 pivot,留在中间区
        }
    }
}

void quickSort3Way(vector<int>& a, int l, int r, long long& cmp, long long& swp) {
    while (l < r) {
        if (r - l + 1 <= 16) {                 // 小区间用插入排序收尾
            for (int i = l + 1; i <= r; ++i) {
                int key = a[i], j = i - 1;
                while (j >= l && a[j] > key) { a[j + 1] = a[j]; --j; }
                a[j + 1] = key;
            }
            return;
        }
        int lt, gt;
        partition3Way(a, l, r, lt, gt, cmp, swp);
        /* 中间段 [lt, gt] 已经就位,不参与递归 */
        if (lt - l < r - gt) { quickSort3Way(a, l, lt - 1, cmp, swp); l = gt + 1; }
        else                 { quickSort3Way(a, gt + 1, r, cmp, swp); r = lt - 1; }
    }
}

/* 对照:两路划分(挖坑法)在大量重复元素上的表现 */
void quickSort2Way(vector<int>& a, int l, int r, long long& cmp, long long& swp) {
    if (l >= r) return;
    int pivot = a[l], i = l, j = r;
    while (i < j) {
        while (i < j && a[j] >= pivot) { --j; ++cmp; }
        if (i < j) { a[i++] = a[j]; ++swp; }
        while (i < j && a[i] <= pivot) { ++i; ++cmp; }
        if (i < j) { a[j--] = a[i]; ++swp; }
    }
    a[i] = pivot;
    quickSort2Way(a, l, i - 1, cmp, swp);
    quickSort2Way(a, i + 1, r, cmp, swp);
}

static void printArr(const vector<int>& a, int limit = 30) {
    cout << "[";
    for (int i = 0; i < (int)a.size() && i < limit; ++i) cout << (i ? ", " : "") << a[i];
    if ((int)a.size() > limit) cout << ", ...";
    cout << "]";
}
static bool isSorted(const vector<int>& a) {
    for (size_t i = 1; i < a.size(); ++i) if (a[i - 1] > a[i]) return false;
    return true;
}

int main() {
    /* 全部元素相同:三路划分一趟搞定,两路划分退化成 O(n^2) */
    const int N = 4000;
    vector<int> same(N, 7);

    vector<int> a = same;
    long long cmp = 0, swp = 0;
    quickSort3Way(a, 0, a.size() - 1, cmp, swp);
    cout << "全相同(三路划分) : 比较 " << cmp << " 次,交换 " << swp
         << " 次,有序? " << (isSorted(a) ? "是" : "否") << "\n";

    a = same; cmp = swp = 0;
    quickSort2Way(a, 0, a.size() - 1, cmp, swp);
    cout << "全相同(两路划分) : 比较 " << cmp << " 次,交换 " << swp
         << " 次 ← 明显退化\n";

    /* 只有三种取值的典型数据(比如按成绩等级排序) */
    vector<int> grade;
    for (int i = 0; i < 3000; ++i) grade.push_back((i * 7919) % 3);
    a = grade; cmp = swp = 0;
    quickSort3Way(a, 0, a.size() - 1, cmp, swp);
    cout << "三种取值(三路)   : 比较 " << cmp << " 次,有序? " << (isSorted(a) ? "是" : "否") << "\n";

    /* 普通随机数据 */
    vector<int> rnd(3000);
    for (int i = 0; i < 3000; ++i) rnd[i] = (i * 7919 + 13) % 3000;
    a = rnd; cmp = swp = 0; quickSort3Way(a, 0, a.size() - 1, cmp, swp);
    cout << "随机数据(三路)   : 比较 " << cmp << " 次,有序? " << (isSorted(a) ? "是" : "否") << "\n";

    vector<int> demo = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0, 11, 10};
    cmp = swp = 0; quickSort3Way(demo, 0, demo.size() - 1, cmp, swp);
    cout << "小样例结果       : "; printArr(demo); cout << "\n";
    return 0;
}

11.8.8 不稳定性,以及「为什么实测最快」

快排不稳定。原因还是那个老熟人——长距离交换。划分时 ij 可能相隔很远,交换一次就会让一个元素跨过一大段,中间夹着的相等元素自然被跨过去了。

具体例子:[5a, 3, 5b, 1],取首元素 5a 为基准做挖坑法划分。 j 从右往左找比 5 小的,停在下标 3(值 1),填进 A[0][1, 3, 5b, 1];接着 i 从左往右找比 5 大的,走到 i = j = 3 相遇, 把基准填回去 → [1, 3, 5b, 5a]5a 与 5b 的次序被颠倒了,不稳定。

那为什么快排实测还是最快?三个原因:

考点:三大 O(n log n) 排序的横向对比 快排:平均最快、原地、不稳定、最坏 O(n²)(可优化到「最坏也是 O(n log n)」的 introsort)。
归并:稳定、最坏也 O(n log n)、需要 O(n) 额外空间、适合链表与外部排序。
堆排:原地、最坏也 O(n log n)、不稳定、缓存不友好所以常数最大。
一句话选型:要稳定用归并,要省内存用堆排,什么都不要求就用快排。

11.9 基数排序:不比较也能排序

11.9.1 LSD 与 MSD:从最低位还是最高位开始

基数排序(radix sort)是分配类排序的代表:它从不比较任何两个关键字的大小, 而是把关键字看成「若干位数字的组合」,按位把元素分配进桶里再收集回来,反复若干轮就排好了。 它是唯一能在 O(n) 量级(当位数 d 是常数时)完成排序的实用算法之一, 也就此绕开了 11.1.3 提到的 Ω(n log n) 比较下界。

按处理顺序,基数排序分两种:

本讲统一用 LSD,因为它是考试与工程里的主流,而且它的正确性依赖于一个关键性质:稳定性(见 11.9.3)。

11.9.2 三轮完整手推([170, 45, 75, 90, 802, 24, 2, 66])

我们用 A = [170, 45, 75, 90, 802, 24, 2, 66] 完整推三轮(个位 → 十位 → 百位):

第 1 轮:按个位分配

元素17045759080224266
个位数字05502426
进入的桶bucket[0]bucket[5]bucket[5]bucket[0]bucket[2]bucket[4]bucket[2]bucket[6]

分配结果:bucket[0] = [170, 90]bucket[2] = [802, 2]bucket[4] = [24]bucket[5] = [45, 75]bucket[6] = [66], 其余桶为空。
bucket[0] → bucket[9] 的顺序收集,得到: [170, 90, 802, 2, 24, 45, 75, 66]。此时序列已按个位有序。

第 2 轮:按十位分配

元素17090802224457566
十位数字79002476
进入的桶bucket[7]bucket[9]bucket[0]bucket[0]bucket[2]bucket[4]bucket[7]bucket[6]

注意 8022 的十位都是 0(不足的位按 0 处理)—— 它们在 bucket[0] 里的顺序是 [802, 2]正好保持了上一轮收集后的先后次序。这一点至关重要。
收集得到:[802, 2, 24, 45, 66, 170, 75, 90]。此时序列已按后两位有序。

第 3 轮:按百位分配

元素80222445661707590
百位数字80000100
进入的桶bucket[8]bucket[0]bucket[0]bucket[0]bucket[0]bucket[1]bucket[0]bucket[0]

收集:bucket[0] = [2, 24, 45, 66, 75, 90]bucket[1] = [170]bucket[8] = [802],得到 [2, 24, 45, 66, 75, 90, 170, 802]——排序完成!

第 1 轮:按个位分配到 10 个桶(LSD 的第一轮) 170 45 75 90 802 24 2 66 颜色 = 个位数字所对应的桶 bucket[0] 170 90 bucket[2] 802 2 bucket[4] 24 bucket[5] 45 75 bucket[6] 66 ↑ 桶内元素保持进入的先后次序(先进先出) bucket[1]、bucket[3]、bucket[7]~bucket[9] 为空 按 bucket[0] → bucket[9] 的顺序收集: 170 90 802 2 24 45 75 66 第 1 轮结束,序列已按个位有序
图 11-8 基数排序第 1 轮的分配与收集(个位)

11.9.3 交互动画:10 个桶的分配与收集

动画把 10 个桶竖着画出来,元素被「丢进」对应的桶里,再按桶号顺序接回结果序列。 请重点盯住两处:① 同一个桶里元素的上下顺序;② 每一轮结束后序列的变化规律 —— 第 1 轮后按个位有序,第 2 轮后按后两位有序,第 3 轮后整体有序

11.9.4 为什么基数排序必须稳定

这是基数排序最核心、也最容易考的一个问题。答案是:因为每一位的处理都要依赖上一轮的结果, 而上一轮的结果正是靠「桶内保持原序」保留下来的。

用具体数字说明。假设第 1 轮按个位排完后有 [802, 2](它们个位都是 2, 802 因为原来在前面所以排在前面)。第 2 轮按十位分配时,两者的十位都是 0, 会被放进同一个桶 bucket[0]。此时:

更严格的表述是:基数排序的正确性建立在「按第 k 位排序时,前面 k−1 位已经有序」这个循环不变式上。 而「低位已经有序」这个信息完全存储在序列的排列顺序里——一旦某一轮破坏了相对次序, 低位的有序信息就永久丢失了,后续轮次无法再恢复。

考点:基数排序的稳定性是「必须」而不是「加分项」 别的算法不稳定只是「少了个特性」,基数排序不稳定则是直接算错。 这也是为什么实现基数排序时,收集阶段必须按桶号从小到大、桶内必须按先进先出, 用计数排序做每一位的分配时也必须从后往前扫描原数组(见下一节的代码)。

11.9.5 复杂度、链式实现与完整代码

时间复杂度:设最大数有 d 位、基数为 r(十进制 r = 10)、元素个数为 n。 每一轮分配要扫一遍 n 个元素,收集要把 r 个桶走一遍,所以每轮 O(n + r), d 轮合计:

T(n) = O( d (n + r) )  空间 S(n) = O(n + r)

d 是常数(比如 32 位整数最多 10 位十进制数)时,它就是 O(n)—— 比任何比较排序都快。但要注意两点:① 如果 d ≈ log n(比如把很大的数当字符串排), 复杂度会变成 O(n log n);② 只有当关键字的取值范围比较小、位数不多时,基数排序才划算, 否则桶的空间开销和清桶的时间会吃掉全部优势。

链式基数排序:如果元素很大(比如每条记录几百字节),每一轮都把它们在数组里搬来搬去太贵了。 工程做法是用链表:把每个桶实现成一条链,分配时只改指针(O(1)),收集时把 10 条链首尾相接。 这样一整轮下来一个元素都不用移动,只有指针在动。 这也是「链式基数排序」在教材里被单独讲一节的原因。

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

/* ============================================================
   基数排序(LSD,最低位优先,十进制)
   每一轮:按当前位「分配」到 10 个桶,再按桶号顺序「收集」
   关键:收集必须稳定(先进先出),否则低位的信息会丢失
   时间 O(d(n+r)),空间 O(n+r)
   ============================================================ */

/* 取整数 x 的第 k 位(k = 0 表示个位) */
int digitAt(long long x, int k) {
    while (k-- > 0) x /= 10;
    return (int)(x % 10);
}

/* 基础版:用计数数组 + 前缀和定位,一趟分配收集 O(n + 10) */
void radixSortLSD(vector<int>& a, long long& moves) {
    int n = a.size();
    moves = 0;
    if (n < 2) return;

    int maxVal = *max_element(a.begin(), a.end());
    int d = 1;                                  // 最大数的位数
    while (maxVal >= 10) { maxVal /= 10; ++d; }

    vector<int> out(n);
    for (int k = 0; k < d; ++k) {               // 第 k 轮:按第 k 位排序
        int cnt[10] = {0};
        for (int i = 0; i < n; ++i) ++cnt[digitAt(a[i], k)];   // ① 统计每个桶的大小

        for (int b = 1; b < 10; ++b) cnt[b] += cnt[b - 1];     // ② 前缀和 → 每个桶的结束位置

        /* ③ 从后往前扫描,把元素放进对应的桶里。
              必须从后往前!这样同一个桶内先出现的元素后写入,
              写入位置从后往前排,最终桶内顺序 = 原顺序,即「稳定」。 */
        for (int i = n - 1; i >= 0; --i) {
            int b = digitAt(a[i], k);
            out[--cnt[b]] = a[i];
            ++moves;
        }
        for (int i = 0; i < n; ++i) a[i] = out[i];             // ④ 收集回原数组
    }
}

/* 支持负数的版本:把负数平移到非负区间再排 */
void radixSortWithNegative(vector<int>& a, long long& moves) {
    int n = a.size();
    moves = 0;
    if (n < 2) return;
    int mn = *min_element(a.begin(), a.end());
    if (mn < 0) for (int& x : a) x -= mn;        // 整体平移成非负
    radixSortLSD(a, moves);
    if (mn < 0) for (int& x : a) x += mn;        // 平移回去
}

/* 链式基数排序(用静态链表模拟,避免元素搬移) */
struct Node { int val; int next; };
void radixSortLinked(vector<int>& a, long long& moves) {
    int n = a.size();
    moves = 0;
    if (n < 2) return;
    int maxVal = *max_element(a.begin(), a.end());
    int d = 1;
    while (maxVal >= 10) { maxVal /= 10; ++d; }

    vector<Node> node(n);
    for (int i = 0; i < n; ++i) {
        node[i].val = a[i];
        node[i].next = (i + 1 < n) ? (i + 1) : -1;   // -1 表示链表结束
    }
    int head = 0;

    for (int k = 0; k < d; ++k) {
        int tail[10], bhead[10];
        for (int b = 0; b < 10; ++b) { tail[b] = -1; bhead[b] = -1; }
        /* 分配:把链表上的每个结点挂到对应桶的链尾,只改指针 */
        for (int p = head; p != -1; ) {
            int nxt = node[p].next;
            int b = digitAt(node[p].val, k);
            node[p].next = -1;
            if (bhead[b] == -1) { bhead[b] = tail[b] = p; }
            else { node[tail[b]].next = p; tail[b] = p; }
            ++moves;
            p = nxt;
        }
        /* 收集:把 10 条桶链首尾相接 */
        head = -1;
        int last = -1;
        for (int b = 0; b < 10; ++b) {
            if (bhead[b] == -1) continue;
            if (head == -1) head = bhead[b];
            else node[last].next = bhead[b];
            last = tail[b];
        }
    }
    /* 把链表写回数组 */
    int i = 0;
    for (int p = head; p != -1; p = node[p].next) a[i++] = node[p].val;
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}
static bool isSorted(const vector<int>& a) {
    for (size_t i = 1; i < a.size(); ++i) if (a[i - 1] > a[i]) return false;
    return true;
}

int main() {
    vector<int> raw = {170, 45, 75, 90, 802, 24, 2, 66};
    long long moves = 0;

    vector<int> a = raw;
    radixSortLSD(a, moves);
    cout << "LSD 基数排序        : "; printArr(a);
    cout << "  搬运元素 " << moves << " 次(d=3 轮 × 8 个)\n";

    a = raw; radixSortLinked(a, moves);
    cout << "链式基数排序        : "; printArr(a);
    cout << "  指针操作 " << moves << " 次(元素零搬移)\n";

    vector<int> neg = {170, -45, 75, -90, 802, 24, -2, 66};
    a = neg; radixSortWithNegative(a, moves);
    cout << "含负数版本          : "; printArr(a);
    cout << "  有序性校验 " << (isSorted(a) ? "通过" : "失败") << "\n";

    /* 大规模测试 */
    vector<int> big(100000);
    for (int i = 0; i < 100000; ++i) big[i] = (int)((i * 7919LL + 13) % 100000);
    a = big; radixSortLSD(a, moves);
    cout << "n=100000            : 有序性校验 " << (isSorted(a) ? "通过" : "失败")
         << ",搬运 " << moves << " 次\n";

    a = big; radixSortLinked(a, moves);
    cout << "n=100000(链式)    : 有序性校验 " << (isSorted(a) ? "通过" : "失败") << "\n";
    return 0;
}

11.9.6 与计数排序、桶排序的关系

这三种「分配类排序」是同一个家族的三兄弟,关系一定要理清:

算法核心思想时间空间稳定适用条件
计数排序
counting sort
统计每个值出现的次数,用前缀和直接算出每个元素的最终位置;不比较、不分配桶 O(n + k)
(k 为值域)
O(n + k) 可以稳定
(倒序扫描 + 前缀和)
关键字必须是(范围很小的)整数,如年龄、成绩、等级
桶排序
bucket sort
按值域把元素分到若干有序的桶里,桶内各自排序,再依次连接 平均 O(n + k)
最坏 O(n²)
O(n + k) 可以稳定
(桶内用稳定排序)
数据均匀分布时最好;分布集中时全挤进一个桶就退化了
基数排序
radix sort
把关键字拆成 d 位,从低位到高位每一轮做一次计数排序(或桶分配) O(d(n + r)) O(n + r) 必须稳定 关键字可拆位(整数、定长字符串、日期);d 不大时极快

一句话概括三者的关系:计数排序是「按值定位」,桶排序是「按区间分堆」,基数排序是「按位重复计数排序」。 计数排序可以看作「桶大小为 1、桶的个数等于值域」的桶排序; 基数排序则是把计数排序当成子过程,用 d 轮「低开销的计数排序」换取对更大值域的适应能力。

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

/* ============================================================
   计数排序(counting sort)—— 基数排序每一轮的子过程
   不比较关键字,而是用「值 → 出现次数」的数组直接算出位置
   时间 O(n + k),空间 O(n + k),k 为值域大小
   ============================================================ */
void countingSort(vector<int>& a, int maxVal) {
    int n = a.size();
    if (n < 2) return;
    vector<int> cnt(maxVal + 1, 0), out(n);

    for (int i = 0; i < n; ++i) ++cnt[a[i]];              // ① 统计次数
    for (int v = 1; v <= maxVal; ++v) cnt[v] += cnt[v - 1];   // ② 前缀和 = 每个值的「结束位置」

    /* ③ 倒序扫描:保证稳定性。
          若正序扫描,相同值的元素会被倒着放进结果数组 → 不稳定。 */
    for (int i = n - 1; i >= 0; --i) out[--cnt[a[i]]] = a[i];

    for (int i = 0; i < n; ++i) a[i] = out[i];
}

/* 桶排序(bucket sort):按值域分桶,桶内用插入排序
   适用前提:数据在值域上「均匀分布」 */
void bucketSort(vector<double>& a) {
    int n = a.size();
    if (n < 2) return;
    int B = n;                                            // 桶的个数
    vector<vector<double>> buckets(B);
    for (double x : a) {
        int idx = (int)(x * B);
        if (idx >= B) idx = B - 1;                        // 处理 x == 1.0 的边界
        buckets[idx].push_back(x);
    }
    for (int b = 0; b < B; ++b) {                         // 桶内插入排序
        for (size_t i = 1; i < buckets[b].size(); ++i) {
            double key = buckets[b][i];
            int j = (int)i - 1;
            while (j >= 0 && buckets[b][j] > key) { buckets[b][j + 1] = buckets[b][j]; --j; }
            buckets[b][j + 1] = key;
        }
    }
    int k = 0;
    for (int b = 0; b < B; ++b)
        for (double x : buckets[b]) a[k++] = x;
}

static void printArr(const vector<int>& a) {
    cout << "[";
    for (size_t i = 0; i < a.size(); ++i) cout << (i ? ", " : "") << a[i];
    cout << "]";
}

int main() {
    /* 计数排序:值域必须小 */
    vector<int> a = {5, 2, 9, 1, 7, 3, 8, 4, 6, 0, 5, 2};
    countingSort(a, 9);
    cout << "计数排序   : "; printArr(a); cout << "\n";

    /* 稳定性验证:相同关键字的相对次序必须保持不变 */
    vector<int> keys = {3, 1, 3, 2, 1};
    vector<char> tag  = {'a', 'b', 'c', 'd', 'e'};
    int n = keys.size();
    vector<int> cnt(4, 0), pos(4, 0);
    for (int i = 0; i < n; ++i) ++cnt[keys[i]];
    for (int v = 1; v < 4; ++v) cnt[v] += cnt[v - 1];
    vector<pair<int, char>> out(n);
    for (int i = n - 1; i >= 0; --i) out[--cnt[keys[i]]] = {keys[i], tag[i]};
    cout << "稳定性验证 : ";
    for (auto& p : out) cout << p.first << p.second << " ";   // 1b 1e 2d 3a 3c
    cout << "\n";

    /* 桶排序:均匀分布的浮点数 */
    vector<double> f = {0.78, 0.17, 0.39, 0.26, 0.72, 0.94, 0.21, 0.12, 0.23, 0.68};
    bucketSort(f);
    cout << "桶排序     : ";
    for (double x : f) cout << x << ' ';
    cout << "\n";

    /* 为什么计数排序不能排大范围数据:值域 k = 10^8 时,
       cnt 数组就要 400 MB —— 空间直接爆掉。
       这时应当改用「排序 + 离散化」或者基数排序。 */
    cout << "注意:计数排序的空间是 O(n + k),k 是值域而不是 n。\n";
    cout << "      k = 1e8 时仅计数数组就需要约 400 MB,必须慎用。\n";
    return 0;
}

11.10 八大排序算法综合对比

11.10.1 一张表看完全部

下表是本讲的核心速查表,建议背下来。表中 n 为元素个数,k 为值域大小, d 为最大位数,r 为基数。所有「稳定」栏的判断依据都是 11.1.2 的定义。

算法类别最好平均最坏 空间稳定交换 / 移动次数特点一句话记忆点
冒泡排序交换类 O(n)O(n²)O(n²) O(1)稳定 交换次数 = 逆序对数,最多 n(n−1)/2 相邻比较,大的往后冒;加 swapped 标志最好 O(n)
简单选择排序选择类 O(n²)O(n²)O(n²) O(1)不稳定 比较恒为 n(n−1)/2;交换 ≤ n−1 次 先找最小再换一次;比较最死板、搬运最省
直接插入排序插入类 O(n)O(n²)O(n²) O(1)稳定 移动次数 = 逆序对数;比较 ≈ 移动 + n 摸牌插牌;基本有序时接近 O(n),工业界小区间首选
希尔排序插入类 O(n log n)约 O(n1.3) O(n²)O(1)不稳定 移动次数远小于插入排序;与增量序列强相关 缩小增量;先粗调后细调,最后一趟必须 gap=1
堆排序选择类 O(n log n)O(n log n)O(n log n) O(1)不稳定 交换 ≤ n−1 次 + 每次下沉 O(log n) 次交换 数组当完全二叉树;O(n) 建堆,最坏也不退化
归并排序归并类 O(n log n)O(n log n)O(n log n) O(n)稳定 每层移动 n 次,共 n log n 次;无交换 分治三步骤;相等取左段 → 稳定;外排序与逆序对的主力
快速排序交换类 O(n log n)O(n log n) O(n²) 平均 O(log n)
最坏 O(n)
不稳定 划分的移动次数约 n log n;交换次数少于堆排序 选基准、划分、递归;平均最快,怕有序 + 端点基准
基数排序分配类 O(d(n+r))O(d(n+r))O(d(n+r)) O(n+r)稳定 每轮搬运 n 次,共 dn 次;链式实现可零搬移 不比较,按位分桶收集;必须稳定才正确

① 只有带 swapped 标志的优化版冒泡才是最好 O(n),原始版恒为 O(n²)。 ② 希尔排序的最坏复杂度取决于增量序列:折半增量是 O(n²),Hibbard 是 O(n1.5), Sedgewick 约 O(n4/3)。 ③ 快排可以通过「三数取中 + 随机化 + 深度超限切堆排序」(introsort)把最坏情况压到 O(n log n)。

11.10.2 怎么选:决策流程图

考试里常问「给定场景该用哪种排序」,工程里更是天天要选。下面这张流程图按 「规模 → 关键字特征 → 稳定性 → 内存 → 是否需要 Top-K」的顺序逐层过滤, 从最上面的入口一路往下走即可。

开始:确认 n、关键字范围、是否要稳定 ① n 是否很小(≤ 50)? 直接插入排序 常数最小,无递归开销 ② 关键字是「小范围整数」? 计数排序 / 基数排序 O(n),不比较 ③ 要求排序必须稳定? 归并排序 稳定 + 最坏 O(n log n),代价 O(n) 空间 ④ 内存极紧张、必须原地? 堆排序 O(1) 空间 + 最坏 O(n log n) ⑤ 只要 Top-K,不要全排序? 大小为 k 的小根堆 O(n log k),空间 O(k) 默认首选:快速排序 三数取中 + 随机化基准 + 小区间插入排序 + 三路划分(重复元素多时) 兜底:任何情况下都怕最坏退化的,用 std::sort / std::stable_sort std::sort = introsort(快排 + 堆排 + 插入),std::stable_sort = 归并
图 11-9 排序算法选择决策流程图
工程上的一句话建议 先把需求问清楚:要不要稳定?内存够不够?关键字是什么类型?n 大概多大? 四个问题问完,答案基本就浮出来了。真正写业务代码时,优先用标准库 (C++ 的 std::sort / std::stable_sort、Python 的 sorted), 只有在这四个维度上有特殊要求时,才自己手写。

11.11 工程视角:排序是程序里跑得最多的代码

前面十节把八种排序的原理讲完了,但看真实的生产系统会发现:写业务代码的人 几乎从来不自己实现排序,只会写一行 sort(),而这一行背后各语言标准库给出的答案 完全不一样。这一节换一副眼镜,不再问「算法怎么写的」,而是问四个工程问题—— 生产系统调用的是哪个算法?为什么必须选它?代价是什么?什么时候会翻车? 每个落点都按「用什么算法 → 为什么必须用它 → 代价 / 陷阱」三段来写。

11.11.1 生产级标准库排序:为什么同一个语言对两类数据用两种算法

先看一张「标准库实现对照表」——它回答的是:你敲的那一行 sort() 底下跑的是什么。

语言 / 库接口底层算法是否稳定最坏复杂度
C++(libstdc++) std::sort 内省排序 introsort=快排 + 堆排 + 插入排序 不稳定 O(n log n)
std::stable_sort 归并排序(有临时缓冲区就归并,没有就退化为原地归并) 稳定 O(n log n)
Java(OpenJDK) Arrays.sort(int[])基本类型 双轴快排 DualPivotQuicksortQUICKSORT_THRESHOLD = 286INSERTION_SORT_THRESHOLD = 47char[] 大数组还会改用计数排序) 不稳定 O(n²),靠「多 run 时切归并」兜底
Arrays.sort(T[])List.sort对象 Timsort(归并的变体,识别天然 run + 插入排序补长) 稳定 O(n log n)
Python(CPython) sorted() / list.sort() Timsort(2002 年由 Tim Peters 为 Python 设计) 稳定 O(n log n)
JavaScript(V8 等现代引擎) Array.prototype.sort Timsort(规范自 ES2019 起要求稳定) 稳定 O(n log n)

最值得琢磨的是 Java 那一栏:同一个 Arrays.sort,对 int[] 用双轴快排, 对 String[] 却用 Timsort。这不是历史包袱,而是一条被反复验证的设计原则—— 「基本类型排序只要快,对象排序还必须稳」。三层理由:

代价同样实在:要维护多套算法与阈值常数,还要为「缓冲区分配失败」写退化路径 (std::stable_sort 拿不到临时内存就退到原地归并,常数立刻变差); 而这些阈值(16、32、47、286)不是推导出来的,是在真实机器上反复实测标定的, 换硬件可能要重调。标准库替你做了正确的选择,但正确是有维护成本的。

11.11.2 插入排序的真实地位:O(n²) 算法活在 O(n log n) 算法内部

很多同学学完 11.4 就认定「插入排序是 O(n²),属于被淘汰的算法」。事实恰好相反: 你在生产环境每一次调用排序,都极可能真的跑到了插入排序——所有工业快排都在小区间切到它: C++std::sort 阈值是 16_S_threshold);Java 双轴快排是 INSERTION_SORT_THRESHOLD = 47、Timsort 的 MIN_MERGE = 32Python / JavaScript 同理。为什么偏偏是这类收尾活?四条理由:

  1. 常数小。内层只有「比较 + 搬一格」,不用选基准、不用划分、不用交换。 n = 16 时最坏也就 120 次移动,而快排还要为这 16 个元素再递归 4 层。
  2. 没有函数调用开销。再往下还有约 log₂16 = 4 层调用栈,每次都要压栈、存寄存器、返回。 n 小的时候,调用开销比比较本身还贵
  3. 缓存友好。它只顺着数组前后移动;快排划分是两个指针从两端来回跳。
  4. 此时的数据往往「基本有序」。快排划分到小区间时,元素已经历若干轮划分, 天然带局部有序性;而插入排序在基本有序时接近 O(n),这是白送的加速。

这条结论的价值不在「记住 16」这个数字,而在它背后的思维方式: 复杂度描述的是 n → ∞ 时的增长形态,常数描述的是 n 很小时的真实耗时。 工程实现必须同时优化这两者,所以才会出现「把最坏 O(n²) 的算法嵌进最坏 O(n log n) 的算法里」 这种在纯理论视角下看起来很荒谬、在工程视角下却完全合理的结构。

为什么不是 n < 100 都用插入排序? 因为 这个项真的会长起来。n = 47 时插入排序最坏约 47²/2 ≈ 1100 次移动, 还能靠「常数小」赢;n = 1000 时就是约 50 万次,任何常数优势都救不回来。 阈值就是「常数优势被平方项吃掉」的那个交点,只能实测,不能推导。

11.11.3 快排的工程风险:最坏 O(n²) 与算法复杂度攻击

快排是平均最快的比较排序,也是唯一一个「输入本身能决定它性能」的常用排序。 11.8.4 已经推导过:朴素取首元素为基准时,一个已经升序的数组就是它的最坏输入。 现在把这件事放到生产环境里看,性质完全变了。

11.11.8 那段代码在本机(g++ 15.2.0,-O2,n = 200 000)的数据里最刺眼的一格是: 已升序输入下朴素快排要 4 424 429 μs(4.4 秒),混合快排只要 1 056 μs

两点必须读懂: 有序输入是日常数据而非稀有品——按主键扫出来的结果、按时间写入的日志、 上一步刚排好的数组本来就基本有序,最坏输入不需要对手构造,日常就能撞上 递归深度 199 999 层意味着 n = 200 000 时栈要吃掉几十 MB,本机栈大勉强没崩, 换到线程栈只有几百 KB 的环境就是直接段错误。另外,大量重复元素是独立的另一种退化, 只有三路划分能治:全相同序列直接降到 O(n)

危险点:算法复杂度也是一种攻击面(DoS) 这还只是性能问题。如果服务端排序的数组由攻击者控制,他只要提交一批已经有序的数据, 就能让一次请求从 1 毫秒变成 4 秒;并发几百个这样的请求,CPU 就被占满—— 不需要任何漏洞,只需要喂对输入。这类攻击有正式名字: 算法复杂度攻击(algorithmic complexity attack)
  • 2011 年的哈希表碰撞攻击是同一族的著名案例:攻击者构造大量哈希值相同的键, 让哈希表从 O(1) 退化到 O(n),POST 一个几 KB 的表单就能打死服务器。 PHP、Java、Python、Ruby 都中过招,随后各家纷纷给哈希函数加随机种子 (这正是第 10 讲里「防卡哈希」的由来)。正则回溯(ReDoS)XML 实体膨胀也是同一族。
  • 更隐蔽的是:这类退化不需要显式漏洞,常规代码审计看不见它——所以工业代码里会写上 「看不懂但很讲究」的防御:Java 的双轴快排在 run 数超过 MAX_RUN_COUNT = 67放弃快排改用归并兜底。
工程结论:只要输入来自外部,就必须让最坏有保证:① 随机化基准(对手无法预知哪组输入最坏); ② 三数取中(治「有序 / 逆序」这类天然退化);③ 三路划分(治重复键); 最后加 introsort 的深度兜底2⌊log₂n⌋ 超限就切堆排序), 从「期望 O(n log n)」升级为「最坏也是 O(n log n)」。
一句话:平均快不等于安全,只有「最坏有保证」才能对外服务。

11.11.4 稳定性为什么重要:多键排序必须靠它

稳定性的全部价值集中在一件事上:多键排序(multi-key sort)。场景:商品表要按 「先按价格升序,价格相同的按销量降序」展示,而原始数据本来就是按销量降序排好的。 工程上最自然的做法不是写双关键字比较器,而是分两次排:先按销量降序,再按 价格升序必须用稳定排序

为什么这样就对?稳定排序保证「价格相同的元素保持第二趟排序前的相对次序」,而那正是按销量降序的次序 ——于是价格相同的商品自然按销量降序排列。一个单键排序器 + 稳定性,等价于一个多键排序器; 换成不稳定排序,前一趟的销量次序当场作废。这条原理到处都是:

陷阱:稳定性的失效是偶发的,所以最难查 不稳定排序在小数据、随机数据上往往恰好保持原序,本地测试全绿; 到了线上大数据量的某个分片才乱序,而且不可复现。 工程上的应对只有两条:要么用标准库明确承诺稳定的接口std::stable_sortlist.sort()、Python 的 sorted()); 要么把次序显式写进比较器(补一个下标关键字),不要依赖任何未承诺的行为。

11.11.5 外部排序:数据比内存大时怎么绕过去

前面所有排序都有一个隐含前提:数据全都在内存里。可生产环境天天遇到反例——100 GB 日志要排序 而机器只有 4 GB 内存。11.7.5 讲了机制,这里把它当真实的工程问题算到底: 100 GB 数据 + 4 GB 内存,到底怎么排、要读几遍磁盘?

先看瓶颈在哪:100 GB 做内部排序,CPU 约 n log₂n ≈ 100G × 27 次比较,按每秒 10⁸ 次估算 是几分钟量级;而读写 100 GB 按 SATA SSD 500 MB/s 算是 200 秒一轮。所以这里 CPU 不是瓶颈、磁盘 I/O 才是,目标就一条:让数据过磁盘的次数尽量少

标准套路分两步,下图是它的完整形态(含每个数字的来历):

外部排序:100 GB 数据 + 4 GB 内存,8 路归并 2 趟完成(总磁盘 I/O 600 GB) 读一遍 100 GB ≈ 200 秒(SATA SSD 500 MB/s),所以「数据过几遍磁盘」几乎决定了总耗时 ① 分块读入内存,块内各自排好,写回磁盘 → 初始归并段 run 4 GB 内存里留 3.5 GB 做数据缓冲区:每次读 3.5 GB → 内部排序(快排/堆排)→ 顺序写回磁盘 共 100 GB ÷ 3.5 GB ≈ 29 块 ⟹ 磁盘上出现 29 个「内部有序、彼此无序」的归并段 这一阶段磁盘 I/O = 读 100 GB + 写 100 GB = 200 GB(每块内部排序只花 CPU 时间,不碰磁盘) 磁盘上的 29 个初始归并段(绿色=磁盘上的有序段,各自有序但段间无序) R1 R2 R3 … R26 — 共 29 个(图里画 26 个示意;数量多到画不下,本身就是「必须多路归并」的理由) ② 8 路归并:每趟把 29 个段折成 4 个,第二趟收成 1 个有序文件 第 1 趟(k = 8) R1 … R8 S1:28 GB R9 … R16 S2:28 GB R17~R29 归成 S3、S4 一趟归并要读写各 100 GB,与 k 无关;k 只决定「搬几趟」 第 2 趟(k = 4) S1 … S4 最终有序文件:100 GB,整体升序 内存里的样子(放大 8 路归并的那一步) 缓冲区块越大,系统调用与寻道越少 R1 输入缓冲 256 MB R2 输入缓冲 256 MB R3 … R7,各 256 MB R8 输入缓冲 256 MB = 8 × 256 MB = 2 GB(橙色=正在归并的输入) 输出缓冲 1.2 GB(蓝色=内存中的块) ① 从 8 个缓冲区首元素里取最小值 → O(log 8) = 3 次比较 ② 写进输出缓冲;缓冲满了顺序写回磁盘,然后补读一块 内存占用合计:2 + 1.2 = 3.2 GB,未超过 4 GB k 的上限由缓冲区大小决定,不是想开多大就开多大 这一趟活儿的账(100 GB 数据 / 4 GB 内存 / k = 8) · 初始归并段:100 GB ÷ 3.5 GB ≈ 29 个          · 归并趟数:S = ⌈log₈29⌉ = 2 · 第 1 趟:29 个段 → 4 个更大的段(28 GB/28 GB/28 GB/16 GB) · 第 2 趟:4 个段 → 1 个有序文件 · 磁盘 I/O = 200 GB(生成归并段)+ 400 GB(2 趟归并,每趟读+写各 100 GB)= 600 GB ⟺ 100 GB 要被完整过 3 遍 · 时间:600 GB ÷ 500 MB/s ≈ 1 200 秒(约 20 分钟),其中绝大多数时间在等磁盘,CPU 是闲着的 如果只用 2 路归并:⌈log₂29⌉ = 5 趟,I/O = 200 + 5×200 = 1 200 GB,要 2 400 秒(40 分钟) 如果用置换选择先生成初始段:平均段长 ≈ 2 × 3.5 GB = 7 GB,只剩约 15 个段(仍是 2 趟、600 GB,但留出了余量) · k 在对数的底数上,所以「把 k 做大」最便宜;但 k 越大缓冲越碎,且要同时打开 k 个文件 · 全程只有顺序读、顺序写,没有一次随机寻道 —— 这正是磁盘最喜欢的形态
图 11-10 100 GB 数据 + 4 GB 内存的外部排序:分块排好写回磁盘得到 29 个归并段,再 8 路归并 2 趟收成一个有序文件(总磁盘 I/O 600 GB,约 20 分钟)

三个结论:

为什么此时必须是归并排序,而不能是快排? 一句话:归并是顺序访问,快排是随机访问。
  • 归并只需要「从头读到尾、写一段」,是纯粹的顺序 I/O。 顺序访问与随机访问在磁盘上差着数量级——顺序读写能跑满带宽,随机读写在等寻道
  • 快排的划分让两个指针来回跳,在磁盘上等价于成千上万次随机读写; 它还是原地算法,天然没有「把结果顺序写出去」的形态。
  • 归并还稳定,对 ORDER BY 之类的语义是加分项;它需要的 O(n) 辅助空间 在外部排序里本来就存在(内存缓冲区),不算额外成本。
教科书结论:块内用什么排都行(快排/堆排),块间必须是归并。

它的生产落点,这几处你每天都在用:

11.11.6 堆排序的真实用途不是排序,而是优先队列

11.6 讲了整节的堆排序,但真实系统里没人用堆排序来排序——它常数比快排大,实测总慢一截。 那堆在工程里干什么?它几乎只以「优先队列」的身份出现。

真实系统用堆做什么为什么必须用它代价 / 局限
定时器 / 超时管理
(Reactor 事件循环、libuv、nginx、Go runtime)
小根堆存「到期时间」,堆顶就是最近要触发的那个 定时器成千上万,但每轮循环只关心最早到期的那个:O(1) 看堆顶、O(log n) 增删 取消任意定时器都要先 O(n) 找到它(惰性删除可缓解);不能按 id 索引
Dijkstra / Prim 的优先队列优化 小根堆存「(距离, 顶点)」,每次取出距离最小的未确定顶点 朴素版每轮要 O(V) 扫描找最小;堆把它降到 O(log V), 总复杂度从 O(V²) 变成 O(E log V)——稀疏图上这是决定性改进(第 09 讲) 标准堆不支持 decrease-key,只能重复入堆 + 出堆判重,堆里最多有 O(E) 个冗余元素
流式 Top-K
(热搜榜、排行榜、「最大的 100 个数」)
大小为 k 的小根堆,堆顶是「前 k 名的门槛」 只要前 k 名就只需要 O(k) 内存而非 O(n),数据流式到达、来多少处理多少(11.6.8) 只给得出「前 k 名」;k 很大时优势消失(不如直接排序)
多路归并的选择器 k 个有序段的首元素进堆,每次取最小的 这就是外部排序里的「k 选 1」:朴素扫描 O(k),堆只要 O(log k), k = 256 时差 32 倍 每趟要维护堆;工程上进一步用败者树把常数压得更小

这里有个值得一提的反例:很多人以为「操作系统任务调度一定用优先队列」, 其实 Linux 的 CFS 调度器用的是红黑树vruntime 最小的进程在最左边),不是堆。 因为调度器不只需要「取最小」,还要快速删除任意进程、修改任意进程的键(睡眠、唤醒、优先级变化): 红黑树是完整的动态有序集,支持 O(log n) 查找/删除/修改;而堆只擅长「反复取最值」, 想删中间某个元素得先 O(n) 找到它。堆不是万能优先队列,它只是「取最值」这件事的最省实现。

所以堆的工程定位是一句话:当「只需反复取最值」「内存紧张」「元素以流的方式到达」同时成立时, 堆是唯一正确的选择——一个普通数组就实现了 O(log n) 的动态最值。但需求里一旦出现「按任意键查找」 「删除任意元素」「范围查询」,就该换红黑树 / B+ 树了。

一个能记住的量级 日活 3 亿的产品做「今日热搜 Top 100」,如果每次查询都把全量数据排一遍, 按 1 亿条、每条 100 ns 的比较成本算,单次就要约 10⁸ × 27 × 10⁻⁷ s ≈ 270 秒, 根本不可能支撑每秒百万次查询。 换成大小为 100 的小根堆,每次查询只要 O(n log k) 时间、O(k) = 100 个元素的内存, 而且可以随数据流增量维护——这就是堆在「只关心头部」的场景里不可替代的原因。

11.11.7 非比较排序的工程边界:什么时候能用,什么时候不能碰

计数、基数、桶排序是工程里一把锋利但很窄的刀:不比较关键字,而是利用键的数值结构 把元素直接「放」到位置上,换来惊人的速度,也换来一个硬边界。

然后是那条不能碰的边界:它们不能用于任意可比较对象。自定义的 Student、一段文本、 一个「按业务规则比较」的结构体,都没有「第几位」的概念,也没有可枚举的值域,基数/计数排序 无从下手(不是「慢」,是根本无法执行)。所以规则很干脆:比较排序是通用解, 非比较排序是专用解,只有三条同时满足才有资格用它——① 键是整数或可无损映射成整数; ② 值域小(计数)或定长(基数);③ 映射开销 ≪ 排序省下的开销。

别把它当银弹:1 亿个整数,基数排序到底快几倍? 这个数字常被夸大,算清楚。1 亿个 32 位整数(400 MB):
  • 快排:比较次数约 n log₂n = 10⁸ × 27 = 2.7 × 10⁹ 次, 按现代 CPU 每秒 10⁸~10⁹ 次比较估,约 几秒到十几秒
  • 基数排序(8 位一档,r = 256,d = 4):4 轮,每轮扫一遍数组 + 清 256 个计数器, 总搬运量约 4 × 10⁸比快排的比较次数少一个数量级
  • 实测加速通常只有 2~5 倍:① 每轮都要把 400 MB 完整读写一遍, 内存带宽成了瓶颈(4 轮就是 3.2 GB 流量);② 它还需要额外的 O(n) 输出数组(再 400 MB)与计数器数组,16 位一档时计数器就要 2¹⁶ × 4 B = 256 KB, 已经打爆 L2 缓存。
所以正确表述是:「基数排序在定长整数上通常比快排快 2~5 倍,且优势随数据量增大而扩大」, 而不是「快十倍」。工程上所有「快 N 倍」的说法都必须带测试条件。

11.11.8 一段可跑的代码:混合排序、阈值与有序输入打回原形

这段程序把前面的结论验证一遍: 一个「区间 ≤ 16 就切插入排序」的混合快排; 一个 「取首元素为基准」的朴素快排当对照; 三路划分版本外加 std::sortchrono 计时,在随机 / 已升序 / 大量重复 / 近乎有序四种输入上打印对照表。编译运行: g++ -std=c++17 -O2 sort_hybrid.cpp && ./sort_hybrid

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

/* ============================================================
   工业界排序的真实拼法:混合快排 = 快排 + 小区间切插入排序
   四种实现同台对比:
     朴素快排   取首元素为基准,小区间也不切插排
     混合快排   三数取中 + 区间 <= 16 切插入排序(libstdc++ 的做法)
     三路快排   专治「大量重复元素」,= pivot 的整段一次排定
     std::sort  introsort:快排 + 堆排 + 插入排序
   四种输入特征:随机 / 已升序 / 大量重复 / 近乎有序
   ============================================================ */

const int THRESHOLD = 16;          /* libstdc++ 的 introsort 用的就是这个阈值 */

/* ---------- 插入排序:小区的王者,O(n²) 却活在 O(n log n) 内部 ---------- */
void insertionSort(vector<int> &a, int l, int r) {
    for (int i = l + 1; i <= r; ++i) {
        int key = a[i], j = i - 1;
        while (j >= l && a[j] > key) { a[j + 1] = a[j]; --j; }   /* 严格大于 => 稳定 */
        a[j + 1] = key;
    }
}

/* ---------- 三数取中:解决「数据本来就有序」这种真实存在的退化 ---------- */
int medianOfThree(vector<int> &a, int l, int r) {
    int m = l + (r - l) / 2;
    if (a[l] > a[m]) swap(a[l], a[m]);
    if (a[l] > a[r]) swap(a[l], a[r]);
    if (a[m] > a[r]) swap(a[m], a[r]);
    swap(a[m], a[r]);                   /* 中位数藏到区间末尾当哨兵 */
    return a[r];
}

/* ---------- Hoare 划分(基准已就位在 r) ---------- */
int partitionHoare(vector<int> &a, int l, int r) {
    int pivot = medianOfThree(a, l, r);
    int i = l - 1, j = r;
    while (true) {
        while (a[++i] < pivot) {}
        while (a[--j] > pivot) { if (j == l) break; }
        if (i >= j) break;
        swap(a[i], a[j]);
    }
    swap(a[i], a[r]);
    return i;
}

/* ---------- 混合快排:区间 <= THRESHOLD 直接甩给插入排序 ---------- */
void hybridSort(vector<int> &a, int l, int r) {
    if (r - l + 1 <= THRESHOLD) { insertionSort(a, l, r); return; }
    int p = partitionHoare(a, l, r);
    hybridSort(a, l, p - 1);
    hybridSort(a, p + 1, r);
}

/* ---------- 朴素快排:首元素当基准。递归深度用全局变量记下来 ---------- */
long long g_naiveDepth = 0;

void naiveSort(vector<int> &a, int l, int r, long long depth) {
    if (l >= r) return;
    if (depth > g_naiveDepth) g_naiveDepth = depth;
    int pivot = a[l];                   /* 首元素当基准 —— 有序数据下这里是灾难 */
    int i = l, j = r;
    while (i < j) {
        while (i < j && a[j] >= pivot) --j;
        if (i < j) a[i++] = a[j];
        while (i < j && a[i] <= pivot) ++i;
        if (i < j) a[j--] = a[i];
    }
    a[i] = pivot;
    naiveSort(a, l, i - 1, depth + 1);
    naiveSort(a, i + 1, r, depth + 1);
}

/* ---------- 三路划分:< p / == p / > p 三段,相等段不参与递归 ---------- */
void quick3Way(vector<int> &a, int l, int r) {
    if (l >= r) return;
    int pivot = a[l + (r - l) / 2];
    int lt = l, i = l, gt = r;
    while (i <= gt) {
        if (a[i] < pivot) swap(a[lt++], a[i++]);
        else if (a[i] > pivot) swap(a[i], a[gt--]);
        else ++i;
    }
    quick3Way(a, l, lt - 1);
    quick3Way(a, gt + 1, r);
}

/* ---------- 计时外壳:同一份数据分别喂给四种实现 ---------- */
bool g_sortedOk;                        /* 结果正确性,防止「快但排错」 */

long long timeIt(vector<int> a, int which, long long &depth) {
    auto b = chrono::steady_clock::now();
    if (which == 0) {
        g_naiveDepth = 0;
        naiveSort(a, 0, (int)a.size() - 1, 1);
        depth = g_naiveDepth;
    } else if (which == 1) hybridSort(a, 0, (int)a.size() - 1);
    else if (which == 2) quick3Way(a, 0, (int)a.size() - 1);
    else sort(a.begin(), a.end());
    auto e = chrono::steady_clock::now();
    for (size_t i = 1; i < a.size(); ++i)
        if (a[i - 1] > a[i]) { g_sortedOk = false; break; }
    return chrono::duration_cast<chrono::microseconds>(e - b).count();
}

void report(const string &name, const vector<int> &data) {
    long long t0, t1, t2, t3, d0 = 0, dummy = 0;
    g_sortedOk = true;
    t0 = timeIt(data, 0, d0);
    t1 = timeIt(data, 1, dummy);
    t2 = timeIt(data, 2, dummy);
    t3 = timeIt(data, 3, dummy);
    printf("%-12s %-12lld %-12lld %-12lld %-12lld %-10lld %s\n",
           name.c_str(), t0, t1, t2, t3, d0, g_sortedOk ? "ok" : "FAIL");
}

int main() {
    const int N = 200000;
    mt19937 rng(20250916);

    vector<int> rnd(N);
    for (int i = 0; i < N; ++i) rnd[i] = (int)(rng() % 1000000u);

    vector<int> sortedArr = rnd;
    sort(sortedArr.begin(), sortedArr.end());

    vector<int> dup(N);
    for (int i = 0; i < N; ++i) dup[i] = (int)(rng() % 10u);      /* 值域只有 10 个 */

    vector<int> nearly = sortedArr;                              /* 近乎有序:只动 10% */
    for (int i = 0; i < N / 10; ++i) {
        int x = (int)(rng() % N), y = (int)(rng() % N);
        swap(nearly[x], nearly[y]);
    }

    printf("n = %d,THRESHOLD = %d,单位 us\n", N, THRESHOLD);
    printf("----------------------------------------------------------------------------\n");
    printf("%-12s %-12s %-12s %-12s %-12s %-10s %s\n",
           "输入特征", "朴素快排", "混合快排", "三路快排", "std::sort", "朴素递归深度", "正确性");
    printf("----------------------------------------------------------------------------\n");
    report("随机", rnd);
    report("已升序", sortedArr);
    report("大量重复", dup);
    report("近乎有序", nearly);
    printf("----------------------------------------------------------------------------\n");
    return 0;
}

本机实测输出(Intel Core Ultra 9 275HX / Windows / g++ 15.2.0 / -O2,n = 200 000):

n = 200000,THRESHOLD = 16,单位 us
----------------------------------------------------------------------------
输入特征 朴素快排 混合快排 三路快排 std::sort    朴素递归深度 正确性
----------------------------------------------------------------------------
随机       10589        9708         12940        9784         39         ok
已升序    4424429      1056         2628         964          199999     ok
大量重复 355876       3147         2033         2839         26124      ok
近乎有序 5151         3535         6608         3402         204        ok
----------------------------------------------------------------------------
实测环境的交代(读性能数字前必看) 上表是 5 次运行的均值。环境:Intel Core Ultra 9 275HX(24 逻辑核)/ Windows / g++ 15.2.0 / -O2;Windows 计时器精度约 1 ms,故单次数值被量化到 1 ms 的整数倍。 各格实测范围:随机行 9 537 ~ 11 135 μs、已升序行 4 243 074 ~ 4 531 012 μs、 大量重复行 353 820 ~ 371 957 μs;而递归深度那一列每次完全相同(39 / 199 999 / 26 124 / 204), 因为它是确定的计数。所以真正稳定的结论是比值:已升序时朴素快排慢 3 500 ~ 4 600 倍, 大量重复时比三路快排慢 115 ~ 175 倍

逐行读一遍,前面的结论就都落地了:

读这份数据要注意的三件事(不然会得出错误结论)
  1. 这是微秒(μs)不是毫秒。4 424 429 μs = 4.42 秒。讨论「几倍」前先统一单位, 这是性能分析里最常见的低级错误。
  2. 绝对值随机器变,倍数关系才重要。换台更快的机器,「4 190 倍」可能变成 2 000 或 8 000 倍, 但数量级差异不会消失。报告性能必须写清 CPU、编译器、优化级别与数据规模。
  3. 不要只跑一次就下结论。本机连跑两次:已升序那格是 4 424 429 与 4 451 535 μs(约 0.6% 抖动), 随机那格在 9 820 ~ 10 589 μs 之间浮动(约 8% 抖动)。 结论要建立在「跑多次取中位数」上,尤其是差距 10% 以内的对比。

11.11.9 工程选型对比表:六个算法的五个现实维度

11.10.1 那张表比的是复杂度,用来初筛;下面这张比的是现实维度—— 「在我这台机器、这个数据、这个业务约束下该调哪个」。两张表对照着看: 复杂度告诉你「能不能用」,现实维度告诉你「该不该用」。

算法稳定性最坏复杂度缓存友好度 能否用于外部排序典型真实用途
插入排序 稳定 O(n²) 极好(纯顺序访问) 不能(没有「分块归并」形态) 所有工业快排的小区间收尾(≤ 16 / 32 / 47);小数组排序
快速排序 不稳定 O(n²)
(introsort 可压到 O(n log n))
好(原地划分,但指针来回跳) 不能(随机访问,磁盘上是灾难) 内存排序首选:std::sort、Java 基本类型排序;只需「有序」不需「稳定」的场合
归并排序 稳定 O(n log n) 好(顺序读、顺序写两段) 唯一能外部排序的(顺序 I/O 是磁盘的命门) 数据库 ORDER BY 落盘、MapReduce shuffle、日志合并;std::stable_sort 与 Timsort 的骨架
堆排序 不稳定 O(n log n) 差(父子结点跳跃访问,缓存不友好) 不能 真实身份是优先队列:定时器、Dijkstra/Prim、流式 Top-K、多路归并选择器; 以及作为 introsort 的深度兜底
计数排序 稳定 O(n + k) 好(两遍顺序扫描 + 随机写计数数组) 不能 值域小的整数统计:年龄、分数、状态码直方图;基数排序每一位的内部引擎
基数排序 稳定 O(d(n + r)) 中(每轮整表读写,吃内存带宽) 不能(但可与外部排序组合成分块基数排序) 定长键批量排序:手机号、IPv4、身份证号、定长十六进制 ID;大数据流水线里的整数键加速器

读表有个诀窍:把「能否用于外部排序」这一列当分水岭。装得下内存时胜负在「常数与缓存」上,快排赢; 装不下时胜负在「顺序 I/O 还是随机 I/O」上,归并赢——这一列一变,前面所有维度的排名全部作废。 所以选型第一步永远是问清数据规模:规模决定瓶颈,瓶颈决定算法

落地到一行代码的选型清单
  • 不知道用什么 → std::sort(或语言默认排序)。先让它跑起来,再谈优化。
  • 需要稳定(多键排序 / 保持原相对次序)→ 显式用稳定接口(std::stable_sortlist.sort()、Python 的 sorted()),或把次序显式写进比较器
  • 只要前 k 名、数据还在源源不断到达 → 大小为 k 的堆,别全排
  • 数据装不下内存 → 外部排序:分块 + 多路归并,先把 k 和缓冲区调大。
  • 键是定长整数、量非常大 → 试着上基数排序,但一定实测对比,别信「快十倍」。
  • 输入来自外部(用户/网络/文件)→ 确保最坏有保证(随机化基准 + 深度兜底), 不要用纯快排对外服务

11.11.10 本节小结:把工程问题变成三个提问

这一节不讲新算法,讲的是一副眼镜。把它压缩成三个可以随手用的提问:

  1. 「数据有多大?装得下内存吗?」装得下 → 比常数与缓存,选快排系(std::sort); 装不下 → 比顺序 I/O 与趟数,选归并系(外部排序),并把 k 与缓冲区尽量做大。
  2. 「结果需要稳定吗?」需要(多键排序、要保持原相对次序)→ 用明确承诺稳定的接口, 或把次序显式写进比较器;不需要 → 用最快的,稳定性对基本类型毫无意义。
  3. 「输入是谁给的?最坏情况会不会被触发?」输入可信、规模可控 → 快排随便用; 输入来自外部 → 必须让最坏有保证(三数取中 + 随机化 + 三路划分 + 深度兜底), 这已经不是性能问题,而是可用性 / 安全问题

回头看本章八种算法的工程分工非常清晰:插入排序活在所有快排的内核里快排是内存排序的默认答案归并扛着「稳定」与「外部排序」两件事堆的真实身份是优先队列计数与基数在定长整数上提供数量级加速; 冒泡、选择、希尔则是理解排序思想的阶梯。它们在真实系统里怎么被调用、参数怎么标定, 还会在第 12 讲继续往源码级深挖。

11.12 本章小结、易错点与自测

11.12.1 必须记住的十二件事

概念与结论

  1. 稳定性:关键字相等的两个记录,排序前后相对次序不变 → 稳定。
  2. O(n²) 家族里,插入排序平均最快、选择排序交换最少、冒泡最好写
  3. 插入排序的移动次数 = 逆序对总数,所以基本有序时是 O(n)。
  4. 折半插入排序只把比较降到 O(n log n),移动仍是 O(n²),总复杂度不变。
  5. 希尔排序的最后一趟必须 gap = 1;增量序列决定复杂度。
  6. 建堆是 O(n) 而不是 O(n log n),靠 Σ h/2h = 2 收敛。
  7. 堆排序、归并排序、快排都是 O(n log n),只有归并稳定、只有归并要 O(n) 空间
  8. 快排最坏 O(n²) 的触发条件是「有序数据 + 端点基准」,此时递归深度退化到 O(n)。
  9. 三路划分让「大量重复元素」从 O(n²) 变成 O(n)。
  10. 基数排序 不比较关键字,用空间换时间,复杂度 O(d(n+r))。
  11. 逆序对可以用归并排序在 O(n log n) 内统计,注意用 long long。
  12. 求最大的 k 个数用大小为 k 的小根堆,O(n log k)。

稳定性速查(必背)

稳定 冒泡 · 直接插入 · 折半插入 · 归并 · 基数 · 计数

不稳定 简单选择 · 希尔 · 堆 · 快速 · 桶(取决于桶内算法)

记忆口诀:「冒插归基计」稳定,「选希堆快」不稳定。
不稳定的共同原因只有一个:发生了「长距离」的元素跨越——要么是远距离交换(选择、堆、快排), 要么是分组后跨界(希尔)。

11.12.2 易错点清单

会把代码写错的六个坑
  1. 冒泡/插入的条件写成 ≤ / ≥:稳定性当场丢失。冒泡要 a[j] > a[j+1], 插入要 a[j] > key,归并要 a[i] <= a[j] 取左段。
  2. 冒泡内层边界写成 j < n-1:比较次数从 n(n−1)/2 涨到 (n−1)²。
  3. 折半插入的二分写成 a[mid] < key:相等时插到前面,破坏稳定性; 并且区间开闭混用会导致死循环。
  4. 堆排序的下标公式用错基:0 基是 2i+1/2i+2/(i−1)/2, 1 基是 2i/2i+1/i/2,混用必错。
  5. 快排的 Hoare 版递归区间写错:必须是 [l, j][j+1, r], 写成 [l, j-1] 会死循环。挖坑法返回的才是基准的最终位置。
  6. 基数排序的收集写成正序扫描:稳定性被破坏,结果直接算错(不是「不够好」,是「错」)。
容易记混的五个结论
  • 「选择排序比较次数少」是错的:它的比较次数恒为 n(n−1)/2,一次都不省。
  • 「希尔排序稳定」是错的:虽然每趟用的是稳定的插入排序,但分组跨越会打乱次序。
  • 「快排总比堆排序快」不完全对:只说平均。若数据被构造成最坏情况且没做随机化, 快排可能比堆排慢得多。
  • 「归并排序空间是 O(1)」是错的:数组归并必须 O(n) 额外空间; 只有链表归并才能省掉这个数组。
  • 「基数排序一定比快排快」是错的:当位数 d 很大或值域稀疏时,桶的空间与清桶时间会吃掉优势。
考点清单(考试前逐条自查)
  • 给定序列,写出某一趟之后的结果(冒泡 / 选择 / 插入 / 希尔 / 快排 / 堆排都考过)。
  • 给定中间状态,反推是哪种排序算法(判别特征见 11.12.3)。
  • 手算比较次数与移动次数,尤其是插入排序「移动次数 = 逆序对数」。
  • 建堆的过程与 O(n) 的证明;判断一个序列是否是大根堆 / 小根堆。
  • 快排退化的推导:T(n) = T(n−1) + (n−1) → n(n−1)/2。
  • 稳定性的判断与反例构造
  • 基数排序必须稳定的原因、复杂度 O(d(n+r)) 的来历。
  • Top-K 为什么用小根堆、复杂度 O(n log k)。

11.12.3 速查:如何从「中间状态」反推算法

观察到的特征最可能的算法判别理由
末尾有若干个已经就位且严格递增的大元素,最大数必在末尾冒泡排序 每趟把当前最大值送到未排序区间的末尾
开头有若干个已经就位的小元素,且是全局最小的若干个简单选择排序 每轮选出最小值放到前面,且一轮只交换一次
前 k 个元素有序,后面的元素完全没动过直接插入排序 插入排序总是维护「前面有序」这个不变量
序列整体大致有序但局部有逆序,且看不出明显的「已就位前/后缀」希尔排序 大 gap 让元素大跨度移动,形成「局部有序、整体渐近」的形态
数组满足堆性质(A[i] ≥ A[2i+1]、A[2i+2])但整体无序堆排序(建堆完成) 只有堆排序会出现「数组是合法堆但不是有序数组」的状态
存在连续的长度为 2k 的有序段,段与段之间无序归并排序 归并是自底向上按 1、2、4、8 的长度逐层合并的
存在一个元素,左边全 ≤ 它、右边全 ≥ 它,而它正处在最终位置上快速排序 划分的核心保证;但注意要检查这个元素是否真的是最终位置
低位有序、高位无序(如按个位排好但十位乱)基数排序 LSD 从最低位开始,第 k 轮后「后 k 位」有序

11.12.4 自测题(答案折叠,先自己做)

1. 对序列 (49, 38, 65, 97, 76, 13, 27, 49'),分别写出:① 冒泡排序第一趟结束后的结果; ② 直接插入排序处理完前 4 个元素(即 i = 3)后的结果;③ 简单选择排序第一趟结束后的结果。

① 冒泡排序第一趟(从左到右相邻比较,逆序就交换):

  • 49 > 38 → 交换 → [38, 49, 65, 97, 76, 13, 27, 49']
  • 49 < 65 → 不动;65 < 97 → 不动
  • 97 > 76 → 交换 → [38, 49, 65, 76, 97, 13, 27, 49']
  • 97 > 13 → 交换;97 > 27 → 交换;97 > 49' → 交换

结果:[38, 49, 65, 76, 13, 27, 49', 97],共 7 次比较、5 次交换。 注意 97 已经冒到末尾,位置确定。

② 直接插入排序处理完 i = 3

  • i = 1,key = 38:49 > 38 → 49 右移,38 落到 A[0] → [38, 49, 65, 97, 76, …]
  • i = 2,key = 65:49 ≤ 65 → 停止,就地不动
  • i = 3,key = 97:65 ≤ 97 → 停止,就地不动

结果:[38, 49, 65, 97, 76, 13, 27, 49']。 前 4 个元素 38, 49, 65, 97 已经是升序的局部有序段。

③ 简单选择排序第一趟:在 [0, 7] 中找最小值是 13(下标 5), 与 A[0] = 49 交换。

结果:[13, 38, 65, 97, 76, 49, 27, 49'],共 7 次比较、1 次交换。

2. 序列 [2, 1, 4, 3, 5, 7, 9]:① 它可能是希尔排序(gap = 2 那一趟之后)的结果吗? ② 它可能是冒泡排序第一趟的结果吗?③ 它可能是简单选择排序第一趟的结果吗?

① 可能是希尔排序 gap = 2 的结果。n = 7,gap = 2 时分成两组: 偶数下标组 A[0], A[2], A[4], A[6] = 2, 4, 5, 9 是升序; 奇数下标组 A[1], A[3], A[5] = 1, 3, 7 也是升序。 两组「组内有序」正是 gap = 2 这一趟的要求,所以它是合法的希尔排序中间状态。

② 不可能是冒泡排序第一趟的结果。冒泡第一趟的第一个动作就是比较 A[0]A[1] 并按需交换,所以一趟结束后必然有 A[0] ≤ A[1]。 而这里 2 > 1,矛盾。(另外冒泡第一趟必须把最大值送到末尾,这里 9 虽然恰好在末尾, 但第一个条件已经不满足。)

③ 不可能是简单选择排序第一趟的结果。选择排序第一趟结束后, A[0] 必须是整个序列的最小值。这里最小值是 1,而 A[0] = 2,矛盾。

3. 对 A = [3, 1, 6, 5, 2, 4]:① 数出逆序对总数; ② 手工执行直接插入排序,统计比较次数与移动次数;③ 验证「移动次数 = 逆序对总数」这个结论。

① 逆序对(i < j 且 A[i] > A[j]):

  • 3 与 1、2 → 2 个
  • 1 → 0 个
  • 6 与 5、2、4 → 3 个
  • 5 与 2、4 → 2 个
  • 2、4(末尾)→ 0 个

合计 7 个逆序对

② 插入排序过程(| 前面是有序区):

  • i=1,key=1:比较 3 > 1 → 3 右移,j 越界,1 落到 A[0] → [1, 3, 6, 5, 2, 4] 比较 1、移动 1
  • i=2,key=6:比较 3 ≤ 6 → 停 → [1, 3, 6, 5, 2, 4] 比较 1、移动 0
  • i=3,key=5:比较 6 > 5 → 移动;比较 3 ≤ 5 → 停 → [1, 3, 5, 6, 2, 4] 比较 2、移动 1
  • i=4,key=2:比较 6 > 2 → 移动;5 > 2 → 移动;3 > 2 → 移动;比较 1 ≤ 2 → 停 → [1, 2, 3, 5, 6, 4] 比较 4、移动 3
  • i=5,key=4:比较 6 > 4 → 移动;5 > 4 → 移动;比较 3 ≤ 4 → 停 → [1, 2, 3, 4, 5, 6] 比较 3、移动 2

总计:比较 11 次、移动 7 次

③ 验证:移动次数 = 7 = 逆序对总数 ✓。 再看比较次数:C = M + (n − 1) − (j 越界的次数) = 7 + 5 − 1 = 11 ✓ (只有 i = 1 那一步 key 一直退到了下标 0 之外,其余 4 步都是因为「遇到 ≤ key 的元素」而停下)。

4. 堆:① 判断 [9, 8, 7, 6, 5, 4, 3] 是不是大根堆; ② 对 [3, 1, 6, 5, 2, 4] 执行建堆(自底向上 sift down),写出每一步的结果; ③ 建堆的时间复杂度是多少?为什么不是 O(n log n)?

① 是大根堆。逐结点检查(n = 7,非叶结点是下标 0、1、2):

  • i = 0:孩子是 8、7,9 ≥ 89 ≥ 7
  • i = 1:孩子是 6、5,8 ≥ 68 ≥ 5
  • i = 2:孩子是 4、3,7 ≥ 47 ≥ 3

所有父子关系都满足「父 ≥ 子」,所以它是合法的大根堆。 (顺带一提:[9,8,7,6,5,4,3] 恰好也是降序数组—— 降序数组天然是大根堆,因为下标越小值越大。)

② 建堆过程(n = 6,最后一个非叶结点 = 6/2 − 1 = 2):

  • 初始:[3, 1, 6, 5, 2, 4]
  • i = 2:A[2] = 6,只有左孩子 A[5] = 4(右孩子下标 6 越界), 4 < 6 → 不交换。数组不变。
  • i = 1:A[1] = 1,孩子 A[3] = 5A[4] = 2,较大的孩子是 5, 5 > 1 → 交换 → [3, 5, 6, 1, 2, 4]; 继续在下标 3 处检查:孩子下标 7、8 都越界 → 是叶子,结束。
  • i = 0:A[0] = 3,孩子 A[1] = 5A[2] = 6,较大的孩子是 6, 6 > 3 → 交换 → [6, 5, 3, 1, 2, 4]; 继续在下标 2 处检查:孩子是 A[5] = 4(下标 6 越界),4 > 3 → 交换 → [6, 5, 4, 1, 2, 3];再在下标 5 处检查:叶子,结束。

最终结果:[6, 5, 4, 1, 2, 3]。 校验:6 ≥ 5, 4 ✓;5 ≥ 1, 2 ✓;4 ≥ 3 ✓。

③ 建堆是 O(n)。不能按「非叶结点数 × 最大下沉层数 = (n/2)·log n」来估, 因为那样默认了每个结点都下沉满 log n 层。事实上高度为 h 的结点最多有 ⌈n/2h+1⌉ 个, 越深的结点越多但下沉层数越少,求和后 T(n) = Σ ⌈n/2h+1⌉·h = O(n · Σ h/2h) = O(2n) = O(n)。 级数 Σ h/2h = 2 收敛是关键。

5. 快速排序:① 写出「已升序数据 + 取首元素为基准」时比较次数的递推式并求解; ② 这种退化会带来什么额外风险?③ 三数取中、随机化、三路划分分别解决什么问题?

① 递推式。设输入为升序的 [1, 2, …, n],基准取 A[l](即当前区间的最小值)。 一趟划分中,右指针 j 要找「小于 pivot」的元素,但所有元素都 ≥ pivot, 所以 jr 一路扫到 l,做 n−1 次比较; 划分结果是一边为空、另一边规模 n−1。于是:

T(n) = T(n − 1) + (n − 1), T(0) = T(1) = 0

展开:T(n) = (n−1) + (n−2) + … + 1 = n(n−1)/2 = O(n²)

② 额外风险:递归深度退化 + 栈溢出。每层递归只减少一个元素, 递归深度从理想的 O(log n) 变成 O(n), 栈空间也从 O(log n) 变成 O(n)。 n = 105 时可能直接栈溢出(RE)。此外,划分时的大量元素移动也让常数变得很大。

③ 三种手段各管一件事:

  • 三数取中:解决「数据已经有序 / 逆序」这一类真实存在的输入导致的退化。 取 A[l]、A[mid]、A[r] 的中位数,有序数据下基准恰好是中位数,划分立刻变平衡。
  • 随机化基准:解决「对手故意构造数据卡你」的问题。 因为基准位置随机,任何固定输入都无法保证每次取到极值,期望复杂度稳定在 O(n log n)。
  • 三路划分:解决「大量重复元素」的问题。 两路划分在全相同序列上会退化成 O(n²);三路划分把「等于 pivot」的整段一次排定、 不参与递归,全相同序列只要 O(n)。

11.12.5 配套练习建议

练习任务考察点
练习 1把本讲 15 段代码全部敲一遍,用同一组随机数据跑出每种算法的比较 / 移动次数,做成对照表 「复杂度」与「实际常数」的差距
练习 2把八种排序都改成「降序」,并检查稳定性结论是否变化比较符号与稳定性的关系
练习 3n = 1000 / 10000 / 100000 分别测「已排序 / 逆序 / 随机 / 大量重复」四种数据的耗时 退化现象的真实体感
练习 4实现一个排序算法判别器:给定中间状态,输出它可能来自哪些算法 11.11.3 的判别表
练习 5用归并排序求解逆序对,并在 n = 105 的随机数据上验证 long long 的必要性 溢出这类「看不见的 bug」
练习 6写一个「动态中位数」程序:数据流式到达,随时输出当前中位数 对顶堆(大根堆 + 小根堆)的经典应用
洛谷练习建议 在洛谷题库中搜索以下关键词即可找到对应题目(题号请以站内搜索结果为准,本讲不提供具体题号): 「排序 模板」(把本讲八种算法都提交一遍,比较耗时)、 「逆序对」(归并排序或树状数组)、 「瑞士轮」(归并思想 + 模拟,是归并排序的绝佳应用题)、 「中位数」(对顶堆)、 「数列合并 / 丑数」(多路归并)、 「堆 模板」(优先队列)。 第 14 讲的洛谷题单里会有更完整的分层练习计划。
下一讲预告 本讲覆盖了八大经典排序。第 12 讲《排序体系与下界分析(下)》会继续往下挖: 比较排序的决策树下界 Ω(n log n) 的严格证明、 计数排序 / 桶排序的深入分析、外部排序的多路归并与置换选择并行排序排序的工程实现(introsort / TimSort 源码级剖析), 以及排序在去重、离散化、贪心预处理中的典型应用。