一、堆的基础概念
详细的堆讲解请参阅这篇文章数据结构---堆
堆排序是一种基于堆(Heap)数据结构的排序算法,其核心思想是利用堆的特性实现高效排序。在讲解堆排序前,需先明确堆的定义与性质:
-
堆的定义:堆是一种完全二叉树(除最后一层外,每一层都被完全填充,且最后一层的节点靠左排列),且满足"堆序性":
- 大顶堆:每个父节点的值 ≥ 其左右子节点的值(根节点为最大值)。
- 小顶堆:每个父节点的值 ≤ 其左右子节点的值(根节点为最小值)。
-
堆的存储 :堆通常用数组实现(无需显式构建二叉树)。对于数组中索引为
i的节点:- 左子节点索引:
2i + 1 - 右子节点索引:
2i + 2 - 父节点索引:
(i - 1) // 2(整数除法)
- 左子节点索引:
例如,数组 [10, 5, 3, 4, 1] 可表示为大顶堆,其结构如下:
10
/ \
5 3
/ \
4 1
二、堆排序的核心思想
堆排序的基本流程分为三步:
- 建堆:将待排序数组转换为大顶堆(或小顶堆,此处以大顶堆为例)。
- 排序 :
- 交换堆顶(最大值)与堆尾元素,此时最大值已放到正确位置。
- 缩小堆的范围(排除已排序的堆尾元素),重新调整剩余元素为大顶堆。
- 重复:重复步骤2,直到所有元素排序完成。
其本质是通过反复提取堆中的最大值(或最小值),实现整体有序。
三、关键操作:堆的调整(下沉操作)
堆排序的核心是"调整堆"的操作(也称"下沉"),即当堆的结构被破坏时,通过将节点向下移动,恢复堆的性质。
调整堆的步骤(以大顶堆为例):
- 设当前节点索引为
i,堆的有效范围为[0, len)(len为当前堆的大小)。 - 找到节点
i的左子节点(left = 2i + 1)和右子节点(right = 2i + 2)。 - 在
i、left、right中找到值最大的节点,记为max_idx。 - 若
max_idx ≠ i,交换i和max_idx的值,此时max_idx位置的堆结构可能被破坏,需递归(或迭代)调整max_idx位置。
四、堆排序的完整步骤
以数组 [4, 10, 3, 5, 1] 为例,演示堆排序的全过程:
步骤1:建堆(构建大顶堆)
建堆需从最后一个非叶子节点开始,依次向前调整每个节点,确保每个子树都是大顶堆。
-
数组长度
n = 5,最后一个非叶子节点索引为(n//2 - 1) = 1(对应值为10)。- 调整索引1(值10):其左右子节点为3(值5)和4(值1),10已是最大值,无需调整。
- 调整索引0(值4):其左右子节点为1(值10)和2(值3)。最大值为10(索引1),交换4和10,数组变为
[10, 4, 3, 5, 1]。交换后需检查索引1的子树:其左子节点4(值5)大于4,交换4和5,数组变为[10, 5, 3, 4, 1],此时堆结构恢复。
建堆完成后,数组为
[10, 5, 3, 4, 1](大顶堆)。
步骤2:排序过程
-
第1轮:
- 交换堆顶(10)与堆尾(1),数组变为
[1, 5, 3, 4, 10](10已排序)。 - 缩小堆范围至
[0, 3],调整堆顶(1):- 子节点为1(5)和2(3),最大值为5(索引1),交换1和5 →
[5, 1, 3, 4, 10]。 - 调整索引1(1):子节点为3(4),交换1和4 →
[5, 4, 3, 1, 10]。
- 子节点为1(5)和2(3),最大值为5(索引1),交换1和5 →
- 交换堆顶(10)与堆尾(1),数组变为
-
第2轮:
- 交换堆顶(5)与堆尾(1),数组变为
[1, 4, 3, 5, 10](5已排序)。 - 缩小堆范围至
[0, 2],调整堆顶(1):- 子节点为1(4)和2(3),最大值为4(索引1),交换1和4 →
[4, 1, 3, 5, 10]。
- 子节点为1(4)和2(3),最大值为4(索引1),交换1和4 →
- 交换堆顶(5)与堆尾(1),数组变为
-
第3轮:
- 交换堆顶(4)与堆尾(3),数组变为
[3, 1, 4, 5, 10](4已排序)。 - 缩小堆范围至
[0, 1],调整堆顶(3):- 子节点为1(1),3已是最大值,无需调整。
- 交换堆顶(4)与堆尾(3),数组变为
-
第4轮:
- 交换堆顶(3)与堆尾(1),数组变为
[1, 3, 4, 5, 10](3已排序)。
- 交换堆顶(3)与堆尾(1),数组变为
最终排序结果:[1, 3, 4, 5, 10]。
五、C++实现代码
以下是堆排序的C++实现,包含堆调整、建堆和排序主函数:
cpp
#include <iostream>
#include <vector>
using namespace std;
// 调整堆(大顶堆):将以i为根的子树调整为大顶堆
void adjustHeap(vector<int>& arr, int i, int len) {
int temp = arr[i]; // 保存当前节点值
// 从左子节点开始遍历
for (int k = 2 * i + 1; k < len; k = 2 * k + 1) {
// 若右子节点存在且大于左子节点,选择右子节点
if (k + 1 < len && arr[k] < arr[k + 1]) {
k++;
}
// 若子节点大于父节点,交换并继续调整子树
if (arr[k] > temp) {
arr[i] = arr[k];
i = k; // 记录交换后的位置,继续向下调整
} else {
break; // 父节点已是最大值,无需继续
}
}
arr[i] = temp; // 将原节点值放到最终位置
}
// 堆排序主函数
void heapSort(vector<int>& arr) {
int n = arr.size();
// 1. 建堆:从最后一个非叶子节点向前调整
for (int i = n / 2 - 1; i >= 0; i--) {
adjustHeap(arr, i, n);
}
// 2. 排序:反复交换堆顶与堆尾,调整堆
for (int j = n - 1; j > 0; j--) {
swap(arr[0], arr[j]); // 交换堆顶(最大值)与堆尾
adjustHeap(arr, 0, j); // 调整剩余j个元素为大顶堆
}
}
// 测试函数
int main() {
vector<int> arr = {4, 10, 3, 5, 1};
cout << "排序前:";
for (int num : arr) {
cout << num << " ";
}
cout << endl;
heapSort(arr);
cout << "排序后:";
for (int num : arr) {
cout << num << " ";
}
cout << endl;
return 0;
}
六、算法分析
-
时间复杂度:
- 建堆:需调整
n/2个节点,每个节点的调整深度为O(log n),总时间O(n)(数学推导可证明)。 - 排序:共
n-1次调整,每次调整时间O(log n),总时间O(n log n)。 - 整体时间复杂度:
O(n log n)(最好、最坏、平均情况均为此值,稳定性优于快速排序)。
- 建堆:需调整
-
空间复杂度 :
O(1),仅使用常数级额外空间(原地排序)。 -
稳定性 :不稳定排序。例如
[2, 2, 1]排序时,两个2的相对位置可能变化。
堆排序的应用场景
- Top K问题 :需从大量数据中找出前K个最大/小值时,堆排序可高效实现(时间
O(n log K))。 - 内存受限场景:因原地排序特性,适合内存紧张的环境。
- 实时数据处理:可动态维护堆结构,支持高效插入和删除操作。
与其他排序算法的对比
| 算法 | 时间复杂度(平均) | 空间复杂度 | 稳定性 |
|---|---|---|---|
| 堆排序 | O(n log n) |
O(1) |
不稳定 |
| 快速排序 | O(n log n) |
O(log n) |
不稳定 |
| 归并排序 | O(n log n) |
O(n) |
稳定 |
堆排序的优势在于最坏时间复杂度稳定且空间开销小,但实际性能略逊于快速排序(因缓存局部性较差)。
堆排序是一种基于堆结构的高效排序算法,通过建堆和反复调整堆实现排序,具有 O(n log n) 的稳定时间复杂度和 O(1) 的空间复杂度。其核心是堆的调整操作,理解这一过程是掌握堆排序的关键。在需要稳定性能和低空间开销的场景中,堆排序是理想选择。