先记住这个答案
堆是一个抽象概念,通常指满足堆性质的树形结构;二叉堆是堆最常见的具体实现,用数组存储完全二叉树。优先级队列更抽象,它只要求每次取出最大或最小元素。面试中默认优先级队列为二叉堆,因为它在插入、删除最值上都是O(log n),且能原地建堆、动态维护,内存和时间开销低。
- 堆是抽象性质,二叉堆是具体实现
- 优先级队列默认二叉堆因复杂度低且省内存
- 二叉堆用数组存储完全二叉树,非二叉堆形态
堆是规则,二叉堆是执行规则的方式
堆(Heap)不规定树的具体形态,只规定偏序关系:任意节点的键值不小于(或不大干)其全部后代,称为最大堆或最小堆。抽象堆的接口通常包含插入、取最值、删除最值,可能还有合并等,但不限定内部组织。二叉堆是实现这些行为的一种经典方案,它额外要求结构是一棵完全二叉树,并用数组的连续索引来表示父子关系。
数组表示让二叉堆索引规则简单直接:从下标1开始时,节点i的左子为2i、右子为2i+1,父节点为⌊i/2⌋。插入时在尾部放入新元素并执行上滤(sift-up),弹出堆顶时用尾部元素补位并执行下滤(sift-down)。上滤和下滤每次只沿一条路径移动,复杂度是O(log n),而完全二叉树保证了树高为⌊log₂n⌋。这种紧凑存储避免了指针开销,也提升了缓存命中率,因而成为优先级队列的默认工程实现。
任务调度:用二叉堆维护最高优先级
假设一个定时任务系统维护10万条待执行任务,每条任务带有触发时间和序号。每时每刻需要取出触发时间最小的任务执行,并在执行后可能插入新任务。若用无序数组,每次选最小需要扫全量,代价O(n);若用有序数组,插入可能移动大量元素。用最小二叉堆实现优先级队列:用时间戳作为键,序号作为次级键(用于稳定性),插入和弹出都是O(log n)。
在10万规模下,实现一个可扩展的PriorityQueue类,内部使用数组存储元素。每次tick执行:若堆顶时间戳小于当前时间则弹出并执行回调,然后插入新延时任务。堆顶始终是全局最早触发的任务,这就是二叉堆作为优先级队列调度器的典型模型。现场试验中,堆化建堆需要O(n),单次操作仅需几十次比较,远超线性扫描方案。
class PriorityQueue {
constructor(compare = (a, b) => a < b) {
this.arr = [null];
this.compare = compare;
}
push(x) {
const a = this.arr;
a.push(x);
let i = a.length - 1;
while (i > 1 && this.compare(a[i], a[i >> 1])) {
[a[i], a[i >> 1]] = [a[i >> 1], a[i]];
i >>= 1;
}
}
pop() {
const a = this.arr;
const top = a[1];
const last = a.pop();
if (a.length > 1) {
a[1] = last;
let i = 1;
while (true) {
const l = i << 1, r = l + 1;
let small = i;
if (l < a.length && this.compare(a[l], a[small])) small = l;
if (r < a.length && this.compare(a[r], a[small])) small = r;
if (small === i) break;
[a[i], a[small]] = [a[small], a[i]];
i = small;
}
}
return top;
}
top() { return this.arr[1]; }
size() { return this.arr.length - 1; }
}
// 测试三组边界
let pq = new PriorityQueue((a, b) => a.priority < b.priority);
pq.push({priority: 5, id: 'a'});
pq.push({priority: 1, id: 'b'});
pq.push({priority: 3, id: 'c'});
console.log(JSON.stringify(pq.pop())); // 最小优先
pq.push({priority: -2, id: 'd'});
console.log(JSON.stringify(pq.pop())); // 新最小
pq.push({priority: 0, id: 'e'});
console.log(JSON.stringify(pq.pop()));
console.log('size = ' + pq.size());查看输出与解释
{"priority":1,"id":"b"}
{"priority":-2,"id":"d"}
{"priority":0,"id":"e"}
size = 2这个实现展示二叉堆的插入和弹出,使用从下标1开始的语法。比较器决定最小堆或最大堆。数组第一个元素置空避免索引混乱。
什么时候二叉堆不再合适
二叉堆的代价主要来自两方面:一是它仅能高效支持取极值,如果需要查找堆中任意元素或修改其优先级,需要遍历,因为数组存储是无序的。二是在合并两个堆时,二叉堆只能把其中一个逐个插入另一个,复杂度O(n log m),缺少可并堆的O(log n)合并。许多语言库(如C++的std::priority_queue)正是基于这种局限性,不提供遍历和合并接口。
另外,实现优先级队列时若输入规模极小(如几十个元素),二叉堆的常数未必优于线性扫描,因为每次操作都有对数比较和层间交换。如果数据流中的极值窗口需要滑动删除过期的旧元素,二叉堆必须配合懒删除或额外索引;否则堆顶的过期元素会长时间阻塞队列。此时可以考虑使用平衡树或多级队列。
容易答错的地方
- 误认为堆就是二叉堆
- 堆是抽象数据结构,只要求父子间满足特定序,不限制实现方式。二项堆、斐波那契堆都属于堆家族,但结构完全不同。题目特别指出不展开可并堆,二叉堆只是最常考、默认的一种。
- 以为优先级队列底层一定是二叉堆
- 优先级队列只是一种接口定义。未规定实现时可用数组、链表甚至有序向量完成,但二叉堆在综合时间、空间和实现简单性上胜出,因此很多语言默认使用。Java的PriorityQueue、C++的priority_queue都基于二叉堆,但不代表不可替代。
面试官还会怎么问?
二叉堆的下标从1开始和从0开始有什么差异?
下标从1开始能保留array[0]不用,父子公式为2i和2i+1,简洁直观;从0开始时左子为2i+1、右子为2i+2,父节点为(i-1)/2,也能工作但每步多一次加减。边界检查上,0开始更符合多数语言习惯,但公式略多。
为什么堆化建堆比连续插入快?
Floyd自底向上堆化时间复杂度为O(n),而连续插入每个元素都要从底部上滤,最坏每个O(log n),总O(n log n)。堆化通过从最后一个非叶子节点开始向下调整,高层的下滤次数有限,累计工作量线性。
优先级队列中相同优先级的元素顺序稳定吗?
标准二叉堆不稳定。相同优先级元素的相对顺序可能在删除和插入过程中变化,因为堆的下滤和上滤只比较键值,不保留插入序。若要求按到达顺序弹出,必须给元素额外记录递增序号作为复合键。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。