35、排序算法(二):快速排序(递归实现)、归并排序、堆排序——原理与实现
上一章我们聊了冒泡、选择和插入排序。说实话,那些都是基础,面试时考官顶多让你手写一个。但今天要讲的这三个——快速排序、归并排序、堆排序——才是真正拉开差距的地方。我当年面试字节跳动时,面试官直接让我在白板上写快排的非递归实现,嗯,那场面至今记忆犹新。
这三个排序算法,平均时间复杂度都是 O(n log n)。但它们的原理、适用场景、稳定性各不相同。我们一个一个来拆解。
一、快速排序(递归实现)
快速排序,说白了就是“选一个基准,把小的放左边,大的放右边”。然后左右两边各自递归,继续这么干。你想想看,这其实是一种分治思想。
核心原理
- 从数组中选一个元素作为 pivot(基准值)
- 将数组分成两部分:小于 pivot 的放左边,大于 pivot 的放右边
- 对左右两部分递归执行同样的操作
我个人习惯选最右边的元素作为 pivot,这样实现起来比较顺手。当然你也可以选中间、最左边,甚至随机选——随机选能有效避免最坏情况。
时间复杂度:平均 O(n log n),最坏 O(n²)(当数组已经有序时)
空间复杂度:O(log 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。
二、归并排序
归并排序的思路更直白:先把数组拆成单个元素,然后再两两合并,合并的时候保证有序。说白了就是“先拆后合”。
核心原理
- 将数组从中间分成两半
- 对左右两半分别递归排序
- 合并两个有序数组
我记得第一次学归并排序时,觉得它比快排好理解多了。但后来发现,它需要额外的 O(n) 空间,这在嵌入式开发中是个硬伤。
时间复杂度:O(n log n)(最好、最坏、平均都一样)
空间复杂度:O(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);
}
}
小技巧:归并排序是稳定的,这在某些场景下很重要。比如你要按成绩排序,成绩相同的还想保持原来的顺序,那就得用归并。我做过一个学生管理系统,排序要求稳定,当时毫不犹豫选了归并。
三、堆排序
堆排序,说白了就是利用堆这种数据结构来排序。先建一个大顶堆,然后每次把堆顶(最大值)和最后一个元素交换,再调整堆。重复这个过程,数组就排好了。
核心原理
- 将数组构建成一个大顶堆
- 将堆顶元素与末尾元素交换
- 调整堆,使其重新满足大顶堆性质
- 重复步骤 2-3,直到堆中只剩一个元素
时间复杂度:O(n log n)(建堆 O(n),每次调整 O(log n))
空间复杂度:O(1)(原地排序)
稳定性:不稳定
空间复杂度: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²)?——当数组已经有序或逆序,且每次选的 pivot 都是最大或最小值时。解决办法:随机选 pivot 或三数取中法。
- 归并排序的空间复杂度能优化吗?——理论上可以做到 O(1),但实现极其复杂,实际中没人这么干。我建议你面试时直接说“标准实现是 O(n)”。
- 堆排序为什么不稳定?——因为堆调整过程中,相同元素的相对顺序可能被打乱。举个例子,两个值相同的元素,一个在堆顶,一个在叶子,交换后顺序就变了。
我的建议:面试时如果让你手写排序,优先写快排。代码短、思路清晰、面试官也爱看。但如果他特意强调“要稳定”,那就老老实实写归并。至于堆排序,我建议你理解原理,但手写时容易出错——堆的下标计算和边界条件,稍不注意就翻车。
好了,这三种排序算法就讲到这里。你想想看,它们虽然都是 O(n log n),但背后的思想和适用场景完全不同。理解这些差异,比死记硬背代码重要得多。