☰
堆(Heap)数据结构:原理、手写实现与Top-K应用场景解析
2026/10/14 7:19:47 网站建设 项目流程

堆(Heap)这个数据结构,在学习曲线里排的位置一直挺玄:说难吧,它比平衡二叉树简单多了;说简单,可真要手写一遍,很多人又会在边界条件和调整方向上绕好一阵。但你在日常工作里根本绕不开它——外卖平台从百万订单里挑出最急需配送的一单,推荐系统对候选内容做热度Top-N排序,数据库执行排序聚合时处理超大结果集,底层都有堆的影子。我第一次把堆想明白,倒不是在教科书上,而是某天自己动手写了个优先队列去处理一批乱序任务,代码跑通的那一刻才真正理解:这东西核心就两句话,最大或最小的值永远待在根上,剩下全靠“上浮”和“下沉”两个动作维护。这篇文章就用最直白的方式,把堆是什么、为什么它找最值这么快、手写堆的完整步骤、以及实际应用里最容易踩的坑一次讲清楚。适合刚接触数据结构的新手,也适合面试前几天需要快速回炉的朋友。

1. 堆到底是个什么东西

1.1 一种“只管上下,不管左右”的完全二叉树

堆在数据结构里的准确定义是:一棵完全二叉树,并且每个节点的值都和它的子节点满足固定的大小次序关系。根据大小关系的方向,分成两种:

  • 大根堆(最大堆):每个节点的值都大于等于它的子节点,根节点是整个堆里最大的数。
  • 小根堆(最小堆):每个节点的值都小于等于它的子节点,根节点是整个堆里最小的数。

这里的关键词是“完全二叉树”。完全二叉树的意思是:除了最后一层之外,每一层都铺满,最后一层从左到右连续填充,不能中间空一格。这个苛刻的形状要求,目的很纯粹——为了能用数组连续存储,一个空位都不浪费。很多教材用“金字塔”来比喻它,我觉得更准确的比喻是“排队填坑”:新元素永远从末尾补齐,拿掉元素永远拿最顶上那个,让队列始终紧凑完整。

还有个非常容易被忽略的点:堆只约束父子关系,不约束兄弟关系。在大根堆里,根是10,左右孩子一个是7一个是8,这完全合法;7有自己的孩子5和3,也没问题。但是左孩子7的兄弟节点8,比7大,这种“倒挂”在堆里是允许的。很多新手看调试数组时,发现兄弟之间大小乱序,就以为堆坏了,其实恰恰是正常的。理解这一点,能帮你省掉大量排错时间。

1.2 用数组存储,凭什么能做到?

既然是完全二叉树,数组表达就非常自然。假设数组下标从0开始,对任意节点 i,有:

  • 左孩子下标:2 * i + 1
  • 右孩子下标:2 * i + 2
  • 父节点下标:(i - 1) // 2

这三个公式是堆所有操作的地基。你不需要真的建一棵树,也不需要指针,一个数组加这三个换算公式,就完整表达了一棵堆树。

给你一个例子。数组 [9, 5, 7, 2, 4] 是合法小根堆吗?看根9的两个孩子5和7,5比9小,所以根节点就违反了小根堆的要求。那 [5, 7, 9, 2, 4] 呢?根5比孩子7、9小,但再看下标1的节点7,它的左孩子下标3是2,7比2大,依然违反小根堆。所以验证堆合法性,不能只看根节点,要逐层核对每一组父子关系。这个例子也顺带说明:写堆代码时,多留一个合法性校验函数,绝对能救命,后面讲到排查时我会再展开。

2. 堆的两个核心操作,把复杂度控制在 O(log n)

2.1 插入:先放在末尾,再“上浮”

堆的插入,逻辑上分两步。第一步,把新元素追加到数组末尾,这样能保证“完全二叉树”形状不变。第二步,比大小:新元素如果比父节点小(小根堆)或大(大根堆),就交换位置,一直往上走,直到不再违反堆序,或已经走到根节点。这个向上走的过程,叫“上浮”。

上浮的直觉可以想象成水里的气泡:气泡从水底慢慢升到它该待的位置。每一层只做一次比较和交换,树高是 log n,所以插入复杂度是 O(log n)。下面是一段小根堆的插入实现:

def heap_push(h, val): h.append(val) i = len(h) - 1 while i > 0: p = (i - 1) // 2 if h[i] >= h[p]: break h[i], h[p] = h[p], h[i] i = p

边界条件在 i > 0:只有 i 还大于0,才存在父节点。很多初学者在这里漏判,导致数组下标变成负数,轻则结果错误,重则越界崩溃。如果你用的是别的编程语言,强烈建议写这行前先想想:你自己的语言里负数下标会不会被静默容忍。

2.2 弹出根:先换到底,再“下沉”

堆在应用中最常见的操作是取最值,也就是拿根。直接删掉根会破坏完全二叉树结构,所以正确做法是:根和最后一个元素交换,把最后一个元素弹出,再对新的根执行“下沉”。下沉比上浮稍微复杂一点,因为要同时看左右两个孩子,选满足堆序方向的那个——小根堆选两个孩子里较小的那个,大根堆选较大的那个——如果父节点和它顺序不对,就交换,然后继续往下走,直到叶子或满足堆序。

为什么必须找两个里更小的那个?因为你想让更小的孩子成为新的父节点,才能保证它的兄弟仍然满足“父 ≤ 子”的关系。如果随便拿一个孩子交换,很可能换完以后另一个孩子比新父节点还小,堆就坏了。这个判断顺序是很多新手写错的重灾区,我先给一个完整的小根堆弹出实现:

def heap_pop(h): if not h: return None top = h[0] last = h.pop() if h: h[0] = last n = len(h) i = 0 while True: l = 2 * i + 1 r = l + 1 m = i if l < n and h[l] < h[m]: m = l if r < n and h[r] < h[m]: m = r if m == i: break h[i], h[m] = h[m], h[i] i = m return top

这里的 m 是“候选最小值位置”的标记,先设为 i,再看左孩子右孩子要不要替换。三个比较动作做完后,如果 m 还是 i,说明父节点已经比两个子节点都合适,直接终止。后面建堆会反复用到同一段下沉逻辑,建议把它抽成一个独立函数 sift_down(heap, i, n),其中 n 是堆的有效长度,方便复用。

3. 建堆与堆排序:O(n) 建堆不是玄学

3.1 从下往上建堆,每个元素只需下沉一次

给一个乱序数组,怎么变成一个堆?最简单的思路是逐个 push,每个 push 都是 O(log n),整体 O(n log n),数据量少时也凑合能用。但真正讲究的做法是 Heapify:从数组中间开始,从后往前对每个节点做一次下沉。关键点在于:叶子节点不需要下沉,因为它们没有孩子。第一个可能需要下沉的是最后一个非叶子节点,下标是 n // 2 - 1。

def sift_down(h, i, n): while True: l = 2 * i + 1 r = l + 1 m = i if l < n and h[l] < h[m]: m = l if r < n and h[r] < h[m]: m = r if m == i: break h[i], h[m] = h[m], h[i] i = m def heapify(arr): n = len(arr) for i in range(n // 2 - 1, -1, -1): sift_down(arr, i, n)

从后往前处理,背后的道理是:下沉操作要求“子节点子树已经是合法堆”。你从最后一个非叶子节点开始,它的左右子树都只有一个节点,天然是堆,所以对它做下沉没问题;处理完它之后,再往前一个节点,它的孩子子树已经被处理成合法堆了,以此类推,直到根。整棵堆就建成了。

为什么复杂度是 O(n) 而不是 O(n log n)?因为大部分节点都接近叶子,高度很小。一个高度为 h 的节点做下沉成本是 O(h),而一棵完全二叉树里高度为 h 的节点数最多不超过 n / 2^(h+1)。把这些成本加起来,是个收敛的级数,总和 O(n)。这个结论违反了很多人“和树有关就 O(n log n)”的直觉,但它是堆这个结构的一个重要特性。想跟面试官讲清楚这一点,画一棵满二叉树标出每个节点的高度,比背公式更有说服力。

3.2 堆排序:交换、收缩、再下沉

堆排序就是反复利用堆顶最值:先建一个大根堆,然后把堆顶和数组末尾元素交换——最大数去了最终位置——再把堆的“有效长度”减一,对新的根做一次下沉,恢复大根堆性质。循环 n - 1 轮,数组就升序排好了。每轮交换成本 O(1),下沉成本 O(log n),一共 n 轮,总复杂度 O(n log n)。而且它是在原数组上操作的,额外空间 O(1),这一点和归并排序对比很鲜明。

但它不稳定。所谓稳定,是指相同元素在排序前后的相对顺序不变。堆排序在反复交换堆顶和末尾元素的过程中,相同值的相对位置很容易被搅乱。所以如果业务要求相同 key 保持原始顺序,不要选堆排序。这里我也给一句良心建议:平时写业务代码,几乎不会为了堆排序去手写堆,语言自带的排序函数是底层实现,又快又成熟。真正需要你手写堆的地方,更多是算法题、作业题,或者对性能细节极度敏感的底层服务。

4. 堆最常见的四个应用场景

4.1 优先队列:一切调度的底座

优先队列是一种抽象数据结构:每个元素有优先级,弹出的总是当前优先级最高(或最低)的元素。堆就是优先队列最经典、最常用的一种实现方式。操作系统底层的任务调度、网络框架的事件循环、任务系统里处理紧急工单,底层都能看到优先队列的影子。

工程语言通常已经封装好了,但默认方向各不相同,跨语言写代码时最容易翻车。我把它们列成一张速查表:

语言/接口默认堆序变成最小堆变成最大堆
Python heapq小根堆直接用push 时存 -x
Java PriorityQueue小根堆直接用传自定义比较器
C++ priority_queue大根堆传 greater 比较器默认

这张表我建议截图收藏。每次跨语言做题,先在本地跑一个三元素的最小样例,确认弹出顺序符合预期,再上正式逻辑,别靠记忆硬猜。

4.2 Top-K:大数据场景里的经典入口

先说“找出数组里最大的 K 个数”。最容易想到的做法是整体排序后切前 K 个,复杂度 O(n log n),数据量小时最省心。但数据量到百万、千万级别,排序全量数据就是浪费。堆的解法是:

  • 维护一个大小为 K 的小根堆;
  • 扫描每个元素,如果比堆顶大,用新元素替换堆顶,再做一次下沉;
  • 全部扫描完,堆里就是最大的 K 个数,堆顶就是第 K 大的数。

为什么这里用小根堆而不是大根堆?因为小根堆的堆顶是当前 Top-K 里最小的那个数,也就是“门槛”。新元素来了,如果它比门槛还小,说明一定进不了前 K,直接淘汰;如果比门槛大,那就把门槛踢出去,让它进来当新门槛。大根堆在这个场景下只能快速拿到最大值,却没法高效地“淘汰最小门槛”。这个思路是面试高频点,一定要把“用小根堆保存 Top-K 大数”的原因记牢。

Python 里甚至不用手写:heapq.nlargest(k, data) 和 nsmallest(k, data) 直接搞定。当然,底层的原理值得了解,真正要对超大数组做分布式 Top-K 时,你还是会把同样的堆逻辑搬到分片流程里。

4.3 合并 K 个有序序列:多路归并

假设有 K 个已经各自有序的数组,要合并成一个大数组。暴力做法是每轮从 K 个数组的当前头部找最小,复杂度 O(Kn);借助堆,可以只花 O(log K) 的时间拿到每轮的最小值,整体复杂度降为 O(n log K)。具体做法是:把每个序列当前的第一个元素连同它所属的序列编号放进一个堆,每次从堆里弹出最小元素写进结果,然后从这个元素所属序列取下一个元素压进堆。需要多记录一个序列编号,是因为堆里只能看到值,看不到它来自哪里。

实现时有个细节:压进堆的元组尽量设计成 (值, 序列编号, 元素偏移) 的格式,确保任何两个元组都能比较大小。如果只放自定义对象并且没实现比较方法,某些语言会在堆排序时直接报类型错误,排查起来特别痛苦。多路归并在外部排序、日志归并、多表 join 里都非常常见,值得你亲手写一遍,弄懂比背代码更有价值。

4.4 动态中位数:两个堆互相咬合

一个数据流,不断插入数字,随时要查询当前中位数。单靠排序数组不行:插入 O(n),查询 O(log n)。单靠一个堆也不行:只能拿到最值,拿不到中位。双堆法的思路是:用小根堆存较大的那一半,用大根堆存较小的那一半,同时保持两个堆的大小最多差 1,并且大根堆的全部元素都比小根堆的全部元素小。这样中位数要么是某个堆的堆顶,要么是两个堆顶的平均值。

插入逻辑可以这么写:新元素先按照和大根堆堆顶的比较决定插入哪个堆,插入后检查两个堆的大小差,超过 1 就从大的一侧把堆顶移到小的一侧。这个结构不只面试爱考,实时排行榜、滑动窗口内的性能监控,都能扩展出类似用法。双堆也是我最建议新手反复手敲几遍的场景,因为它能把上浮、下沉、比较器、边界条件全部串联起来,一次练扎实,收益远大于单个堆的练习。

5. 手写堆时踩过的那些坑

5.1 边界条件永远第一痛点

手写堆最常见的崩溃源头,是下标越界。插入时忘判断根节点,弹出时忘判断空堆,下沉时左右孩子下标没和 n 比较就访问,都会炸。我自己的习惯是:

  • push 函数用 i > 0 作为父节点存在的条件;
  • pop 函数先处理堆空;
  • 下沉循环里,先判断 l < n 再访问左孩子,先判断 r < n 再访问右孩子。

我在本地总会准备一个小测试集,专门覆盖 n = 0、1、2 以及左右子树不对等的情况。这些边界案例手写时最容易出问题,跑完测试集,代码的可靠性立刻上一个台阶。

5.2 下沉方向搞反,数据最终“半生不熟”

下沉时要在两个孩子中选正确的那个。小根堆必须选更小的孩子,大根堆必须选更大的孩子。很多人初学时只注意到“父节点和孩子比较”,忽略了两子比较,结果堆序有时对有时不对。判断方法其实很直白:你在下沉过程中要把“最符合条件的孩子”提上来当父节点。如果小根堆选了较大的孩子上浮,另一个更小的孩子会比新父节点还小,结构立刻坏掉。

调试这类 bug 最快的手段是打印数组,然后递归检查每个父子节点的大小关系。一旦发现某个子树顺序不对,回溯那一步的交换条件,很容易定位到是不是漏了子比较。

5.3 用语言内置堆时的排序误区

Python heapq 不提供自定义比较器参数,堆排序完全依赖元素自身的比较逻辑。想放自定义对象进去,就得让对象实现比较方法,或者用 (priority, id, task) 这样的元组包装。元组比较有一个很隐蔽的坑:如果 priority 相同,Python 会继续比较 id,id 相同会继续比较 task,下一项一旦是不可比较类型,直接抛 TypeError。解决办法非常简单:在元组中间塞一个自增计数器,保证 priority 相同的情况下也永远不碰第三个元素的比较。

Java 和 C++ 正好相反,它们允许传入自定义比较器,但也因为可定制,方向写反的案例特别多。我的个人原则是:写比较器前先明确“我需要的是小根堆还是大根堆”,然后写一个三元素的最小样例实测弹出顺序。文档写得再清楚,观感都不如看到真实输出来得快。

5.4 不要用堆存依赖可变状态的对象

还有一个常见到几乎每人都会踩的坑:把依赖外部状态的对象直接放进堆。比如排序键值是动态变化的,或者对象比较方法内部引用了可变字段。堆序会在后续操作中被破坏,导致弹出顺序完全错乱。正确做法是:入堆前把优先级固化成元组里的一个字段,后续变化不要影响已经入堆的比较值。这算是一个工程经验,很多纯算法题不会考,但真实系统里一旦踩中,排查成本相当高。

6. 堆的进阶与选型建议

6.1 堆还有哪些变种

普通的二叉堆有两个明显短板:一是 decrease-key(降低某个元素的优先级)操作不支持,或者要 O(n) 扫描才能定位元素;二是在某些场景下合并两个堆的代价偏高。针对这些短板,出现了不少变种:

  • d-ary heap:每个节点有 d 个孩子,树更矮,但下沉时每层比较 d 次,是典型的“高度和比较次数”的取舍;
  • 索引堆:额外维护下标映射,支持快速修改堆中某个位置的值,常用来做图算法里的优先队列;
  • 配对堆等高级可合并堆:能在摊还意义下把合并和降低键值做得更高效,但实现复杂度也水涨船高。

实际工程里,绝大多数需求用二叉堆就够了,这些变种更多出现在论文和高级算法课程中。了解变种不是为了背名字,而是知道“存在哪些设计选择”,等哪天真遇到特殊需求场景,你能判断出该往哪个方向调研。

6.2 什么时候该用堆,什么时候别用

堆适合的场景是“动态集合上反复取最值”。如果数据是静态的,一次性取完最值就结束,排序往往更简单;如果需要频繁查询中间某个确切值,堆做不到,得靠哈希表或平衡树。还有一个容易忽略的细节:n 小于一定阈值时,排序和堆的差距很小,甚至排序因为缓存友好反而更快。处理 Top-K 前,先估算 K 和 n 的规模。K 很小比如 K = 5,直接维护一个大小为 K 的数组都比堆直观;K 很大接近 n,那不如直接排序取前 K。堆的优势区往往出现在 K 远小于 n 但也不至于太小,同时 n 大到你不想排全量数据的场景。

最后分享一个我自己用了很多年的习惯:写堆相关的代码时,先写一个几行的 verify 函数,逐节点检查“父和孩子是否满足堆序”,然后在 push 和 pop 的随机操作后反复调用它。很多隐蔽的下标越界、方向颠倒,都是靠这个校验器抓出来的。这不是堆独有的经验,所有结构复杂的数据结构实现,都值得配一个性质验证器。堆之所以让人愿意用它,从来不是因为它能一次性解决所有问题,而是因为它只靠两个动作,就把“动态取最值”这件事做到了足够优雅。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询