堆与优先队列:Top K 问题的经典工具
学到树结构这里,很多人会自然把注意力放在“有序查找树”上,例如 BST、AVL、红黑树。但树的价值并不只体现在查找。还有一类非常高频的问题,并不关心“整体有序”,也不要求中序遍历得到完整顺序,而是只关心某个局部最值,比如:
- 当前最小或最大元素是谁;
- 每次都想快速取出优先级最高的任务;
- 数据流里最大的前
K个值是什么; - 调度系统里下一个最该处理的对象是谁。
这类问题最经典的结构就是堆,以及建立在堆之上的优先队列。它们的设计思路和 BST 非常不同:BST 强调全局搜索有序,而堆强调局部父子有序。也正因为这种局部有序,堆特别适合做“快速取最值”,而不适合做有序范围查找。
所以这一篇最重要的,不只是知道“堆是完全二叉树”,而是看清它到底在优化什么,又为什么会成为 Top K、调度和优先任务处理里的经典工具。
一、什么是堆
堆通常可以定义为:
一种基于完全二叉树实现的特殊树结构,并满足特定的堆序性质。
这里有两个关键词必须先抓住。
1. 完全二叉树
堆在形状上必须是一棵完全二叉树,也就是说:
- 除最后一层外,前面每层都满;
- 最后一层节点从左到右连续排列。
2. 堆序性质
根据比较方向不同,堆通常分为两类:
- 大根堆:每个节点值都大于等于其孩子节点;
- 小根堆:每个节点值都小于等于其孩子节点。
堆的关键就在于:
- 它只保证父子之间的局部顺序;
- 不保证整棵树像 BST 那样有全局有序性。
二、为什么完全二叉树这个形状很重要
因为完全二叉树非常适合用数组存储。
对于一棵按层从左到右编号的完全二叉树:
- 某个节点下标为
i; - 它的左孩子通常是
2i或2i + 1; - 右孩子通常是
2i + 1或2i + 2; - 父节点也能直接通过下标计算出来。
这意味着:
- 不需要像链式二叉树那样显式维护左右指针;
- 用数组就能高效表示整棵堆;
- 局部交换和上浮下沉操作都很方便。
所以堆之所以工程上实用,除了堆序性质之外,完全二叉树带来的顺序存储便利性也非常关键。
三、堆和 BST 的根本区别是什么
这是学习堆时最容易混淆的一点。
1. BST
- 强调左小右大;
- 有全局搜索顺序;
- 中序遍历能得到有序序列;
- 适合查找某个具体值。
2. 堆
- 只强调父子之间的局部大小关系;
- 根节点一定是全局最值或最大值;
- 其他节点之间没有完整有序关系;
- 适合快速取最值。
可以把区别浓缩成一句话:
BST 擅长有序查找,堆擅长最值维护。
四、什么是优先队列
优先队列可以理解成:
出队顺序不再按进入先后,而是按优先级高低决定的队列。
例如:
- 普通队列是先进先出;
- 优先队列则是谁优先级高,谁先出来。
堆之所以和优先队列紧密绑定,是因为:
- 堆能快速拿到当前最小或最大元素;
- 插入新元素后也能高效恢复有序关系;
- 这恰好满足优先队列的核心需求。
所以很多语言标准库里的优先队列,底层默认都是堆实现。
五、为什么堆特别适合“快速取最值”
因为根节点永远是当前全局最值之一:
- 大根堆的根是最大值;
- 小根堆的根是最小值。
于是:
- 取最值通常是
O(1); - 删除根后,通过一次下沉调整恢复堆序,复杂度通常是
O(log n); - 插入新值后,通过上浮调整,也通常是
O(log n)。
这说明堆非常适合那种“不断加入新元素,同时不断取当前最优元素”的过程。
六、堆的插入为什么叫上浮
向堆中插入新元素时,通常会先把它放到完全二叉树的最后一个位置,也就是数组末尾。这样可以保持树形不变。
但放进去之后,它未必满足堆序性质。于是就需要:
- 拿它和父节点比较;
- 如果违反堆序,就交换;
- 继续向上比较,直到位置正确。
这个过程就叫上浮。
本质上,上浮是在回答:
这个新元素应该往上爬到哪个位置,才能让局部父子关系重新成立?
七、删除堆顶为什么叫下沉
删除堆顶时,不能直接把根删掉后留下空洞。通常做法是:
- 用最后一个元素补到根位置;
- 然后看它是否破坏堆序;
- 如果破坏,就和更合适的孩子交换;
- 一路向下调整到正确位置。
这个过程叫下沉。
也就是说:
- 插入靠上浮;
- 删除堆顶靠下沉。
这两类局部调整,就是堆维护的核心。
八、为什么建堆可以比逐个插入更高效
如果要把一个无序数组变成堆,最直观的想法是一个个插入,但那样复杂度通常是 O(n log n)。
更高效的方法通常是:
- 直接把数组看成一棵完全二叉树;
- 从最后一个非叶子节点开始,依次向前做下沉;
- 最终整体调整成合法堆。
这个过程叫原地建堆,它的整体复杂度可以做到 O(n)。
这个结论很多人第一次看到会觉得反直觉,但它非常经典,也说明“批量构建”和“逐个维护”在复杂度上可能完全不同。
九、堆排序为什么成立
堆排序的核心思想其实很直接:
- 先把数组建成大根堆;
- 此时堆顶就是最大值;
- 把堆顶和最后一个元素交换;
- 缩小堆范围,再继续调整;
- 重复直到所有元素归位。
这样就能不断把当前最大元素放到数组末尾。
堆排序的特点通常是:
- 时间复杂度稳定为
O(n log n); - 原地排序;
- 但常数和缓存局部性通常不如快速排序友好。
所以堆不只是优先队列工具,也是一种排序基础。
十、为什么 Top K 问题经常用堆
这是堆最经典的应用场景之一。
假设你有海量数据,只想要最大的前 K 个值。这时如果把所有数据全排序,代价往往太大。
更高效的思路通常是:
- 维护一个大小为
K的小根堆; - 当前堆里保存“目前见过的前 K 大元素”;
- 每来一个新元素,就和堆顶比较;
- 如果更大,就替换堆顶并重新调整;
- 如果不够大,就直接丢弃。
这样最终堆中留下的就是答案。
这说明堆最大的工程价值之一,就是:
它非常适合维护“有限个最优候选”。
十一、堆在真实开发里常见在哪里
堆和优先队列在工程里非常高频,例如:
- 定时任务调度;
- 操作系统或线程调度的优先级管理;
- 事件驱动系统中的最早到期任务;
- 合并多个有序流;
Top K、中位数维护、数据流统计;- 图算法中的 Dijkstra、Prim。
这些场景的共同点是:
- 数据不断变化;
- 但每次只关心最优或最小的一小部分。
十二、堆和优先队列有哪些局限
1. 不适合查找任意元素
堆只对堆顶有最值保证,内部节点之间不具备完整搜索顺序。
2. 不适合范围查询
你不能像 BST 那样轻松获取某个区间内所有元素。
3. 删除任意指定元素不如删除堆顶自然
堆最擅长的是围绕堆顶操作,而不是任意位置操作。
所以堆并不是“更强的 BST”,而是针对不同问题优化出来的另一种结构。
十三、学习堆时最容易踩的坑
1. 把堆和完全排序混为一谈
堆只保证局部堆序,不代表整体有序。
2. 把堆和 BST 混淆
它们优化目标完全不同。
3. 不理解上浮和下沉的本质
它们本质上都是局部恢复堆序。
4. 只记住堆顶最值,不理解为什么完全二叉树适合数组实现
这一点恰恰是堆高效的重要基础。
总结
堆和优先队列的重要性,不只是因为它们经常出现在算法题里,而是因为它们为“动态维护最值”提供了一种非常高效的结构方案。真正值得先建立起来的,是这些核心认识:
- 堆是一种基于完全二叉树和局部堆序性质的特殊树结构;
- 它不提供全局有序查找,但能快速维护当前最值;
- 优先队列本质上就是按照优先级而不是到达顺序出队的队列;
- 插入靠上浮、删除堆顶靠下沉,二者都是局部恢复堆序;
- 堆在
Top K、调度、图算法和数据流统计里都非常高频; - 真正学会堆,不是会背定义,而是理解它为什么特别适合“只关心最优元素”的问题模式。
把这一篇理解透之后,后面再去学并查集、哈夫曼树和图算法时,你会越来越明显地感受到:很多高效算法背后,真正起支撑作用的,并不是完整排序,而是像堆这样只维护“局部最关键顺序”的结构。
参考资源: