16、排序算法(下):快速排序、归并排序、堆排序,分治思想与递归实现

排序算法这个话题,咱们上次聊了冒泡、选择和插入。说实话,那些属于「入门级」的功夫。今天要讲的这三个——快速排序、归并排序、堆排序,才是真正能在项目里扛大梁的角色。

它们有一个共同的思想内核:分治。说白了,就是把大问题拆成小问题,小问题再拆成更小的问题,直到拆不动了,再逐层合并回来。这个思想在嵌入式里太常用了——你想想看,处理一个大数据包,是不是也经常拆成小块来处理?

核心观点:分治思想 + 递归实现 = 高效排序的基石。理解了这个组合,你就能写出 O(n log n) 级别的排序代码。

分治思想与三种高效排序 分治思想 快速排序 归并排序 堆排序 选基准 + 分区 平均 O(n log n) 不稳定排序 拆分 + 合并 稳定 O(n log n) 需要额外空间 建堆 + 调整 O(n log n) 不稳定排序 共同点:分治 + 递归 | 时间复杂度 O(n log n)

快速排序:选个「老大」来分家

快速排序的思路很直白:从数组里挑一个元素当「基准」,然后把比它小的放左边,比它大的放右边。左右两边各自再重复这个过程。

我个人习惯选中间元素当基准,这样在大多数情况下分区比较均衡。不过要注意,如果数组已经有序,选第一个或最后一个当基准,性能会退化到 O(n²)。我在项目中遇到过这种情况——一个传感器数据采集系统,数据本身就有递增趋势,结果快排慢得像蜗牛爬。

// 快速排序实现
void quick_sort(int arr[], int left, int right) {
    if (left >= right) return;  // 递归终止条件
    
    int i = left, j = right;
    int pivot = arr[(left + right) / 2];  // 选中间为基准
    
    while (i <= j) {
        while (arr[i] < pivot) i++;
        while (arr[j] > pivot) j--;
        if (i <= j) {
            swap(&arr[i], &arr[j]);
            i++;
            j--;
        }
    }
    
    // 递归处理左右两部分
    quick_sort(arr, left, j);
    quick_sort(arr, i, right);
}

小技巧:当数据量较小时(比如少于10个元素),快排的递归开销反而比插入排序大。我一般会在递归函数里加个判断:如果区间长度小于阈值,直接调用插入排序。这叫「混合排序」,性能能再提升10%-15%。

归并排序:先拆后合,稳扎稳打

归并排序的思路更「规整」:把数组从中间一分为二,左右各自排好序,然后再合并起来。这个过程递归下去,直到每个子数组只有一个元素——单个元素当然是有序的。

归并排序最大的优点是稳定。什么意思?就是相等的元素在排序后,相对顺序不变。这在某些场景下很重要。比如你按时间排序后,再按优先级排序,稳定排序能保证时间顺序不被破坏。

但归并排序有个「硬伤」:它需要额外的 O(n) 空间来存储临时数组。在嵌入式系统里,内存可是金贵的。我曾经在一个只有 64KB RAM 的 MCU 上做数据排序,归并排序直接撑爆了内存,最后换成了原地排序的堆排序才解决问题。

// 归并排序的合并操作
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 merge_sort(int arr[], int left, int right) {
    if (left < right) {
        int mid = left + (right - left) / 2;
        merge_sort(arr, left, mid);
        merge_sort(arr, mid + 1, right);
        merge(arr, left, mid, right);
    }
}

避坑指南:我曾经在递归实现归并排序时,忘记检查 left 和 right 的边界条件,导致栈溢出。递归深度是 log n,但每次递归都会压栈。如果数组有 10 万个元素,递归深度大约 17 层,没问题。但如果数据量上千万,递归深度到 24 层以上,就要小心栈空间了。

堆排序:用二叉树的思想排序

堆排序的思路稍微绕一点,但理解了就很简单。它把数组看作一棵完全二叉树,然后构建一个「大顶堆」——父节点比子节点都大。建好堆之后,根节点就是最大值,把它和最后一个元素交换,然后对剩下的元素重新调整堆。

堆排序的好处是:不需要额外空间,而且时间复杂度稳定在 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) {
        swap(&arr[i], &arr[largest]);
        heapify(arr, n, largest);  // 递归调整
    }
}

void heap_sort(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--) {
        swap(&arr[0], &arr[i]);   // 把最大值放到末尾
        heapify(arr, i, 0);        // 调整剩余堆
    }
}

我的经验:堆排序特别适合「实时系统」中的优先级调度。比如一个任务队列,你需要不断取出最高优先级的任务,同时还要插入新任务。用堆来实现优先级队列,插入和删除都是 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(log n) 栈空间 O(n) 额外数组 O(1) 原地排序
稳定性 不稳定 稳定 不稳定
适用场景 通用排序,数据随机 需要稳定排序,内存充足 内存受限,需要稳定性能

嗯,这里要注意:没有「最好」的排序算法,只有「最合适」的。在嵌入式项目里,我通常会这样选:

  • 内存充足、数据随机:用快速排序,速度最快
  • 需要稳定排序:用归并排序,但要注意内存开销
  • 内存紧张、实时性要求高:用堆排序,性能稳定不退化

递归的「代价」与优化

这三种排序都用了递归。递归写起来确实优雅,但递归是有代价的——每次函数调用都要压栈、传参、返回。在嵌入式系统里,栈空间通常只有几 KB,递归深度太深会直接导致栈溢出。

我建议你养成一个习惯:预估递归深度。对于 n 个元素的排序,快排和归并的递归深度大约是 log₂n,堆排序的递归深度也是 log₂n。如果 n=1024,深度就是 10,没问题。但如果 n=1,000,000,深度就是 20,也还好。但如果你在递归函数里用了大量局部变量,那就要小心了。

优化建议:对于快排,可以手动实现「尾递归优化」——只递归处理较短的那一半,较长的用循环处理。这样能把最坏情况下的栈深度从 O(n) 降到 O(log n)。代码改动不大,但效果很明显。

好了,排序算法这块儿就聊到这儿。分治思想不仅在排序里用,在二分查找、归并、FFT 等很多算法里都能看到它的影子。理解了分治,你就掌握了一把解决复杂问题的钥匙。


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