2.java中堆的详解

56 阅读10分钟

基本概念(直观)

  • 堆(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(nlog⁡n)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(nlog⁡n)O(n\log n) 还是 O(n)O(n)” 是数据结构里一个常被问到且值得深入理解的点。下面我用两种不同的、互相补充的严格推导来证明结论,并说明各自的直观含义与常数因子。结论先给出:

  • 逐个插入法(每次 insert/offer)把 n 个元素插入空堆:时间复杂度是 Θ(nlog⁡n)\Theta(n\log n)。
  • 自底向上 heapify(把 n 个元素放到数组,然后从最后一个非叶节点向上做 sink/down) :时间复杂度是 Θ(n)\Theta(n)。

接下来详细证明并解释为什么。


一、逐个插入法是 Θ(nlog⁡n)\Theta(n\log n)

做法:把元素逐个 offer(或 add)到空堆。第 ii 次插入时,堆大小大约为 ii,插入代价为上浮(sift-up),最坏需要 O(log⁡i)O(\log i) 次比较/交换(堆高度约 ⌊log⁡i⌋\lfloor\log i\rfloor)。

总时间

T(n)=∑i=1nO(log⁡i).T(n) = \sum_{i=1}^n O(\log i).

上界:

T(n)≤∑i=1nO(log⁡n)=O(nlog⁡n).T(n) \le \sum_{i=1}^n O(\log n) = O(n\log n).

下界也成立(大多数 ii 的 log⁡i\log i 与 log⁡n\log n 同阶),因此

T(n)=Θ(nlog⁡n).T(n) = \Theta(n\log n).

这是最直接也最容易理解的结论:每次插入都要按高度做上浮,平均每个插入付出 Θ(log⁡n)\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⌊log⁡n⌋(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 ⁣(nlog⁡n−∑i=1nlog⁡i)=O ⁣(nlog⁡n−log⁡n!).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).

利用斯特林近似: log⁡n!=nlog⁡n−n+O(log⁡n)\log n! = n\log n - n + O(\log n),因此

T(n)=O(nlog⁡n−(nlog⁡n−n+O(log⁡n)))=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(nlog⁡n)O(n\log n))。
  • 若你逐个 offer(比如从空堆开始不断 offer),那是 O(nlog⁡n)O(n\log n)。但若已知所有元素,把它们放进数组然后一次 heapify,则是 O(n)O(n),对大数据很重要。

四、总结与对比

  • 逐个插入(每次 offer):

    • 复杂度:Θ(nlog⁡n)\Theta(n\log n)
    • 适用场景:动态逐步添加元素(不能一次拿到所有元素)。
  • 自底向上 heapify(给定全部数据):

    • 复杂度:Θ(n)\Theta(n)
    • 适用场景:已知整个集合且想一次性构造堆(例如 new PriorityQueue<>(collection))。
  • 两种方法在常数因子和实现细节上也有差别;实际中若能一次性 heapify,通常总时间更短。