排序算法通关手册:从 O(n²) 到 O(n log n),用一篇文章打通算法内功心法

一句话概括: 排序算法的江湖,本质是"时间"与"空间"的博弈,是"简单直觉"与"精妙设计"的较量。这篇文章不止教你背会代码,更要带你推导出每一个复杂度数字是怎么算出来的 ,以及为什么 n log n 是比较排序永远无法突破的天花板。


目录

  1. 为什么排序算法值得你啃下来
  2. 排序算法的"体检指标":稳定性、原地性、复杂度
  3. O(n²) 三兄弟:冒泡、选择、插入
  4. 突破平方级:希尔排序
  5. 分治法双雄:快速排序 vs 归并排序(含复杂度严格推导)
  6. 二叉树的智慧:堆排序
  7. 打破 O(n log n) 下限的非比较排序:计数排序 & 基数排序
  8. 排序算法下限证明:为什么比较排序永远跨不过 O(n log n)
  9. 实战演练场:七种算法真实数据竞速
  10. 总对比表与工程选择指南
  11. 彩蛋:.NET / Java 底层排序源码究竟怎么写的
  12. 课后练习

一、为什么排序算法值得你啃下来

工程中我们确实很少手写排序------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;
    }
}

为什么它比冒泡/选择更值得留意?

  1. 对"基本有序"的数据极其高效------最好情况 O(n),只需一轮线性扫描确认无需移动。
  2. 是唯一天然支持"边输入边排序"(在线算法)的三兄弟------适合数据流式到达的场景。
  3. 是工业级排序算法的"收尾担当"------见第十一节,.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() 核心逻辑大致如下:

  1. 数据量较大时 :使用快速排序(配合三数取中选 pivot)快速缩小问题规模;
  2. 递归深度超过阈值 (意味着快排可能已经退化为最坏情况):自动切换成堆排序兜底,保证最坏情况也能维持 O(n log n);
  3. 子区间缩小到较小规模 (.NET 中阈值为 16):自动切换成插入排序收尾,利用其在小数据、近乎有序数据上的常数优势。

这正是本文按顺序讲解六种比较排序的意义所在 ------工业级排序算法,本质上就是在精确知道每种算法优缺点的基础上,扬长避短地把它们缝合在一起。理解了这篇文章的每一节,你就理解了 Introsort 为什么要这么设计。


十二、写在最后

排序算法这条路走下来,你会发现一条清晰的进化主线:

冒泡/选择/插入教会你"暴力也能解决问题";希尔排序教会你"分组能打破常规";快排/归并教会你"分治是通用的降维武器";堆排序教会你"数据结构能兼顾时间与空间";计数/基数排序教会你"跳出比较的框架,答案可能更优雅"。

这背后其实是整个算法世界最迷人的主题------用更精巧的结构化思考,换取更低的时间成本,直到触碰到理论的边界,再想办法绕过这个边界。这条思路,会在你未来学习的每一个算法领域中反复出现。


📝 课后练习

  1. 手动推导:为什么归并排序合并两个长度为 m 的有序数组,最多需要 2m-1 次比较?
  2. 编程实现快速排序的非递归(迭代)版本(提示:用一个栈手动模拟递归过程)。
  3. 思考题:给定一个几乎有序、但有 k 个元素明显偏离正确位置的数组(k 远小于 n),哪种排序算法效率最高?为什么?
  4. 基数排序默认是"从低位到高位"(LSD),尝试证明如果改成"从高位到低位"(MSD),是否还能保证排序结果正确?

觉得有收获?欢迎点赞收藏,下次面试官追问"为什么快排最坏是 O(n²),归并却不会",你已经能画出递归树,讲得明明白白 😎

相关推荐
外收内放44 分钟前
Python基础语法练习题(55-56)
开发语言·python
Do_It_Today1 小时前
php 可变函数使用小结
开发语言·php
Do_It_Today1 小时前
PHP 实现数组分页
开发语言·php
weishuangyun11 小时前
小程序制作平台怎么选?三大平台对比!
大数据·开发语言·javascript
我滴老baby1 小时前
Navidrome:飞牛OS安装、曲库导入与远程收听全流程
开发语言·数据库·php
code2cat1 小时前
Java进阶篇之AtomicLongArray:大数累加、上限检查与溢出边界
java·开发语言
小比特-combat1 小时前
LVGL_1(使用示例讲解父子关系)
开发语言·前端·javascript
开开心心_Every1 小时前
电脑OCR识别软件支持表格识别图片转Excel
java·开发语言·微信·计算机外设·ocr·intellij-idea·excel
徐小黑ACG1 小时前
Golang 基础03 函数
开发语言·后端·golang