#007. 排序算法:稳定性、归并、快排与堆排

数据结构与算法板块核心知识地图:排序、查找选择、组合枚举与进阶概念

#学习目标:把排序讲成三个决策

排序是量化技术面试里出现频率最高的「看似基础」话题。面试官很少让你调用 std::sort,而是围绕三个决策追问:要不要保序(稳定性)、要什么保证(最坏情况复杂度还是平均速度)、数据在哪(内存数组、链表,还是磁盘上的大文件)。把这三个决策想清楚,任何一个排序追问都能拆解成「需求 × 算法性质」的匹配问题。

本章先讲稳定性的精确定义和必须稳定的真实场景;再用递归树把归并排序的 \(O(n \log n)\) 推导清楚,并解释它为什么天然适合外部排序(这是 010 章的伏笔);然后深入快排的两种 partition 写法、有序输入的 \(O(n^2)\) 退化、三路划分与随机化;最后解释堆排如何做到原地 \(O(1)\) 空间、以及它在实践上常输给快排的原因。所有代码用 C++ 手写,面试时需要的就是这个版本。

稳定性契约

相等元素的相对次序是否保留。多关键字排序(先按次要键、再按主要键稳定排序)必须用它,这是「再排一次也不破坏已有顺序」的保证。

复杂度三档

最好、平均、最坏各是什么,额外空间算不算递归栈,稳不稳定。四个维度都要能当场推导,不能只背表。

手写能力

归并、快排(Lomuto 与 Hoare partition、三路划分、随机化)、堆排(自底向上建堆)四段代码能在白板上写对,并处理空输入、单元素、全相等元素。

#稳定性:排序算法的隐藏契约

定义。设两个元素按排序关键字相等:\(a_i = a_j\) 且 \(i \lt j\)(排序前 \(a_i\) 在前)。若排序后仍有 \(\mathrm{pos}(a_i) \lt \mathrm{pos}(a_j)\),即相等元素的先后关系原样保留,则称这个排序算法是稳定(stable)的。怎么读:稳定性约束的不是「值」的顺序(值相等顺序无所谓),而是「同值的两个个体」谁在前——它保护的是关键字以外的信息。

常见算法的稳定性可以直接从「是否做远距离交换」看出来。归并排序在合并相等元素时总是先取左半边,次序天然保留;插入排序把元素插到「不小于自己的第一个位置」前面,也稳定;冒泡排序只在逆序时交换相邻元素,稳定。反过来,选择排序每轮把全局最小值与前方位置交换、可能跨越一堆相等元素,堆排序的下沉(sift down)会把孩子换到很远的位置、快排的 partition 同样做长距离搬运——这三者都不稳定。

稳定阵营:归并排序、插入排序、冒泡排序、计数排序、基数排序。共同点是「相等元素只在相邻或左右半区之间移动」。

不稳定阵营:快速排序、堆排序、选择排序、希尔排序。共同点是「存在跨越相等元素的长距离交换」。

记忆钩子:不稳定 = 换得远。三种「选一个放到正确位置」的算法(选最小、选堆顶、选基准落位)都不稳定。

什么时候必须稳定:多关键字排序

经典场景:交易清单已按时间升序排好,现在要求按股票代码升序、代码相同的保持时间序。做法是直接对代码做一次稳定排序——稳定保证同代码内部的时间序不被打散,一次排序就得到 (代码, 时间) 的字典序。若用不稳定排序,时间序可能被打乱,只能重新比较两个键。这就是 Excel 多级排序和数据库多列排序内部依赖的原理:「先按次要键排,再按主要键稳定排」等价于按 (主, 次) 复合键排序。

#归并排序:递归树、O(n log n) 与外部性

分治结构。归并排序把区间 \([l, r)\) 对半分成 \([l, m)\) 与 \([m, r)\),递归排序后把两个有序段合并。合并是线性扫描:双指针比较两端队首,小者进入输出。递推式为

\[T(n) = 2\,T(n/2) + \Theta(n) = \Theta(n \log n).\]

怎么读:把递归调用画成一棵树——第 0 层 1 个规模为 \(n\) 的合并,第 1 层 2 个规模 \(n/2\) 的合并,第 k 层 \(2^k\) 个规模 \(n/2^k\) 的合并;每一层的合并总量都是 \(\Theta(n)\),树高 \(\lceil \log_2 n \rceil\),总共 \(\Theta(n \log n)\) 层工作量。关键是这个论证与输入内容无关:无论原始数组什么样,切分方式都一样,所以归并的最好、平均、最坏全是 \(O(n \log n)\)——这是它相对快排的核心卖点。

稳定性从哪来。合并时若 \(a_i = a_j\)(左半元素与右半元素相等),代码里写「小于才取右」,相等时优先取左半,左半元素原本就在前,次序保留。这一行的差别就是归并稳定而快排不稳定的原因。

代价与外部性。归并需要 \(O(n)\) 的辅助数组加 \(O(\log n)\) 递归栈,这是它输给快排的地方。但它只做顺序访问:合并时两个输入指针只前进、输出指针只前进,从不随机跳。这让它在两个场景不可替代:一是链表排序(改指针即可,辅助空间 \(O(1)\),随机访问在链表上是灾难);二是外部排序——数据在磁盘上时顺序读写远快于随机读写,所以外部排序的主体就是多路归并,完整设计在 010 章。还有个实用延伸:合并时统计「右半先出、左半还剩多少」就是逆序对个数,归并框架顺带解决了逆序对计数。

自底向上归并:外部排序的雏形

先把数组看成 n 个长度 1 的有序段(run),再按长度 2、4、8 逐轮两两合并,消掉递归。外部排序做的事一模一样,只是 run 从「内存排序的输出」开始、两两合并推广成 k 路合并。理解这里的「有序段逐步翻倍」,010 章的多路归并就是同一张图。

#快排与堆排:两种原地策略的取舍

快排的核心是 partition。任选一个基准 pivot,一趟扫描把数组重排成「小于等于 pivot 的都在左、大于的都在右」,基准落到最终位置 p,再对两侧递归。两种经典写法:

Lomuto 版:取最右元素为基准,维护指针 i 使 a[lo, i) 恒不超过基准,j 从左扫到右,遇到小的就换到 i 位置。写法最简单,是面试默认版本;缺点是相等元素多时交换偏多。

Hoare 版:双指针从两端向中间夹逼,左指针停在第一个不小于基准处、右指针停在第一个不大于基准处,交换后继续。交换次数大约是 Lomuto 的三分之一,但边界细节多(返回的是 j 而非落位点,递归区间也不同)。

复杂度:平均好、最坏坏。划分若大致对半,递推式与归并相同,\(T(n) = 2T(n/2) + \Theta(n) = \Theta(n \log n)\);若每次划分极度不平衡(例如数组已经有序、又固定取端点为基准),一侧规模为 0、另一侧为 \(n-1\):

\[T(n) = T(n-1) + \Theta(n) = \Theta(n^2).\]

怎么读:每层只减少一个元素、每层仍做 \(\Theta(n)\) 的扫描,\(n\) 层共 \(\Theta(n^2)\)。所以「快排最坏 \(O(n^2)\)」不是理论摆设——对已经有序的输入用朴素快排就立刻触发,这是面试最爱的追问。随机化(随机选基准)或三数取中让最坏只对「恶意构造」出现,自然输入下概率趋近零;introsort(std::sort 的实现策略)再加一层保险:递归深度超过 \(2\log_2 n\) 就切换堆排,把最坏也钉死在 \(O(n \log n)\)。

三路划分处理重复。若数组全相等,普通 partition 每次只剥掉基准一个元素,同样退化为 \(O(n^2)\)。三路划分(Dutch national flag,荷兰国旗问题)一趟把数组切成「小于 pivot | 等于 pivot | 大于 pivot」三段,等于段整体落位不再递归——全相等数组一次划分直接结束,\(O(n)\)。交易数据里同一价格大量重复,这个变式非常实用。

堆排:原地且有最坏保证。先自底向上把数组建成大顶堆(总代价 \(O(n)\),不是 \(O(n \log n)\)——大部分节点在浅层,下沉距离短的节点多),然后每轮把堆顶(最大值)与堆尾交换、堆边界左移一位、对新堆顶做一次 \(O(\log n)\) 的下沉,共 n 轮。全程只动数组内的元素:额外空间 \(O(1)\),最坏 \(O(n \log n)\),这两点是它对快排的优势;代价是不稳定,以及实践上明显慢。慢的原因有二:下沉访问孩子下标 \(2i+1\)、\(2i+2\),跨半个数组跳,缓存不友好(快排是顺序扫描);每层比较两次、且交换多,内层循环常数大。所以工程上堆排的定位是「introsort 的兜底」和「只需要 \(O(1)\) 空间时的选择」,而不是默认排序。

归并排序

三档全 \(O(n \log n)\),稳定,顺序访问;辅助数组 \(O(n)\)。链表与外部数据的默认选择,逆序对计数的载体。

快速排序

平均 \(O(n \log n)\)、最坏 \(O(n^2)\)(有序输入 + 端点基准即触发);原地、缓存友好、常数小,实践最快。随机化与三路划分是必备补丁。

堆排序

最坏 \(O(n \log n)\) 且原地 \(O(1)\) 空间;不稳定、缓存跳跃,常数大。要最坏保证又不许额外空间时用它。

算法最好平均最坏额外空间稳定一句话定位
归并排序\(O(n \log n)\)\(O(n \log n)\)\(O(n \log n)\)\(O(n) + O(\log n)\) 栈顺序访问,外部排序与链表首选
快速排序\(O(n \log n)\)\(O(n \log n)\)\(O(n^2)\)\(O(\log n)\) 栈(期望)原地且缓存友好,实践最快
堆排序\(O(n \log n)\)\(O(n \log n)\)\(O(n \log n)\)\(O(1)\)有最坏保证的原地排序
插入排序\(O(n)\)\(O(n^2)\)\(O(n^2)\)\(O(1)\)小数组收尾、近乎有序输入

#例题详解 I:源题问答与稳定性场景

例题 1(源题·编程 31):你了解归并排序和快速排序吗?它们的时间复杂度分别是多少?

建模。这是复杂度问答题,考点不是背数字,而是「平均与最坏的区分、空间与稳定性是否一并说出来、能不能解释为什么」。按「结论 → 原因 → 什么时候失效」三层组织答案。

关键步骤。归并排序:递推 \(T(n) = 2T(n/2) + \Theta(n)\),递归树每层合并总量 \(\Theta(n)\)、共 \(\Theta(\log n)\) 层,因此最好 = 平均 = 最坏 = \(O(n \log n)\);代价是 \(O(n)\) 辅助数组,换来的性质是稳定与纯顺序访问。快速排序:平均 \(O(n \log n)\)(随机基准下期望划分均衡),最坏 \(O(n^2)\),触发条件是划分持续极不平衡——典型就是对已排序输入固定取端点为基准,递推退化为 \(T(n) = T(n-1) + \Theta(n)\);它是原地的,期望递归栈 \(O(\log n)\),但不稳定。

数值对照。对 \(n = 10^6\):\(n \log_2 n \approx 2 \times 10^7\),而 \(n^2 = 10^{12}\)——最坏与平均差了五个数量级,这就是随机化必须写的原因。

面试怎么讲。先各用一句话给结论,再主动补三点:快排最坏的触发与补救(随机基准、三数取中、三路划分压重复、introsort 深度超限切堆排);归并为什么三档一致(切分与输入无关);最后一句工程口径——「std::sort 是 introsort:快排打底、递归过深换堆排、小区间换插入排序,所以它有 \(O(n \log n)\) 的最坏保证还有接近快排的平均速度」。能把 std::sort 的实现策略讲出来,这题就从及格变成加分。

例题 2:一张成交记录表已按时间升序排好。现在要求整体按股票代码升序、同一代码内仍按时间升序,只允许再做一次排序,怎么做?

建模。两个关键字(代码为主、时间为次),但只允许排一次。如果排序算法稳定,那么「对主关键字做一次稳定排序」就自动保留按次关键字的原有次序——时间序已经排好,就是那个「原有次序」。

关键步骤。对代码列做稳定排序(归并或插入排序系)。相等代码的元素在输出中保持输入里的先后,即时间序。等价性论证:任意两条同代码记录 \(r_i\)、\(r_j\),输入中 \(i \lt j\)(时间在前),稳定性保证输出中仍 \(i\) 在前。

数值演示。输入(已按时间排好):

时间代码
09:30AAPL
09:31MSFT
09:35AAPL
09:36GOOG
09:40MSFT

按代码稳定排序后:AAPL 09:30、AAPL 09:35、GOOG 09:36、MSFT 09:31、MSFT 09:40——每只代码内部时间序完好。若改用快排或堆排,输出可能是 AAPL 09:35、AAPL 09:30、…,时间序被打乱。

面试怎么讲。一句话点名机制:「稳定排序保证相等键的相对次序不变,所以先按次要键排好、再按主键稳定排一次,等价于按复合键排序」。再补一个反向追问的答案:如果只能用不稳定排序,就必须把两个键拼成复合比较函数(代码相等时比时间),或者排完后对同代码段再排一次时间。

#例题详解 II:手写归并、快排与堆排

例题 3:手写归并排序,要求稳定,并分析复杂度与边界。

思路。区间约定为左闭右开 \([l, r)\),长度不超过 1 直接返回;否则取中点递归两侧,再双指针合并。稳定性由合并时「相等取左」这一行保证。

#include <vector>
using namespace std;

void mergeSort(vector<int>& a, int l, int r, vector<int>& tmp) {
    if (r - l <= 1) return;                 // [l, r) 长度 <= 1,天然有序
    int m = l + (r - l) / 2;
    mergeSort(a, l, m, tmp);                // 递归排左半 [l, m)
    mergeSort(a, m, r, tmp);                // 递归排右半 [m, r)
    int i = l, j = m, k = l;
    while (i < m && j < r)                 // 稳定性关键:相等时取左半
        tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    while (i < m) tmp[k++] = a[i++];        // 左半剩余直接接上
    while (j < r) tmp[k++] = a[j++];        // 右半剩余直接接上
    for (int t = l; t < r; ++t) a[t] = tmp[t];
}

复杂度。时间 \(O(n \log n)\)(每层合并总量 \(\Theta(n)\),\(\Theta(\log n)\) 层);空间 \(O(n)\) 辅助数组加 \(O(\log n)\) 递归栈。

边界。空数组与单元素在第一行被 \(r - l \le 1\) 拦下;全相等数组走满递归树,仍是 \(O(n \log n)\);负数、大数不受影响(只做比较)。追问延伸:把「取右半时」计数左半剩余元素个数,即得逆序对数目。

面试怎么讲。写完立刻自报家门:「区间是左闭右开,合并相等取左所以稳定,临时数组可以提到外层只分配一次」。主动提这两点,比等面试官抓 bug 印象分高得多。

例题 4:手写快速排序:Lomuto partition 加随机化,并给出处理大量重复元素的三路版本;说明有序输入为什么退化。

思路。随机选基准与最右元素交换,再跑 Lomuto 划分;递归两侧。区间约定为左闭右闭 \([lo, hi]\)。

#include <cstdlib>
using namespace std;

int partition(vector<int>& a, int lo, int hi) {   // Lomuto 版,闭区间 [lo, hi]
    int r = lo + rand() % (hi - lo + 1);          // 随机基准:防有序输入退化
    swap(a[r], a[hi]);
    int pivot = a[hi];
    int i = lo;                                   // 不变量:a[lo, i) 均 <= pivot
    for (int j = lo; j < hi; ++j)
        if (a[j] <= pivot) swap(a[i++], a[j]);
    swap(a[i], a[hi]);                            // 基准落位:左侧 <=,右侧 >
    return i;
}

void quickSort(vector<int>& a, int lo, int hi) {
    if (lo >= hi) return;
    int p = partition(a, lo, hi);
    quickSort(a, lo, p - 1);                      // 递归基准左侧
    quickSort(a, p + 1, hi);                      // 递归基准右侧
}

退化分析。若去掉随机化、固定取 a[hi] 为基准,而输入已升序:每轮划分结果是「左侧 \(n-1\) 个、右侧 0 个」,递推 \(T(n) = T(n-1) + \Theta(n) = \Theta(n^2)\)。随机化后,持续命中最坏划分的概率随 n 指数衰减,期望复杂度回到 \(O(n \log n)\)。Hoare 双向夹逼版交换更少,但返回的是分界 j,递归区间为 \([lo, j]\) 与 \([j+1, hi]\),边界极易写错,面试选 Lomuto 更稳。

三路划分(重复元素多时)。

void quickSort3(vector<int>& a, int lo, int hi) {   // 三路:< p | == p | > p
    if (lo >= hi) return;
    int pivot = a[lo + rand() % (hi - lo + 1)];
    int lt = lo, i = lo, gt = hi;    // [lo, lt) < p;[lt, i) == p;(gt, hi] > p
    while (i <= gt) {
        if (a[i] < pivot)      swap(a[lt++], a[i++]);
        else if (a[i] > pivot) swap(a[i], a[gt--]);
        else                    ++i;            // 等于段整体跳过,不再递归
    }
    quickSort3(a, lo, lt - 1);                  // 只递归小于段
    quickSort3(a, gt + 1, hi);                  // 和大于段
}

复杂度与边界。平均 \(O(n \log n)\)、最坏 \(O(n^2)\);全相等数组对三路版本是一次 \(O(n)\) 划分即结束,对两路版本是 \(O(n^2)\)。栈深最坏 \(O(n)\)(可先递归小半边、对大半边改循环,把栈压到 \(O(\log n)\))。边界:空区间、单元素由 \(lo \ge hi\) 拦截;负数无影响;快排不稳定,需要稳定时换归并。

面试怎么讲。先声明「随机基准必须写,否则有序输入直接平方级」,写完 partition 后用手样例 [3, 1, 2] 走一遍 i、j 的移动。追问 std::sort 时答 introsort:快排 + 深度超 \(2\log_2 n\) 切堆排 + 小区间插入排序。

例题 5:手写堆排序,要求原地 \(O(1)\) 空间;解释建堆为什么是 O(n)、以及它实践上为什么常输给快排。

思路。把数组 \([0, n)\) 视为完全二叉树(节点 i 的孩子是 \(2i+1\)、\(2i+2\))。第一阶段自底向上建大顶堆:从最后一个非叶节点 \(n/2 - 1\) 倒着做下沉;第二阶段每轮把堆顶(最大值)换到堆尾并缩短堆边界。

void siftDown(vector<int>& a, int root, int end) {  // 堆区间 [0, end]
    while (2 * root + 1 <= end) {                   // 左孩子下标 2*root+1
        int child = 2 * root + 1;
        if (child + 1 <= end && a[child] < a[child + 1]) ++child;  // 取更大孩子
        if (a[root] >= a[child]) return;            // 已满足堆序,提前终止
        swap(a[root], a[child]);
        root = child;                               // 继续向下修复
    }
}

void heapSort(vector<int>& a) {
    int n = (int)a.size();
    for (int i = n / 2 - 1; i >= 0; --i)            // 建堆:总代价 O(n)
        siftDown(a, i, n - 1);
    for (int end = n - 1; end > 0; --end) {         // 每轮把最大值归位到末尾
        swap(a[0], a[end]);
        siftDown(a, 0, end - 1);                    // 堆边界左移一位
    }
}

建堆为什么是 \(O(n)\)。高度为 h 的节点约 \(n/2^{h+1}\) 个,每个下沉至多 h 步,总代价 \(\sum_h h \cdot n/2^{h+1} = O(n)\)——绝大多数节点在底层、几乎不动。怎么读:不是每个节点都花 \(\log n\),只有树顶少数节点花得多,加权求和是线性的。

复杂度与边界。最好 = 平均 = 最坏 \(O(n \log n)\),额外空间 \(O(1)\)(原地交换、无递归);不稳定(下沉会跨越相等元素)。空数组、单元素:两个循环都自然不执行。全相等输入:建堆后每轮下沉立即终止(a[root] ≥ a[child] 成立),接近线性但仍做 n 次交换,不如三路快排干脆。

为什么实践常输给快排。两点:一是访问模式——孩子下标 \(2i+1\)、\(2i+2\) 每步跨半个数组,缓存命中率远低于快排的顺序扫描;二是常数——每层最多比较两次、且大量交换。快排平均要快数倍。但堆排有两个不可替代的时刻:需要最坏 \(O(n \log n)\) 保证,或只允许 \(O(1)\) 额外空间。堆结构本身(优先队列)的用法见 004 章

面试怎么讲。强调「建堆 O(n) + n 次 \(O(\log n)\) 下沉」,这两个数分开说,很多候选人误把建堆也当成 \(O(n \log n)\)。被问「那 std::sort 为什么不用堆排」时,答缓存与常数,再带出 introsort 的组合策略。

#误区与边界

五个高频错误

一,「快排稳定」——错,长距离交换必然破坏相等元素次序,稳定的是归并/插入/冒泡。二,「堆排要 \(O(n)\) 或 \(O(\log n)\) 额外空间」——错,堆在原数组上就地构建,空间严格 \(O(1)\)。三,比较快排与归并的空间时忘算递归栈:快排原地但期望仍有 \(O(\log n)\) 栈帧,归并是 \(O(n)\) 数组加 \(O(\log n)\) 栈。四,只答「快排 \(O(n \log n)\)」不说最坏,被追问有序输入就卡壳——平均与最坏必须分开说。五,手写归并时丢掉「相等取左」,稳定性悄悄丢了;手写堆排时忘记建堆从 \(n/2 - 1\) 开始、或下沉时孩子越界。

考官常见追问与变式

「链表排序选什么?」——归并,链表上合并只需改指针,辅助空间 \(O(1)\),而快排的随机访问在链表上是 \(O(n)\) 一次。「几乎有序的数组呢?」——插入排序接近 \(O(n)\)。「为什么小区间切插入排序?」——插入排序常数小且 \(n\) 小于约 16 时 \(O(n^2)\) 项尚不主导。「100 GB 文件怎么排?」——外部多路归并,见 010 章。「只要求第 k 小呢?」——不必排全序,QuickSelect,见 008 章。「逆序对怎么数?」——归并排序合并时顺带计数,复杂度 \(O(n \log n)\)。

#检查清单

  • 我能给出稳定性的精确定义(相等元素的相对次序保留),并说出稳定与不稳定阵营各自的名单和原因。
  • 我能用「先按次要键排、再按主键稳定排」解释多关键字排序,并举一个交易数据的例子。
  • 我能用递归树推导归并的 \(T(n) = 2T(n/2) + \Theta(n)\),并说明它为什么三档都是 \(O(n \log n)\)。
  • 我能手写 Lomuto partition 与随机化快排,并解释有序输入退化到 \(O(n^2)\) 的递推过程。
  • 我能写出三路划分,并说明它把全相等输入从 \(O(n^2)\) 救回 \(O(n)\) 的原因。
  • 我能手写原地堆排,解释建堆 \(O(n)\) 的加权求和,以及它实践慢于快排的两个原因。
  • 我能完整回答源题 31:归并与快排的最好/平均/最坏复杂度、空间与稳定性,并主动补充 introsort。
  • 我能根据「稳定性 / 最坏保证 / 空间 / 数据在内存还是磁盘」四个维度为场景选择排序算法。