一、核心思路:利用二叉堆
堆排序分两步:
- 建堆:把数组变成最大堆(父节点 ≥ 子节点)。
- 排序:反复把堆顶(最大值)交换到数组末尾,然后调整剩余部分为堆。
heapify 整体操作流程:
- 初始化:假设当前节点
i是最大值,记录其左右子节点位置。 - 找最大:比较
nums[i]与其左右子节点,将最大值的索引赋值给largest。 - 交换与下沉:如果
largest != i,交换nums[i]和nums[largest],然后递归对largest位置进行heapify(下沉调整)。
heapSort 整体操作流程:
- 建堆:从最后一个非叶子节点(
n/2 - 1)开始,从下往上调用heapify,构建最大堆。 - 排序:循环
n-1次:将堆顶(最大值)与末尾元素交换 → 堆大小减 1 → 对堆顶执行heapify恢复堆性质。 - 结果:所有元素交换完毕后,数组按升序排列。
void heapify(vector<int>& nums, int n, int i) {
int largest = i; // ① 假设当前节点最大
int left = 2 * i + 1; // ② 左子节点
int right = 2 * i + 2; // ③ 右子节点
if (left < n && nums[left] > nums[largest]) // ④ 左子更大 → 更新
largest = left;
if (right < n && nums[right] > nums[largest]) // ⑤ 右子更大 → 更新
largest = right;
if (largest != i) { // ⑥ 最大值不是当前节点
swap(nums[i], nums[largest]); // ⑦ 交换,让最大值上浮
heapify(nums, n, largest); // ⑧ 递归下沉被换下去的节点
}
}
void heapSort(vector<int>& nums) {
int n = nums.size();
for (int i = n / 2 - 1; i >= 0; i--) { // ① 从最后一个非叶子节点开始建堆
heapify(nums, n, i);
}
for (int i = n - 1; i > 0; i--) { // ② 逐个取出堆顶
swap(nums[0], nums[i]); // ③ 堆顶(最大值)放到末尾
heapify(nums, i, 0); // ④ 对剩余 i 个元素调整堆
}
}
建堆的过程(nums = [3, 7, 8, 5, 2, 1, 9]):
初始数组: 3 7 8 5 2 1 9
树形:
3
/ \
7 8
/ \ / \
5 2 1 9
i=2 (值8):8≥1,8≥9 → 和 9 交换
3
/ \
7 9
/ \ / \
5 2 1 8
i=1 (值7):7≥5,7≥2 → 不动
i=0 (值3):3≥7? → 和 7 交换
7
/ \
3 9
/ \ / \
5 2 1 8
3≥5 → 和 5 交换
7
/ \
5 9
/ \ / \
3 2 1 8
建堆完成!堆顶 9 是最大值。
二、堆排序的特性
时间复杂度:$O(N \log N)$(建堆 $O(N)$ + 排序 $O(N \log N)$) 空间复杂度:$O(1)$(原地排序) 稳定性:不稳定(堆顶和末尾元素交换时,可能破坏相等元素的相对顺序)
三、面试追问 Q&A
Q1:为什么建堆的时间复杂度是 $O(N)$ 而不是 $O(N \log N)$? A:因为从下往上建堆,大部分节点高度小。高度为 0(叶子节点,占 N/2)不需要 heapify,高度为 1(N/4 个节点)最多下沉 1 层……总操作次数 = $\sum_{h=0}^{\log N} \frac{N}{2^{h+1}} \cdot h \approx N$。
Q2:堆排序比快排慢的原因?
A:快排对连续内存的顺序扫描对 CPU 缓存友好。堆排序的 heapify 是跳跃式访问(i → 2i+1 → 4i+2),每次访问的地址不连续,缓存命中率差。数据量越大差距越明显。