排序算法全景 — 从零精通算法与数据结构——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 小和大的两组,递归排序两组。

关键理解——为什么实际最快:

  1. 就地操作——不需要归并的辅助数组(虽然递归调用栈占 O(log n) 空间)
  2. 缓存友好——partition 在连续内存上扫描
  3. 常数因子小——每个元素只和 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 的最后出现位置,正序时指向起始位置

本章小结

  1. 插入排序 O(n²),稳定,n < 50 时实际最快——std::sort 内部也用它收尾
  2. 归并排序 Θ(n log n),稳定,代价是 O(n) 额外空间——是稳定的渐进最优比较排序
  3. 随机化快速排序 期望 O(n log n),不稳定,O(log n) 栈空间——实践中最快的比较排序
  4. 堆排序 Θ(n log n),O(1) 空间,不稳定——当空间是瓶颈时的首选
  5. 计数排序 O(n + k),突破比较排序下界——前提是输入是有限范围整数
  6. 基数排序 O(d·n),用稳定排序逐位处理——适合定长整数/字符串
  7. 比较排序的理论下界是 Ω(n log n)——由决策树 + Stirling 近似严格证明
  8. std::sort = Introsort = 快排 + 堆排保底 + 插入排序收尾

关键术语

术语 释义
稳定性 相等元素的相对顺序在排序后保持不变
决策树 用二叉树模拟比较排序的决策过程,每片叶子对应一种排序结果
Introsort 混合排序:快排 + 堆排保底 + 插入收尾,C++ std::sort 的实现方式
原地排序 只需要 O(1) 额外空间的排序算法(除输入本身外)
递归树 将递归调用的结构画成树,标注每层工作量以分析复杂度
posted @ 2026-06-22 01:09  Yobeeo  阅读(21)  评论(0)    收藏  举报