基本概念(直观)
-
堆(heap)是一种基于完全二叉树的优先队列结构。常见两种含义:
- 最小堆(min-heap) :堆顶是最小元素。
- 最大堆(max-heap) :堆顶是最大元素。
-
Java 标准库没有单独的
Heap类,通常使用java.util.PriorityQueue来实现堆(默认是最小堆)。
PriorityQueue(最常用)
PriorityQueue<E> pq = new PriorityQueue<>(); // 默认最小堆,按元素自然顺序
常见构造
PriorityQueue<Integer> pq1 = new PriorityQueue<>(); // 默认最小堆
PriorityQueue<Integer> pq2 = new PriorityQueue<>(Comparator.reverseOrder()); // 最大堆
PriorityQueue<Integer> pq3 = new PriorityQueue<>(initialCapacity); // 指定初始容量
PriorityQueue<Integer> pq4 = new PriorityQueue<>(collection); // 从集合 heapify,复杂度 O(n)
PriorityQueue<Integer> pq5 = new PriorityQueue<>(collection, comparator); // 指定比较器
常用方法(API)
boolean offer(E e):插入元素(失败返回false,一般用于有容量限制的实现)。boolean add(E e):插入元素(若失败抛异常)。通常offer更稳妥。E poll():移除并返回堆顶(为空返回null)。E remove():移除并返回堆顶(为空抛异常)。E peek():查看堆顶但不移除(为空返回null)。E element():查看堆顶但不移除(为空抛异常)。int size(),boolean isEmpty(),void clear()。boolean remove(Object o):移除指定元素(时间复杂度 O(n),不是高效操作)。Iterator<E> iterator():注意迭代器不保证按优先级顺序遍历。
复杂度
offer/add:O(log n)poll:O(log n)peek:O(1)- 用集合构造
new PriorityQueue<>(collection)是 O(n)(heapify)
最小堆 / 最大堆写法示例
最小堆(默认):
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5);
minHeap.offer(1);
minHeap.offer(3);
System.out.println(minHeap.poll()); // 1
最大堆(使用比较器):
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
// 更安全的写法避免溢出: Comparator<Integer> cmp = (a,b) -> Integer.compare(b,a);
maxHeap.offer(5);
maxHeap.offer(1);
maxHeap.offer(3);
System.out.println(maxHeap.poll()); // 5
自定义对象与 Comparator
当元素是自定义类型时要提供 Comparator 或让类实现 Comparable。
class Node {
int val;
int id;
Node(int v, int i){ val=v; id=i; }
}
// 按 val 升序(最小堆)
PriorityQueue<Node> pq = new PriorityQueue<>((a,b) -> Integer.compare(a.val, b.val));
// 带次级比较(稳定/确定顺序)
PriorityQueue<Node> pq2 = new PriorityQueue<>((a,b) -> {
int c = Integer.compare(a.val, b.val);
return c != 0 ? c : Integer.compare(a.id, b.id);
});
示例:合并 K 个有序链表(经典题)用到 PriorityQueue<Node>。
常见题型 & 案例代码(重点)
1) 找前 K 大(Kth largest)
思路:维护一个容量为 k 的最小堆,遍历数组,堆满后若新元素 > 堆顶,弹出堆顶再加新元素。最终堆顶就是第 k 大。
int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int x : nums) {
minHeap.offer(x);
if (minHeap.size() > k) minHeap.poll();
}
return minHeap.peek(); // 第 k 大
}
复杂度:O(n log k),常用于题目 215。
2) Top K 最小 / 最大
- Top K 最小:维护最大堆(容量 k),堆顶为当前最大的最小元素。
- Top K 最大:维护最小堆(容量 k)。
示例:前 k 个最小
int[] topKSmallest(int[] nums, int k) {
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a,b)->Integer.compare(b,a));
for (int x : nums) {
if (maxHeap.size() < k) maxHeap.offer(x);
else if (x < maxHeap.peek()) {
maxHeap.poll();
maxHeap.offer(x);
}
}
// maxHeap 包含 top k smallest
}
3) 合并 K 个有序链表 / 数组(最小堆)
把每个链表/数组的当前元素放入最小堆(存下标或 Node),弹出的就是当前最小,输出并把对应来源的下一个元素入堆。
伪代码示意:
PriorityQueue<Node> pq = new PriorityQueue<>(Comparator.comparingInt(n -> n.val));
for each list i: if (!empty) pq.offer(new Node(list[i], i, idxInList));
while (!pq.isEmpty()) {
Node cur = pq.poll();
output(cur.val);
if (hasNext(cur)) pq.offer(nextNode);
}
4) 滑动窗口的中位数 / Top-k in window
滑动窗口问题也常用堆(通常需要两个堆:小根堆 + 大根堆,维护中位数),或用 TreeMap(支持删除/平衡)。
其他相关点 & 注意事项
- 不要把 null 放入 PriorityQueue(会抛
NullPointerException)。 PriorityQueue的迭代顺序不是有序的,不能依赖迭代器得到排序结果。remove(Object)是 O(n),如果题目需要大量删除堆中任意元素,PriorityQueue不是好选择(可以用TreeMap或自实现带索引的堆)。PriorityQueue非线程安全。并发场景用PriorityBlockingQueue(阻塞队列,java.util.concurrent)。- 当用
Comparator.reverseOrder()时对整数使用Integer.compare(b,a)更安全,避免b-a的溢出。 - 构造
new PriorityQueue<>(collection)会基于 collection 做 heapify,时间 O(n)。
手写二叉堆(如果你想更底层理解)
简短思路:用数组表示堆
- parent(i) = (i-1)/2
- left(i) = 2i+1, right(i) = 2i+2
- 插入:append 到尾部,向上调整(sift-up)
- 弹出堆顶:把尾部放到顶,向下调整(sift-down)
你需要时我可以给完整 ArrayHeap 的实现代码(入门很有帮助)。
例子集合(你可以拷贝运行)
最小堆 / 最大堆 / 自定义对象示例:
import java.util.*;
public class HeapExamples {
public static void main(String[] args) {
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5); minHeap.offer(1); minHeap.offer(3);
System.out.println(minHeap.poll()); // 1
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
maxHeap.offer(5); maxHeap.offer(1); maxHeap.offer(3);
System.out.println(maxHeap.poll()); // 5
// 自定义对象
class Node { int val; int id; Node(int v,int i){val=v;id=i;} }
PriorityQueue<Node> pq = new PriorityQueue<>((a,b) -> {
int c = Integer.compare(a.val, b.val);
return c != 0 ? c : Integer.compare(a.id, b.id);
});
pq.offer(new Node(2,1)); pq.offer(new Node(1,2));
System.out.println(pq.poll().val); // 1
}
}
你常用的“模板”(面试/刷题用)
- 第 k 大(最小堆)模板(简洁):
PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int x : nums) {
pq.offer(x);
if (pq.size() > k) pq.poll();
}
return pq.peek();
在 Java 或者一般数据结构里,构造堆(Heapify/Build Heap) 的时间复杂度常见有两种情况:
1. 逐个插入法(naive build)
如果我们一开始有一个空堆,然后把 n 个元素依次插入堆里,每次插入需要 O(log n) 的调整。
- 总复杂度:
O(nlogn)O(n \log n)
2. 自底向上建堆(heapify)
这是 堆排序 里用的经典方法:
- 我们把
n个元素直接放进数组(完全二叉树结构), - 然后从最后一个非叶子节点开始,逐个执行
siftDown(下沉)。
📌 关键点:大部分节点在树底部,高度很小,因此下沉操作的代价远小于 O(log n)。
经过数学推导(把每层节点数和高度加起来):
时间复杂度=O(n)\text{时间复杂度} = O(n)
结论
- 逐个插入建堆:
O(n log n) - 自底向上建堆(heapify) :
O(n)✅(更优,堆排序和PriorityQueue初始化就是这么干的)
— “构造堆(build-heap)到底是 O(nlogn)O(n\log n) 还是 O(n)O(n)” 是数据结构里一个常被问到且值得深入理解的点。下面我用两种不同的、互相补充的严格推导来证明结论,并说明各自的直观含义与常数因子。结论先给出:
- 逐个插入法(每次
insert/offer)把 n 个元素插入空堆:时间复杂度是 Θ(nlogn)\Theta(n\log n)。 - 自底向上 heapify(把 n 个元素放到数组,然后从最后一个非叶节点向上做 sink/down) :时间复杂度是 Θ(n)\Theta(n)。
接下来详细证明并解释为什么。
一、逐个插入法是 Θ(nlogn)\Theta(n\log n)
做法:把元素逐个 offer(或 add)到空堆。第 ii 次插入时,堆大小大约为 ii,插入代价为上浮(sift-up),最坏需要 O(logi)O(\log i) 次比较/交换(堆高度约 ⌊logi⌋\lfloor\log i\rfloor)。
总时间
T(n)=∑i=1nO(logi).T(n) = \sum_{i=1}^n O(\log i).
上界:
T(n)≤∑i=1nO(logn)=O(nlogn).T(n) \le \sum_{i=1}^n O(\log n) = O(n\log n).
下界也成立(大多数 ii 的 logi\log i 与 logn\log n 同阶),因此
T(n)=Θ(nlogn).T(n) = \Theta(n\log n).
这是最直接也最容易理解的结论:每次插入都要按高度做上浮,平均每个插入付出 Θ(logn)\Theta(\log n) 成本。
二、自底向上 heapify 是 O(n)O(n)
方法描述(常见的 heapify)
把数组 A[1..n]A[1..n] 视为完全二叉树的数组表示,然后从最后一个非叶节点开始(下标 ⌊n/2⌋\lfloor n/2\rfloor),对每个节点执行 siftDown(把该节点下沉到合适位置)。每个 siftDown 的代价与该节点的高度(到叶子的距离)成正比。我们要把所有节点的下沉代价相加。
证明(按高度计数的标准推导)
用高度 hh 表示从节点到叶子的边数(叶子的高度为 00)。对于高度为 hh 的节点,siftDown 最坏需要 O(h)O(h) 的工作量。我们要统计所有高度的节点数乘以高度,再求和。
第 1 步:节点数量的上界。
在一棵完全二叉树中,高度为 hh 的节点数至多 ⌈n/2h+1⌉\lceil n/2^{h+1}\rceil。(直观:离叶子越远的层,节点数成倍减少,精确上界可以取 ≤n/2h+1\le n/2^{h+1} 做上界估算。)
于是总工作量 T(n)T(n) 满足
T(n)≤∑h=0⌊logn⌋(n2h+1)⋅c⋅hT(n) \le \sum_{h=0}^{\lfloor\log n\rfloor} \left(\frac{n}{2^{h+1}}\right) \cdot c\cdot h
其中 cc 为常数(一次比较/交换的常数因子)。把 nn 提出来:
T(n)≤cn∑h=0∞h2h+1.T(n) \le c n \sum_{h=0}^{\infty} \frac{h}{2^{h+1}}.
计算无穷级数(这是几何级数的变种):
∑h=0∞h2h+1=12∑h=0∞h2h=12⋅2=1.\sum_{h=0}^{\infty} \frac{h}{2^{h+1}} = \frac{1}{2}\sum_{h=0}^{\infty} \frac{h}{2^{h}} = \frac{1}{2}\cdot 2 = 1.
(使用公式 ∑h≥0hxh=x(1−x)2\sum_{h\ge0} h x^h = \frac{x}{(1-x)^2},令 x=12x=\tfrac12 得 ∑h/2h=2\sum h/2^h = 2。)
因此
T(n)≤cn⋅1=O(n).T(n) \le c n \cdot 1 = O(n).
这就说明自底向上 heapify 的总代价是线性的(常数 cc 取决于具体实现,但总量与 nn 成正比)。
直观解释:虽然某些节点下沉可能较深(代价高),但这样的节点非常少;而大量节点是接近叶子的,下沉代价很小,所以总和是线性的。
另一种等价且优雅的证明(利用对数和阶乘)
对第 ii 个节点(按某种从左到右编号),其下沉的最大高度与 log(n/i)\log(n/i) 相关。可以上界:
T(n)=O (∑i=1nlog (ni))=O (nlogn−∑i=1nlogi)=O (nlogn−logn!).T(n) = O!\left(\sum_{i=1}^{n} \log!\left(\frac{n}{i}\right)\right) = O!\left(n\log n - \sum_{i=1}^n \log i\right) = O!\left(n\log n - \log n!\right).
利用斯特林近似: logn!=nlogn−n+O(logn)\log n! = n\log n - n + O(\log n),因此
T(n)=O(nlogn−(nlogn−n+O(logn)))=O(n).T(n) = O\big(n\log n - (n\log n - n + O(\log n))\big) = O(n).
这个推导更偏代数方法,但同样得出线性界。
三、常数因子、精确常数(实践意义)
- 上面高度级数法的展开实际上给出了常数因子上界:T(n)≤c⋅n⋅1T(n) \le c \cdot n \cdot 1,也就是 T(n)=O(n)T(n) = O(n) 并且常数较小(通常常数在 2 左右或更小,具体取决于比较/交换实现)。所以 heapify 在实践中往往比逐次插入快很多。
PriorityQueue的构造函数new PriorityQueue<>(Collection)在 Java 中就是用 heapify(实际上以 O(n) 的方式初始化内部数组并做下沉),因此直接用集合构造优先队列是线性的,而不是把元素逐个offer(那样是 O(nlogn)O(n\log n))。- 若你逐个
offer(比如从空堆开始不断offer),那是 O(nlogn)O(n\log n)。但若已知所有元素,把它们放进数组然后一次 heapify,则是 O(n)O(n),对大数据很重要。
四、总结与对比
-
逐个插入(每次
offer):- 复杂度:Θ(nlogn)\Theta(n\log n)
- 适用场景:动态逐步添加元素(不能一次拿到所有元素)。
-
自底向上 heapify(给定全部数据):
- 复杂度:Θ(n)\Theta(n)
- 适用场景:已知整个集合且想一次性构造堆(例如
new PriorityQueue<>(collection))。
-
两种方法在常数因子和实现细节上也有差别;实际中若能一次性 heapify,通常总时间更短。