堆排序原理与C++实现详解
堆排序(Heap Sort)是一种基于堆数据结构的高效排序算法,它利用堆的特性(大顶堆或小顶堆)来实现排序,时间复杂度稳定为 O(n log n),空间复杂度为 O(1),属于原地排序算法。本文将从堆的基础概念出发,详细讲解堆排序的核心原理,再一步步实现C++版本的堆排序代码,并对算法性能进行分析。
一、堆的基础概念
在讲解堆排序之前,我们需要先明确"堆"的定义和特性:
-
堆的结构:堆是一棵完全二叉树,完全二叉树的特点是除了最后一层外,每一层的节点数都是满的,最后一层的节点从左到右依次排列,没有空缺。
-
堆的类型:
-
大顶堆:每个父节点的值都大于等于其两个子节点的值,堆顶(根节点)是整个堆中的最大值。
-
小顶堆:每个父节点的值都小于等于其两个子节点的值,堆顶是整个堆中的最小值。
-
-
堆的存储:由于堆是完全二叉树,我们可以用数组(顺序表)来高效存储,无需额外的指针开销。假设数组下标从 0 开始,对于索引为 i 的节点:
-
左子节点的索引:2 * i + 1
-
右子节点的索引:2 * i + 2
-
父节点的索引:(i - 1) / 2(整数除法,自动向下取整)
-
二、堆排序的核心原理
堆排序的核心思路是利用大顶堆(或小顶堆)的堆顶元素是最大值(或最小值)的特性,通过不断提取堆顶元素并调整堆结构,最终得到有序数组。具体步骤分为 3 步:
1. 构建初始堆(Heapify)
将待排序的无序数组转换为大顶堆(升序排序用大顶堆,降序排序用小顶堆)。构建堆的关键是"堆化"操作------从最后一个非叶子节点开始,依次向前对每个节点进行调整,确保每个节点都满足大顶堆的特性。
为什么从最后一个非叶子节点开始?因为叶子节点没有子节点,本身已经是合法的堆结构,无需调整。最后一个非叶子节点的索引就是 (n - 2) / 2(n 为数组长度,最后一个节点索引为 n-1,其父节点即为 (n-1-1)/2 = (n-2)/2)。
2. 提取堆顶元素并调整堆
堆构建完成后,堆顶元素是最大值。将堆顶元素与数组末尾的元素交换,此时数组末尾就存放了当前的最大值;然后将剩余的 n-1 个元素重新调整为大顶堆(因为交换后堆顶元素可能破坏堆结构,需要从堆顶开始重新堆化)。
3. 重复步骤2
不断重复"交换堆顶与末尾元素→调整堆"的过程,直到剩余元素个数为 1,此时数组已经完全有序。
三、C++实现堆排序的步骤拆解
我们以升序排序为例,用大顶堆实现堆排序。C++代码的核心是两个函数:
-
heapify 函数:调整指定节点为根的子树为大顶堆
-
heapSort 函数:主排序函数,负责构建初始堆和循环提取堆顶元素
1. 实现 heapify 函数
函数功能:给定数组、数组长度、当前需要调整的节点索引,将该节点为根的子树调整为大顶堆。
实现思路:
-
假设当前节点 i 是最大值节点,记录其索引为 largest;
-
计算当前节点的左子节点 l = 2i + 1,右子节点 r = 2i + 2;
-
比较左子节点与 largest 的值,若左子节点更大,则更新 largest 为 l;
-
比较右子节点与 largest 的值,若右子节点更大,则更新 largest 为 r;
-
若 largest 不等于 i(说明当前节点不是最大值,需要交换):
-
交换节点 i 和节点 largest 的值;
-
递归调整 largest 所在的子树(因为交换后,largest 节点可能破坏了子树的堆结构)。
-
2. 实现 heapSort 函数
函数功能:对数组进行堆排序(升序)。
实现思路:
-
构建初始大顶堆:从最后一个非叶子节点 (n-2)/2 开始,向前遍历每个节点,调用 heapify 函数调整;
-
循环提取堆顶元素:
-
从数组末尾开始(索引 n-1),依次与堆顶(索引 0)交换;
-
交换后,剩余未排序元素的个数减 1,对新的堆顶(索引 0)调用 heapify 函数,调整剩余元素为大顶堆;
-
重复上述过程,直到未排序元素个数为 1。
-
四、完整C++代码实现
cpp
#include <iostream>
#include <vector>
using namespace std;
// 堆化函数:调整以i为根的子树为大顶堆
void heapify(vector<int>& arr, int n, int i) {
int largest = i; // 初始化最大值为根节点
int l = 2 * i + 1; // 左子节点索引
int r = 2 * i + 2; // 右子节点索引
// 若左子节点大于根节点,更新最大值索引
if (l < n && arr[l] > arr[largest]) {
largest = l;
}
// 若右子节点大于当前最大值,更新最大值索引
if (r < n && arr[r] > arr[largest]) {
largest = r;
}
// 若最大值不是根节点,需要交换并递归调整子树
if (largest != i) {
swap(arr[i], arr[largest]); // 交换根节点和最大值节点
heapify(arr, n, largest); // 递归调整交换后的子树
}
}
// 堆排序主函数(升序)
void heapSort(vector<int>& arr) {
int n = arr.size();
// 第一步:构建初始大顶堆
// 从最后一个非叶子节点开始向前遍历
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); // 调整剩余i个元素为大顶堆(i是当前未排序元素个数)
}
}
// 辅助函数:打印数组
void printArray(const vector<int>& arr) {
for (int num : arr) {
cout << num << " ";
}
cout << endl;
}
// 测试代码
int main() {
vector<int> arr = {12, 11, 13, 5, 6, 7};
cout << "排序前的数组:";
printArray(arr);
heapSort(arr);
cout << "排序后的数组:";
printArray(arr);
return 0;
}
代码说明
-
使用 vector 存储数组,便于动态调整长度,适配不同规模的排序需求;
-
heapify 函数采用递归实现,逻辑清晰,也可以用迭代实现(避免递归栈开销,适合大规模数据);
-
printArray 函数用于辅助打印排序前后的数组,方便验证结果;
-
测试用例为 {12, 11, 13, 5, 6, 7},排序后输出为 {5, 6, 7, 11, 12, 13},符合升序要求。
五、堆排序的性能分析
1. 时间复杂度
-
构建初始堆的时间复杂度:O(n)。虽然构建堆时需要遍历 O(n/2) 个节点,每个节点的堆化操作时间复杂度为 O(log n),但整体复杂度经过数学推导为 O(n);
-
循环提取堆顶元素的时间复杂度:O(n log n)。共需要循环 n-1 次,每次堆化操作的时间复杂度为 O(log n),因此总复杂度为 O(n log n);
-
堆排序的整体时间复杂度:O(n log n),且最好、最坏、平均时间复杂度均为 O(n log n),稳定性优于快速排序(快速排序最坏时间复杂度为 O(n²))。
2. 空间复杂度
堆排序是原地排序算法,除了递归调用栈(递归实现的 heapify 函数)外,不需要额外的存储空间。若采用迭代实现 heapify 函数,空间复杂度为 O(1);递归实现的空间复杂度为 O(log n)(递归深度为堆的高度,即 log n)。
3. 稳定性
堆排序是不稳定排序。因为在交换堆顶元素和末尾元素时,可能会导致相同值的元素相对位置发生变化。例如,数组 [2, 2, 1],构建大顶堆后堆顶为 2(第一个 2),与末尾的 1 交换后得到 [1, 2, 2],此时两个 2 的相对位置未变;但对于数组 [3, 2, 2, 1],交换过程中可能会导致两个 2 的位置颠倒。
六、堆排序的应用场景
堆排序适合处理大规模数据,尤其是对排序稳定性要求不高的场景,例如:
-
操作系统中的进程调度(利用堆快速获取优先级最高的进程);
-
Top K 问题(例如获取数组中前 K 个最大元素,无需完整排序,用堆实现效率更高);
-
大数据排序(堆排序的原地排序特性使其在内存有限的场景下更具优势)。
七、常见问题与优化
1. 递归堆化的栈溢出问题
对于大规模数据(例如数组长度超过 1e5),递归实现的 heapify 函数可能会导致栈溢出。解决方案:将递归实现改为迭代实现。
迭代版 heapify 函数实现:
cpp
void heapifyIterative(vector<int>& arr, int n, int i) {
while (true) {
int largest = i;
int l = 2 * i + 1;
int r = 2 * i + 2;
if (l < n && arr[l] > arr[largest]) {
largest = l;
}
if (r < n && arr[r] > arr[largest]) {
largest = r;
}
if (largest == i) {
break; // 无需调整,退出循环
}
swap(arr[i], arr[largest]);
i = largest; // 更新当前节点索引,继续调整子树
}
}
2. 降序排序的实现
若需要实现降序排序,只需将大顶堆改为小顶堆即可。修改 heapify 函数中的比较逻辑:将"大于"改为"小于",确保每个父节点的值小于等于子节点的值。
八、总结
堆排序是一种高效、稳定的排序算法,核心是利用堆的特性通过"构建堆→提取堆顶→调整堆"的流程实现排序。其时间复杂度为 O(n log n),空间复杂度低,适合大规模数据排序。本文通过C++实现了基于大顶堆的升序堆排序,并讲解了核心函数的实现逻辑、性能分析和常见优化方案,希望能帮助大家深入理解堆排序的原理与应用。