20. 排序算法(二):快速排序、归并排序、堆排序、排序算法性能对比

好,咱们接着聊排序。上一章我把冒泡、选择、插入这几个基础排序讲透了。这一章,咱们要上硬菜了——快速排序、归并排序、堆排序。这三个家伙,是面试里的常客,也是实际工程中最常用的排序利器。

我个人习惯把这三兄弟称为「高级三剑客」。为什么高级?因为它们都用到了同一个思想——分治。说白了,就是把大问题拆成小问题,小问题解决了,大问题自然就解决了。你想想看,是不是这个理?

20.1 快速排序:分而治之的典范

快速排序,简称快排。我当年第一次学快排的时候,觉得这名字起得真随意——因为它确实快。平均时间复杂度 O(n log n),而且常数系数小,实际跑起来比归并排序还快。

快排的核心思想就三步:

  1. 选基准:从数组中挑一个元素,叫 pivot。
  2. 分区:把比 pivot 小的放左边,比 pivot 大的放右边。
  3. 递归:对左右两个子数组重复上述过程。

嗯,这里要注意:基准选得好不好,直接影响性能。我曾经在项目里遇到过,数据几乎有序时,我傻傻地选了第一个元素做基准,结果快排退化成了 O(n²),跑得比冒泡还慢。后来我学乖了,要么随机选,要么取三数中值。

核心要点:快排是不稳定的排序算法。什么意思?就是相等的元素,排序后相对位置可能变。这一点在有些场景下很要命。

来看代码实现:

// 快速排序 - 分区函数
int partition(int arr[], int low, int high) {
    // 我习惯取中间元素作为基准,避免最坏情况
    int pivot = arr[(low + high) / 2];
    int i = low - 1;
    int j = high + 1;
    
    while (1) {
        do { i++; } while (arr[i] < pivot);
        do { j--; } while (arr[j] > pivot);
        if (i >= j) return j;
        // 交换 arr[i] 和 arr[j]
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
}

void quickSort(int arr[], int low, int high) {
    if (low < high) {
        int pi = partition(arr, low, high);
        quickSort(arr, low, pi);
        quickSort(arr, pi + 1, high);
    }
}

避坑指南:我曾经在递归深度上吃过亏。数据量一大,递归调用栈可能溢出。建议在工程中用迭代版本,或者限制递归深度。

20.2 归并排序:稳定可靠的排序

归并排序,说白了就是「先拆后合」。把数组拆成单个元素,再两两合并成有序序列。这个过程非常稳定——时间复杂度永远是 O(n log n),不管数据长什么样。

我为什么说它可靠?因为它是稳定排序。我在做数据库索引排序时,就偏爱归并排序。为什么?因为稳定!相等元素的相对顺序不会变,这在多关键字排序时特别重要。

但归并排序有个硬伤——需要额外的 O(n) 空间。你想想看,如果你在嵌入式设备上跑,内存就那么几 KB,归并排序可能就玩不转了。

// 归并排序 - 合并两个有序子数组
void merge(int arr[], int left, int mid, int right) {
    int n1 = mid - left + 1;
    int n2 = right - mid;
    
    // 创建临时数组
    int L[n1], R[n2];
    
    for (int i = 0; i < n1; i++) L[i] = arr[left + i];
    for (int j = 0; j < n2; j++) R[j] = arr[mid + 1 + j];
    
    int i = 0, j = 0, k = left;
    while (i < n1 && j < n2) {
        if (L[i] <= R[j]) arr[k++] = L[i++];
        else arr[k++] = R[j++];
    }
    while (i < n1) arr[k++] = L[i++];
    while (j < n2) arr[k++] = R[j++];
}

void mergeSort(int arr[], int left, int right) {
    if (left < right) {
        int mid = left + (right - left) / 2;
        mergeSort(arr, left, mid);
        mergeSort(arr, mid + 1, right);
        merge(arr, left, mid, right);
    }
}

注意:归并排序的递归深度是 log n,比快排安全。但如果你用迭代版,要小心控制合并的步长。我见过有人把步长写错了,结果排序出来是乱的。

20.3 堆排序:利用堆结构的排序

堆排序,说白了就是「建堆 + 取顶」。先把数组建成一个大顶堆,然后反复把堆顶(最大值)取出来,放到数组末尾,再调整堆。

堆排序有个很有意思的特点:它不需要额外空间,是原地排序。而且时间复杂度稳定在 O(n log n)。但它的常数系数比快排大,实际跑起来往往比快排慢一点。

我记得有一次做实时系统,内存非常紧张,快排的递归栈可能爆,归并又需要额外数组。最后我选了堆排序——稳,省内存,虽然慢一点点,但能跑起来才是王道。

// 堆排序 - 调整堆
void heapify(int arr[], int n, int i) {
    int largest = i;
    int left = 2 * i + 1;
    int right = 2 * i + 2;
    
    if (left < n && arr[left] > arr[largest])
        largest = left;
    if (right < n && arr[right] > arr[largest])
        largest = right;
    
    if (largest != i) {
        int temp = arr[i];
        arr[i] = arr[largest];
        arr[largest] = temp;
        heapify(arr, n, largest);
    }
}

void heapSort(int arr[], int n) {
    // 建堆
    for (int i = n / 2 - 1; i >= 0; i--)
        heapify(arr, n, i);
    // 一个个取堆顶
    for (int i = n - 1; i > 0; i--) {
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;
        heapify(arr, i, 0);
    }
}

小技巧:堆排序的 heapify 函数,我习惯写成迭代版本而不是递归。因为递归版本在堆很大时,函数调用开销不小。迭代版用 while 循环,性能更好。

20.4 排序算法性能对比

好了,三剑客都讲完了。咱们来做个对比,看看它们到底谁强谁弱。

算法 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性 适用场景
快速排序 O(n log n) O(n²) O(log n) 不稳定 通用排序,数据随机
归并排序 O(n log n) O(n log n) O(n) 稳定 需要稳定,内存充足
堆排序 O(n log n) O(n log n) O(1) 不稳定 内存紧张,实时系统

你可能会问:「那我到底该用哪个?」

我的建议是:

  • 如果数据随机,内存够用,快排是首选。C 标准库的 qsort 就是快排。
  • 如果需要稳定排序,或者数据量超大但内存也大,归并排序更靠谱。
  • 如果内存是瓶颈,或者你不想操心递归栈的问题,堆排序最省心。

下面这张图,是我整理的排序算法知识体系,帮你理清思路:

排序算法知识体系 排序算法 快速排序 平均 O(n log n) | 不稳定 空间 O(log n) | 递归 归并排序 稳定 O(n log n) | 稳定 空间 O(n) | 需要额外数组 堆排序 O(n log n) | 不稳定 空间 O(1) | 原地排序 选型建议 数据随机 → 快排 | 需要稳定 → 归并 | 内存紧张 → 堆排序

最后说一句:排序算法这东西,光看是学不会的。我建议你打开 IDE,把上面三个代码都敲一遍,跑一跑,看看不同数据量下的表现。只有亲手调过 bug,才能真正理解它们。


公众号:蓝海资料掘金营,微信deep3321