应用场景:Top K · 优先队列 · Dijkstra · 合并 K 链表
交互式动画演示,可调整参数并单步执行。
全屏打开堆(Heap) 是一种特殊的完全二叉树,满足堆序性:
graph TD 1((1)) --> 3((3)) 1((1)) --> 2((2)) 3((3)) --> 7((7)) 3((3)) --> 5((5))
数组表示:[1, 3, 2, 7, 5],下标 0-4。
完全二叉树 用数组存储没有空间浪费(下标从 0):
| 关系 | 公式 |
|---|---|
| 父节点 | parent(i) = (i - 1) / 2 |
| 左孩子 | left(i) = 2*i + 1 |
| 右孩子 | right(i) = 2*i + 2 |
如果下标从 1 开始(更简洁):
| 关系 | 公式 |
|---|---|
| 父节点 | parent(i) = i / 2 |
| 左孩子 | left(i) = 2*i |
| 右孩子 | right(i) = 2*i + 1 |
| 操作 | 说明 | 时间 |
|---|---|---|
insert | 插入 + sift-up | O(log n) |
extractMax/Min | 取堆顶 + sift-down | O(log n) |
peek | 查看堆顶 | O(1) |
heapify | 数组建堆 | O(n) |
decrease-key / increase-key | 改某个位置的值 | O(log n) |
decrease-key(关键能力):
- 减小某节点的值 → 用 sift-up(因为新值更小,可能要往上浮)。
- 增大某节点的值 → 用 sift-down(可能往下沉)。
Java 的
PriorityQueue不支持 decrease-key,需要删除 + 重新插入,或者用索引堆(自己实现)。在 Dijkstra 中,如果要优化"重复入队的旧条目"必须用索引堆,否则 O((V+E) log V) 退化成 O(V² log V)。
private void siftUp(int i) {
while (i > 0 && heap[i] < heap[parent(i)]) { // 最小堆
swap(i, parent(i));
i = parent(i);
}
}private void siftDown(int i) {
int n = size;
while (true) {
int l = left(i), r = right(i), smallest = i;
if (l < n && heap[l] < heap[smallest]) smallest = l;
if (r < n && heap[r] < heap[smallest]) smallest = r;
if (smallest == i) break;
swap(i, smallest);
i = smallest;
}
}从最后一个非叶子节点 (n/2 - 1) 向前,逐个 sift-down:
public void heapify(int[] arr) {
this.heap = arr;
this.size = arr.length;
for (int i = size / 2 - 1; i >= 0; i--) siftDown(i);
}为什么是 O(n) 而不是 O(n log n)?
因为大部分节点都在底层,sift-down 的代价很低。数学证明:
T(n) = Σ_{i=0}^{h} (节点数 at level i) × (h - i) ≤ n
public class MinHeap {
private int[] heap;
private int size;
public MinHeap(int capacity) { heap = new int[capacity]; }
public void insert(int value) {
if (size == heap.length) resize();
heap[size] = value;
siftUp(size++);
}
public int extractMin() {
if (size == 0) throw new NoSuchElementException();
int min = heap[0];
heap[0] = heap[--size];
siftDown(0);
return min;
}
public int peek() {
if (size == 0) throw new NoSuchElementException();
return heap[0];
}
public int size() { return size; }
private void siftUp(int i) {
while (i > 0 && heap[i] < heap[(i - 1) / 2]) {
swap(i, (i - 1) / 2);
i = (i - 1) / 2;
}
}
private void siftDown(int i) {
while (true) {
int l = 2 * i + 1, r = 2 * i + 2, smallest = i;
if (l < size && heap[l] < heap[smallest]) smallest = l;
if (r < size && heap[r] < heap[smallest]) smallest = r;
if (smallest == i) break;
swap(i, smallest);
i = smallest;
}
}
private void swap(int i, int j) { int t = heap[i]; heap[i] = heap[j]; heap[j] = t; }
private void resize() { heap = Arrays.copyOf(heap, heap.length * 2); }
}Java 提供堆实现:
// 默认是最小堆
PriorityQueue<Integer> minPQ = new PriorityQueue<>();
minPQ.offer(50); minPQ.offer(30); minPQ.offer(70);
minPQ.poll(); // 30
// 自定义最大堆
PriorityQueue<Integer> maxPQ = new PriorityQueue<>((a, b) -> b - a);
// 自定义比较器
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]); // 按第一个字段排序注意:
PriorityQueue不允许 null,迭代顺序不保证有序(要顺序遍历请用poll)。
利用堆的 extractMin 反复取出最小值:
public void heapSort(int[] arr) {
int n = arr.length;
// 1. 建堆
for (int i = n / 2 - 1; i >= 0; i--) siftDown(arr, n, i);
// 2. 反复取堆顶,放到末尾
for (int i = n - 1; i > 0; i--) {
swap(arr, 0, i);
siftDown(arr, i, 0);
}
}
private void siftDown(int[] arr, int n, int i) {
while (true) {
int l = 2*i + 1, r = 2*i + 2, smallest = i;
if (l < n && arr[l] < arr[smallest]) smallest = l;
if (r < n && arr[r] < arr[smallest]) smallest = r;
if (smallest == i) break;
swap(arr, i, smallest);
i = smallest;
}
}求前 K 大元素:
// 用最小堆维护 K 个最大元素
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int x : nums) {
pq.offer(x);
if (pq.size() > k) pq.poll(); // 把最小的扔掉
}
return pq.peek();复杂度:O(n log k),空间 O(k)。当 k << n 时远优于排序。
快速选择(QuickSelect)更快(O(n)),但实现复杂。堆解法 O(n log k) 更简单。
// 双堆:最大堆存较小一半,最小堆存较大一半
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a); // 较小一半
PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // 较大一半
void addNum(int num) {
maxHeap.offer(num);
minHeap.offer(maxHeap.poll());
if (maxHeap.size() < minHeap.size()) maxHeap.offer(minHeap.poll());
}
double findMedian() {
return maxHeap.size() > minHeap.size()
? maxHeap.peek()
: (maxHeap.peek() + minHeap.peek()) / 2.0;
}复杂度:插入 O(log n),查询 O(1)。
详见图论专题——优先队列取当前距离最小的节点。
每次合并两个最小频率节点 → 用最小堆优化到 O(n log n)。
CPU 调度、按优先级处理任务。
| 操作 | 时间 |
|---|---|
| 插入 | O(log n) |
| 取堆顶 | O(1) |
| 删除堆顶 | O(log n) |
| Heapify | O(n) |
| 堆排序 | O(n log n) |
a < b;最大堆用 a > b。PriorityQueue 输出不一定有序。peek / poll 空堆会抛异常,要先 isEmpty()。| 场景 | 推荐 |
|---|---|
| 频繁取最大/最小 | 堆 |
| 范围查询 | BST |
| 队列先来先服务 | 普通队列 |
| 滑动窗口最值 | 单调队列 |
| Top K | 堆 / QuickSelect |
| 难度 | 题目 | 类型 |
|---|---|---|
| 🟢 | 数据流中第 K 大元素 | Top K |
| 🟢 | 数组中的第 K 个最大元素 | 堆排序 |
| 🟡 | 前 K 个高频元素 | 堆 + 哈希 |
| 🟡 | 数据流的中位数 | 双堆 |
| 🟡 | 滑动窗口中位数 | 平衡树 |
| 🟠 | 接雨水 II | 优先队列 BFS |
| 🟠 | 丑数 II | 堆 / 三指针 |
| 🟠 | 超级丑数 | 堆 / 指针 |
| 🔴 | IPO | 堆 + 贪心 |
堆 = 优先队列的底层。
看到"取最大/最小"想堆;看到"Top K"想堆。
不想手写就用PriorityQueue,但要知道 sift-up / sift-down 的原理。