ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

堆数据结构详解:从底层原理到Top K、优先队列实战

2026/9/13 9:32:10 拓冰建站 浏览量
堆数据结构详解:从底层原理到Top K、优先队列实战 堆这个数据结构说实话初学的时候我总觉得它有点“拧巴”——你说它是树吧又用数组存你说它有序吧又不是完全有序。但后来真正在业务里频繁用到它处理Top K、做定时任务、写优先队列之后我才意识到这货是数据结构里最“闷声发大财”的一个。这么说吧如果你准备面试堆几乎是必考的核心高频知识点如果你写业务代码凡是涉及“动态取最值”的场景堆基本都是最优解之一。这篇文章我就从底层原理、手写实现、工程应用到面试考点把堆一次性讲透希望对正在啃数据结构的你有点帮助。很多人在学堆的时候卡住的点不是“不会用”而是“不理解为什么”。比如为什么建堆的时间复杂度是O(n)而不是O(n log n)为什么堆排序不稳定为什么Java的PriorityQueue默认是小顶堆但C的priority_queue默认是大顶堆这些细节才是真正拉开差距的地方。所以这篇文章不会只给你贴一段代码而是把这些“为什么”都拆开揉碎讲清楚。1. 堆的核心概念与存储结构——为什么堆能高效找最大值1.1 大顶堆与小顶堆的定义堆在逻辑上是一棵完全二叉树在物理存储上通常用数组来实现。所谓完全二叉树就是除了最后一层其它层都是满的最后一层的节点从左到右连续排列中间不能有空缺。基于这个结构堆再附加一条约束任意父节点的值都不小于或不大于其子节点的值。如果父节点总是大于等于子节点那堆顶就是整个集合中的最大值这叫大顶堆。如果父节点总是小于等于子节点那堆顶就是最小值这叫小顶堆。这个约束只发生在父子之间兄弟之间谁大谁小是不管的。所以你只能说“堆顶是最大或最小值”不能说“堆是全局有序的”。这种“部分有序”的特性正是堆高效的原因——它用最小的代价维护了“取最值”这个核心诉求。我习惯把堆想象成一个公司的层级结构老板是堆顶能力值最高每一层的领导都比自己的下属强但平级部门之间的强弱没有硬性要求。如果你想找公司最强的人直接看老板就行如果老板离职了就从两个总监里挑一个顶上再由下面的人一层层补位。这个补位过程就是堆的“下沉”和“上浮”。1.2 为什么用数组存储堆而不是链表这一点很多初学者会忽略。堆用数组存储关键在于完全二叉树的编号规律。如果根节点编号为0那么对于任意下标i的节点左子节点下标2 * i 1右子节点下标2 * i 2父节点下标(i - 1) / 2也就是说不需要存储任何指针通过下标计算就能在父子节点之间来回跳转。对比一下链表实现的二叉树每个节点要存左指针、右指针在堆这种需要频繁“从下往上比较、从顶往下交换”的场景里数组的缓存命中率更高性能更好内存开销也更小。我当年第一次手写堆的时候最大的体会就是数组下标运算虽然看起来枯燥但一旦写顺手了比指针操作省心太多不用担心引用指向空、不用担心释放内存逻辑清晰到可以闭着眼睛调。堆和普通的二叉搜索树BST最大的区别在于BST要求左子树所有节点小于根、右子树所有节点大于根这是强约束而堆只要求父子之间满足大小关系兄弟之间随意。所以BST的查找、插入、删除都是O(log n)但堆可以在O(1)时间拿到最大值或最小值。如果你只是想“快速拿最值”堆是比BST更轻量的选择。2. 堆的核心操作与复杂度分析——上浮、下沉与建堆2.1 上浮siftUp与下沉siftDown堆维护的两个核心动作堆的几乎所有操作本质上都是两个动作的组合上浮也叫向上调整、percolate up和下沉向下调整、percolate down。上浮的场景是某个节点的值变得比它的父节点更大大顶堆或更小小顶堆破坏了堆的性质。此时把该节点和父节点交换继续向上比较直到满足堆的性质。这个操作的时间复杂度是O(log n)因为它从某个节点一路向上走最多走到根节点。下沉的场景是某个节点的值比它的某个子节点更小大顶堆或更大小顶堆破坏了堆的性质。此时把该节点和较大或较小的子节点交换继续向下比较直到满足堆的性质。时间复杂度同样是O(log n)。我来打两个比方上浮一个刚入职的年轻人业绩特别突出公司觉得他是个人才就一层层往上提拔直到坐到适合他的位置。下沉一个空降的高管实际能力撑不起这个位置于是被一层层降级直到找到他真正能胜任的岗位。这两个动作就是堆的灵魂。插入元素时先把新元素放到数组末尾然后上浮删除堆顶时把数组末尾的元素放到堆顶然后下沉。理解了这两个动作堆就理解了80%。2.2 建堆的时间复杂度为什么是O(n)而不是O(n log n)这是一个很经典的问题也是很多面试官喜欢问的细节。从一个无序数组构建一个堆最直观的想法是从第一个元素开始一个个往后执行“插入上浮”操作这样每个元素插入是O(log n)n个元素就是O(n log n)。但更优的做法是从最后一个非叶子节点开始从后往前对每个节点执行一次下沉操作。这样建堆的时间复杂度是O(n)。为什么我们来做一个直觉层面的推演。假设堆的高度是h根节点所在层是第0层叶子节点所在层是第h层。从最后一个非叶子节点开始下沉意味着倒数第1层叶子节点上一层的节点每个最多下沉1次倒数第2层的节点每个最多下沉2次根节点下沉h次。总的下沉次数就是1 * 2^(h-1) 2 * 2^(h-2) ... h * 2^0。这个求和算下来数量级是O(2^h)也就是O(n)。因为大多数节点都集中在树的底部而这些底部节点下沉的深度很小所以总成本是线性的。简单记法高层的节点少但下沉深低层的节点多但下沉浅加起来就是O(n)。所以如果要手写建堆一定要用从后往前下沉的方式而不是一个个插入上浮。插入和删除堆顶的时间复杂度都是O(log n)。但注意查找最大值小顶堆查最小值是O(1)。这是堆最核心的优势——用O(log n)的代价换来O(1)的“取最值”能力。2.3 常用操作梳理为了方便复习我把堆的核心操作总结成一张速查表操作实现方式时间复杂度取堆顶最大/最小值直接返回数组第0个元素O(1)插入元素末尾追加 上浮O(log n)删除堆顶末尾元素覆盖堆顶 下沉O(log n)删除任意已知下标的元素先与末尾交换删除再视情况上浮或下沉O(log n)建堆从最后一个非叶子节点开始向前下沉O(n)堆排序反复取堆顶 重建堆O(n log n)这张表基本覆盖了堆的所有核心操作面试前可以拿它做一次快速自测。你能顺手写出建堆的代码能在白板上解释通为什么是O(n)堆这块就基本过关了。3. 堆的高频应用场景——从堆排序到Top K再到优先队列3.1 堆排序不是最快的但很稳定堆排序的思路特别朴素把数组建成大顶堆堆顶就是最大值把堆顶和数组末尾交换堆的大小减一再对新的堆顶执行下沉。反复执行数组就从小到大排列好了。堆排序的时间复杂度是稳定的O(n log n)不管是最好情况还是最坏情况。这一点比快排强——快排在极端情况下会退化到O(n²)。但堆排序在实际中往往跑不过快排原因在于它访问数组的方式是跳跃式的对CPU缓存不友好而且交换次数比较多。堆排序是不稳定排序。举个简单例子数组[5a, 5b, 3]建大顶堆后堆顶是5a堆排序第一步把5a和3交换此时5a就被排到了5b后面两个等值元素的相对顺序被打破了。所以如果业务里要求相同值的元素保持原有顺序堆排序是不能用的。实际工程中堆排序用得非常少因为快排的平均性能更好。但是手写堆排序是面试经典题而且理解堆排序是理解优先队列、Top K这些问题的基础。我个人建议你至少手写三遍以上直到能闭着眼写出来。3.2 优先队列堆最自然的应用优先队列本质上就是一个“能自动维护最值的队列”出队的时候总是弹出优先级最高或最低的元素。堆就是优先队列最常见的底层实现。举几个实际场景任务调度操作系统或任务框架里线程优先级最高的先执行新任务随时插入堆能很好地支持这种动态插入取最值的需求。Dijkstra最短路径算法每次从候选节点中取出距离起点最近的节点这个“取最近”的操作用堆实现算法复杂度能从O(V²)降到O((VE)log V)。定时器管理比如Redis的定时器、网络框架里的延时任务用一个最小堆按到期时间排列每次取堆顶就是最近要执行的任务。在Java里PriorityQueue就是堆的典型实现Python则是heapq模块。工程上很少需要自己手写堆但理解底层实现能帮你避开很多坑。我以前曾在项目里直接用PriorityQueue做任务调度因为没注意到它是线程不安全的在高并发下出现了偶发性的数据错乱。后来换成带锁的DelayQueue或者自己加同步问题才解决。所以用API之前一定要先看底层实现的线程安全属性。3.3 Top K问题海量数据中求最大/最小的K个元素Top K是堆在算法面试和实际业务中出现频率最高的应用比如“从一亿个用户中找出消费额最高的100个人”。直观做法是把所有数据排序但一亿个数据全排序很浪费。用堆的做法是求最大的K个元素维护一个小顶堆堆的大小固定为K。遍历数据时如果堆没满就直接入堆如果堆满了就让当前数据跟堆顶比只要比堆顶大就把堆顶删掉插入当前数据。这样遍历完一遍堆里剩下的K个元素就是最大的K个。求最小的K个元素反过来维护一个大顶堆。为什么求最大K要用小顶堆因为堆顶是堆里最小的元素它是“当前K个最大的里最容易掉出去的候选人”。新元素只要比它大就说明它不配留在Top K里替换掉它。这个方案在面试中几乎是必考的而且它能处理数据流——数据不是一次性给完而是源源不断进来你不需要把所有数据都存下来只需要维护一个K大小的堆内存占用非常低。如果K很大比如一亿里取一千万堆的维护成本会偏高这时候可以考虑分治、布隆过滤器、或者抽样估算等其它方案但面试和一般业务里K通常不会太大。3.4 顺带聊聊“堆外内存”热搜词里出现了“堆外内存”其实这里的“堆”和数据结构里的“堆”不是一回事。JVM的堆内存是Java对象分配的主要区域而堆外内存是直接操作native memory的那部分典型的就是DirectByteBuffer、Map文件映射用的FileChannel、某些框架里的内存池。堆外内存绕过了GC管理适合大块数据、高性能网络通信的场景但分配和释放成本比较高、不易调试还有内存泄漏风险。这是JVM知识体系里的另一个话题但和堆结构有一个共同点——都是“先搞懂底层机制再决定要不要用”。我记得第一次排查堆外内存泄漏时翻了好几天代码才发现是一个ByteBuffer.allocateDirect申请了之后没有释放可见这两块内容都有各自的门道。数据结构中的堆也可以在大型场景里控制好内存比如Top K只维护K个元素就是在控制内存增长的成本。4. 手写堆实现与工程API实操要点4.1 用Java手写一个小顶堆手写堆是面试的高频操作而且写得熟练之后使用任何语言的优先队列都会顺手很多。我给你一份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 offer(int value) { if (size capacity) { throw new IllegalStateException(Heap is full); } data[size] value; siftUp(size); size; } // 获取堆顶最小值 public int peek() { if (size 0) { throw new IllegalStateException(Heap is empty); } return data[0]; } // 删除堆顶末尾元素覆盖堆顶然后下沉 public int poll() { if (size 0) { throw new IllegalStateException(Heap is empty); } int result data[0]; data[0] data[size - 1]; size--; siftDown(0); return result; } // 从指定下标开始向上调整 private void siftUp(int index) { while (index 0) { int parent (index - 1) / 2; if (data[index] data[parent]) { break; } swap(index, parent); index parent; } } // 从指定下标开始向下调整 private void siftDown(int index) { while (index size) { int left 2 * index 1; int right 2 * index 2; int smallest index; if (left size data[left] data[smallest]) { smallest left; } if (right size data[right] data[smallest]) { smallest right; } if (smallest index) { break; } swap(index, smallest); index smallest; } } private void swap(int i, int j) { int tmp data[i]; data[i] data[j]; data[j] tmp; } }几个容易写错的地方我单独强调一下siftUp的循环条件不只是index 0还要判断当前节点是否已经满足堆的性质不满足才继续交换否则直接break。siftDown在选择交换对象时要在左子、右子中找到更小小顶堆的这一个不能随便选一个。选错的话堆的性质可能被破坏。删除堆顶时一定要先size--再siftDown否则下沉时会访问到已经不在堆里的“幽灵元素”。如果你想改成大顶堆只需要把比较符号从改成把smallest改成largest所有逻辑完全一致。4.2 各语言优先队列API的对比与踩坑不同语言对堆的封装差异很大选错方向是新手最容易踩的坑。语言/框架API默认堆类型改为反方向的写法JavaPriorityQueueoffer/poll/peek小顶堆传入Comparator.reverseOrder()Pythonheapqheappush/heappop小顶堆存入负值实现大顶堆Cpriority_queuepush/pop/top大顶堆传入std::greaterGocontainer/heapPush/Pop/Init无默认需实现接口实现Less方法决定大小Java里我踩过比较典型的坑是PriorityQueue的迭代顺序不等于堆的弹出顺序。堆内部只是部分有序iterator()遍历得到的元素并不是从小到大的。想要有序输出只能不断poll()或者用toArray()之后自己排序。Python的heapq没有提供直接的大顶堆实现。你有两种变通办法存入原始值的负数或者存一个包装对象重写比较逻辑。存负数最常用但是有个隐患如果原始数据是浮点数-0.0和0.0会互相干扰如果原始数据重复太多负数方案偶尔会造成优先级错乱最好在元素里加上递增序号来保证稳定顺序。C的priority_queue默认是大顶堆这点和Java刚好相反。如果你在团队里同时见过Java和C代码最容易出现的bug就是把“小顶堆”和“大顶堆”的直觉混用。Go标准库的container/heap不提供泛型你需要自己实现Len、Less、Swap、Push、Pop这5个方法灵活性高但样板代码多。Go 1.18之后社区有泛型堆库比如github.com/emberfarkas/go-btree之类但在标准库里依然没有内置。综合来看我的建议是哪怕你在生产环境不用手写堆也一定要会用数组手写一遍小顶堆和大顶堆。这不是面试要难为你而是只有手写过一次你才能真正理解PriorityQueue背后的行为逻辑遇到诡异问题时才有排查方向。4.3 编译器的堆空间不足是什么问题热搜词里还有个“编译器的堆空间不足”这里顺便区分一下。编译器报“heap space”或者“out of memory”的时候通常指的是运行时内存的堆区域不够用了比如Java启动参数里-Xmx设置过小或者程序存在内存泄漏导致堆不断增长。这跟数据结构里的堆没有直接关系但经常被初学者混淆。如果真遇到这类错误优先排查三件事是不是单例对象里错误地持有大量集合是不是流或连接没有关闭是不是递归调用无限膨胀。这些和数据结构中的堆“用数组保存数据”的概念没有任何关系但名字相同容易造成困惑。同理栈和堆的区别这个经典面试题也值得在学完数据结构后进行区分——栈和堆是两个维度的问题一个是“调用层级与局部变量”一个是“动态分配对象”千万不要把内存中的堆与数据结构里的堆画等号。5. 常见问题、面试高频考点与避坑技巧5.1 堆与栈的区别一个必考但容易混淆的问题“堆和栈的区别”几乎每次面试都会出现它通常指的是JVM内存中的堆和栈不是数据结构里的堆。我见过很多人把这两个概念混为一谈其实是完全不同的两套体系。简单梳理一下区别维度栈Stack堆Heap内存概念存储内容局部变量、方法调用栈帧对象实例、数组等动态数据生命周期方法调用结束即释放由GC管理不知道什么时候回收内存分配方式编译期确定连续分配运行时动态分配可能产生碎片访问速度快相对较慢是否线程私有每个线程一个栈进程中多个线程共享一个堆而数据结构里的“堆”是一种抽象的数据结构它和内存里的堆没有任何直接关系只是同名而已。面试时如果你能把这两者的不同讲清楚会比单纯背概念分数更高。5.2 面试高频考点与解题模板结合我自己的面试经验和面人的视角堆相关的高频考点主要集中在以下几个方向手写小顶堆/大顶堆核心是上浮、下沉两个方法要烂熟于心边界条件处理好。堆排序先建堆再反复取堆顶和交换代码量不大但容易在边界上出错。Top K问题求最大K用小顶堆求最小K用大顶堆能解释清楚为什么方向是反的。合并K个有序链表用一个小顶堆维护K个链表的当前头节点每次弹出最小节点再把它的下一个节点入堆时间复杂度O(n log k)是最经典的多路归并问题。数据流中的中位数用一个大顶堆和一个小顶堆配合大顶堆存较小的一半小顶堆存较大的一半维护两个堆的大小差不超过1中位数就是堆顶或两个堆顶的平均值。这是面试里更高阶一点的扩展题能答出来会很加分。任务调度/延迟队列给一堆带优先级的任务动态拿出来执行优先队列是最自然的选择。在这里我提供一个Top K题的通用模板思路建立一个大小为K的堆方向取决于你要最大还是最小。遍历所有数据如果堆没满就入堆。如果堆满了且新数据与堆顶相比更符合“留在堆里”的条件就先poll堆顶再offer新数据。最终堆里就是答案。这套模板不仅适用于数组也适用于数据流。很多候选人在面试时卡住往往是因为没有意识到“堆的大小可以固定为K”这个关键点而去维护一个巨大的堆。5.3 实操中踩过的坑最后分享一些我自己在实际开发和写算法题过程中踩过的坑希望对你有帮助坑一PriorityQueue不能存null。在Java里PriorityQueue的底层用Comparable比较元素如果元素为null会在offer的时候直接抛NullPointerException。所以用堆实现定时任务时要提前做好非空判断。坑二大顶堆比较器方向写反。Java里要用大顶堆写new PriorityQueue(Collections.reverseOrder())是没问题的但如果是自定义对象比如按对象的某个字段排大顶堆很多新手会写成PriorityQueuePerson pq new PriorityQueue((a, b) - a.age - b.age);这个其实是小顶堆按年龄从小到大。如果想让年龄大的优先出队要写成b.age - a.age。这两者的区别就是一个符号但错了之后整个程序的逻辑全反了。坑三Python的heapq不能直接修改堆内已存在的元素的优先级。如果你用堆实现Dijkstra算法需要更新某个点到起点的距离不能直接修改堆里对应元素的值否则堆的性质会被破坏。标准做法是“惰性删除”——不再更新旧元素而是直接推入一个新元素弹出时通过一个数组记录该节点是否已被处理。这个技巧在处理图算法时特别重要。坑四手动删堆中任意元素时上浮和下沉都可能发生。如果只是删除堆顶方向是确定的但删除中间某个元素后要用最后一个元素替换它此时新元素可能比父节点大也可能比子节点小需要先尝试上浮再尝试下沉或者写一个通用调整函数。我以前做JVM的延迟队列踩过这个坑最后统一封装了heapify逻辑手动维护才稳定下来。坑五堆排序的稳定性问题。如前所述堆排序是不稳定排序。如果业务数据里要求相同优先级按插入顺序处理堆就不合适这时候该考虑“只要元素进堆时带上递增序号做次级比较”比如Java里可以构造Pair(value, seq)这能保住稳定性但代价是内存多存一列序号。5.4 学习资料与复习建议热搜词里提到了一些教材比如严蔚敏的《数据结构》C语言版、王道数据结构、大话数据结构这些。我个人对它们的定位是严蔚敏《数据结构》经典教材偏理论堆这一章的核心逻辑讲得最严谨。如果你追求啃透原理这本值得细读。大话数据结构讲解比较通俗适合入门阶段建立直觉但代码示例相对简单。王道数据结构面向考研和面试堆排序和优先队列的考点总结得很到位适合冲刺复习。我的建议是不要只盯着其中一本而是以一本为主线刷完可视化演示再动手手写一遍代码最后再配合算法题巩固。堆结构本身就是“动手比看更有用”的内容只看书很容易产生“我懂了”的错觉。我第一次学堆的时候看了三遍书还是写不出siftDown后来闭着眼睛在白板上画了几轮数组的交换过程才真正通了。如果你需要做课程设计或实验报告堆也是一个非常好的选题。课设里最常见的做法是结合“植物百科数据的管理与分析”这类主题用堆结构实现数据的排序和Top K统计。这个方向我现在看其实挺讨巧的因为堆的代码量不大、核心逻辑清晰又能串起排序、查找、优先队列多个知识点实验报告也容易写得有层次。如果想扩展还可以加上堆排序和优先队列的对比分析把复杂度推导和实验结果放在一起报告的分量立刻就上来了。还有一点如果你正在用PyTorch做深度学习可能会看到“小土堆pytorch学习笔记”这类内容这个名字里的“土堆”其实是作者的昵称和数据结构里的堆没有关系。真正的堆结构在机器学习里最常见的应用场景是序列数据中动态取Top K特征、损失函数中挑选最难样本、或是在聚类算法里维护近邻堆。概念要区分清楚但工具思维是通用的——凡是需要“从动态变化的集合里不断取最值”的地方堆都是成本最低的方案。6. 从入门到实战的扩展思路学完堆的基本操作之后我建议你做一个综合实验把它们串起来。比如下面这几个方向每个都能让理解上一个台阶做一个可视化堆排序工具用Python的matplotlib或JavaScript的canvas把每次下沉交换的过程画出来。你会直观看到“建堆”和“堆排序”的差异建堆是整体调整堆排序是一个个取堆顶然后尾部交换。实现一个延迟任务队列不依赖现成的DelayQueue自己用堆实现一个精简版。入队时记录到期时间出队时判断堆顶是否到期如果到期就弹出否则就sleep到堆顶到期时间。做完这个你就明白了为什么很多框架的定时器底层都用最小堆。刷一批堆题建议按这个顺序刷——合并K个有序链表、数组中的第K个最大元素、前K个高频元素、滑动窗口最大值、数据流的中位数。这五道题能覆盖堆在算法题里的绝大多数变体而且难度曲线合理。我在实际带新人的时候发现一个有意思的现象很多人学完堆的API之后遇到Top K问题第一反应还是排序。这也不能怪他们因为排序确实简单粗暴在数据量不大的时候性能差异也不明显。所以这里有个判断标准只有当数据是动态变化的时候堆的优势才会充分体现。如果你的数据是固定的、不会再变的排序然后取前K个可能比堆更好写、更好调。堆不是银弹它只是“动态最值”场景下的最优解。最后忍不住再说一句堆这个知识点除了算法题本身它对工程能力的提升体现在一个很微妙的地方——它教你如何用“部分有序”的松弛约束去换取“操作效率”的大幅提升。这种权衡思想在系统设计里同样重要。比如你不可能让整个系统所有数据都保持严格有序那样代价太高但你可以在局部维护一个“近似有序”的结构保证核心链路的高效。这就是堆给我的最大启示不是所有问题都需要全局有序找到那个“最关键的序”把力气花在刀刃上。数据结构这条路堆只是其中一站。但如果你能沉下心把这个结构吃透后面学平衡树、红黑树、跳表这些更复杂的结构时你会明显感觉自己比其他同学多了几分底气。希望这篇文章能帮你在堆这个知识点上少走一些弯路。如果有哪里写得不清楚也欢迎随时交流讨论。