一、堆是什么?先甩掉那些吓人的定义
想象你是一家奶茶店的老板,店里有个排队系统。顾客休息区有一排座位,但你有一个硬规矩:坐第一个位置的人永远是“优先级最高”的那位——比如老人、孕妇、还有外卖小哥。每次有人离开,后面的人就自动补上。新来的客人进门后,不会随便找个位子坐,而是从队伍尾巴开始,跟前面的人比“优先级”,如果自己更急,就往前挪。这个“自动维持秩序”的机制,就是堆的核心思想。
堆在计算机里是一棵完全二叉树。什么叫完全二叉树?简单说,除了最后一层,每一层都必须塞满,最后一层的节点都靠左排列,不能有“豁牙”。为什么非要这造型?因为完全二叉树可以用数组一个不浪费地存下来,而且父子关系通过下标就能算出来,连指针都不用存。
具体来说,如果你把树的根节点放在数组的第0位(有的语言喜欢放第1位,这个后面会提醒),那么对于下标为 i 的节点,有这么几个神一样的公式:
- 左孩子下标 =
2 * i + 1 - 右孩子下标 =
2 * i + 2 - 父节点下标 =
(i - 1) / 2然后向下取整
只要记住这三条,你手里就等于拿了一张整棵树的“寻宝图”。比如数组 [3, 8, 6, 12, 10],下标0是根,下标1和2是两个孩子,下标3和4是下标1的孩子。你完全可以不用画图,直接在脑子里把这棵树立起来。
1.1 大顶堆和小顶堆
堆还有个重要约定:大顶堆要求每个节点都比它的孩子大(或相等),所以根节点是最大值;小顶堆相反,每个节点都比它的孩子小,所以根节点是最小值。这一条约定,让“堆顶永远是最值”成了铁打的定律。也正是这个特性,让堆成了实现优先队列的天然选择。
二、数组存储机制:为什么能用数组装下一棵树
我们之间的对话,配合代码才够味儿。下面统一使用 JavaScript(ES6+)来演示。先写一个最小堆的基础骨架,把刚才那几个下标公式变成代码。
// 技术栈:JavaScript(ES6+)
class MinHeap {
constructor() {
this.heap = []; // 装树的数组
}
// 父节点下标
getParentIndex(i) {
return Math.floor((i - 1) / 2);
}
// 左孩子下标
getLeftIndex(i) {
return 2 * i + 1;
}
// 右孩子下标
getRightIndex(i) {
return 2 * i + 2;
}
// 看一眼堆顶,不删除
peek() {
return this.heap.length > 0 ? this.heap[0] : null;
}
// 交换数组里两个位置的值
swap(i, j) {
[this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
}
}
你看,代码里没有保存任何“左孩子指针”或者“右孩子指针”,全靠下标现算。这就是堆省内存的秘密:几乎没有额外开销。
三、上浮操作:新元素怎么“走”到正确的位置
往堆里加一个元素,最省事的思路是:先硬塞到数组尾部,然后让它像气泡一样“往上冒”,直到重新满足堆性质。这个过程叫上浮,英语叫 bubble up 或 sift up。
上浮的规则很简单:小顶堆里,如果新节点比父节点小,就跟父节点交换,然后继续跟更上层的父节点比,直到比对方大或者到了根。
3.1 插入元素完整示例
// 技术栈:JavaScript(ES6+)
class MinHeap {
constructor() {
this.heap = [];
}
// 父节点下标
getParentIndex(i) {
return Math.floor((i - 1) / 2);
}
// 左孩子下标
getLeftIndex(i) {
return 2 * i + 1;
}
// 右孩子下标
getRightIndex(i) {
return 2 * i + 2;
}
swap(i, j) {
[this.heap[i], this.heap[j]] = [this.heap[j], this.heap[i]];
}
// 插入一个新值
insert(value) {
this.heap.push(value); // 先放到数组末尾
this.bubbleUp(this.heap.length - 1); // 从最后一个位置开始上浮
}
// 上浮过程
bubbleUp(index) {
while (index > 0) {
const parent = this.getParentIndex(index);
// 如果当前节点比父节点小,就交换
if (this.heap[index] < this.heap[parent]) {
this.swap(index, parent);
index = parent; // 往上继续检查
} else {
break; // 位置对了,直接收工
}
}
}
}
// 使用示例
const heap = new MinHeap();
heap.insert(10);
heap.insert(5);
heap.insert(3);
heap.insert(12);
heap.insert(1);
console.log(heap.heap); // 输出 [1, 5, 3, 12, 10],满足最小堆
插入的时间复杂度是 O(log n)。因为最坏情况也就是从叶子一路爬到根,爬过的层数就是树的高度。
四、下沉操作:删除堆顶后如何“补位”
优先队列里最常用的操作是“取出最值并删除”。如果直接把根节点拿走,整棵树就碎了一地。标准做法是:把数组最后一个元素搬到根节点,然后让这个“替补队员”往下沉,跟两个孩子里的较小者比,如果它比孩子大,就换下去,直到合适的位置。这个过程叫下沉,也叫 sift down。
4.1 删除堆顶完整示例
// 技术栈:JavaScript(ES6+)
class MinHeap {
constructor() {
this.heap = [];
}
// 省略 getParentIndex、getLeftIndex、getRightIndex、swap 等基础方法,实现时可参考上文
// 删除并返回堆顶元素
extractMin() {
if (this.heap.length === 0) return null;
if (this.heap.length === 1) return this.heap.pop();
const min = this.heap[0]; // 先存下最小值
this.heap[0] = this.heap.pop(); // 把最后一个元素挪到根
this.sinkDown(0); // 让它下沉到该去的位置
return min;
}
// 下沉过程
sinkDown(index) {
const size = this.heap.length;
while (true) {
const left = this.getLeftIndex(index);
const right = this.getRightIndex(index);
let smallest = index;
// 找父、左、右里最小的那个
if (left < size && this.heap[left] < this.heap[smallest]) {
smallest = left;
}
if (right < size && this.heap[right] < this.heap[smallest]) {
smallest = right;
}
// 如果需要交换,就换,然后继续往下
if (smallest !== index) {
this.swap(index, smallest);
index = smallest;
} else {
break; // 到位了
}
}
}
}
// 使用示例
const heap = new MinHeap();
[10, 5, 3, 12, 1].forEach(v => heap.insert(v));
console.log(heap.extractMin()); // 输出 1
console.log(heap.heap); // 输出 [3, 5, 10, 12],依然是最小堆
下沉操作的时间复杂度也是 O(log n)。注意我们是用“最后一个元素”来补位,没有把数组中间的元素往前挪,所以弹性能量很高。
五、线性时间建堆:把数组变成堆的高效方式
如果你想从一个现有数组直接建堆,最朴素的方式是逐个调用 insert,时间复杂度是 O(n log n)。但更聪明的做法是“自底向上下沉”,时间复杂度能做到 O(n)。这个结论是不是很反直觉?别急,我解释一下。
5.1 为什么能到 O(n)
假设数组长度为 n。建堆时,我们从最后一个非叶子节点开始,往前逐个下沉。叶子节点数量多,但不需要处理;越靠近根部的节点数量越少,而且每个节点的下沉代价跟它的“高度”有关。把所有这些代价加起来,是一个常数乘以 n,而不是 n 个 log n。数学上可以严格证明,这里不展开公式轰炸,你记住结论:用对方法,建堆是线性时间。
5.2 真实代码:从数组构建最小堆
// 技术栈:JavaScript(ES6+)
function buildMinHeap(arr) {
const size = arr.length;
// 从最后一个非叶子节点开始,往前挨个下沉
for (let i = Math.floor(size / 2) - 1; i >= 0; i--) {
heapify(arr, i, size);
}
return arr;
}
// 对下标 i 执行下沉
function heapify(arr, i, size) {
let smallest = i;
const left = 2 * i + 1;
const right = 2 * i + 2;
if (left < size && arr[left] < arr[smallest]) smallest = left;
if (right < size && arr[right] < arr[smallest]) smallest = right;
if (smallest !== i) {
[arr[i], arr[smallest]] = [arr[smallest], arr[i]]; // 交换
heapify(arr, smallest, size); // 继续下沉
}
}
const result = buildMinHeap([12, 5, 8, 3, 10, 1]);
console.log(result); // 输出合法的最小堆数组,例如 [1, 3, 8, 12, 10, 5]
上面用到了递归。设计生产代码时,大数组可能造成调用栈过深,我建议你改成循环版本。原理完全一样,代码稍长一点,但更稳妥。
六、实际工程应用场景
堆在工程里遍地都是,讲几个你可能已经踩过的场景。
6.1 优先队列
浏览器的事件循环、操作系统的进程调度,背后都有优先队列的身影。比如 Node.js 里的定时器,底层就用了一个最小堆来管理。定时时间最小的任务排在堆顶,时间一到,立刻从堆里拿出并执行。如果每个定时器都开一个数组去遍历,早就卡成幻灯片了。
6.2 Top K 问题
比如从一亿条访问日志里找出访问量最大的前 100 个 IP。如果你把一亿条全塞进内存排序,机器大概率当场冒烟。换个姿势:维护一个大小为 100 的小顶堆,每来一个新数据,就跟堆顶比较。如果新数据比堆顶大,就替换掉堆顶,然后下沉。这样一来,堆里永远是当前遇到的最大 100 个,最后堆顶就是第 100 大的那个。时间复杂度 O(n log 100),差不多等于线性。
// 技术栈:JavaScript(ES6+)
// 用最小堆找 Top K 大元素,这里直接用一个简化版
function topKLargest(nums, k) {
const minHeap = [];
function push(val) {
if (minHeap.length < k) {
// 堆没满,直接加,然后上浮
minHeap.push(val);
let i = minHeap.length - 1;
while (i > 0) {
const parent = Math.floor((i - 1) / 2);
if (minHeap[i] < minHeap[parent]) {
[minHeap[i], minHeap[parent]] = [minHeap[parent], minHeap[i]];
i = parent;
} else break;
}
} else if (val > minHeap[0]) {
// 比堆顶大,替换堆顶,然后下沉
minHeap[0] = val;
let i = 0;
while (true) {
let smallest = i;
const l = 2 * i + 1;
const r = 2 * i + 2;
if (l < k && minHeap[l] < minHeap[smallest]) smallest = l;
if (r < k && minHeap[r] < minHeap[smallest]) smallest = r;
if (smallest !== i) {
[minHeap[i], minHeap[smallest]] = [minHeap[smallest], minHeap[i]];
i = smallest;
} else break;
}
}
}
nums.forEach(n => push(n));
return minHeap;
}
console.log(topKLargest([4, 1, 7, 9, 3, 8, 2], 3)); // 输出可能是 [7, 9, 8]
6.3 堆排序
堆排序就是“建堆 + 反复取堆顶”的合体。先建一个最大堆,然后把根节点和末尾元素交换,再把堆的规模缩小一,最后对新根做下沉。反复做,数组就从后往前排好了。这个算法不稳定,但是空间复杂度 O(1),在嵌入式等内存受限的环境里非常香。
七、技术优缺点和常见陷阱
7.1 优点
- 插入和删除最值稳定在 O(log n),最坏情况也不慌。
- 数组存储,缓存友好,内存占用低。
- 能在线性时间内完成建堆。
- 适合处理动态数据流,比如实时排行榜、任务调度。
7.2 缺点
- 只能快速拿到最值,想找任意元素?得遍历。
- 删除任意值需要额外维护“索引映射”,不然你根本不知道它藏在数组哪个角落。
- 堆排序的常数比快排大,实际速度不一定赢过内置排序。
- 完全二叉树的高度决定了操作次数,数据量上亿时递归写法容易爆栈。
7.3 常见陷阱(重点看)
陷阱一:下标从 0 还是从 1 开始。 很多经典教材用从 1 开始的下标,左孩子是 2*i,右孩子是 2*i+1。你从网上抄代码时,先看清楚这段代码的下标约定。如果混着用,数组越界是家常便饭。我推荐统一使用从 0 开始,代码里注释写清楚。
陷阱二:下沉时比较哪个孩子? 小顶堆下沉,必须和孩子中“最小”的那个比,不能只盯着左孩子。如果漏了右孩子,堆的性质就守不住。我见过太多新手在这里翻车,明明写的像模像样,取最值却不对。
陷阱三:建堆的起始下标。 最后一个非叶子节点的下标是 Math.floor(n / 2) - 1,不是 Math.floor(n / 2)。差一个位置,可能导致部分节点根本没参与下沉,堆是假的。
陷阱四:对象属性比较。 如果堆里存的是对象,比如任务对象 { name: '写代码', priority: 1 },你让 JavaScript 直接比较两个对象?它会把对象转成字符串 "[object Object]",那结果简直没法看。你必须显式指定比较哪个字段。
// 技术栈:JavaScript(ES6+)
// 比较两个任务对象时,应该使用 priority 字段
function compareTask(a, b) {
return a.priority - b.priority; // 小于 0 说明 a 优先级更高
}
陷阱五:堆输出不是有序数组。 堆顶是最值,但你不能随便遍历就拿到有序序列,因为堆不是二叉搜索树。想要有序结果,得反复 extractMin(),那其实就是堆排序了。
7.4 应用注意事项
动手用堆之前,先问自己三个问题:我需要频繁取最值吗?我需要随机查找吗?数据量大到不能直接排序吗?如果前两个回答是“是”,第三个也是“是”,堆基本就是你的菜。如果只是偶尔取一次最值,直接 Math.max(...arr) 或者排序,反而更省事。
另外,如果你需要频繁合并两个堆,普通二叉堆就有点吃力了。可以考虑二项堆或斐波那契堆,但工程中很少真要你手写,了解有这回事就好。
八、手把手写一个完整的最小堆(收尾示例)
为了让你有一个能直接复制粘贴运行的完整参考,我把前面那些零散的代码凑成一份,加上注释,你可以在浏览器控制台或 Node.js 里跑一跑。
// 技术栈:JavaScript(ES6+)
class MinHeap {
constructor(arr = []) {
this.heap = [];
// 通过现有数组建堆
for (const item of arr) {
this.insert(item);
}
}
size() {
return this.heap.length;
}
isEmpty() {
return this.heap.length === 0;
}
peek() {
return this.heap.length > 0 ? this.heap[0] : undefined;
}
insert(value) {
this.heap.push(value);
this._bubbleUp(this.heap.length - 1);
}
extractMin() {
if (this.isEmpty()) return undefined;
if (this.heap.length === 1) return this.heap.pop();
const min = this.heap[0];
this.heap[0] = this.heap.pop();
this._sinkDown(0);
return min;
}
// 上浮
_bubbleUp(index) {
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (this.heap[index] < this.heap[parent]) {
[this.heap[index], this.heap[parent]] = [this.heap[parent], this.heap[index]];
index = parent;
} else {
break;
}
}
}
// 下沉
_sinkDown(index) {
while (true) {
const left = 2 * index + 1;
const right = 2 * index + 2;
let smallest = index;
if (left < this.heap.length && this.heap[left] < this.heap[smallest]) {
smallest = left;
}
if (right < this.heap.length && this.heap[right] < this.heap[smallest]) {
smallest = right;
}
if (smallest !== index) {
[this.heap[index], this.heap[smallest]] = [this.heap[smallest], this.heap[index]];
index = smallest;
} else {
break;
}
}
}
}
// 演示全部操作
const heap = new MinHeap([8, 3, 9, 1, 7]);
heap.insert(0);
console.log(heap.peek()); // 0
console.log(heap.extractMin()); // 0
console.log(heap.extractMin()); // 1
console.log(heap.heap); // 剩余元素依然满足最小堆性质
九、总结
堆是一棵用数组装的完全二叉树,通过上浮和下沉两个动作维护秩序。它牺牲了随机查找的能力,换来了极致的取最值效率。工程上,优先队列、任务调度、TopK、堆排序都离不开它。你只需要抓住四个关键词:完全二叉树、数组存储、上浮下沉、线性建堆。把今天这份代码吃透,以后不管是用 JavaScript 还是换到别的语言,核心心智模型都是一样的。
最后再多说一句:如果你在业务代码里需要优先队列,请优先使用语言自带的高质量实现,比如 Python 的 heapq、Java 的 PriorityQueue、JavaScript 里成熟的库,别重复造轮子。但面试或者底层优化的时候,能把这套逻辑手写出来,才是真正的本事。希望这篇“人话版”解读,能让你对堆产生“原来如此”的清爽感,而不是被一堆术语劝退。
Comments