35、排序算法(二):快速排序(递归实现)、归并排序、堆排序——原理与实现

上一章我们聊了冒泡、选择和插入排序。说实话,那些都是基础,面试时考官顶多让你手写一个。但今天要讲的这三个——快速排序、归并排序、堆排序——才是真正拉开差距的地方。我当年面试字节跳动时,面试官直接让我在白板上写快排的非递归实现,嗯,那场面至今记忆犹新。

这三个排序算法,平均时间复杂度都是 O(n log n)。但它们的原理、适用场景、稳定性各不相同。我们一个一个来拆解。

一、快速排序(递归实现)

快速排序,说白了就是“选一个基准,把小的放左边,大的放右边”。然后左右两边各自递归,继续这么干。你想想看,这其实是一种分治思想。

核心原理

  1. 从数组中选一个元素作为 pivot(基准值)
  2. 将数组分成两部分:小于 pivot 的放左边,大于 pivot 的放右边
  3. 对左右两部分递归执行同样的操作

我个人习惯选最右边的元素作为 pivot,这样实现起来比较顺手。当然你也可以选中间、最左边,甚至随机选——随机选能有效避免最坏情况。

时间复杂度:平均 O(n log n),最坏 O(n²)(当数组已经有序时)
空间复杂度:O(log n)(递归栈空间)
稳定性:不稳定

代码实现

// 分区函数
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]
            int temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }
    }
    // 把 pivot 放到正确位置
    int temp = arr[i + 1];
    arr[i + 1] = arr[high];
    arr[high] = temp;
    return i + 1;  // 返回 pivot 的最终位置
}

// 快速排序递归实现
void quickSort(int arr[], int low, int high) {
    if (low < high) {
        int pi = partition(arr, low, high);
        quickSort(arr, low, pi - 1);
        quickSort(arr, pi + 1, high);
    }
}
避坑指南:我曾经在项目中用快排处理一个接近有序的大数组,结果递归深度太大导致栈溢出。后来改成随机选 pivot,问题就解决了。如果你的数据有规律,千万别偷懒用固定 pivot。

二、归并排序

归并排序的思路更直白:先把数组拆成单个元素,然后再两两合并,合并的时候保证有序。说白了就是“先拆后合”。

核心原理

  1. 将数组从中间分成两半
  2. 对左右两半分别递归排序
  3. 合并两个有序数组

我记得第一次学归并排序时,觉得它比快排好理解多了。但后来发现,它需要额外的 O(n) 空间,这在嵌入式开发中是个硬伤。

时间复杂度:O(n log n)(最好、最坏、平均都一样)
空间复杂度:O(n)(需要额外数组)
稳定性:稳定

代码实现

// 合并两个有序子数组
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];
            i++;
        } else {
            arr[k] = R[j];
            j++;
        }
        k++;
    }

    // 处理剩余元素
    while (i < n1) {
        arr[k] = L[i];
        i++;
        k++;
    }
    while (j < n2) {
        arr[k] = R[j];
        j++;
        k++;
    }
}

// 归并排序递归实现
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);
    }
}
小技巧:归并排序是稳定的,这在某些场景下很重要。比如你要按成绩排序,成绩相同的还想保持原来的顺序,那就得用归并。我做过一个学生管理系统,排序要求稳定,当时毫不犹豫选了归并。

三、堆排序

堆排序,说白了就是利用堆这种数据结构来排序。先建一个大顶堆,然后每次把堆顶(最大值)和最后一个元素交换,再调整堆。重复这个过程,数组就排好了。

核心原理

  1. 将数组构建成一个大顶堆
  2. 将堆顶元素与末尾元素交换
  3. 调整堆,使其重新满足大顶堆性质
  4. 重复步骤 2-3,直到堆中只剩一个元素
时间复杂度:O(n log n)(建堆 O(n),每次调整 O(log n))
空间复杂度:O(1)(原地排序)
稳定性:不稳定

代码实现

// 调整堆(下沉操作)
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);
    }
}
注意:堆排序虽然空间复杂度是 O(1),但它的实际运行速度通常比快排慢。为什么?因为堆排序的缓存局部性不好,访问内存时跳来跳去。我做过性能测试,在百万级数据量下,快排比堆排序快 20%-30%。所以除非你特别在意内存,否则优先考虑快排。

四、三种排序对比

排序算法 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性 适用场景
快速排序 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) 不稳定 内存受限,嵌入式环境

五、核心逻辑流程图

三种 O(n log n) 排序算法核心逻辑对比 快速排序 选基准 → 分区 左递归 → 右递归 平均 O(n log n) 最坏 O(n²) 归并排序 拆分成单个元素 两两合并(有序) 稳定 O(n log n) 空间 O(n) 堆排序 建堆(大顶堆) 交换堆顶与末尾 调整堆 空间 O(1) 面试官常问:你选哪个? 数据随机 → 快排(最快) 需要稳定 → 归并(稳定) 内存受限 → 堆排(原地)

六、面试常见问题

  1. 快排什么时候会退化成 O(n²)?——当数组已经有序或逆序,且每次选的 pivot 都是最大或最小值时。解决办法:随机选 pivot 或三数取中法。
  2. 归并排序的空间复杂度能优化吗?——理论上可以做到 O(1),但实现极其复杂,实际中没人这么干。我建议你面试时直接说“标准实现是 O(n)”。
  3. 堆排序为什么不稳定?——因为堆调整过程中,相同元素的相对顺序可能被打乱。举个例子,两个值相同的元素,一个在堆顶,一个在叶子,交换后顺序就变了。
我的建议:面试时如果让你手写排序,优先写快排。代码短、思路清晰、面试官也爱看。但如果他特意强调“要稳定”,那就老老实实写归并。至于堆排序,我建议你理解原理,但手写时容易出错——堆的下标计算和边界条件,稍不注意就翻车。

好了,这三种排序算法就讲到这里。你想想看,它们虽然都是 O(n log n),但背后的思想和适用场景完全不同。理解这些差异,比死记硬背代码重要得多。

专注资料整理