如果你是个刚学完线性表就开始刷题的程序员,大概率会遇到这样一个困惑:明明数据结构教材里说堆(Heap)是一种树形结构,可真正动手实现的时候,老师、教材、甚至面试官都让你用数组去存它。链表式二叉树不也挺直观的,为什么偏要用顺序结构?这个问题卡了我相当长时间,直到我把堆的插入、删除、建堆、排序完整手推了一遍,才明白数组这个看似“笨拙”的存储方式,恰恰是堆能够高效工作的灵魂所在。
这篇内容我会从堆的本质讲起,把“顺序结构实现堆”这一件事彻底拆开。不只讲代码怎么写,更会讲清楚每一个操作背后的数学原理、复杂度推导和实际应用场景。无论你是正准备数据结构考试的学生,还是在准备算法面试的求职者,又或者是在实际项目中需要处理优先队列、TopK 问题的开发者,这篇内容都能帮你把堆的知识真正焊死在脑子里。
1. 先分清两件容易被搞混的事:数据结构里的堆和内存里的堆
很多初学者一看到“堆”这个字就懵了,因为操作系统内存区域里也有一个叫“堆”的东西。这个混淆非常普遍,甚至在网络热搜词里都能看到“栈、堆、队列的定义”和“java堆内存设置”同时出现,说明大量开发者对这两个概念是模糊的。我决定开门见山先把这件事厘清,否则后面讲顺序结构堆的时候,你脑子里总是会浮现出 malloc 和 new 的分配逻辑。
内存里的堆,本质是一块用来动态分配内存的运行时区域。C语言里的 malloc、C++ 里的 new、Java 里的 new 对象,都是在操作系统或者语言运行时管理的“堆区”里找一块连续或不连续的空间。它和数据结构里的堆,除了英文单词都叫 heap 之外,没有任何直接关系。内存堆关心的是“这块空间怎么分配、怎么回收、碎片怎么处理”,这是一种内存管理策略。
数据结构里的堆,是一种基于完全二叉树形态的特殊数据结构。它有两个核心约束:第一,这棵树必须是完全二叉树,也就是除了最后一层,其他所有层的节点都是满的,并且最后一层的节点从左到右连续排列,中间不能有空洞;第二,堆中任意一个节点的值,都必须满足堆序性质——最大堆要求父节点的值永远大于等于子节点,最小堆要求父节点的值永远小于等于子节点。正因为有这两个严格约束,堆才能用数组干净利落地存储,也能在 O(log n) 时间内完成关键的插入和删除操作。
这里有一个非常重要的推论:堆不是二叉搜索树(BST)。二叉搜索树要求左子树所有节点小于根、右子树所有节点大于根,这种严格的排序关系让 BST 的查询效率很高。但堆只要求父节点和子节点之间的相对大小关系,左孩子和右孩子之间并没有任何次序要求。换句话说,你无法在堆上做高效的元素查找,堆的存在意义不是“查找”,而是“在动态变化的数据流中,始终以极低成本拿到最大值或最小值”。
还有一个常被忽略的语义点:如果某个语境里说“小土堆”或者“大顶堆”,说的就是堆序性质的两种方向。大顶堆就是最大堆,堆顶是全局最大值;小顶堆就是最小堆,堆顶是全局最小值。热搜词里的“最大堆动画”和“大顶堆”,其实都是描述同一种东西的图形化演示。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 顺序结构堆的下标魔法:为什么数组能表达完全二叉树
2.1 从数组下标推导父子关系
用数组实现堆,核心靠的是一套下标映射公式。假设我们用一个一维数组 arr,索引从 0 开始,那么对于任意一个下标为 i 的节点,它的左孩子下标是 2 * i + 1,右孩子下标是 2 * i + 2,父节点下标是 (i - 1) / 2(整除)。这三个公式看似简单,但它们是整个顺序堆的基石,几乎所有操作都是围绕它们展开的。
举个例子,一个小顶堆数组 [3, 7, 5, 15, 9, 8, 6]。下标 0 的元素是 3,它的左孩子是下标 1 的 7,右孩子是下标 2 的 5。你要找下标 5 的元素 8 的父节点,带入公式 (5 - 1) / 2 = 2,也就是下标 2 的 5,而 8 确实是 5 的右孩子。这套映射关系在数学上是严密的,因为完全二叉树的天然结构决定了所有节点可以按层序编号一一对应到数组下标,中间不会有空洞。
为什么只有完全二叉树可以用数组这么存?因为如果是一棵普通的二叉树,节点位置是任意的,你往数组里放的时候中间就会有很多空位,造成空间浪费,而且无法通过简单的乘法运算法推导出父子关系。而完全二叉树的形状是“被严格规定”的,每一层从左到右按顺序排布,数组的下标天然就是层序编号,所以不需要额外存储任何指针信息。
2.2 顺序存储空间的占用与扩容策略
顺序结构的另一个现实问题是容量管理。使用数组就需要提前申请一块固定大小的内存,或者像动态数组那样在容量不够时进行扩容。扩容的本质是:申请一块更大的连续空间,把旧数据 copy 过去,然后释放旧空间。这个过程的时间复杂度是 O(n),但因为动态数组的扩容通常采用倍增策略(比如容量翻倍),所以摊还下来每次 push 的平均时间复杂度仍然是 O(1)。
实际操作中我给堆结构做扩容时,一般会设置一个初始容量(比如 16 或者 64),当 size 等于 capacity 时,就执行 grow() 操作。这里的增长因子我通常取 1.5 或 2。取 2 的优点是位运算可以优化,但内存占用可能偏大;取 1.5 在内存回收方面更温和,Java 的 ArrayList 用的就是 1.5。我的建议是,在算法竞赛或刷题场景下直接取 2,因为省心;在实际开发的项目代码里,如果明确知道数据量的量级,不如直接分配一个足够大的初始容量,从根上避免扩容。
还有一个存储细节值得注意:数组的索引 0 位置可以直接存储堆顶元素,也可以留空不用而从 1 开始。这两种做法的区别在于父子下标公式。如果从 0 开始,左孩子是 2i+1,右孩子是 2i+2;如果从 1 开始,左孩子是 2i,右孩子是 2i+1,父节点是 i/2。从 1 开始的写法在某些编程语言里位运算更简洁,但与用户习惯的 0 基数组不统一,还要多占一个空位。刷题和实际工程我更推荐从 0 开始,统一标准,减少出错率。
3. 堆的核心操作:上浮、下沉、插入与删除的完整推导
3.1 上浮操作(swim)的触发场景与过程
堆的插入操作其实只有两步:先把新元素放到数组的末尾,也就是完全二叉树的最后一个位置,然后再执行“上浮”调整,让新元素一路向上移动到属于它的位置。为什么放到末尾不会破坏“完全二叉树”的结构?因为完全二叉树的新节点必须从最后一层的最右边补位,数组末尾恰好就是这个位置。所以这一步插入不需要任何额外计算,直接无脑 append 就完事。
但 append 之后,堆序性质可能被破坏了。比如一个小顶堆 [2, 5, 8, 10],你往末尾插入一个 3,数组变成 [2, 5, 8, 10, 3]。此时 3 的父节点是下标 1 的 5,3 < 5,小顶堆性质被破坏了,所以需要让 3 上浮。上浮的逻辑是:比较当前节点和父节点,如果当前节点优先级更高(小顶堆就是值更小),交换两者,然后继续和新的父节点比较,直到合适位置。这个过程也叫 swim 或 sift up。
这里有一个很容易被忽视的边界:当节点上浮到下标 0 时,说明它已经是堆顶,没有父节点了,循环必须停止。写代码的时候判断条件是 i > 0 且 arr[i] < arr[(i-1)/2],两个条件缺一不可。上浮操作的时间复杂度是 O(log n),因为它最多从叶子上浮到根,路径长度就是树高 log₂n。这也是堆插入为什么高效的根本原因。
3.2 下沉操作(sink)的触发场景与选择逻辑
下沉操作主要出现在两个场景:删除堆顶元素,以及堆排序中调整根节点。它的目的是当一个较大(或较小)的节点出现在堆顶时,把它一路“压”到合适的位置。下沉的逻辑稍微复杂一点,因为它要比较当前节点和两个子节点,选出优先级最高(最大堆就是值最大,最小堆就是值最小)的孩子,然后判断是否需要交换。
以小顶堆删除堆顶为例,标准做法是:先把数组末尾的元素覆盖到堆顶,把 size 减一,然后从堆顶开始执行下沉。为什么要用末尾元素覆盖堆顶,而不是直接把堆顶删了然后让某个子节点顶上来?这是因为如果直接让某个子节点顶上来,整棵树就会出现“空洞”,破坏完全二叉树的连续结构,后面再用数组表示就不成立了。而把末尾元素搬到堆顶,只是改变了数据,整体形状依然是一棵完整的完全二叉树,这个技巧非常精妙。
下沉操作的核心循环如下:令 k 为当前节点下标,比较 arr[2k+1] 和 arr[2k+2] 中较小者,如果 arr[k] > arr[较小孩子],则交换,k 移动到孩子下标,继续循环。这里要注意判断右孩子是否存在,因为最后一个节点可能存在左孩子但右孩子越界的情形。我在写代码时习惯把“右孩子下标 < size”作为能否访问右孩子的前提,避免数组越界。
3.3 插入与删除的完整时间复杂度分析
把插入和删除放一起分析复杂度,你会发现一个非常漂亮的对称性:插入 = O(1) 的末尾追加 + O(log n) 的上浮;删除堆顶 = O(1) 的覆盖赋值 + O(log n) 的下沉。所以两者都是 O(log n) 的时间复杂度,而且这个复杂度不会因为数据量增大而急剧退化。
对比一下其他需要保持最大值/最小值可达的数据结构:如果用一个有序数组存有 n 个元素,取最大元素是 O(1),但插入一个新元素需要二分查找位置再移动后续元素,最坏 O(n);如果用无序数组,插入是 O(1),但取最大元素要遍历整个数组,O(n);如果用二叉搜索树,理论上是 O(log n),但普通 BST 在最坏情况下会退化成链表,变成 O(n)。堆的优势在于,它用完全二叉树的结构约束,保证了树高始终不超过 log₂n,从而让插入和删除两个操作都稳定在 O(log n)。
这里还要补充一个实际开发中容易踩的坑:优先级队列(PriorityQueue)其实底层就是堆。Java 的 PriorityQueue 默认是小顶堆,如果你要按自己的比较规则来,必须实现 Comparator 接口,否则会默认按照自然顺序。我在几年前写一个任务调度模块时,就因为忘记传 Comparator,导致优先级排序完全反了,线上出了个不大不小的故障。从此我对“这个容器的默认语义是什么”格外敏感。
4. 建堆的两种姿势与复杂度真相
4.1 自顶向下建堆:逐个插入的简单思路
有了 insert 操作,最简单的建堆方式就是从一个空堆开始,把 n 个元素一个一个插进去。每一次插入需要 O(log n) 时间,所以 n 次插入的总复杂度是 O(n log n)。这个思路非常直观,很多初学者觉得这没什么问题,也确实没太大问题——它只是在复杂度上不是最优,但逻辑几乎不会出错。
这种自顶向下的建堆方式,本质上是“增量式”构建。每来一个新元素,就把它放在末尾然后上浮,整个过程模拟的是动态数据流持续进入堆的过程。这种方法有一个被低估的优点:它天然支持在线处理,也就是你不需要一次性拿到全部数据,来一个处理一个就行了。这也是为什么优先队列在处理实时数据流时,用的就是这种逐次插入的思路。
4.2 自底向上建堆:Floyd 算法的原理与 O(n) 推导
真正的高潮来了:如果给你一个无序数组,你可以在 O(n) 时间内直接把它调整成一个堆。这个方法叫 Floyd 建堆法,也被称为自底向上的堆化。它的核心步骤是:从最后一个非叶子节点开始,依次向前执行下沉操作,直到根节点。
为什么从“最后一个非叶子节点”开始?因为叶子节点没有子节点,天然满足堆序性质,根本不需调整。最后一个非叶子节点的下标是 n/2 - 1(0 基数组),你可以通过数学推导验证:最后一个节点的父节点就是 n/2 - 1。从它开始向前遍历,每个节点都做一次下沉,这样整个数组就被堆化了。
那么这个算法为什么是 O(n) 而不是 O(n log n)?这是堆领域非常经典的一个复杂度分析案例。关键在于:并非每个节点下沉时都会走完从根到叶的完整路径。高度为 1 的节点最多下沉 1 层,高度为 2 的节点最多下沉 2 层,以此类推。而完全二叉树中,高度越低的节点数量越多。具体来说,高度为 h 的节点最多有 n/2^(h+1) 个。总的工作量是求和:
T(n) = ∑_{h=0}^{log n} (n / 2^(h+1)) × h
这个级数的值小于 n × ∑(h / 2^h),而 ∑(h / 2^h) 是一个收敛的常数级数(等于 2),所以 T(n) = O(n)。这个结论可能反直觉,但它是堆这个数据结构最引以为傲的性质之一——用一个无序数组构造优先队列,只需线性时间,不需要排序。
这里我强烈建议你做一个小实验验证一下。拿 16 个元素的数组,按 Floyd 方法逐层下沉过程中打印每一步的数组状态,你会发现整个堆化的过程非常像一堆泡沫在从底部向上逐渐稳定。理解了这种“泡沫稳定”的过程,后面看堆排序的交换步骤就会觉得顺理成章。
4.3 两种建堆方式的选择场景对比
既然有两种方式,就要面临选择。我把两者放在一起对比一下:
| 维度 | 自顶向下逐个插入 | 自底向上 Floyd 堆化 |
|---|---|---|
| 时间复杂度 | O(n log n) | O(n) |
| 数据获取方式 | 支持在线流式输入 | 需要一次性拿到完整数组 |
| 实现复杂度 | 极简单,直接用 insert | 略复杂,需要从 n/2-1 开始下沉 |
| 适用场景 | 数据不断到达的实时场景 | 已知全部数据的初始化场景 |
| 额外空间 | 需要新数组或扩容 | 无需额外空间,原地堆化 |
这个结论几乎可以当口诀来背:动态数据进来用插入,静态数据初始化用 Floyd。实际刷题时,如果题目给你一个现成的数组,上来就用 Floyd 堆化,时间和代码量都更省;如果题目是模拟一个数据流逐步 offer,那就只能老老实实用插入。
5. 堆排序:利用堆结构完成原地排序
5.1 堆排序的基本思路与和选择排序的关系
堆排序是一个特别有意思的算法,它本质上是一种优化的“选择排序”。选择排序每一轮扫描剩余数组找到最小值(或最大值),时间复杂度 O(n²),而堆排序利用堆这种结构,把“找最小值/最大值”这一步从 O(n) 降到了 O(log n),所以总时间复杂度是 O(n log n)。
堆排序的第一步是建堆,第二步是反复执行“删除堆顶”。如果我们要升序排序,需要构建最大堆,然后每次把堆顶(最大值)和数组末尾元素交换,接着把数组长度视为 n-1,对堆顶元素做一次下沉,恢复堆性质。这个过程重复 n-1 次,数组就从小到大排好了。
为什么升序排序要用最大堆而不是最小堆?这是原地排序的巧妙之处。如果构建的是最小堆,堆顶虽然是最小值,但你把它拿出来之后,堆还需要重新调整,而且最小值应该放在数组最前面,这会和堆的存储空间冲突。而用最大堆时,每一轮把堆顶最大值放到数组末尾,这个位置刚好是“已排好序”的区域,不需要额外开辟空间。堆的存储区越来越小,排好序的区域越来越大,整个过程完全不依赖额外空间。
5.2 堆排序代码实现的关键细节
堆排序的实现里最容易出错的地方,是下沉操作中“当前堆的有效范围”在动态变化。每一轮交换后,数组末尾被排除在堆外,所以下沉时的边界不再是数组总长度,而是一个逐渐减小的 heapSize 变量。这个细节稍不注意就会造成数组越界,或者把已经排好的元素重新拉回堆里,导致排序失败。
我用 Java 写过一个完整的堆排序版本,核心代码段大概长这样:
java复制public void heapSort(int[] arr) {
int n = arr.length;
// Floyd 建堆,构建最大堆
for (int i = n / 2 - 1; i >= 0; i--) {
siftDown(arr, i, n);
}
// 逐个交换堆顶与末尾
for (int i = n - 1; i > 0; i--) {
swap(arr, 0, i);
siftDown(arr, 0, i);
}
}
private void siftDown(int[] arr, int k, int heapSize) {
while (2 * k + 1 < heapSize) {
int j = 2 * k + 1;
if (j + 1 < heapSize && arr[j + 1] > arr[j]) {
j++;
}
if (arr[k] >= arr[j]) {
break;
}
swap(arr, k, j);
k = j;
}
}
注意我在 siftDown 方法里多带了一个 heapSize 参数,而不是直接用 arr.length。这个参数就是堆的有效边界,非常关键。每次交换完,边界就收缩一个单位。还有一个细节是:当 arr[k] >= arr[j] 时我们直接 break,因为父节点已经比两个子节点都大了,堆序性质恢复,不需要继续下沉。
5.3 堆排序和快排的实际对比
堆排序的理论时间复杂度是 O(n log n),且在最坏情况下依然稳定,这一点胜过快速排序。但实际工程中,堆排序往往不是首选,为什么?答案是局部性(locality)。
快速排序在操作时,总是对相邻或者较近的内存区域做比较和交换,CPU 缓存命中率高;而堆排序的交换发生在完全二叉树中距离很远的节点之间,数组下标可能跳跃很大,导致缓存命中率低。所以尽管两者理论复杂度一样,真实运行时快排通常比堆排序快 1.5 到 2 倍。这也是为什么 Java 的 Arrays.sort 对基本类型用的是双轴快排,而不是堆排序。
但堆排序有一个杀手级优势:它的最坏时间复杂度是真正的 O(n log n),不会像快排那样在数组已经有序时退化到 O(n²)。虽然快排可以通过随机选择基准点来降低这种退化概率,但需要额外的随机数成本。在时间敏感、数据分布不可预测、且不能接受最坏情况退化的系统中,堆排序反而更稳。比如某些嵌入式系统、实时控制场景,或者明确要求最差延迟可控的任务。
还有一个细节值得提:堆排序是不稳定排序。因为相同值的元素在堆调整过程中可能会交换相对位置。如果你需要稳定排序,堆排序不适合直接使用,可以考虑归并排序或稳定版快排。
6. 热词背后那些真正用到堆的实战场景
6.1 优先级队列与任务调度
优先队列是堆最常见的工程化封装。操作系统进程调度、网络请求队列、短视频推荐系统的候选召回排序,凡是你需要“从一堆不断变化的元素中快速取出优先级最高的那一个”,背后基本都是堆。
以 Java 的 PriorityQueue 为例,它底层就是一个对象数组实现的小顶堆。每 offer 一个元素,就执行一次上浮操作;每 poll 一次,就执行一次下沉操作。你在实际开发中根本不需要自己实现堆,但需要理解它的优先级比较逻辑。用错了 Comparator 的返回值正负方向,队列的行为就会完全相反。我的习惯是:写 Comparator 时,先写一行注释声明“返回值 < 0 表示第一个参数优先”,然后把比较逻辑写清楚再返回,避免正负号搞混。
这里再展开讲一个网络热搜词里特别典型的问题:poi 增量写入 excel 导致堆内存溢出。很多人一看到“堆”字就以为是数据结构堆导致的,但实际这是 JVM 内存区域堆的内存不够用了。Apache POI 在处理大 Excel 文件时,普通的 HSSFWorkbook 会把整个文件的所有行、所有单元格对象都加载到 JVM 堆内存中,一个几万行的表格就可能吃掉几百 MB 甚至几个 GB 的内存。解决思路通常有:改用 SXSSFWorkbook 流式写入,控制滑动窗口大小;或者调大 JVM 堆内存参数;或者用 EasyExcel 这类基于 SAX 模式解析的库。这个问题的核心是内存管理,和数据结构堆无关,但因为名字撞了,很多人搜索时都被带偏了。
6.2 TopK 问题:堆的经典应用战场
TopK 问题指的是:从海量数据中找出最大(或最小)的 K 个元素。这类问题最经典的解法就是用大小为 K 的堆。求最大的 K 个,用小顶堆;求最小的 K 个,用大顶堆。这里很多人会搞反,我详细解释一下。
求最大的 K 个元素,为什么用最小堆?因为小顶堆的堆顶是整个堆中最小的元素,也就是“当前已选出的 K 个最大元素”中的候补门槛。每次来一个新元素,如果它大于堆顶,说明它比当前第 K 大的元素更大,于是把堆顶弹出,把这个新元素插进去;如果它小于等于堆顶,说明它不可能进入前 K 大,直接丢弃。这样一来,堆中始终维护着“当前见过的最大的 K 个元素”,而堆顶就是这 K 个里面的最小值,也就是整个数据流的第 K 大值。整个过程的复杂度是 O(n log K),在 K 远小于 n 时,几乎可以认为是 O(n)。
这个思路在搜索引擎的“热门关键词 Top10”、排行榜系统、监控系统中的“耗时最长 Top 100 接口”里都有应用。我记得有一年做性能监控平台,需要从每天上亿条请求日志里找出延迟最高的 100 个请求,数据量很大,根本不可能全部加载到内存。我们用了一个固定大小为 100 的最小堆,日志逐条流式读取实时判断,内存占用始终只有几百个字节,效果极佳。
还有一种场景是数据量大到单机内存装不下,需要用多路归并配合外部排序。堆在这里也扮演了关键角色:多路归并时,每次从 K 个有序子文件中选出最小元素,就是从大小为 K 的堆中取堆顶。这个过程在数据库的 ORDER BY 和 MapReduce 的 shuffle 阶段都能看到。
6.3 堆在 JVM 内存区域中被误用的辨析
我在第 1 节讲过数据结构堆和内存堆是完全不同的东西,这里再做一次辨析,因为热搜词里有太多与之相关的内容。诸如“新生代、老生代、GC 怎么配置堆内存大小”“堆外内存”“编译器的堆空间不足”,这些全部都是内存管理领域的堆,不是数据结构堆。
JVM 的堆内存分为新生代和老年代,新生代里再分 Eden 区和 Survivor 区,GC 垃圾回收器在这里面做对象生命周期管理。你调 Xmx、Xms 参数,配 G1 或 CMS 回收器,这些都是内存管理的事。堆外内存则是通过 DirectByteBuffer 等方式绕开 JVM 堆,直接在操作系统层面分配内存,减少 GC 压力。
有些初学者会把这些概念混在一起,比如想着“我用堆排序的时候是不是会占用 JVM 的堆内存更少”。这个想法是错的。数据结构里的堆是算法逻辑层面的抽象,无论你用它解决什么问题,代码运行时的对象都是在 JVM 堆内存里分配的。这两者处在完全不同的抽象层级,互相之间没有直接影响。理解了这层区别,你在面试时说起堆来才不会被面试官带偏。
6.4 堆在数据流中位数和滑动窗口中的应用示例
除了 TopK,堆还有几个极为经典的变形场景。求数据流中的中位数,就是一个可以用两个堆完美解决的问题。思路是:用一个大顶堆维护左半部分较小的数,用一个小顶堆维护右半部分较大的数,保证大顶堆的所有元素都小于等于小顶堆的所有元素,且两个堆的元素数量之差不超过 1。这样中位数就是大顶堆的堆顶元素,或者两个堆顶元素的平均值。
这个思路非常巧妙,也是面试高频题。每次来一个新元素时,先判断应该放进哪个堆,然后根据两个堆的大小关系做平衡调整。整个过程每个操作都是 O(log n),比每次排序求中位数 O(n log n) 高效得多。
滑动窗口最大值问题也很典型。给定一个数组和一个窗口大小 K,窗口每次向右移动一格,要输出窗口内 K 个元素的最大值。暴力解法是每移动一次就遍历窗口,复杂度 O(nK)。用堆来解决的话,可以用一个大顶堆存储窗口内元素,同时用延迟删除技巧处理被滑出窗口的元素。具体做法是:堆里记录元素值和下标,取堆顶时检查它的下标是否还在窗口范围内,如果不在就弹掉,直到堆顶是合法元素。这样每个元素最多进堆一次、出堆一次,总复杂度 O(n log n),而且代码实现比单调队列更直观。虽然单调队列能把复杂度压到 O(n),但需要理解“单调”的核心思想,对初学者来说堆的思路更好掌握。
这两个例子说明一件事:堆不只是“一个数据结构”,它更像是在动态数据流中维持全局极值的一种思维框架。掌握了上浮、下沉这些底层操作,你就能在多种问题中把它组合出各种变形应用。
7. 顺序结构堆的薄弱环节与常见实现错误
7.1 堆的查找能力弱与“改值”操作的陷阱
堆之所以叫堆而不是搜索树,就是因为它把“查找”这个能力彻底牺牲了。堆只保证堆顶是极值,却不保证堆中间的元素有任何有序性。你想在堆里面找某一个特定值,最坏情况下要遍历整个堆,O(n)。这就意味着,如果你的业务场景需要频繁做“这个元素在不在里面”这种判断,堆不是一个合适的结构,需要搭配一个 HashMap 来辅助定位下标。
有一个非常经典的工程案例是 Dijkstra 算法。朴素的 Dijkstra 用数组存 dist,每次遍历找最小距离节点,复杂度 O(n²);如果用优先队列优化,每次从堆里取最小值是 O(log n)。但 Dijkstra 有一个操作:当某条边的松弛操作更新了一个节点的 dist 值,需要更新它在优先队列中的优先级。Java 的 PriorityQueue 默认不支持更新优先级,所以常见做法是“延迟删除”——新值直接入堆,旧值留在堆里,通过一个 visited 或 dist 数组判断它是否是过期数据。这种方法虽然会让堆里存在一些冗余元素,但整体复杂度依然可控,而且实现简单。我在实现自己的图算法库时,也采用了同样的策略,几百行代码就搞定了比较高效的最短路径算法。
7.2 数组越界与比较器方向两个高频 Bug
顺序结构堆的代码量不大,但有两个 Bug 出现频率极高。第一个是数组越界,尤其是右孩子判断。看下面这段有问题的代码:
java复制while (2 * k + 1 < size) {
int j = 2 * k + 1;
if (arr[j + 1] < arr[j]) {
j++;
}
// ...
}
如果 2 * k + 1 正好是 size - 1,说明当前节点只有左孩子没有右孩子,此时 arr[j+1] 就是访问 arr[size],直接数组越界。所以正确的写法必须加 j + 1 < size 这个条件。这个问题我在初学写堆排序的时候踩过,排查了很久才发现是少了这个边界判断。
第二个 Bug 是对比较器方向理解错误。Java 的 PriorityQueue 默认是小顶堆,但如果你用自定义对象且没有正确实现 compareTo,堆的性质就会完全不符合预期。Comparator 的约定是:返回负数表示第一个参数排在前面,返回正数表示第二个参数排在前面。如果你想要大顶堆,需要把比较结果取反,或者用 Comparator.reverseOrder()。很多人写到这里正负号会写反,结果代码跑起来才发现优先级完全颠倒了。
7.3 堆的遍历输出“不是有序的”这一误区
最后一个想提醒大家的误区:你写一个 for 循环把堆的底层数组遍历一遍,看到的并不是有序序列。很多人第一次打印堆数组的时候以为代码写错了,因为输出看起来毫无规律。这是堆和二叉搜索树的重要区别:BST 的中序遍历是有序的,堆的任意遍历都不是有序的,因为堆只约束了父子关系,没有约束同层节点之间的顺序。
想要从堆里拿到有序序列,正确做法是反复 poll 堆顶,每次 poll 都会重建堆性质,这样拿出来的元素才是有序的。这个操作本质上就是堆排序的核心逻辑。如果你只是想看一眼堆里大概有哪些元素,直接遍历数组是可以的;但如果你想判断“堆是否正确”,应该检查每个节点和其子节点的堆序关系,而不是看整体是否有顺序。
我在调试堆相关代码时的一般做法是写一个辅助校验函数,遍历数组检查所有非叶子节点是否满足堆序性质。这个函数虽然不能证明堆构建的每一步都对,但能在关键时刻快速定位到底哪一步破坏了堆结构,比肉眼检查数组高效太多了。
从数组下标的映射关系,到上浮下沉的调整逻辑,再到建堆的 O(n) 推导,最后到 TopK、中位数、滑动窗口这些实战场景,顺序结构堆的知识脉络其实非常清晰。它牺牲了查找能力,却换来了极值的快速访问和动态维护,这种取舍在很多真实系统里恰恰是最划算的。希望这篇内容能帮你把堆的底层逻辑彻底打通,下次再面对和堆相关的问题时,你能够条件反射般地想到数组、下标公式和上浮下沉的过程。
