2722 字
约 9 分钟
13
S03-X11 · 堆与优先队列的典型用法

堆的接口只有 offer / poll / peek,用起来像个干净的抽象;出问题的地方几乎全在抽象之外:建堆方式选错、比较器写错、把非线程安全当线程安全、以为能有序遍历。这些都不会抛异常,只会让结果慢慢偏掉。

先把四条结论摆出来:PriorityQueue 是小顶堆、迭代器无序、非线程安全、删除中间元素是 O(n)。记住这四条,下面九成的坑不必踩。


1. 堆的性质与数组表示

堆是"完全二叉树 + 堆序":每层填满才往下一层放,且每个节点都不大于(小顶)或不小于(大顶)它的孩子。完全二叉树这一条让它能平铺进数组,不需要指针。

// 下标关系:0-based
int parent(int i) { return (i - 1) >>> 1; }
int left(int i)   { return (i << 1) + 1; }
int right(int i)  { return (i << 1) + 2; }
关系 0-based 公式 说明
父 / 左 / 右 (i-1)/2、2i+1、2i+2 孩子下标 ≥ n 即不存在
最后一个非叶节点 n/2 - 1 建堆下沉的起点
高度 ⌊log₂n⌋ 决定 offer/poll 的 O(log n)

堆序只保证"根是极值",不保证任何其他顺序。所以"第二大元素在哪"没有答案——可能是根的任一孩子,也可能更深。


2. 上浮、下沉与两种建堆

static void swap(int[] h, int i, int j) { int t = h[i]; h[i] = h[j]; h[j] = t; }

static void siftUp(int[] h, int i) {                 // 插入
    while (i > 0 && h[(i - 1) >>> 1] > h[i]) {
        int p = (i - 1) >>> 1;
        swap(h, p, i);
        i = p;
    }
}

static void siftDown(int[] h, int i, int n) {        // 删堆顶与建堆
    while (true) {
        int l = (i << 1) + 1, r = l + 1, m = i;
        if (l < n && h[l] < h[m]) m = l;
        if (r < n && h[r] < h[m]) m = r;
        if (m == i) break;                           // 已是三者最小
        swap(h, m, i);
        i = m;
    }
}

插入走上浮(新元素放末尾,逆序就与父亲交换);删堆顶走下沉(末尾元素搬到根,再一路和较小的孩子换)。

建堆有两种写法,代价差一个 log:

方式 代码 复杂度 说明
逐个插入 for (int x : a) { h[n++] = x; siftUp(h, n-1); } O(n log n) 每次插入最多上浮到根
自底向下沉 for (int i = n/2 - 1; i >= 0; i--) siftDown(h, i, n); O(n) 大部分节点在底层,下沉距离很短

O(n) 的来源值得记住:高度 h 的节点约 n/2^(h+1) 个,每个最多下沉 h 步,Σ h·n/2^(h+1) 收敛到 O(n)。代价按节点高度而非节点数计,绝大多数节点根本不下沉。

static void heapify(int[] h) {
    for (int i = h.length / 2 - 1; i >= 0; i--) siftDown(h, i, h.length);
}

i 必须从 n/2 - 1 递减,保证下沉时两棵子树已经是堆。


3. PriorityQueue 的默认行为与比较器

默认是小顶堆,按自然序(Comparable)比较。要大顶堆有几种写法,选哪种只看可读性与溢出风险:

PriorityQueue<Integer> min = new PriorityQueue<>();                      // 小顶(默认)
PriorityQueue<Integer> max1 = new PriorityQueue<>(Comparator.reverseOrder());
PriorityQueue<Integer> max2 = new PriorityQueue<>((a, b) -> Integer.compare(b, a));

其中 reverseOrder() 最该优先用;-x 与存负数都有溢出隐患(Integer.MIN_VALUE 取负还是它自己),只适合值域已知为正的场合,别进主干代码。

绝对不要写 (a, b) -> a - b。差值在 int 上会溢出:a = 2_000_000_000, b = -2_000_000_000 时结果溢出成负数,比较器给出反序,堆序当场坏掉——而 poll 照样返回"某个值",只是不是极值。

自定义对象要小心两件事:比较器必须全序且与 equals 一致(否则行为未定义),以及入堆后字段不能再变。


4. Top-K 与数据流第 K 大

求前 K 大,直觉是"大顶堆",正确做法却是大小为 K 的小顶堆:

static int[] topK(int[] nums, int k) {
    PriorityQueue<Integer> heap = new PriorityQueue<>(k + 1);   // 小顶堆
    for (int x : nums) {
        heap.offer(x);
        if (heap.size() > k) heap.poll();       // 弹掉当前第 k+1 大的
    }
    int[] out = new int[heap.size()];
    for (int i = out.length - 1; i >= 0; i--) out[i] = heap.poll();
    return out;
}

反直觉点的解释:堆顶是候选集里最小的那个,也就是第 K 大的守门员。新元素比守门员大才有资格进来,进来后旧守门员被弹掉。堆里始终只有 K 个元素,内存 O(K)、时间 O(n log K)。

方案 时间 额外空间 何时用
全排序 O(n log n) O(n) K 接近 n,或本来就要全序
大小 K 的堆 O(n log K) O(K) K 远小于 n、数据流、内存受限
快速选择 期望 O(n),最坏 O(n²) O(1) 一次性、可改动原数组

数据流的第 K 大就是同一结构长期驻留:来一个 offer 一个,超了就 poll,任意时刻 peek() 即第 K 大。要同时支持第 K 大与第 K 小,就维护两个堆并保持总量为 K。


5. 合并 K 个有序链表与多路归并

static ListNode mergeK(ListNode[] lists) {
    PriorityQueue<ListNode> pq = new PriorityQueue<>(Comparator.comparingInt(n -> n.val));
    for (ListNode h : lists) if (h != null) pq.offer(h);
    ListNode dummy = new ListNode(0), tail = dummy;
    while (!pq.isEmpty()) {
        ListNode n = pq.poll();
        tail.next = n;
        tail = n;
        if (n.next != null) pq.offer(n.next);      // 只补回刚被取走那条链的下一个
    }
    return dummy.next;
}

这就是多路归并:堆里始终只有 K 个"各链当前头",每次取全局最小、再补一个,代价 O(N log K)。K = 2 时它退化成两两归并、常数更小,所以两路归并不需要堆。不用全排序的原因是规模:多路归并是流式的,只需 K 个指针常驻。


6. 延迟队列与惰性删除

定时任务的最小实现是把"到期时间"当 key 放进堆:到期的 take 出来执行,没到期就等。

final class DelayTask implements Delayed {
    private final long expireAt = System.nanoTime() + delayNanos;   // 绝对时间戳
    public long getDelay(TimeUnit u) { return u.convert(expireAt - System.nanoTime(), TimeUnit.NANOSECONDS); }
    public int compareTo(Delayed o) { return Long.compare(expireAt, ((DelayTask) o).expireAt); }
}
DelayQueue<DelayTask> queue = new DelayQueue<>();   // take() 会阻塞到最早任务到期

生产代码更常见的是惰性删除:取消时不去堆里删(那是 O(n) 还要加锁),只在取消集合里记一笔,出堆时检查并丢弃。

堆在定时场景的局限很清楚:它只能高效地取最小、删最小。业务一旦涉及"随机取消",只能靠惰性删除兜底,废弃任务堆积会让堆无谓变大。


7. 双堆求中位数与快速选择的取舍

中位数是"动态求第 K 小、且 K 随规模变化",堆正合适:大顶堆存较小的一半,小顶堆存较大的一半,两个堆顶就是中位数的候选。

final class MedianFinder {
    private final PriorityQueue<Integer> lo = new PriorityQueue<>(Comparator.reverseOrder()); // 较小半,大顶
    private final PriorityQueue<Integer> hi = new PriorityQueue<>();                          // 较大半,小顶
    void add(int x) {
        if (lo.size() == hi.size()) { lo.offer(x); hi.offer(lo.poll()); }
        else { hi.offer(x); lo.offer(hi.poll()); }        // 保证 hi.size() ∈ {lo.size(), lo.size()+1}
    }
    double median() { return lo.size() == hi.size() ? (lo.peek() + hi.peek()) / 2.0 : hi.peek(); }
}

不变量只有一条:lo.size() <= hi.size() <= lo.size() + 1,且 lo 的每个元素 ≤ hi 的每个元素。add 里"先进后出"(offer 进去再弹出来)就是在强制这条不变量。

选型很干脆:一次性求第 K 小用快速选择或排序(期望 O(n)、无需额外结构);数据流持续插入、随时要中位数用双堆(插入 O(log n)、查询 O(1))。快速选择的代价藏在最坏情况:枢轴总取到端点时退化成 O(n²),随机枢轴只是压概率而非保证。


8. 排错:六种"取出来的不是极值"

现象 原因 处理
比较器偶发反序、结果乱 (a, b) -> a - b 差值溢出 改 Integer.compare / Comparator.comparingInt
改了入堆对象的字段后顺序全乱 堆序在入堆时确定,改字段不会重排 对象入堆后视为不可变,要改就先删再插
并发下丢元素或 size 异常 PriorityQueue 非线程安全 换 PriorityBlockingQueue 或外部加锁
remove(x) 很慢 按 equals 线性查找,O(n) 改惰性删除,或用 TreeMap / 索引堆
遍历得到乱序 iterator() 按数组下标遍历 要顺序就反复 poll
双堆查询差一 两堆大小差超过 1 add 里统一走"先进后出"

遍历那条值得单独强调:PriorityQueue 的迭代器只保证"每个元素出现一次",完全不承诺顺序。日志里 pq.toString() 乱序不是 bug,拿它当有序列表用才是。


9. 边界:什么需求堆本来就不合适

需求 更好的结构 原因
只要区间最值、还要滑动删除 单调队列 / 线段树 堆删非堆顶元素是 O(n)
频繁按键取值、要前驱后继 TreeMap 天然有序 + O(log n) 删除
只需要一次性最小/最大 一次线性扫描 建堆是 O(n),但常数更大
优先级队列 + 阻塞语义 PriorityBlockingQueue 自带锁与条件等待
定时任务数万以上 时间轮 堆的 O(log n) 与锁竞争成为瓶颈
需要同优先级稳定出队 比较器加序号做次关键字 堆本身不保证稳定性
元素几乎有序 插入排序 近似有序时它是 O(n + 逆序对)

最后一条最常被忽略:元素近乎有序时,堆是拿优势换通用性——插入排序在这种输入上是 O(n + 逆序对),堆仍是 O(n log n)。


10. 自测

  1. 堆的数组表示里,为什么建堆要从 n/2 - 1 递减,而不是从 0 递增?
  2. 自底向下的 heapify 为什么是 O(n),而逐个插入是 O(n log n)?代价是按什么求和的?
  3. 求前 K 大为什么用小顶堆?堆顶在算法里扮演什么角色?
  4. (a, b) -> a - b 在什么输入下会让堆序坏掉?坏掉后 poll 会抛异常吗?
  5. 为什么 PriorityQueue 的 iterator() 不能当作有序遍历用?要按序取出该怎么做?
  6. 定时任务需要"随机取消",堆为什么不适合?替代结构是什么?
S03-X11 · 堆与优先队列的典型用法
https://www.clxhxhhr.top/posts/5879/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。