Priority Queue and Heap
假设我们正在写一个网络模拟器。系统中不断产生未来事件:
10.2 ms:数据包到达路由器7.8 ms:定时器超时12.5 ms:下一次重传8.1 ms:链路恢复这些事件不是按“加入容器的顺序”执行,而必须始终取出时间戳(timestamp)最小的事件。处理一个事件后,还可能立即插入新的未来事件。
如果每次都扫描整个线性表寻找最早事件,事件数一大,重复查找的成本会非常高。
怎样既允许事件不断加入,又能快速取出当前最早、最大或最小的那个对象?
同一种需求还出现在:
这些场景并不要求“整体排好序”,它们真正反复执行的是两件事:插入一个新对象,以及取出当前极值。
优先队列就是对这类工作负载的抽象;而堆的价值在于,它不追求所有元素完全有序,只维护足以支持高效极值访问的局部结构。
最大优先队列:
| 操作 | 含义 |
|---|---|
create() |
创建空优先队列 |
size() |
返回元素个数 |
max() |
返回最大优先级元素 |
insert(x) |
插入元素 |
deleteMax() |
删除并返回最大优先级元素 |
最小优先队列把 max/deleteMax 换成 min/deleteMin。
| 表示 | 插入 | 查找最大 | 删除最大 |
|---|---|---|---|
| 无序线性表 | \(\Theta(1)\) | \(\Theta(n)\) | \(\Theta(n)\) |
| 有序线性表 | \(\Theta(n)\) | \(\Theta(1)\) | \(\Theta(1)\) 或 \(\Theta(n)\) |
| 二叉堆 | \(O(\log n)\) | \(O(1)\) | \(O(\log n)\) |
堆在插入和删除之间取得平衡。
最大堆满足:
最小堆满足:
堆不要求左右子树之间有全局有序关系。
根结点一定是最大值。
本章所有堆操作都使用 0-based 下标:根结点位于 heap[0]。
static final class MaxHeap<T extends Comparable<? super T>> {
private final ArrayList<T> heap;
private final int capacity;
MaxHeap(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive");
}
this.capacity = capacity;
heap = new ArrayList<>(capacity);
}
boolean isEmpty() {
return heap.isEmpty();
}
int size() {
return heap.size();
}
T max() {
if (isEmpty()) {
throw new NoSuchElementException("heap is empty");
}
return heap.get(0);
}MaxHeap 同时维护两条不变量:数组表示完全二叉树,父结点不小于孩子。
heap 保存 0-based 连续数组,capacity 只限制可容纳的元素数。
0..size-1。
isEmpty() 判断是否存在根结点。
O(1)。
size() 返回当前完全二叉树的结点数。
max() 读取最大优先级元素,但不改变堆结构。
heap[0],所以查询为 O(1)。
void insert(T value) {
Objects.requireNonNull(value, "value");
if (size() == capacity) {
throw new IllegalStateException("heap capacity exceeded");
}
heap.add(value);
int child = size() - 1;
while (child > 0) {
int parent = (child - 1) / 2;
if (heap.get(parent).compareTo(heap.get(child)) >= 0) {
break;
}
Collections.swap(heap, parent, child);
child = parent;
}
}insert(…) 在保持完全二叉树形状的同时恢复最大堆序。
null,否则比较操作没有定义。
parent=(child-1)/2,因此上滤每轮都沿唯一祖先边向上。
O(log n)。
T deleteMax() {
if (isEmpty()) {
throw new NoSuchElementException("heap is empty");
}
int last = size() - 1;
Collections.swap(heap, 0, last);
T maximum = heap.remove(last);
if (!isEmpty()) {
percolateDown(0);
}
return maximum;
}
private void percolateDown(int parent) {
while (parent * 2 + 1 < size()) {
int child = parent * 2 + 1;
if (child + 1 < size() && heap.get(child + 1).compareTo(heap.get(child)) > 0) {
child++;
}
if (heap.get(parent).compareTo(heap.get(child)) >= 0) {
break;
}
Collections.swap(heap, parent, child);
parent = child;
}
}deleteMax() 删除根,同时保持完全二叉树形状和最大堆序。
percolateDown(parent) 从一个可能过小的父结点开始恢复最大堆。
2*parent+1。
O(log n)。
private void percolateDown(int parent) {
while (parent * 2 + 1 < size()) {
int child = parent * 2 + 1;
if (child + 1 < size() && heap.get(child + 1).compareTo(heap.get(child)) > 0) {
child++;
}
if (heap.get(parent).compareTo(heap.get(child)) >= 0) {
break;
}
Collections.swap(heap, parent, child);
parent = child;
}
}percolateDown(parent) 修复从一个可能过小的父结点开始的局部违例。
2*parent+1。
O(log n)。
堆高为:
\[ \lfloor \log_2 n \rfloor \]
因此:
| 操作 | 时间 |
|---|---|
max() |
\(O(1)\) |
insert() |
\(O(\log n)\) |
deleteMax() |
\(O(\log n)\) |
逐个插入 \(n\) 个元素。
每次插入最坏 \(O(\log n)\)。
总时间:
\[ O(n\log n) \]
这种方法简单,但不是最优建堆。
把所有元素先放入数组,再从最后一个内部结点开始下滤:
static <T extends Comparable<? super T>> MaxHeap<T> initialize(List<T> values) {
MaxHeap<T> heap = new MaxHeap<>(Math.max(values.size(), 1));
for (T value : values) {
heap.heap.add(Objects.requireNonNull(value, "heap element"));
}
for (int parent = heap.size() / 2 - 1; parent >= 0; parent--) {
heap.percolateDown(parent);
}
return heap;
}initialize(…) 先建立完全二叉树形状,再自底向上恢复最大堆序。
size/2-1,从这里向下标 0 逆序处理。
O(n)。
靠近叶子的结点很多,但下滤距离短。
靠近根的结点少,虽然下滤距离长。
总工作量为:
\[ \sum_{h\ge 0} \frac{n}{2^{h+1}}h = O(n) \]
因此 Floyd 建堆是线性时间。
堆排序流程:
复杂度:
\[ O(n)+nO(\log n)=O(n\log n) \]
堆排序原地,但不稳定。
稳定排序要求相等关键字保持原相对顺序。
堆排序中,元素会沿树路径与远处元素交换。
相同关键字可能跨越彼此,因此堆排序通常不稳定。
给定数组:
\[ [3, 5, 8, 9, 1, 10] \]
要求:找出第 \(3\) 大元素。
排序后从大到小是:
\[ 10, 9, 8, 5, 3, 1 \]
所以答案是 8。
但是回答这个问题不需要全量排序。只用本章的优先队列工具,怎样只维护与答案有关的候选,而不是把所有元素完全排好序?
方法1:全量最大堆
deleteMax方法2:大小 k 的最小堆
方法 2 的初始 \(k\) 个元素既可以逐个 offer(简单、整体仍为 \(O(n\log k)\)),也可以先放入数组再用 Floyd 在 \(O(k)\) 时间建堆;下面的复杂度对比采用后者。
\[ [3, 5, 8, 9, 1, 10] \]
static int kthLargest(int[] values, int k) {
if (k <= 0 || k > values.length) {
throw new IllegalArgumentException("k out of range");
}
PriorityQueue<Integer> smallestOfTopK = new PriorityQueue<>(k);
for (int value : values) {
if (smallestOfTopK.size() < k) {
smallestOfTopK.offer(value);
} else if (value > smallestOfTopK.element()) {
smallestOfTopK.remove();
smallestOfTopK.offer(value);
}
}
return smallestOfTopK.element();
}kthLargest(…) 只保留当前最大的 k 个候选;最小堆根就是第 k 大阈值。
O(1) 时间读取的门槛。真正把堆大小限制在 k 的是后面的控制逻辑,而不是 Java PriorityQueue 的初始容量参数。
O(n log k)、额外空间 O(k);当 k 远小于 n 时,优势来自始终只维护 k 个候选。
static void buildMinHeap(int[] heap) {
for (int parent = heap.length / 2 - 1; parent >= 0; parent--) {
percolateDownMin(heap, parent, heap.length);
}
}
static void percolateDownMin(int[] heap, int parent, int size) {
if (size < 0 || size > heap.length || parent < 0 || (size > 0 && parent >= size)) {
throw new IllegalArgumentException("invalid heap range");
}
while (parent * 2 + 1 < size) {
int child = parent * 2 + 1;
if (child + 1 < size && heap[child + 1] < heap[child]) {
child++;
}
if (heap[parent] <= heap[child]) {
break;
}
int temp = heap[parent];
heap[parent] = heap[child];
heap[child] = temp;
parent = child;
}
}buildMinHeap(…) 用 Floyd 的自底向上顺序把 0-based 数组整体变成最小堆。
length/2-1;从这里向下标 0 处理,叶子无需单独下滤。
O(k)。
percolateDownMin 从一个可能过大的父结点开始,沿一条根到叶路径恢复最小堆序。
size 划定本次有效堆区,所有比较和交换都只能发生在这个前缀内。
2*parent+1。
O(log k)。
static void percolateDownMin(int[] heap, int parent, int size) {
if (size < 0 || size > heap.length || parent < 0 || (size > 0 && parent >= size)) {
throw new IllegalArgumentException("invalid heap range");
}
while (parent * 2 + 1 < size) {
int child = parent * 2 + 1;
if (child + 1 < size && heap[child + 1] < heap[child]) {
child++;
}
if (heap[parent] <= heap[child]) {
break;
}
int temp = heap[parent];
heap[parent] = heap[child];
heap[child] = temp;
parent = child;
}
}percolateDownMin(…) 从一个可能过大的父结点开始恢复最小堆序。
size 划定当前有效堆区;所有比较与交换都只能发生在这个前缀内。
2*parent+1。
O(log k)。
塔防游戏里,很多行为都不是“立刻发生”,而是约定在未来某个时间触发。
离散事件引擎维护的是 所有已知的未来事件。每轮循环先 deleteMin 取出最早事件,执行后可能立刻 insert 新事件。
下面为了讲清时间关系,把未来事件按时间展开成概念列表;这不是堆的数组形状。真实最小堆只保证堆顶是最早事件,并不保证其余位置全局有序。重点观察:事件会出堆,也会从内部逻辑或外部输入入堆。