一句话概括: 排序算法的江湖,本质是"时间"与"空间"的博弈,是"简单直觉"与"精妙设计"的较量。这篇文章不止教你背会代码,更要带你推导出每一个复杂度数字是怎么算出来的 ,以及为什么
n log n是比较排序永远无法突破的天花板。
目录
- 为什么排序算法值得你啃下来
- 排序算法的"体检指标":稳定性、原地性、复杂度
- O(n²) 三兄弟:冒泡、选择、插入
- 突破平方级:希尔排序
- 分治法双雄:快速排序 vs 归并排序(含复杂度严格推导)
- 二叉树的智慧:堆排序
- 打破 O(n log n) 下限的非比较排序:计数排序 & 基数排序
- 排序算法下限证明:为什么比较排序永远跨不过 O(n log n)
- 实战演练场:七种算法真实数据竞速
- 总对比表与工程选择指南
- 彩蛋:.NET / Java 底层排序源码究竟怎么写的
- 课后练习
一、为什么排序算法值得你啃下来
工程中我们确实很少手写排序------Array.Sort() 一行代码解决问题。但排序算法之所以是计算机科学的"地基课",是因为它浓缩了几乎所有核心算法思想:
- 分治(Divide and Conquer) ------ 快速排序、归并排序
- 贪心(Greedy) ------ 选择排序每轮贪心选最小值
- 二叉堆结构 ------ 堆排序
- 空间换时间 ------ 计数排序、基数排序
- 递归与数学归纳法证明 ------ 每一种分治算法复杂度的推导
更现实的理由: 排序算法几乎是算法面试的"准入门槛"。但比"背会代码"更重要的是------你能不能讲清楚为什么快排平均是 O(n log n),最坏又为什么会退化成 O(n²)。这篇文章的核心目标,就是把"为什么"讲透。
二、排序算法的"体检指标"
| 指标 | 含义 | 为什么重要 |
|---|---|---|
| 时间复杂度(最好/平均/最坏) | 数据规模增长时运行时间的增长趋势 | 决定算法在大数据量下是否"能用" |
| 空间复杂度 | 排序过程中额外占用的内存 | 内存受限场景(嵌入式、超大数据)的关键指标 |
| 稳定性 | 值相等的元素,排序前后相对顺序是否不变 | 多级排序(先按时间排,再按金额排)的必要条件 |
| 原地排序(In-place) | 是否只需 O(1) 额外空间 | 决定能否处理超出内存容量的数据 |
| 比较 vs 非比较 | 是否通过"比较元素大小"来排序 | 非比较排序能突破 O(n log n) 下限,但有使用条件限制 |
稳定性为什么值得单独强调? 举例:电商后台先按"下单时间"给订单排过序,现在业务要求再按"订单金额"重新排序,此时你必须用稳定排序------否则金额相同的订单,下单时间的先后顺序会被打乱,这在真实业务中是绝对不能接受的 bug。
三、O(n²) 三兄弟:朴素但重要的基础
3.1 冒泡排序(Bubble Sort)------ 相邻交换,最大值"冒泡"到末尾
核心思想: 每一轮遍历,相邻元素两两比较,逆序则交换,一轮结束后当前最大值"浮"到正确位置。
[5, 3, 8, 1, 9]
第一轮:5,3→3,5 | 5,8→不换 | 8,1→1,8 | 8,9→不换
结果:[3, 5, 1, 8, 9] ← 9 归位
第二轮:3,5→不换 | 5,1→1,5 | 5,8→不换
结果:[3, 1, 5, 8, 9] ← 8 归位
第三轮:3,1→1,3
结果:[1, 3, 5, 8, 9] ← 排序完成,且可提前终止
csharp
static void BubbleSort(int[] arr)
{
int n = arr.Length;
for (int i = 0; i < n - 1; i++)
{
bool swapped = false;
// 关键优化:每轮末尾 i 个元素已确定,无需再比
for (int j = 0; j < n - 1 - i; j++)
{
if (arr[j] > arr[j + 1])
{
(arr[j], arr[j + 1]) = (arr[j + 1], arr[j]);
swapped = true;
}
}
if (!swapped) break; // 本轮无交换 → 已完全有序 → 提前退出
}
}
复杂度推导: 外层循环 n-1 次,内层循环平均约 n/2 次比较,总比较次数约为 n×(n-1)/2,这是一个关于 n 的二次函数,因此时间复杂度为 O(n²)。最好情况(已排序 + 提前退出优化)为 O(n)。
| 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|
| O(n) | O(n²) | O(n²) | O(1) | ✅ 稳定 |
3.2 选择排序(Selection Sort)------ 每轮"选出"最小值
核心思想: 每轮从未排序区间里挑出最小值,放到已排序区间末尾------它不像冒泡那样频繁交换,而是"先找齐再动手"。
csharp
static void SelectionSort(int[] arr)
{
int n = arr.Length;
for (int i = 0; i < n - 1; i++)
{
int minIdx = i;
for (int j = i + 1; j < n; j++)
if (arr[j] < arr[minIdx]) minIdx = j; // 只记录索引,不急着交换
if (minIdx != i)
(arr[i], arr[minIdx]) = (arr[minIdx], arr[i]); // 每轮最多交换 1 次
}
}
为什么不稳定? 反例:[5, 5', 2](5' 表示第二个值相同的 5,用于观察相对顺序)。第一轮选出最小值 2(索引2),与索引0交换:[2, 5', 5]------第一个 5 和第二个 5 的相对顺序被交换打乱了。这就是选择排序不稳定的铁证。
独特优势: 无论输入如何,交换次数恒定为最多 n-1 次,比较次数恒定为 n(n-1)/2------是三兄弟中**性能最"可预测"**的一个,适合"交换成本极高"的特殊场景(如每次交换都要写入 Flash 存储)。
| 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|
| O(n²) | O(n²) | O(n²) | O(1) | ❌ 不稳定 |
3.3 插入排序(Insertion Sort)------ 像打牌一样理牌
核心思想: 维护一个"已排序区间",每次将新元素插入到该区间的正确位置------这是最贴近人类直觉的排序方式。
csharp
static void InsertionSort(int[] arr)
{
for (int i = 1; i < arr.Length; i++)
{
int current = arr[i];
int j = i - 1;
// 已排序区间从右往左扫描,比 current 大就整体后移
while (j >= 0 && arr[j] > current)
{
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
}
为什么它比冒泡/选择更值得留意?
- 对"基本有序"的数据极其高效------最好情况 O(n),只需一轮线性扫描确认无需移动。
- 是唯一天然支持"边输入边排序"(在线算法)的三兄弟------适合数据流式到达的场景。
- 是工业级排序算法的"收尾担当"------见第十一节,.NET/Java 底层排序在小区间上都会自动切换成插入排序。
| 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|
| O(n) | O(n²) | O(n²) | O(1) | ✅ 稳定 |
四、突破平方级:希尔排序(Shell Sort)
4.1 插入排序的软肋,与希尔排序的破局思路
插入排序的性能瓶颈在于:如果一个很小的值恰好排在数组最右边,它需要被逐个"挪动"到最左边,移动次数与距离成正比。
希尔排序(1959 年由 Donald Shell 提出,是第一个突破 O(n²) 的排序算法)给出的破局方案是:先用较大的间隔(gap)做"粗筛",让小值能一次跳跃较长距离,再逐步缩小间隔精修,最后一轮 gap=1 时退化为普通插入排序做收尾。
原始:[9, 1, 8, 3, 7, 4, 6, 2, 5] (9个元素,gap 初始取 n/2=4)
gap=4 分组:
组1(索引0,4,8): 9,7,5 → 排序 → 5,7,9
组2(索引1,5) : 1,4 → 排序 → 1,4
组3(索引2,6) : 8,6 → 排序 → 6,8
组4(索引3,7) : 3,2 → 排序 → 2,3
回填:[5, 1, 6, 2, 7, 4, 8, 3, 9]
gap=2 分组:继续按间隔2分组排序......
gap=1:退化为普通插入排序,完成最终精修
4.2 代码实现
csharp
static void ShellSort(int[] arr)
{
int n = arr.Length;
for (int gap = n / 2; gap > 0; gap /= 2) // gap 每轮减半
{
for (int i = gap; i < n; i++) // 间隔为 gap 的"跳跃版"插入排序
{
int current = arr[i];
int j = i - gap;
while (j >= 0 && arr[j] > current)
{
arr[j + gap] = arr[j];
j -= gap;
}
arr[j + gap] = current;
}
}
}
4.3 复杂度的"玄学"之处
希尔排序的复杂度高度依赖间隔序列的选取,这是它在教科书中最特殊的一点:
| 间隔序列 | 时间复杂度(最坏) |
|---|---|
| 简单 n/2 逐次减半(本文实现) | O(n²) |
| Hibbard 序列(2^k - 1) | O(n^1.5) |
| Sedgewick 序列 | O(n^(4/3)) |
一句话点评: 希尔排序的价值不在于"性能天花板有多高",而在于它第一次证明了**"分组处理再精修"可以打破平方级的枷锁**------这个"先粗后精"的思想在归并排序、外部排序中都能看到影子。
| 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|
| O(n log n) | 约 O(n^1.3) | O(n²) | O(1) | ❌ 不稳定 |
五、分治法双雄:快速排序 vs 归并排序
这一节是全文的核心。两者都基于**分治(Divide and Conquer)**思想,但"分"与"治"的顺序恰好相反------快排先分后排,归并先分再排后合。理解这个本质差异,你就理解了分治法的精髓。
5.1 快速排序(Quick Sort)
核心思想: 选一个基准值 pivot,通过一趟"划分(Partition)"把数组分成"比 pivot 小"和"比 pivot 大"两部分,pivot 本身归位,然后对左右两部分递归执行同样操作。
原始:[5, 3, 8, 1, 9, 2] 选最后一个元素 2 作为 pivot
划分过程:
遍历 [5,3,8,1,9],把比 2 小的移到左侧
只有 1 比 2 小 → 移到左侧区域
划分结果:[1, 3, 8, 5, 9, 2] → pivot(2) 换到分界点
最终: [1, 2, 8, 5, 9, 3]
↑
pivot 2 已归位:左边 {1} 都比它小,右边 {8,5,9,3} 都比它大
递归处理左半 [1](已完成)和右半 [8,5,9,3],直到区间长度 ≤ 1
csharp
static void QuickSort(int[] arr, int low, int high)
{
if (low >= high) return;
int pivotIndex = Partition(arr, low, high);
QuickSort(arr, low, pivotIndex - 1);
QuickSort(arr, pivotIndex + 1, high);
}
// Lomuto 划分方案:以最后一个元素为基准
static int Partition(int[] arr, int low, int high)
{
int pivot = arr[high];
int i = low - 1; // i 指向"小于pivot区域"的最后一个位置
for (int j = low; j < high; j++)
{
if (arr[j] < pivot)
{
i++;
(arr[i], arr[j]) = (arr[j], arr[i]);
}
}
(arr[i + 1], arr[high]) = (arr[high], arr[i + 1]); // pivot 归位
return i + 1;
}
复杂度严格推导
设 T(n) 为排序 n 个元素的时间开销,每次划分需要 O(n) 时间遍历数组。
平均/最好情况(划分基本均衡): 每次划分把问题规模减半,递归深度约为 log n 层,每层总划分开销为 O(n):
T(n) = 2·T(n/2) + O(n)
根据主定理(Master Theorem),此递推式的解为:
T(n) = O(n log n)
最坏情况(划分极度不均衡,如数组已完全有序,且总选边界值当 pivot): 每次只能排除 1 个元素,递归深度退化为 n 层:
T(n) = T(n-1) + O(n)
= O(n) + O(n-1) + O(n-2) + ... + O(1)
= O(n²)
这就是为什么"已排序数组 + 固定选最后一个元素做 pivot"是快排的天敌------每次划分都只排除一个元素,退化成了选择排序的复杂度。
工程规避方案:
csharp
// 三数取中法:从首、中、尾三个位置选出中位数作为 pivot,大幅降低最坏情况概率
static int MedianOfThree(int[] arr, int low, int high)
{
int mid = low + (high - low) / 2;
if (arr[low] > arr[mid]) (arr[low], arr[mid]) = (arr[mid], arr[low]);
if (arr[low] > arr[high]) (arr[low], arr[high]) = (arr[high], arr[low]);
if (arr[mid] > arr[high]) (arr[mid], arr[high]) = (arr[high], arr[mid]);
return mid; // 此时 arr[mid] 就是三者中位数
}
| 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|
| O(n log n) | O(n log n) | O(n²) | O(log n)(递归栈) | ❌ 不稳定 |
5.2 归并排序(Merge Sort)
核心思想: 先"分":不断二分数组直到每份只剩一个元素(天然有序);再"合":两两合并成有序数组,逐层合并回完整数组。
原始:[5, 3, 8, 1, 9, 2]
━━ 分解阶段(不断二分)━━
[5,3,8,1,9,2] → [5,3,8] + [1,9,2]
→ [5]+[3,8] + [1]+[9,2]
→ [5]+[3]+[8] + [1]+[9]+[2]
━━ 合并阶段(两两合并,比较后拼接)━━
[3]+[8] → [3,8] [9]+[2] → [2,9]
[5]+[3,8] → [3,5,8] [1]+[2,9] → [1,2,9]
[3,5,8]+[1,2,9] → [1,2,3,5,8,9] 排序完成
csharp
static void MergeSort(int[] arr, int left, int right)
{
if (left >= right) return;
int mid = left + (right - left) / 2;
MergeSort(arr, left, mid);
MergeSort(arr, mid + 1, right);
Merge(arr, left, mid, right);
}
static void Merge(int[] arr, int left, int mid, int right)
{
int[] temp = new int[right - left + 1];
int i = left, j = mid + 1, k = 0;
while (i <= mid && j <= right)
// 用 <= 而非 < :左边相等元素优先取用,这是"稳定性"的关键一笔
temp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++];
while (i <= mid) temp[k++] = arr[i++];
while (j <= right) temp[k++] = arr[j++];
Array.Copy(temp, 0, arr, left, temp.Length);
}
复杂度严格推导
归并排序无论输入如何,每次都精确二分,递归树的深度恒为 log n 层,每层合并操作总耗时恒为 O(n):
T(n) = 2·T(n/2) + O(n) ← 与快排"平均情况"是同一个递推式!
根据主定理:T(n) = O(n log n),且不存在"退化"的最坏情况
为什么归并排序不会像快排一样退化? 因为它的"分"是结构性的、和数据无关的 ------永远从中点二分,不依赖任何数据比较;而快排的"分"依赖 pivot 划分的结果,数据分布会直接影响划分是否均衡。这正是两者最坏情况表现不同的根本原因。
| 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|
| O(n log n) | O(n log n) | O(n log n) | O(n) | ✅ 稳定 |
归并排序是外部排序的基石: 当数据量大到无法一次性装入内存(比如 100GB 的日志文件),可以把大文件切成能装入内存的小块,分别排序后写回磁盘,再用"多路归并"的方式合并------这正是数据库、大数据处理系统中排序海量数据的标准方案。
六、二叉树的智慧:堆排序(Heap Sort)
前面几种算法都没有真正"兼顾"时间和空间------快排空间省但最坏会退化,归并稳定但要 O(n) 额外空间。堆排序则做到了 O(n log n) 的稳定上限 + O(1) 的原地排序,代价是牺牲了稳定性。
6.1 核心思想
把数组看成一棵完全二叉树,先构建成"大顶堆"(父节点永远大于子节点),然后不断把堆顶(最大值)与末尾元素交换、缩小堆的范围、重新调整堆结构,循环直到有序。
数组 [4, 10, 3, 5, 1] 看作完全二叉树:
4(索引0)
/ \
10(索引1) 3(索引2)
/ \
5(索引3) 1(索引4)
第一步:构建大顶堆 → [10, 5, 3, 4, 1]
10
/ \
5 3
/ \
4 1
第二步:交换堆顶与末尾,缩小堆范围,重新调整堆
[10,5,3,4,1] → 交换(10,1) → [1,5,3,4,10] → 调整前4个 → [5,4,3,1,10]
↑10已归位
......重复此过程,直到堆范围缩小为1,排序完成
6.2 代码实现
csharp
static void HeapSort(int[] arr)
{
int n = arr.Length;
// 第一步:从最后一个非叶子节点开始,自底向上构建大顶堆
for (int i = n / 2 - 1; i >= 0; i--)
Heapify(arr, n, i);
// 第二步:依次把堆顶(当前最大值)交换到末尾,缩小堆范围后重新调整
for (int i = n - 1; i > 0; i--)
{
(arr[0], arr[i]) = (arr[i], arr[0]); // 堆顶(最大值)归位到末尾
Heapify(arr, i, 0); // 对缩小后的堆重新"下沉调整"
}
}
// 对以 root 为根的子树执行"下沉"调整,维持大顶堆性质
static void Heapify(int[] arr, int heapSize, int root)
{
int largest = root;
int left = 2 * root + 1;
int right = 2 * root + 2;
if (left < heapSize && arr[left] > arr[largest]) largest = left;
if (right < heapSize && arr[right] > arr[largest]) largest = right;
if (largest != root)
{
(arr[root], arr[largest]) = (arr[largest], arr[root]);
Heapify(arr, heapSize, largest); // 递归下沉,直到子树重新满足堆性质
}
}
6.3 复杂度推导
构建初始堆需要 O(n) 时间(严格数学证明超出本文范围,直觉上大部分节点在树的下层,调整成本很低);之后 n-1 次"取堆顶+重新调整",每次调整为 O(log n)(树高),因此总时间为:
T(n) = O(n) + (n-1)×O(log n) = O(n log n)
堆排序独一无二的价值: 它是唯一能同时满足"最坏情况 O(n log n) "和"原地排序 O(1) 空间 "的比较排序算法------快排最坏会退化,归并需要额外空间,堆排序两头都占了,代价是牺牲稳定性、以及实际运行中的常数因子较大(大量非连续内存访问,缓存命中率较低)。
| 最好 | 平均 | 最坏 | 空间 | 稳定性 |
|---|---|---|---|---|
| O(n log n) | O(n log n) | O(n log n) | O(1) | ❌ 不稳定 |
七、打破 O(n log n) 下限:非比较排序
前面所有算法都属于"比较排序 "------通过比较两个元素的大小来决定顺序。第八节会证明,比较排序有一个无法突破的理论下限 O(n log n)。但如果我们不依赖比较,而是利用数据本身的特性,就能实现更快的 O(n) 排序。
7.1 计数排序(Counting Sort)
核心思想: 如果数据范围已知且不大(比如都是 0~100 的整数),可以直接用一个数组统计每个值出现的次数,再按顺序展开即可,完全不需要比较。
csharp
static int[] CountingSort(int[] arr, int maxValue)
{
int[] count = new int[maxValue + 1]; // 统计每个值出现的次数
foreach (int num in arr)
count[num]++;
int[] result = new int[arr.Length];
int index = 0;
for (int value = 0; value <= maxValue; value++)
for (int c = 0; c < count[value]; c++)
result[index++] = value;
return result;
}
复杂度: O(n + k),其中 k 是数据的取值范围。注意 k 不能太大 ------如果数据是 [1, 1000000000] 这种稀疏分布,计数数组会大到无法接受,这正是计数排序的使用限制。
| 最好/平均/最坏 | 空间 | 稳定性 | 限制 |
|---|---|---|---|
| O(n + k) | O(n + k) | ✅ 稳定(改进版本) | 仅适用于范围较小的整数 |
7.2 基数排序(Radix Sort)
核心思想: 对于位数较多的整数,从最低位到最高位,依次按每一位数字做一次稳定的"桶排序"(通常用计数排序实现每一轮),排完所有位数后,整体自然有序。
原始:[170, 45, 75, 90, 802, 24, 2, 66]
按个位排序:[170, 90, 802, 2, 24, 45, 75, 66]
按十位排序:[802, 2, 24, 45, 66, 170, 75, 90]
按百位排序:[2, 24, 45, 66, 75, 90, 170, 802] ← 排序完成
csharp
static void RadixSort(int[] arr)
{
int max = arr.Max();
// 从个位开始,位权逐渐放大(1, 10, 100, ...),直到超过最大值
for (int exp = 1; max / exp > 0; exp *= 10)
CountingSortByDigit(arr, exp);
}
static void CountingSortByDigit(int[] arr, int exp)
{
int n = arr.Length;
int[] output = new int[n];
int[] count = new int[10]; // 0~9 十个桶
foreach (int num in arr)
count[(num / exp) % 10]++;
for (int i = 1; i < 10; i++)
count[i] += count[i - 1]; // 前缀和,确定每个桶的结束位置
// 从后往前遍历,保证同一位数字相同时,原相对顺序不变(稳定性关键)
for (int i = n - 1; i >= 0; i--)
{
int digit = (arr[i] / exp) % 10;
output[--count[digit]] = arr[i];
}
Array.Copy(output, arr, n);
}
复杂度: O(d × (n + k)),d 是最大数字的位数,k 是每一位的取值范围(十进制固定为 10)。当 d 是常数时,整体退化为 O(n)------这正是非比较排序能突破 O(n log n) 的秘密:它把"位数"当作常数,用空间和轮次换取了理论下限的突破。
| 最好/平均/最坏 | 空间 | 稳定性 | 限制 |
|---|---|---|---|
| O(d×(n+k)) | O(n + k) | ✅ 稳定 | 仅适用于整数、定长字符串等"可分解成位"的数据 |
八、为什么比较排序永远跨不过 O(n log n)?
这是很多文章一笔带过、但面试中经常被追问的问题,这里给出直观的证明思路。
8.1 决策树模型
对 n 个不同元素排序,本质上是要从 n! 种可能的排列 中,确定唯一正确的那一种。每一次"比较两个元素",只能得到"谁大谁小"这一个二元结果,相当于在一棵二叉决策树上往下走一步。
比较 a[0] vs a[1]?
/ \
a[0]<a[1] a[0]>a[1]
/ \
比较 a[1] vs a[2]? 比较 a[0] vs a[2]?
... ...
要能区分所有 n! 种排列结果,这棵决策树至少需要 n! 个叶子节点。
8.2 树高的数学下限
一棵二叉树,若有 L 个叶子节点,树高 h 至少满足 2^h ≥ L,即 h ≥ log₂(L)。
代入 L = n!,利用斯特林公式(Stirling's Approximation)近似展开:
h ≥ log₂(n!) ≈ n·log₂(n) - n·log₂(e) = Ω(n log n)
结论:任何基于"比较"的排序算法,最坏情况下所需的比较次数下限是 Ω(n log n)。 这就是为什么归并排序、堆排序的 O(n log n) 已经是比较排序的"理论最优"------不是算法设计者不够聪明,而是这是这类问题的数学天花板。
而计数排序、基数排序之所以能做到 O(n),正是因为它们压根不通过"比较"来排序,绕开了这个下限的适用范围。这也是"面试官问你能不能设计一个 O(n) 的通用比较排序"时,正确答案是"不可能"的理论依据。
九、实战演练场:七种算法真实数据竞速
csharp
using System;
using System.Diagnostics;
using System.Linq;
class SortingArena
{
static void Main()
{
int size = 8000;
int[] original = GenerateRandomArray(size);
Console.WriteLine($"随机整数数组规模:{size}\n");
Race("冒泡排序", (int[])original.Clone(), BubbleSort);
Race("选择排序", (int[])original.Clone(), SelectionSort);
Race("插入排序", (int[])original.Clone(), InsertionSort);
Race("希尔排序", (int[])original.Clone(), ShellSort);
Race("快速排序", (int[])original.Clone(), arr => QuickSort(arr, 0, arr.Length - 1));
Race("归并排序", (int[])original.Clone(), arr => MergeSort(arr, 0, arr.Length - 1));
Race("堆排序 ", (int[])original.Clone(), HeapSort);
Race("内置Sort", (int[])original.Clone(), Array.Sort);
}
static void Race(string name, int[] arr, Action<int[]> sortMethod)
{
var sw = Stopwatch.StartNew();
sortMethod(arr);
sw.Stop();
bool ok = IsSorted(arr);
Console.WriteLine($"{name} 耗时:{sw.ElapsedMilliseconds,5} ms 结果{(ok ? "✅" : "❌")}");
}
static int[] GenerateRandomArray(int size)
{
var rnd = new Random(42); // 固定种子,保证每种算法拿到完全相同的数据
return Enumerable.Range(0, size).Select(_ => rnd.Next(0, 100000)).ToArray();
}
static bool IsSorted(int[] arr)
{
for (int i = 1; i < arr.Length; i++)
if (arr[i - 1] > arr[i]) return false;
return true;
}
// 各算法实现同前文第三~六节
}
参考运行结果(8000 个随机整数,具体数值因机器而异,仅体现数量级差异)
text
随机整数数组规模:8000
冒泡排序 耗时: 118 ms 结果✅
选择排序 耗时: 72 ms 结果✅
插入排序 耗时: 45 ms 结果✅
希尔排序 耗时: 3 ms 结果✅
快速排序 耗时: 2 ms 结果✅
归并排序 耗时: 3 ms 结果✅
堆排序 耗时: 4 ms 结果✅
内置Sort 耗时: 1 ms 结果✅
数量级差异一目了然: O(n²) 三兄弟耗时是 O(n log n) 家族的 20~40 倍,且这个差距会随着数据量增大而指数级拉大------这正是"复杂度分析"从抽象理论落地为真实性能差异的直观证明。
十、总对比表与工程选择指南
| 算法 | 最好 | 平均 | 最坏 | 空间 | 稳定 | 原地 |
|---|---|---|---|---|---|---|
| 冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | ✅ | ✅ |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | ❌ | ✅ |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | ✅ | ✅ |
| 希尔排序 | O(n log n) | O(n^1.3) | O(n²) | O(1) | ❌ | ✅ |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | ❌ | ✅ |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | ✅ | ❌ |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | ❌ | ✅ |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(n+k) | ✅ | ❌ |
| 基数排序 | O(d(n+k)) | O(d(n+k)) | O(d(n+k)) | O(n+k) | ✅ | ❌ |
工程选择决策树
数据量很小(<50)?
└─ 是 → 插入排序(常数因子小,实测比"高级算法"更快)
数据是整数,且范围/位数可控?
└─ 是 → 计数排序 / 基数排序(O(n) 碾压一切比较排序)
必须保证排序稳定性?
└─ 是 → 归并排序(唯一稳定的 O(n log n))
内存严格受限,不能用额外 O(n) 空间?
└─ 是 → 堆排序(唯一同时满足"最坏O(n log n)"+"原地"的比较排序)
以上都不是,追求综合性能最优?
└─ 快速排序(average case 最快,配合三数取中法规避最坏情况)
十一、彩蛋:.NET / Java 底层排序源码究竟怎么写的
你可能好奇,Array.Sort()、List<T>.Sort() 底层到底用了哪种算法?答案是:它们都不是"单一算法",而是精心设计的混合策略(Introspective Sort,内省排序)。
.NET 的 Array.Sort() 核心逻辑大致如下:
- 数据量较大时 :使用快速排序(配合三数取中选 pivot)快速缩小问题规模;
- 递归深度超过阈值 (意味着快排可能已经退化为最坏情况):自动切换成堆排序兜底,保证最坏情况也能维持 O(n log n);
- 子区间缩小到较小规模 (.NET 中阈值为 16):自动切换成插入排序收尾,利用其在小数据、近乎有序数据上的常数优势。
这正是本文按顺序讲解六种比较排序的意义所在 ------工业级排序算法,本质上就是在精确知道每种算法优缺点的基础上,扬长避短地把它们缝合在一起。理解了这篇文章的每一节,你就理解了 Introsort 为什么要这么设计。
十二、写在最后
排序算法这条路走下来,你会发现一条清晰的进化主线:
冒泡/选择/插入教会你"暴力也能解决问题";希尔排序教会你"分组能打破常规";快排/归并教会你"分治是通用的降维武器";堆排序教会你"数据结构能兼顾时间与空间";计数/基数排序教会你"跳出比较的框架,答案可能更优雅"。
这背后其实是整个算法世界最迷人的主题------用更精巧的结构化思考,换取更低的时间成本,直到触碰到理论的边界,再想办法绕过这个边界。这条思路,会在你未来学习的每一个算法领域中反复出现。
📝 课后练习
- 手动推导:为什么归并排序合并两个长度为 m 的有序数组,最多需要
2m-1次比较? - 编程实现快速排序的非递归(迭代)版本(提示:用一个栈手动模拟递归过程)。
- 思考题:给定一个几乎有序、但有 k 个元素明显偏离正确位置的数组(k 远小于 n),哪种排序算法效率最高?为什么?
- 基数排序默认是"从低位到高位"(LSD),尝试证明如果改成"从高位到低位"(MSD),是否还能保证排序结果正确?
觉得有收获?欢迎点赞收藏,下次面试官追问"为什么快排最坏是 O(n²),归并却不会",你已经能画出递归树,讲得明明白白 😎