排序算法全景 — 从零精通算法与数据结构——Google 面试系统备战 第2篇
第2章:排序算法全景
本章目标
读完本章你会:
- 手写插入排序、归并排序、快速排序、堆排序、计数排序、基数排序
- 解释每种排序的稳定性和适用场景
- 用决策树模型证明基于比较的排序下界 Ω(n log n)
- 理解快排的随机化为何能将期望复杂度从 O(n²) 降到 O(n log n)
- 将排序算法添加到 algo_toolkit 中
知识讲解
从一个生活例子开始
你有一摞扑克牌,需要按数字从小到大排好。
方法一(插入排序): 把牌摊在桌上,每次拿一张,插入到手里已经排好的牌中的正确位置。就像打牌时整理手牌——新摸的牌在手里"插入"到合适的位置。牌少时特别快。
方法二(归并排序): 把牌分成两堆,每堆先排好,然后合并——两个手指分别指在两堆的顶部,每次拿较小的那张。这是"分而治之"——难的问题拆成小问题。
方法三(快速排序): 选一张"基准牌"(比如第一张),比它小的放左边,大的放右边,然后左右各自再排。就像教室排队——"身高 > 170cm 站右边,≤ 170 站左边",然后再各自按身高排。
三种方法都能排好,但效率天差地别。本章我们就来搞清楚:什么时候该用哪一种。
工作原理
2.1 插入排序:简单、稳定、小数据最快
思想: 维护一个"已排序前缀",每次把下一个元素插入到前缀中的正确位置。
伪代码:
INSERTION-SORT(A, n)
for i = 2 to n
key = A[i]
j = i - 1
while j > 0 and A[j] > key
A[j+1] = A[j]
j = j - 1
A[j+1] = key
循环不变量: 第 i 次迭代开始前,A[1..i-1] 已经排好序。(详见第 1 章的推导)
复杂度:
| 情况 | 复杂度 | 场景 |
|---|---|---|
| 最好 | Θ(n) | 数组已经有序,内层 while 从不执行 |
| 最坏 | Θ(n²) | 逆序,每次 key 都要走到数组开头 |
| 平均 | Θ(n²) | 随机数据,约 n²/4 次比较 |
关键洞察——为什么平均情况下插入排序是 O(n²): 对随机数据,每个元素平均需要和前面一半的元素比较。总共约 (1+2+...+n)/2 ≈ n²/4 次比较。
实用的真理: 插入排序在 n < 50 时是最快的排序算法(常数极小,无递归开销)。许多生产级排序(包括
std::sort)在小数据量时退化为插入排序。
2.2 归并排序:稳定、可预测、分治典范
思想: 递归地对半拆分,分别排序,再合并两个有序数组。
合并过程(核心):
MERGE(A, left, mid, right):
n1 = mid - left + 1
n2 = right - mid
复制 L = A[left..mid], R = A[mid+1..right]
i = 0, j = 0, k = left
while i < n1 and j < n2:
if L[i] ≤ R[j]:
A[k] = L[i]; i++
else:
A[k] = R[j]; j++
k++
把 L 剩余尾部或 R 剩余尾部复制回 A
递归树分析:
[n] ← 层 0:合并 n 个元素
/ \
[n/2] [n/2] ← 层 1:合并 n 个元素
/ \ / \
[n/4] [n/4] [n/4] [n/4] ← 层 2:合并 n 个元素
... ... ... ...
总层数:log₂ n
每层工作量:O(n)
总复杂度:O(n log n)
复杂度:
| 指标 | 值 |
|---|---|
| 时间(最好/平均/最坏) | Θ(n log n) |
| 空间 | Θ(n)(需要辅助数组) |
| 稳定性 | ✅ 稳定 |
稳定性: 相等元素的相对顺序在排序后保持不变。归并排序中
L[i] ≤ R[j](而非<)保证相等时优先取左边的元素。
2.3 快速排序:不稳定、就地、实际最快
思想: 选 pivot,分成比 pivot 小和大的两组,递归排序两组。
关键理解——为什么实际最快:
- 就地操作——不需要归并的辅助数组(虽然递归调用栈占 O(log n) 空间)
- 缓存友好——partition 在连续内存上扫描
- 常数因子小——每个元素只和 pivot 比较一次
但裸快排的最坏情况是 O(n²): 如果 pivot 永远选到最小/最大值,每次 partition 只减少 1 个元素,递归深度变成 n,每层 O(n) → O(n²)。
解决办法——随机化: 随机选 pivot,使最坏情况发生的概率指数级小。期望复杂度 O(n log n)。
RANDOMIZED-QUICKSORT(A, left, right):
if left < right:
pivot_index = RANDOM(left, right) // 随机化核心
swap(A[pivot_index], A[right])
q = PARTITION(A, left, right)
RANDOMIZED-QUICKSORT(A, left, q-1)
RANDOMIZED-QUICKSORT(A, q+1, right)
PARTITION(A, left, right):
pivot = A[right]
i = left - 1
for j = left to right-1:
if A[j] ≤ pivot: // ≤ 不是 <,避免无限循环
i++
swap(A[i], A[j])
swap(A[i+1], A[right])
return i+1
三路划分优化(荷兰国旗问题): 当有大量重复元素时,把数组分成 < pivot / = pivot / > pivot 三部分,等于 pivot 的部分就不需递归了。这就是 std::sort 内部使用的 introsort 的基础。
2.4 堆排序:就地、O(n log n)、但不稳定
思想: 先用数组建一个最大堆(O(n)),然后反复取出堆顶(最大值),放到数组尾部,下沉修复堆。
详见第 6 章《堆、优先队列与摊还分析》——本章先给结论:
| 指标 | 值 |
|---|---|
| 建堆 | Θ(n)(虽然直觉上是 O(n log n)) |
| 排序 | Θ(n log n) |
| 空间 | O(1) |
| 稳定性 | ❌ 不稳定 |
2.5 计数排序与基数排序:突破 O(n log n) 下界
关键思想: O(n log n) 下界只适用于基于比较的排序。如果不对元素做比较,可以做到 O(n)。
计数排序: 假设输入是 0~k 范围内的整数。统计每个值出现的次数,然后直接按次数"输出"。
COUNTING-SORT(A, n, k):
创建 C[0..k] 全为 0
for i = 1 to n: C[A[i]]++ // 统计出现次数
for i = 1 to k: C[i] += C[i-1] // 前缀和 = 每个数的最后出现位置
for i = n downto 1: // 倒序遍历保证稳定性
B[C[A[i]]] = A[i]
C[A[i]]--
复杂度 O(n + k)。当 k = O(n) 时,整体 O(n)。代价是额外 O(k) 空间。
基数排序: 按每一位(如个位→十位→百位)依次做稳定排序(通常用计数排序作为子过程)。
RADIX-SORT(A, d): // d 位数字
for i = 1 to d:
用稳定排序按第 i 位数排序 A
复杂度 O(d × (n + k))。当 d 和 k 是小常数时,实际是 O(n)。
排序算法决策表
| 场景 | 推荐 | 原因 |
|---|---|---|
| n < 50 | 插入排序 | 常数最小 |
| n 中等(~1000),任意数据 | 快排(随机化) | 最好平均性能 |
| 需要稳定排序 | 归并排序 | 稳定 + Θ(n log n) 保证 |
| O(1) 额外空间,不需要稳定 | 堆排序 | 就地 O(n log n) |
| 整数在有限范围 [0, k] | 计数排序 | O(n+k) |
| 定长字符串/多位数整数 | 基数排序 | O(d·n) |
| 生产代码 | std::sort |
O(n log n),内部 introsort,防止退化 |
比较排序下界:Ω(n log n) 从何而来
这是本章最理论的部分——但理解了它,你就会明白为什么计数排序/基数排序的价值这么大。
决策树模型:
比较 a₁ 和 a₂
/ \
a₁ < a₂ a₁ ≥ a₂
/ \ / \
比较 a₂,a₃ ... ... 比较 a₁,a₃
/ \ / \
[1,2,3] [1,3,2] [2,1,3] [3,2,1]
- 每片叶子是 n! 种排列中的一种
- 树的高度 = 最坏情况下需要的比较次数
- 高度 h 的二叉树最多有 2ʰ 片叶子
- n! 种可能的输入 → 至少需要 n! 片叶子 → 2ʰ ≥ n! → h ≥ log(n!)
- Stirling 近似:n! ≈ √(2πn)(n/e)ⁿ → log(n!) = n log n - n log e + O(log n) = Θ(n log n)
所以:任何基于比较的排序算法,其最坏比较次数 Ω(n log n)。 归并排序和堆排序达到了这个下界,因此是渐进最优的。
代码实战
在 algo_toolkit 中添加 sorting.h。按 Google C++ Style,接口声明在头文件,模板实现在 impl 头文件。
include/algo/sorting.h
#ifndef ALGO_SORTING_H_
#define ALGO_SORTING_H_
#include <cstddef>
#include <cstdint>
#include <iterator>
#include <vector>
namespace algo {
// 插入排序:O(n²),稳定,最适合 n < 50
// 返回比较次数(用于复杂度验证)
template <typename RandomIt>
int64_t InsertionSort(RandomIt begin, RandomIt end);
// 归并排序:Θ(n log n),稳定,需要 O(n) 额外空间
template <typename RandomIt>
void MergeSort(RandomIt begin, RandomIt end);
// 快速排序(随机化 pivot):期望 O(n log n),就地,不稳定
template <typename RandomIt>
void QuickSort(RandomIt begin, RandomIt end);
// 堆排序:Θ(n log n),O(1) 空间,不稳定
template <typename RandomIt>
void HeapSort(RandomIt begin, RandomIt end);
// 计数排序:O(n + k),稳定,仅限非负整数
// 范围 [0, max_val]
void CountingSort(std::vector<int>& data, int max_val);
} // namespace algo
#include "algo/sorting_impl.h"
#endif // ALGO_SORTING_H_
include/algo/sorting_impl.h(核心实现)
#ifndef ALGO_SORTING_IMPL_H_
#define ALGO_SORTING_IMPL_H_
#include <algorithm>
#include <cstdlib>
#include <functional>
#include <random>
#include <utility>
#include <vector>
namespace algo {
// ========== 插入排序 ==========
// 设计决策:模板化 + 返回比较次数 —— 既实用又方便教学验证
template <typename RandomIt>
int64_t InsertionSort(RandomIt begin, RandomIt end) {
if (begin == end) return 0;
int64_t comparisons = 0;
for (auto it = std::next(begin); it != end; ++it) {
auto key = std::move(*it);
auto j = it;
// 边比较边后移 —— 避免了独立的 swap 操作
while (j != begin) {
auto prev = std::prev(j);
++comparisons;
if (*prev <= key) break;
*j = std::move(*prev);
j = prev;
}
*j = std::move(key);
}
return comparisons;
}
// ========== 归并排序 ==========
namespace detail {
template <typename RandomIt>
void Merge(RandomIt begin, RandomIt mid, RandomIt end) {
using ValueType = typename std::iterator_traits<RandomIt>::value_type;
std::vector<ValueType> left(begin, mid);
std::vector<ValueType> right(mid, end);
auto lit = left.begin();
auto rit = right.begin();
auto out = begin;
while (lit != left.end() && rit != right.end()) {
// ≤ 而非 < :保证稳定性
if (*lit <= *rit) {
*out++ = std::move(*lit++);
} else {
*out++ = std::move(*rit++);
}
}
// 只有一边会剩余 —— 直接搬
out = std::move(lit, left.end(), out);
out = std::move(rit, right.end(), out);
}
} // namespace detail
template <typename RandomIt>
void MergeSort(RandomIt begin, RandomIt end) {
auto size = std::distance(begin, end);
if (size <= 1) return;
// 插入排序在小规模时更快 —— 生产级优化
if (size <= 32) {
InsertionSort(begin, end);
return;
}
auto mid = std::next(begin, size / 2);
MergeSort(begin, mid);
MergeSort(mid, end);
detail::Merge(begin, mid, end);
}
// ========== 快速排序(随机化 pivot) ==========
template <typename RandomIt>
void QuickSort(RandomIt begin, RandomIt end) {
auto size = std::distance(begin, end);
if (size <= 1) return;
// 小规模退化为插入排序
if (size <= 32) {
InsertionSort(begin, end);
return;
}
// 随机选 pivot,换到末尾
static thread_local std::mt19937 rng(std::random_device{}());
auto pivot_idx = std::uniform_int_distribution<std::ptrdiff_t>(0, size - 1)(rng);
std::iter_swap(std::next(begin, pivot_idx), std::prev(end));
// Lomuto partition
auto& pivot = *std::prev(end);
auto i = begin;
for (auto j = begin; j != std::prev(end); ++j) {
if (*j <= pivot) {
std::iter_swap(i, j);
++i;
}
}
std::iter_swap(i, std::prev(end));
QuickSort(begin, i);
QuickSort(std::next(i), end);
}
// ========== 堆排序 ==========
namespace detail {
// 下沉操作:维护以 root 为根的最大堆
template <typename RandomIt>
void SiftDown(RandomIt begin, RandomIt end, RandomIt root) {
auto size = std::distance(begin, end);
auto idx = std::distance(begin, root);
while (true) {
auto largest = idx;
auto left = 2 * idx + 1;
auto right = 2 * idx + 2;
if (left < size && *(begin + left) > *(begin + largest)) {
largest = left;
}
if (right < size && *(begin + right) > *(begin + largest)) {
largest = right;
}
if (largest == idx) break;
std::iter_swap(begin + idx, begin + largest);
idx = largest;
}
}
} // namespace detail
template <typename RandomIt>
void HeapSort(RandomIt begin, RandomIt end) {
auto size = std::distance(begin, end);
if (size <= 1) return;
// 1. 建堆:从最后一个非叶节点开始,逐个下沉
for (auto i = size / 2 - 1; i >= 0; --i) {
detail::SiftDown(begin, end, begin + i);
}
// 2. 反复取出堆顶(最大值),放到数组尾部
for (auto i = size - 1; i > 0; --i) {
std::iter_swap(begin, begin + i); // 堆顶 ↔ 尾部
detail::SiftDown(begin, begin + i, begin);
}
}
// ========== 计数排序 ==========
inline void CountingSort(std::vector<int>& data, int max_val) {
if (data.empty()) return;
std::vector<int> count(max_val + 1, 0);
for (int x : data) {
++count[x];
}
// 前缀和:count[i] = 最后一个值为 i 的元素应放的位置
// 注:此处使用显式 prefix 变量以清晰展示前缀和的语义,
// 等效于 CLRS 的标准写法 C[i] += C[i-1],但分离 prefix 更直观
int prefix = 0;
for (int i = 0; i <= max_val; ++i) {
int cnt = count[i];
count[i] = prefix;
prefix += cnt;
}
// 现在 count[i] = 值为 i 的元素在输出数组中的起始位置
std::vector<int> output(data.size());
for (int x : data) {
output[count[x]++] = x; // 放置并移动指针
}
data = std::move(output);
}
} // namespace algo
#endif // ALGO_SORTING_IMPL_H_
逐行关键解释:
std::move在插入排序中避免不必要的拷贝——C++11 移动语义让就地的值操作几乎零开销- 归并排序的
Merge用了std::move搬移剩余元素——std::move返回一个迭代器,指向最后一个搬移元素的下一位- 快排的
iter_swap交换迭代器指向的值——注意std::prev(end)指向末尾元素- 堆排序的下沉
SiftDown用数组索引模拟二叉树——节点 i 的左子是 2i+1,右子是 2i+2(0-based)- 计数排序的
prefix技巧使得算法稳定——倒序时 count[x] 指向 x 的最后出现位置,正序时指向起始位置
本章小结
- 插入排序 O(n²),稳定,n < 50 时实际最快——
std::sort内部也用它收尾 - 归并排序 Θ(n log n),稳定,代价是 O(n) 额外空间——是稳定的渐进最优比较排序
- 随机化快速排序 期望 O(n log n),不稳定,O(log n) 栈空间——实践中最快的比较排序
- 堆排序 Θ(n log n),O(1) 空间,不稳定——当空间是瓶颈时的首选
- 计数排序 O(n + k),突破比较排序下界——前提是输入是有限范围整数
- 基数排序 O(d·n),用稳定排序逐位处理——适合定长整数/字符串
- 比较排序的理论下界是 Ω(n log n)——由决策树 + Stirling 近似严格证明
std::sort= Introsort = 快排 + 堆排保底 + 插入排序收尾
关键术语
| 术语 | 释义 |
|---|---|
| 稳定性 | 相等元素的相对顺序在排序后保持不变 |
| 决策树 | 用二叉树模拟比较排序的决策过程,每片叶子对应一种排序结果 |
| Introsort | 混合排序:快排 + 堆排保底 + 插入收尾,C++ std::sort 的实现方式 |
| 原地排序 | 只需要 O(1) 额外空间的排序算法(除输入本身外) |
| 递归树 | 将递归调用的结构画成树,标注每层工作量以分析复杂度 |

浙公网安备 33010602011771号