尧图精选

hot100堆专题:从大根堆小根堆到TopK与优先队列实战

🕒 发布时间:2026/9/24 22:38:42 📁 来源:尧图网络
我一直觉得在算法训练这个圈子里hot100是一个很有分量的词。它不是单纯的题单更像是一张“高频考点地图”把面试里最容易出现的题型、最核心的数据结构、最经典的解题套路全部浓缩在了一起。而堆Heap恰恰是这张地图上非常特殊的一个节点——它不算基础课里的常客但一旦出现往往就是拉开差距的地方。很多人对堆的感觉是“既熟悉又陌生”。熟悉是因为你天天听说大根堆、小根堆、堆排序、优先队列甚至在业务代码里也经常用PriorityQueue陌生是因为真正到了手撕代码的时候不是忘了siftUp和siftDown的顺序就是死活想不起来TopK到底该用大根堆还是小根堆。我最初刷hot100的堆专题时也没少在这上面翻车所以这篇就把我的理解、踩过的坑、以及总结出来的套路一次性梳理清楚。这篇文章不只讲题目本身我还会连带解释堆排序的稳定性问题、PriorityQueue的迭代器陷阱、堆内存和数据结构堆的区别以及真实业务里怎么用堆解决“编译器堆空间不足”这类外围问题。适合刚开始刷hot100的读者也适合那些刷过一遍但“一看就会、一写就废”的朋友拿来当成复习提纲看也可以。1. 内容整体设计与思路拆解1.1 堆的本质为什么hot100里它是常客先聊一个最根本的问题堆到底是个什么东西一句话总结堆是一种完全二叉树并且满足“堆序性”——每个父节点的值要么大于等于全部子节点大根堆要么小于等于全部子节点小根堆。这个结构最大的优势在于获取最值的时间复杂度是O(1)插入和删除最值的时间复杂度是O(log n)。这个特性在算法题里简直太讨喜了。你要处理“动态求最大/最小”的场景比如实时排行榜、数据流中位数、任务调度数组做不到O(1)拿最值排序又太重而堆恰好卡在两者之间的甜点上。正因如此hot100里涉及堆的题目虽然不算多但每一道都能精准考察“你懂不懂动态极值问题的本质”。还有一个藏在背后的考察点你能不能识别出“可以用堆”的信号。很多题目不会直接告诉你“请用堆”而是通过“第K大”“前K个”“中位数”“合并K个有序链表”这些说法来暗示。如果你没有建立起这种敏感度很容易往排序、二分、滑窗这些方向跑偏然后就越写越复杂。1.2 两种“堆”必须分清数据结构堆 vs 内存堆讨论堆的算法题之前我建议先把一个概念混淆点理清楚。hot100相关热搜里同时出现了“堆和栈”“堆外内存”“编译器的堆空间不足”这些词说的其实是内存管理里的堆区Heap Area不是算法数据结构里那个堆。这俩完全没有关系只是共享了同一个英文单词。很多新手在这上面吃过亏——以为学了数据结构的堆就能理解JVM堆内存或者反过来以为理解了内存分布就能秒杀算法题。实际上数据结构堆一种抽象数据类型用于动态维护最值典型实现是二叉堆、斐波那契堆等。内存堆区程序运行时动态分配内存的区域由new或malloc产生的对象在这里存放由GC或手动释放管理。栈区存放局部变量、函数调用帧速度快、容量小由编译器自动管理。搞清楚这一点你在看“编译器的堆空间不足”这类报错时就能理性分析——它说的是内存分配的堆区不够用了可以考虑调大堆内存、检查内存泄漏而不是去改你的PriorityQueue逻辑。1.3 选型思考手撕二叉堆还是直接调优先队列我在刷hot100的过程中最大的一个纠结就是到底应该每道题都手写堆还是直接调用语言自带的优先队列我的结论是分阶段。第一遍刷题优先使用系统库。以Java为例就是PriorityQueue以Python为例就是heapq。核心目的是先把“什么时候用堆、堆能解决什么问题”的思路练通不要在底层实现上耗费太多时间。系统库的性能足够好面试里也允许用。第二遍刷题一定要用手写堆把核心题型过一遍。为什么因为很多进阶题目比如“数据流中位数”的延迟删除、TopK的堆内元素更新需要你精准控制堆的容量和堆内元素此时只是调用API而不理解内部细节会非常被动。更现实的原因是面试官很可能追问一行“PriorityQueue的remove(Object o)时间复杂度是多少”如果你不知道它实际上是O(n)就很容易翻车。后面我会专门展开这个细节。2. 核心细节解析与实操要点2.1 hot100堆题目全景盘点我按题型把hot100里出现频率较高的堆相关题目做了个分类方便你建立全局视角。题型归类典型题目核心思路复杂度TopK问题数组中的第K个最大元素维护一个容量为K的小根堆O(n log K)前K个高频元素前K个高频元素哈希统计 小根堆O(n log K)合并有序结构合并K个升序链表链表节点入堆每次弹出最小O(n log K)中位数问题数据流的中位数大根堆 小根堆维持数量平衡O(log n) / 查询O(1)贪心调度任务调度器统计频率 堆模拟冷却O(n log n)双堆技巧滑动窗口中位数两个堆 延迟删除O(n log k)你会发现堆题目并不是独立存在的它常常和哈希表、贪心、滑动窗口这些技巧组合出现。所以刷的时候我的建议是不要孤立刷堆题尽量放在“哈希 堆”“双指针 堆”这种组合场景里去理解。2.2 核心原理解读小根堆的“删小留大”思想真正的重点来了。TopK问题里很多人会搞反——找“第K大”为什么用小根堆而不是大根堆我在这里给你彻底讲透。假设数组是[3,2,1,5,6,4]要找第3大的元素。如果你用大根堆每次弹出最大值弹出3次就能得到第3大但问题是你需要先把所有数据全部建堆然后执行K次弹出整体时间复杂度是O(n K log n)。如果K很大这个方案就不太划算。更好的方式是维护一个容量为K的小根堆遍历数组先把前K个元素放入堆中。继续遍历剩余元素每遇到一个比堆顶大的元素就弹出堆顶再把这个元素入堆。遍历结束后堆顶就是第K大的元素。为什么这样是对的因为容量为K的小根堆堆顶永远是堆内K个元素里最小的那个。当所有元素都处理完堆内保留的就是“全局最大的K个元素”而堆顶就是这K个里最小的一一恰好是“第K大”。整个过程不需要对所有元素排序也不需要保留整个堆空间复杂度从O(n)降到了O(K)。2.3 实操要点PriorityQueue的四个常见陷阱很多人在笔试里用PriorityQueue翻车不是思路错了而是API用错了。我总结四个最常见的问题陷阱一PriorityQueue默认是小根堆不是大根堆。Java里默认构造器就是小根堆。想用大根堆需要传入一个反转比较器new PriorityQueue(Comparator.reverseOrder())。Python的heapq默认也是小根堆大根堆需要存相反数。陷阱二迭代器顺序不等于堆序。PriorityQueue只保证poll()、peek()的时候能拿到正确的最值但它的iterator()返回的顺序是完全随机的不保证有序。如果你写了pq.iterator()去检查堆内元素的前K个大概率会看到乱序数据这不是bug而是设计如此。陷阱三remove(Object o)时间复杂度是O(n)。堆的删除最值操作是O(log n)但按值删除不是。PriorityQueue的remove(Object o)需要先线性扫描找到元素位置然后再删除调整时间复杂度O(n)。这个在“滑动窗口中位数”这类需要主动删掉过期元素的题里非常致命我后面会讲替代方案。陷阱四自定义对象的比较器不能只靠compareTo的默认实现。如果你往堆里塞的是Map.Entry、int[]这种结构务必手写比较逻辑。比如int[]默认比较的是引用地址不按数组内容比较——这是新手特别容易忽略的一点。2.4 手写二叉堆的底层实现细节虽然日常可以用系统库但理解了手写堆才能真正懂堆的精髓。核心就两个操作上浮siftUp和下沉siftDown以及一个堆化heapify过程。上浮操作出现在插入场景新元素先放到数组末尾然后不断和父节点比较如果破坏堆序性就交换。下沉操作出现在弹出场景取出堆顶后把最后一个元素移到堆顶然后不断和左右子节点中“更小”小根堆的那个比较并下沉。这里有一个很多人容易写错的地方heapify的下沉起点不是根节点而是从倒数第一个非叶子节点开始往前遍历。最后一个非叶子节点的下标是n/2 - 1索引从0开始。从n/2 - 1递减到0逐一执行下沉操作即可在O(n)时间内完成建堆。我写一个简化版本的Java代码小根堆供你参考public class MinHeap { private int[] data; private int size; private int capacity; public MinHeap(int capacity) { this.capacity capacity; this.data new int[capacity]; this.size 0; } public void push(int val) { if (size capacity) { throw new IllegalStateException(heap is full); } data[size] val; siftUp(size); size; } public int pop() { if (size 0) { throw new IllegalStateException(heap is empty); } int res data[0]; data[0] data[size - 1]; size--; siftDown(0); return res; } private void siftUp(int i) { while (i 0) { int parent (i - 1) / 2; if (data[parent] data[i]) { break; } swap(parent, i); i parent; } } private void siftDown(int i) { while (2 * i 1 size) { int left 2 * i 1; int right 2 * i 2; int smallest left; if (right size data[right] data[left]) { smallest right; } if (data[i] data[smallest]) { break; } swap(i, smallest); i smallest; } } private void swap(int i, int j) { int tmp data[i]; data[i] data[j]; data[j] tmp; } }注意siftDown的循环条件2 * i 1 size说明至少存在左子节点。这个条件写错容易数组越界或者漏掉右子节点比较。我最初写的时候经常会忘记判断right size结果越界。3. 实操过程与核心环节实现3.1 实战一热题TopK——数组中的第K个最大元素这是hot100堆题里最经典的一道也是面试频率极高的一个变体。我给出用小根堆的完整解法并顺带对比一下其他常见解法。public int findKthLargest(int[] nums, int k) { PriorityQueueInteger minHeap new PriorityQueue(); for (int num : nums) { minHeap.offer(num); if (minHeap.size() k) { minHeap.poll(); } } return minHeap.peek(); }这段代码很短但信息量很大。每次插入后一旦堆的大小超过K就弹出堆顶——也就是当前堆内最小的元素。这个操作保证了堆内永远是“已经扫描过的元素中最大的K个”。最终堆顶就是第K大。如果你觉得这个思路有点绕换个角度理解把堆想象成一个“淘汰赛场地”场地只能站K个人。每个新选手进来和当前场地里最弱的比如果新选手更弱就直接淘汰如果更强就把最弱的请出去。这样比到最后场地里站着的一定是全体选手里最强的K个而最弱的那个站在淘汰边缘的就是第K强。这个题目还有一个替代方案是快速选择Quick Select平均时间复杂度O(n)但最坏情况O(n²)而且需要手写分区逻辑。堆解法的优势是稳定O(n log K)而且不需要修改原数组。笔试阶段我推荐优先用堆解法面试时再提一句“也可以用快速选择优化到平均O(n)”瞬间展示知识宽度。3.2 实战二合并K个升序链表——堆的“多路归并”应用这道题的直观做法是每次从K个链表的头节点中选最小值然后指针后移。如果每次用扫描找最小那每次是O(K)总复杂度O(nK)K很大时效率很低。用堆优化后每次选最小值变成O(log K)整体降到O(n log K)。具体做法是把K个链表的头节点全部塞入小根堆比较器按节点值排序。每次弹出最小节点接入结果链表尾部然后把该节点的next节点入堆继续循环。import heapq class Solution: def mergeKLists(self, lists): dummy ListNode(0) cur dummy heap [] for i, node in enumerate(lists): if node: heapq.heappush(heap, (node.val, i, node)) while heap: val, i, node heapq.heappop(heap) cur.next node cur cur.next if node.next: heapq.heappush(heap, (node.next.val, i, node.next)) return dummy.next这里有一个Python专属的坑heapq会比较元组的第二个元素。如果两个节点的val相同它会接着比较第二个元素。所以我们在元组里加入了i链表索引来打破平局防止它去比较ListNode对象ListNode不支持比较操作时会直接抛TypeError。Java版本里比较器一般是PriorityQueueListNode pq new PriorityQueue((a, b) - a.val - b.val);这个写法在val差值可能超过int范围时有溢出风险严谨一点应该写成Integer.compare(a.val, b.val)。虽然这个题目的val范围一般不会触发但编码习惯还是要养好。3.3 实战三堆与哈希表的组合——前K个高频元素这道题是“哈希计数 堆”的经典组合。第一步用哈希表统计每个元素的频率第二步把小根堆按频率排序维护K个高频元素。public int[] topKFrequent(int[] nums, int k) { MapInteger, Integer freq new HashMap(); for (int num : nums) { freq.put(num, freq.getOrDefault(num, 0) 1); } PriorityQueueInteger minHeap new PriorityQueue((a, b) - freq.get(a) - freq.get(b)); for (int key : freq.keySet()) { minHeap.offer(key); if (minHeap.size() k) { minHeap.poll(); } } int[] res new int[k]; for (int i 0; i k; i) { res[i] minHeap.poll(); } return res; }这里我的习惯是入堆时不直接塞Map.Entry而是只塞key比较器里去查频率。这样堆内元素更轻逻辑也更清晰。另外一个优化点是如果k和freq.size()差不多大其实可以用大根堆然后全部入堆再弹出K次复杂度是O(n log n)反而更简单。刷题不是死记模板而是要能根据数据规模调整策略。3.4 实战四双堆技巧——数据流的中位数数据流中位数是一个很漂亮的堆应用。思路是维护两个堆一个大根堆maxHeap存较小的一半一个小根堆minHeap存较大的一半同时保证两个堆的大小差不超过1。这样中位数要么是某个堆的堆顶要么是两个堆顶的平均值。具体插入逻辑如果新元素小于等于大根堆堆顶插入大根堆否则插入小根堆。如果大根堆元素个数比小根堆多2就把大根堆堆顶移到小根堆反之亦然。查询中位数时如果两堆大小相等返回堆顶均值否则返回元素多的那个堆的堆顶。class MedianFinder: def __init__(self): self.max_heap [] # 存较小的一半Python用负数模拟大根堆 self.min_heap [] # 存较大的一半 def addNum(self, num: int) - None: if not self.max_heap or num -self.max_heap[0]: heapq.heappush(self.max_heap, -num) else: heapq.heappush(self.min_heap, num) if len(self.max_heap) len(self.min_heap) 1: heapq.heappush(self.min_heap, -heapq.heappop(self.max_heap)) elif len(self.min_heap) len(self.max_heap): heapq.heappush(self.max_heap, -heapq.heappop(self.min_heap)) def findMedian(self) - float: if len(self.max_heap) len(self.min_heap): return -self.max_heap[0] return (-self.max_heap[0] self.min_heap[0]) / 2这里Python的大根堆模拟是个高频考点heapq没有大根堆只能push负数、pop时再取负。我见过很多人写着写着忘记加负号调试半天才发现问题是数据进堆的时候被改了符号。双堆问题还有一个非常恶心的进阶变体滑动窗口中位数。它要求在滑动窗口动的过程中堆里的过期元素需要被移除。此时直接调用remove(Object)是O(n)可能超时。业界常用的优化是延迟删除lazy deletion不直接删除元素而是用一个哈希表记录待删除元素的次数每次poll()或peek()时检查堆顶是否被标记删除如果是就弹掉并继续检查。延迟删除的本质把删除操作从“立刻执行”变成“延迟到堆顶再处理”。这个技巧非常重要它本质上是“堆无法随机删除”这一缺陷的通用解法。你在面试里能想到主动使用延迟删除通常会被视为对堆有深入理解。4. 常见问题与排查技巧实录4.1 问题速查表我把堆相关题目里最常见的报错、错误结果、逻辑bug整理成了一个速查表方便你写题时自查。现象原因解决方案堆内出现null元素入堆前没判空入堆前检查node null结果顺序不对PriorityQueue迭代器无序改用poll()逐次取出找TopK结果偏大/偏小堆类型选反第K大用小根堆第K小用大根堆比较器报错未处理相等情况或差值溢出用Integer.compare或显式处理内存不足业务场景堆区容量不够或内存泄漏调大-Xmx参数排查泄漏点Python比较元组报错heapq试图比较不可比较对象元组中添加唯一索引字段打破平局双堆数据不平衡插入方向/修正逻辑写错插入后立刻检查两堆大小差是否 ≤ 14.2 编译器的堆空间不足到底是什么热词里的“编译器的堆空间不足”也值得说一句。它和你刷题用的数据结构堆没关系但很多人在本地IDE里遇到过。这个报错的完整含义是程序运行时Java虚拟机的堆内存不够分配新对象了。排查思路按照这个顺序来先确认是不是偶发性的如果每次运行都报看代码里有没有死循环创建对象、大集合没释放等。如果确定是测试数据过大导致的可以临时调大JVM堆内存。IDEA里在Run Configuration - VM options里加-Xms256m -Xmx1024m。如果数据量正常但还是报错用jmap或VisualVM导出堆转储文件排查是不是某个类被无限缓存了。还有一种“隐蔽”情况递归太深导致栈溢出但报错信息可能被误报成堆相关。栈和堆是两块区域报错名称不一样StackOverflowError和OutOfMemoryError处理方式完全不同。搞明白这点你就能把“内存的堆”和“算法的堆”彻底分开面试被问到也不会慌。4.3 堆排序的不稳定性与一次真实复盘堆排序在工程上不如快速排序常用但它的思想你必须掌握。堆排序分两步建堆 排序。利用大根堆每次把最大值交换到数组末尾然后把剩余部分重新堆化重复执行。public void heapSort(int[] arr) { int n arr.length; 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); } }这里要强调的是堆排序是不稳定的。原因是堆内元素会进行大范围的“跳跃式”交换相同的元素可能在堆化过程中改变相对顺序。如果你需要稳定排序或者排序的对象的equals依赖多个字段就不能用堆排序。我在一次业务开发中踩过这个坑当时的排序需求是“按分数降序分数相同按时间升序”我图方便用PriorityQueue做全排序结果发现同分数的记录顺序每次重新跑都不一样。后来改成先按完整比较器排序或在入堆时给每个元素加一个自增序号才解决。提示在自定义比较器里如果两个对象“相等”compare返回0它们相对顺序可能被堆重新打乱。想要稳定要么用稳定排序MergeSort/TimSort要么在入堆时附加一个递增序号字段。4.4 延迟删除的实现细节刚才提到延迟删除我在这里给出一个最小可用的实现思路方便你在“数据流中位数”“滑动窗口中位数”里直接套用。核心数据结构是三件套堆本身PriorityQueue待删除计数Map元素, 删除次数一个cleanUp()方法在每次peek()/poll()前调用private void cleanUp(PriorityQueueInteger heap, MapInteger, Integer toDelete) { while (!heap.isEmpty()) { int top heap.peek(); int cnt toDelete.getOrDefault(top, 0); if (cnt 0) { heap.poll(); if (cnt 1) { toDelete.remove(top); } else { toDelete.put(top, cnt - 1); } } else { break; } } }这个写法的关键是删除元素的时候不要直接操作堆而是记录到toDelete每次真正要看堆顶的时候先把堆顶已经“被标记删除”的元素全部弹掉。它在均摊意义下保证每个元素最多入堆一次、出堆一次整体复杂度仍然是O(n log n)。我强烈建议你在练习时把这段代码抄下来反复理解。因为延迟删除这个概念不仅出现在堆题里也出现在很多缓存淘汰、调度系统的设计里属于通用性很强的工程技巧。5. 堆在业务与进阶场景中的扩展5.1 优先队列在业务里的真实应用算法题里的堆落到工程上就是优先队列。它的典型应用场景我列一下任务调度多个定时任务每次取最近到期的一个执行本质是小根堆。TopK榜单比如热门文章Top100不必维护全量排序一个容量100的小根堆就够了。合并有序文件大数据处理中外排External Sort的核心就是多路归并。Dijkstra最短路径每次取当前距离最小的未访问节点经典用小根堆优化。我实际参与过的一个项目里需要实时计算“当前在线用户中活跃度最高的10个用户”。最初方案是每次全量排序QPS一高就扛不住。后来改成维护一个容量10的小根堆每个用户活跃度变化时“先删后加”QPS提升了近一个数量级。核心思路和hot100里的TopK题几乎一模一样区别只是数据来源从数组变成了流式事件。5.2 从小土堆pytorch热词聊起被名字误导的搜索热搜词里有个“小土堆pytorch学习笔记”这里也顺手澄清一下。“小土堆”是一位知识博主的昵称不是数据结构“堆”更不是堆排序。它出现在hot100堆的热搜组合里大概率是算法学习者和深度学习学习者之间的搜索串扰逗号把两个群体的关键词连在了一起。但这种现象背后其实有值得说的一点不同领域里的同一个词解决的是完全不同的两类问题。在PyTorch学习里“小土堆”指的是B站上一个讲深度学习的up主在算法题里“堆”是一种数据结构在JVM调优里“堆”是一块内存区域。搜索引擎把它们聚在一起是因为关键词的字面匹配而不是语义共通。作为技术学习者分辨这些信息的能力其实很重要它能帮你节省大量的检索成本。我不展开讲深度学习的内容只强调一个搜索技巧搜索技术关键词尽量带上下文限定词。比如搜“堆 算法”或“堆 数据结构”而不是只搜“堆”搜“堆内存 调优”而不是“堆空间不足”。这个习惯看起来很简单但确实能大幅提升搜索准确率。5.3 堆的进阶变体堆外内存与直接内存既然热词里提到了“堆外内存”我顺便把这块也讲透它和算法堆、JVM堆都不太一样但属于同一个“堆”字引发的知识扩展。堆外内存Off-Heap Memory指的是JVM管理的内存中不归GC管理的部分典型代表是DirectByteBuffer和Unsafe.allocateMemory。它的优点是减少GC压力、适合I/O操作零拷贝缺点是手动释放容易泄漏。场景举例Netty默认使用堆外内存做数据读写缓冲区性能比堆内高。操作方式Java在ByteBuffer.allocateDirect()中分配通过finalizer或显式引用清理。注意事项堆外内存也有OutOfMemoryError风险但报错会和普通堆OOM不同通常是OutOfMemoryError: Direct buffer memory。如果你在面试中聊到堆相关的话题能顺手从“优先队列”延伸到“堆外内存”这个概念会让面试官觉得你的知识面不局限在刷题层面。但切记不要硬扯只有相关的时候提一句就够了。5.4 堆排序的工程定位什么时候该用它很多人问既然堆排序不是稳定排序时间复杂度和快排一样是O(n log n)实际工程里到底什么时候用它我的经验是这个分法需要对数组原地排序、且不需要稳定性的场景可以考虑堆排序。对链表排序堆排序需要额外O(n)空间不合适归并排序才是正解。大数据量下的“前N个”问题堆是首选因为它不需要把全部数据排好序。“动态插入数据每次都要取最值”的场景优先队列堆天然合适。一句话总结堆不是用来替代快排的它是为解决“动态最值/局部排序”而存在的。把握住这个定位你就能在合适的场景里自然而然地选用堆而不是把堆排序当成万能排序工具。6. 刷题与实战的整合复盘6.1 我的hot100堆专题刷题节奏如果你现在正准备刷hot100我建议按下面的顺序推进效率会高很多第一轮先掌握TopK的两个变体数组中的第K大、前K个高频元素。这两道题覆盖了堆最核心的“容量限制”思路也涵盖了哈希表的配合。第二轮做“合并K个升序链表”和“数据流中位数”。前者练堆的节点组织与比较器后者练双堆配合同时引入“大小堆平衡”这个高频考点。第三轮做“任务调度器”或带有贪心色彩的堆题理解“堆 贪心”的组合套路。这轮之后你基本可以应对面试里绝大多数堆相关题目。每道题完成之后我要求自己必须做一次“反刍”这题不用堆能不能做复杂度差多少如果面试官限制空间复杂度必须O(1)我该怎么办这题的输入规模如果变成流式数据我还能不能用同样的思路这种追问比把题背下来有用得多。因为hot100刷完之后你一定会遇到新题真正起作用的是那个“识别问题本质、选择合适数据结构”的思维模型。6.2 面试现场的手撕堆问答参考手撕堆相关题目时面试官大概率会追问以下几个点我把推荐回答整理如下QPriorityQueue的底层是什么A完全二叉树二叉堆用数组存储下标i的左右子节点是2i1和2i2。Q插入、删除最值的时间复杂度是多少A插入是O(log n)删除最值是O(log n)获取最值是O(1)。按值删除是O(n)。Q堆排序的空间复杂度A原地排序O(1)额外空间如果辅助函数用递归实现可能隐式占用O(log n)栈帧用迭代实现就是O(1)。Q为什么堆排序不稳定A堆化过程中存在父子节点的大跨度交换相等元素的相对顺序无法保证。举例[5a, 5b, 3]建堆后堆顶可能是任意一个5顺序已经乱。这几个问题的核心其实都在考察你对底层原理的理解而不是背答案。所以我一直强调刷堆专题不要只停留在“调API解决问题”的层面一定要花时间理解数组下标映射、上浮下沉、以及复杂度来源。6.3 延迟删除与系统设计的关联延迟删除不仅存在于算法题里在现代系统设计里也是一种常见的一致性优化思路。比如Redis的过期键清理策略就采用了“惰性删除 定期删除”的组合“惰性删除”和堆题的延迟删除思路非常相似。JVM的并发标记清理CMS/G1也在GC过程中采用增量式、延迟化的标记策略。消息队列里的延迟队列本质上是小根堆存到期时间戳配合定时触发。如果你能在理解“堆”的同时意识到它和这些系统机制的关联你的技术理解就会从一个数据结构题目升级到对系统设计的整体把握。这也是我特别建议读者在刷题之外多看源码、多接触真实项目的原因。7. 踩坑心得与实战建议7.1 写堆相关代码时最该养成的三个习惯第一个习惯手写比较器时始终使用完整comparator不依赖默认自然序。哪怕只是两个Integer比较也要写清楚是升序还是降序避免团队协作时别人改了一行Comparator导致结果静默变化。第二个习惯入堆之前考虑相等元素的处理。如果你的业务要求稳定排序或者对象的equals与比较器结果不一致一定要额外设计“打破平局”的规则比如附加自增ID。第三个习惯注意堆容量的边界条件。使用PriorityQueue时如果没传容量默认是11会扩容。扩容本身也是拷贝数组频繁扩容在大数据量下会有性能开销。在已知K的情况下一开始就new PriorityQueue(K 1)既避免扩容也在语义上表达了“这个堆的用途是容量限制”。7.2 一次经典翻车TopK误用大根堆的教训前几年我在一个面试模拟群里看到有人发了一道“数组中第K大”的代码他用的是大根堆逻辑是先建全量堆然后poll()K次代码如下public int findKthLargest(int[] nums, int k) { PriorityQueueInteger maxHeap new PriorityQueue(Comparator.reverseOrder()); for (int num : nums) maxHeap.offer(num); for (int i 0; i k - 1; i) maxHeap.poll(); return maxHeap.peek(); }这个方案在功能上是正确的但面试官问了一句“如果nums有一亿个元素K是1000你的方案内存占用多少”他答不上来。大根堆需要把一亿个元素全部入堆空间O(n)小根堆方案只维护K个元素空间O(K)。面试官真正想考察的是你有没有考虑到数据规模对内存的影响。所以我的建议很明确看到“第K大/前K个”优先往“容量为K的小根堆”上想看到“第K小/前K个最小”优先往“容量为K的大根堆”上想。这个方向感建立起来之后TopK系列的基本盘就稳了。7.3 给新手的最终建议如果你现在刚开始接触堆我建议你先别急着刷题。花20分钟用笔在纸上画出一个小根堆的插入和删除全过程亲手动一动“上浮”和“下沉”比看100分钟教程都有用。我第一次理解堆就是在纸上画了三个小时的树状图之后突然开窍的。接下来打开编辑器把前面我给出的MinHeap代码抄一遍、跑一遍、单步调试一遍。重点观察每次插入时数组下标的变化以及每次弹出时siftDown的路径。这段调试经验会成为你后续理解PriorityQueue内部机制最宝贵的资产。然后去刷hot100里的堆题。第一遍不要怕写错哪怕是照着题解抄完、再自己默写一遍也可以。第二遍关上题解独立实现。第三遍尝试换一种语言实现比如你用Java刷那就用Python的heapq再写一遍你会更加深刻理解“堆”这个抽象概念和具体语言API之间的边界。等这三步走完你再看堆相关的题目就会有一种“不过如此”的感觉。那些曾经让你头痛的TopK、中位数、合并链表都会变成“无非是堆 某种辅助结构”的套路题。到了这个阶段你就可以去挑战更难的堆题、去阅读PriorityQueue的JDK源码甚至去研究斐波那契堆这种进阶结构了。我个人在刷完堆专题之后最大的体会是数据结构的学习千万不要只停留在“会做题”的层面。堆这个结构之所以在面试里高频出现是因为它足够小、足够经典、又能串联起贪心、分治、系统设计等大量内容。把它吃透收益的绝对不只是一道题而是一整套解决“动态极值”问题的思维方式。希望这篇梳理能帮你把堆这块硬骨头啃下来。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →