排序算法入门(四):堆排序 —— 用完全二叉树原地排序

为什么一个数组就能当成一棵完全二叉树?因为完全二叉树逐层从左到右填满,最后一层也只缺右侧若干位置,中间没有空洞。这种形状让它的层序遍历序列能原样放进一段连续内存,父子关系完全由下标算出来,一个左右孩子指针都不用存。
数组与完全二叉树的对应关系
以 0 为起点编号,任意下标 i 的结点满足三条恒等式:
- 父结点:
(i - 1) / 2(Java 整数除法向下取整,i >= 1时结果非负) - 左孩子:
2 * i + 1 - 右孩子:
2 * i + 2
由此能推出哪些结点是叶子:下标 i 有左孩子当且仅当 2i + 1 < n,解得 i <= n/2 - 1。所以从下标 n/2 起直到末尾全是叶子,最后一个非叶结点是 n/2 - 1。
这也解释了建堆为什么从 n/2 - 1 倒着往前跑。下沉有个前提:被下沉结点的左右子树必须已经是合法的堆。叶子没有子树,天然满足;按 i = n/2-1, ..., 0 的顺序处理时,轮到 i 时两个孩子下标都大于 i,早就处理完了,子树都是堆。
大顶堆与小顶堆
堆只施加一条约束:每个结点的值都不小于(大顶堆)或不大于(小顶堆)它的两个孩子。它不要求整棵树有序,左右孩子之间、同层结点之间都没有大小关系,堆唯一保证的是堆顶为全局最值。所以升序排序用大顶堆,堆顶那个最大值挪到末尾正好落在最终位置上。
下沉(sift down)才是主角
上浮用在插入:新元素追加到末尾,一路向上和父结点比。下沉用在删除堆顶和建堆:把元素放到某个位置,一路向下和较大的孩子比。两者不要混,堆排序里一次上浮都用不到。建堆是原地调整,不涉及插入;主循环把堆顶换到末尾后,被换到根上的元素破坏了堆性质,但它的左右子树完好,从根往下沉就够了。
三个细节:先挑出较大的孩子再比较,直接拿左孩子去比是常见错法;交换后要继续往下走,换下去的元素可能比下一层还小;边界用可控的 size,已经归位的尾部绝不能再碰。
建堆为什么是 O(n) 而不是 O(n log n)
最笨的估计是 n/2 个非叶结点各下沉 log n 层,得 O(n log n),但这个上界太松。结点数量和它能下沉的高度是反着来的:约一半结点是叶子,高度 0;约四分之一最多下沉 1 层;再往上一层只有约 n/8 个结点,最多 2 层。逐层把结点个数乘以最大高度再相加,后面的项衰减得比前面涨得还快,总和只与 n 成正比。
树高虽然是 log n,但站在那个高度上的结点只有 1 个,绝大多数结点贴着地面几乎不动。所以建堆是 O(n),O(n log n) 完全来自排序主体那 n 次换堆顶加下沉。
完整实现
java
public class HeapSort {
public static void sort(int[] a) {
if (a == null || a.length < 2) {
return; // 0 或 1 个元素已经有序,也避免后面下标越界
}
buildHeap(a);
// 每轮把堆顶(当前最大值)换到有效区间末尾,之后它永久归位
for (int end = a.length - 1; end > 0; end--) {
swap(a, 0, end);
// end 已归位,堆的有效范围缩到 [0, end),此时只有根被破坏
siftDown(a, 0, end);
}
}
private static void buildHeap(int[] a) {
// 下标 >= n/2 的结点全是叶子,本身就是合法的堆,不用处理
// 从最后一个非叶结点倒着走,保证下沉时左右子树已经是堆
for (int i = a.length / 2 - 1; i >= 0; i--) {
siftDown(a, i, a.length);
}
}
/** 让 a[i] 在 [0, size) 这个范围内下沉到合适位置 */
private static void siftDown(int[] a, int i, int size) {
while (true) {
int left = 2 * i + 1;
if (left >= size) {
return; // 没有孩子,已经落到叶子
}
int larger = left;
int right = left + 1;
if (right < size && a[right] > a[left]) {
larger = right; // 先选出较大的孩子,它才有资格和父结点竞争
}
if (a[i] >= a[larger]) {
return; // 父结点不小于较大的孩子,堆性质已满足,可以停
}
swap(a, i, larger);
i = larger; // 继续从被换下去的位置往下检查
}
}
private static void swap(int[] a, int i, int j) {
int t = a[i];
a[i] = a[j];
a[j] = t;
}
public static void main(String[] args) {
int[] data = {4, 1, 7, 3, 9, 2, 8, 0, 5, 6};
sort(data);
System.out.println(java.util.Arrays.toString(data));
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}
}
复杂度与稳定性
堆的形状只由元素个数决定,任何输入都走同一条路径,所以最好、平均、最坏都是 O(n log n),不存在快排那种本来有序反而退化的问题。额外空间只有几个循环变量,O(1),是严格的原地排序。
但它是不稳定的:主循环把堆顶与末尾互换,下沉时父结点又与较远的后代互换,相等元素的相对次序很容易被打乱且无法补救。要稳定性只能改用归并排序。
| 算法 | 平均 | 最坏 | 额外空间 | 稳定性 | 是否原地 |
|---|---|---|---|---|---|
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 是 |
| 快速排序 | O(n log n) | O(n^2) | O(log n) 递归栈 | 不稳定 | 是 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 否 |
堆排序的取舍
时间和空间都挑不出毛病,为什么工程里的通用排序几乎都用快排?答案是访存模式。快排在数组上顺序扫描,两个指针贴着走,缓存预取和分支预测都能吃满;堆排序在树里来回跳,访问 2i+1、2i+2 和 (i-1)/2,n 一大,每次跳转都可能踩到没进缓存的 cache line。比较次数同阶,实际耗时通常差出一截。
但堆排序有一条别人替代不了:最坏也是 O(n log n),额外空间 O(1)。快排的 O(n log n) 只是平均,最坏 O(n^2),递归栈还要 O(log n) 到 O(n)。这个差别在对上承诺"任意输入都不超时"时很值钱:C++ 标准库的 std::sort 用 introsort,主体是快排,递归深度一旦超过 2 * log n 就切堆排序兜底。
堆更常见的用途:优先队列与 TopK
业务代码里手写堆排序的机会不多,但堆这套机制天天在用,只是被封装好了。java.util.PriorityQueue 默认是小顶堆,offer 上浮、poll 弹出堆顶后下沉都是 O(log n),而 peek 只看堆顶,是 O(1):
java
PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // 默认小顶堆
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
minHeap.offer(5);
minHeap.offer(1);
minHeap.peek(); // 1,O(1):堆顶就是全局最小值
minHeap.poll(); // 1,O(log n):弹出后自动修复堆
调度和定时任务也靠它:ScheduledThreadPoolExecutor 内部是 DelayedWorkQueue,DelayQueue 同理,都是小顶堆,堆顶就是最早到期的任务,调度线程看一眼就知道该不该干活:
java
ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);
// 堆顶即最近到期的任务
pool.schedule(() -> System.out.println("run"), 3, TimeUnit.SECONDS);
合并 K 个有序链表是"多路归并 + 小顶堆"。不建堆就得每轮在 K 个链表头里线性找最小,整体 O(NK);堆里只维护 K 个当前候选,每取一个元素 O(log K),总计 O(N log K):
java
class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
}
static ListNode mergeKLists(ListNode[] lists) {
// 堆里只放每条链表"当前最小的那个候选",保证堆顶是全局最小
PriorityQueue<ListNode> pq = new PriorityQueue<>(Comparator.comparingInt(n -> n.val));
for (ListNode head : lists) {
if (head != null) {
pq.offer(head); // 空链表不进堆,否则后面取 val 会 NPE
}
}
ListNode dummy = new ListNode(0);
ListNode tail = dummy;
while (!pq.isEmpty()) {
ListNode node = pq.poll();
tail.next = node; // 直接接上已有结点,不新建对象
tail = node;
if (node.next != null) {
pq.offer(node.next); // 该链表的下一个元素成为新候选
}
}
return dummy.next;
}
TopK 是堆出现频率最高的用法。求最大的 K 个数用小顶堆:堆里始终保存目前见过的最大的 K 个,堆顶是其中最小的,也是唯一有资格被淘汰的。新来一个数比堆顶大就替换掉堆顶,否则丢弃。复杂度 O(n log K),n 大而 K 小时比全排序划算得多,流式数据也能用,内存里只需要 K 个元素:
java
import java.util.Arrays;
import java.util.PriorityQueue;
public class TopK {
/** 返回数组中最大的 k 个数,结果不保证有序 */
public static int[] topK(int[] nums, int k) {
if (nums == null || nums.length == 0 || k <= 0) {
return new int[0];
}
k = Math.min(k, nums.length);
// 求最大的 k 个用小顶堆:堆顶是这 k 个里最小的,充当"守门员"
// 新元素只有比堆顶大才值得进来,进来就要挤掉堆顶
PriorityQueue<Integer> heap = new PriorityQueue<>(k);
for (int v : nums) {
if (heap.size() < k) {
heap.offer(v); // 还没装满,先无条件收下
} else if (v > heap.peek()) {
heap.poll(); // 堆顶不够格,淘汰它
heap.offer(v);
}
}
int[] res = new int[heap.size()];
for (int i = 0; i < res.length; i++) {
res[i] = heap.poll(); // 小顶堆依次弹出,结果是升序
}
return res;
}
public static void main(String[] args) {
int[] nums = {3, 2, 1, 5, 6, 4};
System.out.println(Arrays.toString(topK(nums, 2))); // [5, 6]
}
}
方向必须记牢:求最大的 K 个用小顶堆,求最小的 K 个用大顶堆。记法是堆顶当守门员,它得是能被淘汰的那个。
小结
下标三条恒等式是一切的起点,n/2 - 1 既是最后一个非叶结点,也是建堆循环的起点;结点数随深度递减而可下沉高度递增,建堆因此收敛到 O(n),那 n 次换堆顶加下沉才是 O(n log n) 的来源。堆排序原地、空间 O(1)、最坏也是 O(n log n),代价是访问跳跃、缓存不友好且不稳定,所以它很少当主力,更像快排身边负责兜底的那一个。
评论