ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

堆数据结构实战:从优先队列到Top K与堆排序全解析

堆数据结构实战:从优先队列到Top K与堆排序全解析 1. 堆是什么不只是“一棵树”而是一种有序的懒散很多接触数据结构的同学第一次看到Heap堆脑子里蹦出来的概念是“树结构”接着就去背那些插入、删除的操作流程。但我个人的经验是如果只把它当树去学很容易陷入“会背不会用”的尴尬如果换个角度把它理解为“一种支持快速取极值、但又懒得完全排序的存储策略”很多问题一下子就想通了。堆本质上是一棵完全二叉树它只承诺两件事第一父节点和子节点之间存在某种顺序关系要么父节点永远不大于子节点这是小顶堆要么父节点永远不小于子节点这是大顶堆第二整棵树是“从左到右、从上到下”按层填满的。除此之外堆对同层节点之间、左右子树之间的相对顺序一概不管。这种“部分有序”的设计非常有意思。比如一个小顶堆它只能保证根节点是整个结构里的最小值但第二小的可能在左子树也可能在右子树你没法像二叉搜索树那样沿着某条路径去搜某个具体值。这个“缺点”恰恰成就了它最核心的三个优势取最小值O(1)插入O(log n)删除最小值O(log n)。如果你仔细品一下这三个特性组合在一起几乎就是所有“需要频繁取最值”场景的最佳答案。我经常用一个生活化类比堆就像一个优先级叫号系统。每个人进来后按自己的“重要性”排队但不要求所有人严格按线性顺序排好只要求每个人清楚地知道“谁在我前面最该被处理”。服务台永远能第一时间叫出最该处理的那个人队伍里的人也能在O(log n)时间内被安排进正确的位置。所以学堆核心不是背诵它的代码而是理解它这种**“局部有序全局够用”**的思路。这个结构能解决的问题跨度非常大任务调度、带权路径计算、Top K问题、中位数维护、哈夫曼编码、Dijkstra最短路径算法甚至连操作系统的定时器管理里都能看到堆的影子。下面我会完整拆解堆的实现原理、核心操作、经典应用和实际工程中的坑力求让看完的人不仅会写堆还会在合适的场景里主动想到用它。2. 堆的两个核心性质结构性和堆序性2.1 结构性为什么必须是完全二叉树堆的第一条铁律是“完全二叉树”这个约束不是随便定的它直接决定了堆可以用数组来高效存储。完全二叉树的意思是除了最后一层每一层都是满的最后一层的节点都靠左排列。正因如此整棵树的节点可以按层序编号用一个连续数组存放时不会有任何空洞。这个性质带来的索引映射关系是堆实现中最关键的基础。假设数组下标从0开始对任意位置i父节点位置是(i - 1) / 2左孩子位置是2 * i 1右孩子位置是2 * i 2有了这三条公式堆的所有操作都变成数组下标运算根本不需要真正去构建带有指针的树节点既省内存又省指针访问的缓存开销。这也是工程实现里几乎清一色用数组实现堆的根本原因。注意很多教材习惯从下标1开始存储数组这样父节点公式变成i/2子节点是2i和2i1位运算上更漂亮但会浪费一个数组位置。具体用哪种取决于个人习惯关键是写代码时保持一致。2.2 堆序性父节点与子节点的大小约束堆的第二条铁律是堆序性。大顶堆要求array[i] array[2i1]且array[i] array[2i2]小顶堆反过来要求array[i] array[2i1]且array[i] array[2i2]。请注意这个比较只发生在父子之间兄弟之间没有约束。这个性质说直白点就是“根永远是全局最值”。这也是堆能成为优先队列底层核心的原因。但随之而来的一个问题也要清楚堆不支持高效的查找。想在一个堆里找某个特定值的节点必须遍历一整棵树时间复杂度是O(n)。这是堆和二叉搜索树最大的分野。很多人学完之后问“堆能不能像平衡树那样查值”答案是“能但没有任何优势”。理解了这两条性质就掌握了堆的全部“宪法”。后面所有操作无论是插入还是删除本质都是在维护这两条宪法不被破坏。3. 堆的自我修复机制上浮与下沉堆的所有增删操作都遵循同一个底层模式先破坏某条性质再用“上浮”或“下沉”把它修复回来。这两个修复动作是堆的核心算法所有变体都离不开它们。3.1 上浮插入时的修复路径插入操作非常简单先把新元素放到数组末尾也就是完全二叉树的最后一个位置然后不断地和父节点比较如果违反堆序性就交换位置一路向上走直到到达根节点或者找到自己合适的位置。这个动作就叫上浮。在实际代码里上浮过程的交换可以优化成“暂存目标值逐步向下挪动父节点”这样可以减少一次赋值操作。我在实际实现中更倾向于这种写法尤其是数据规模大时少一次赋值就是少一次内存写操作。部分排序在大数据量下的性能差异有时候就是从这种细节里抠出来的。上浮的时间复杂度是O(log n)因为最多只需要从叶子走到根而树的高度是log n。但在某些场景下如果新插入的值本身就是全局最值那它一次交换都不需要直接停在原位。所以插入的均摊成本也比较可预期非常适合高频插入的场景。3.2 下沉删除和修改时的修复路径删除堆顶元素时直接取出根节点不会破坏堆序但会导致树的结构缺失。正确的做法是先把最后一个元素移到堆顶位置相当于用数组末尾元素顶替根然后从根开始不断比较它和两个子节点。如果是小顶堆就选择两个子节点中较小的那个做交换交换后继续向下比直到叶子节点或者满足堆序为止。这个动作叫下沉。下沉比上浮稍微复杂一点有一个隐藏细节比较两个子节点并选择合适的那一个。如果左右两个孩子都满足交换条件必须选更“极端”的那个。在小顶堆里就选更小的那个因为选较大的那个交换上去后较小的那个仍然会留在下面堆序性还是被破坏的。注意如果是用数组从0开始的下标在判断右孩子是否存在时必须检查2*i2 heap.size()。只判断左孩子存在是够的但选了左孩子之后得确认右孩子确实存在且更小才能做替换判断很多人初级版本就在这个边界上出错。修改任意位置的元素值实际操作中很少会直接“改完再修复”通常是把这个位置的值替换后从该位置同时向上和向下各执行一次修复看哪个方向不满足堆序就往哪个方向处理。这个问题最常出现在索引堆和带映射关系的堆里后面讲工程扩展时会细说。3.3 建堆O(n)的秘密把一段无序数组变成堆最直观的做法是从空堆开始一个个往里插入这样建堆的复杂度是O(n log n)——每个元素插入都需要O(log n)的上浮。但标准库和教科书里真正用的方法复杂度只有O(n)。这个方法叫“下滤建堆”流程是从最后一个非叶子节点开始依次往前逐个执行下沉操作直到根节点。为什么从最后倒数第二层开始因为叶子节点自己已经满足堆序不需要处理。为什么复杂度是O(n)这个结论初看反直觉我用一个简化方式来理解它。设堆的高度为h最后一层有约n/2个节点它们的高度为0不需要处理倒数第二层有约n/4个节点每个最多下沉1层倒数第三层有约n/8个节点每个最多下沉2层。总的节点移动层数是S n/4 * 1 n/8 * 2 n/16 * 3 ... n * (1/4 2/8 3/16 ...)后面这个无穷级数收敛于常数所以总代价是O(n)。如果你问“为什么从上往下逐个下沉和从下往上逐个上浮效果不一样”关键就在这个求和下滤时大部分节点都位于底层它们贡献的代价小而上浮建堆时叶子节点全部要上溯到根附近每个都要付出接近完整树高的代价。这个O(n)建堆是堆“性价比”极高的原因之一也是优先队列能在大数据量下快速初始化的底气。4. 堆的经典应用场景从优先队列到Top K4.1 优先队列几乎所有异步系统的基石优先队列是堆最常见的“马甲”。普通队列是先进先出优先队列是“优先级高的先出”。实现优先队列可以有很多方案最简单的就是基于数组线性扫描取最大值但那样每次取最大值的代价是O(n)。如果队列里有几十万任务每次弹出一个最高优先级任务都要扫全量这个代价是不可接受的。用堆做优先队列入队和出队都是O(log n)取极值O(1)这是所有方案里综合表现最均衡的。实际工程里操作系统任务调度器的就绪队列、业务系统里多个定时任务按触发时间排序、网络框架里的事件循环底层几乎都能看到堆或是基于堆的变体结构。4.2 Top K问题为什么不能直接排序“从海量数据中找到最大或最小的K个元素”是面试中最高频的问题之一也是堆最值得炫耀的舞台。最容易想到的做法是把所有数据排序然后取前K个复杂度O(n log n)。这在大数据场景下有浪费因为它把所有元素都排好了我根本不需要第二大到第K大的精确顺序。用堆的思路是维护一个容量为K的小顶堆遍历数据时如果当前元素比堆顶大就把堆顶扔掉把当前元素插进去。遍历结束后堆里剩下的就是最大的K个元素。这个方案的时间复杂度是O(n log K)。如果K远小于n优势非常明显而且整个遍历是一次性的数据流式操作对内存极其友好——这意味着数据甚至不需要全部加载到内存可以从磁盘流式读取。很多人会问为什么这里要用小顶堆来维护最大的K个关键原因是“堆顶是入口的阈值”。对于维护最大K个值堆顶是这个堆里最小的元素也就是进入“前K名”的门槛。新元素只需要和门槛比较低于门槛的直接丢弃高于门槛的替换掉门槛。这是整个算法成立的核心直觉。4.3 堆排序不稳定的“三步走”堆排序的思路建立在“每次取堆顶最大值”之上。一个大顶堆建好之后把堆顶元素和数组末尾元素交换此时末尾就是全局最大值。接着把数组长度减一再对新的堆顶执行下沉恢复堆序。重复这个过程数组就原地完成了排序。堆排序的优势是时间复杂度稳定在O(n log n)而且在最坏情况下不会像快速排序那样退化到O(n²)然而它有个重要缺点是不稳定。举个例子两个值相同的元素原始顺序是先出现A再出现B堆排序过程中因为堆顶交换和下沉操作这两个元素的位置关系可能被调换。对排序稳定性有要求的场景比如先按主键排再按副键排堆排序就不合适。堆排序的最优场景是“空间紧张且不能忍受最坏情况时间退化”完全用原地数组操作空间复杂度O(1)不需要额外辅助数组。4.4 第K小/第K大元素与中位数维护求第K小元素是Top K问题的姊妹篇。维护一个容量为K的大顶堆遍历数据凡是比堆顶小的就把堆顶替换掉最终堆顶就是第K小的元素。这里需要注意堆顶是“当前已遍历元素里第K小的值”是整个堆里最大的那个因为大顶堆的堆顶是最大值。我见过很多初学者在这里绕晕其实只要始终记得“堆顶 门槛”这个关系就不会迷路。中位数维护是个极其优雅的双堆应用。我维护两个堆一个大顶堆存较小的一半数一个小顶堆存较大的一半数并且保证两堆元素数量差不超过1。那么全局中位数一定在“两个堆顶之一”取到。每次插入新数时先和当前中位数比较决定放入哪一侧堆然后调整两个堆的平衡。这个方案的插入是O(log n)取中位数是O(1)。在线流式数据中求中位数几乎只有这个方案能兼顾时间和空间。4.5 图算法与哈夫曼编码中的堆Dijkstra最短路径算法和Prim最小生成树算法里堆是“贪心选择当前最优边/路径”的加速器。比如Dijkstra原始实现每次从未访问集合里找距离最小的顶点需要O(n)如果用堆来维护候选集每次取距离最小顶点降到O(log n)整体复杂度从O(n²)降到O((mn)log n)。在大型稀疏图上这个优化是数量级的提升。哈夫曼编码建树时需要反复从集合里取频率最小的两个节点合并。用堆直接维护频率表每次取两次最小值合并后插回整个过程就是一系列堆操作编码树的构建因此非常干净。很多“贪心”算法里“找最大/最小”这个动作堆都是天然的加速器。5. 从原理到代码完整实现与工程细节5.1 基础实现从0开始写一个小顶堆用数组实现小顶堆时需要先定义几个内部函数返回父节点下标、左右孩子下标、以及下沉和上浮操作。下面是经典模式下最简洁的Python实现为了便于阅读我仍采用标准写法class MinHeap: def __init__(self): self.heap [] def _parent(self, i): return (i - 1) // 2 def _left(self, i): return 2 * i 1 def _right(self, i): return 2 * i 2 def _sift_up(self, i): while i 0 and self.heap[self._parent(i)] self.heap[i]: self.heap[self._parent(i)], self.heap[i] self.heap[i], self.heap[self._parent(i)] i self._parent(i) def _sift_down(self, i): n len(self.heap) while True: smallest i left self._left(i) right self._right(i) if left n and self.heap[left] self.heap[smallest]: smallest left if right n and self.heap[right] self.heap[smallest]: smallest right if smallest i: break self.heap[i], self.heap[smallest] self.heap[smallest], self.heap[i] i smallest def push(self, value): self.heap.append(value) self._sift_up(len(self.heap) - 1) def pop(self): if not self.heap: return None root self.heap[0] last self.heap.pop() if self.heap: self.heap[0] last self._sift_down(0) return root def peek(self): return self.heap[0] if self.heap else None def size(self): return len(self.heap)这个实现的关键点有几个第一下沉循环的终止条件我用一个smallest变量记录当前位置与左右孩子中最小的那个如果最小的是自己说明已经满足堆序直接跳出循环。这个模式比直接比较后再决定是否交换要清晰得多也不容易漏掉左右孩子的边界判断。第二在执行pop时先把最后一个元素保存下来再把数组末尾弹掉最后把保存的最后一个元素放到堆顶并进行下沉。注意下沉函数里的right n判断不能省否则在没有右孩子时数组越界访问会直接报错。这种错误在LeetCode级别的代码里非常常见因为很多测试数据到最后一层恰恰只有左孩子。5.2 建堆操作与原地堆化如果直接接受一个数组并把它变成一个堆我不会一个个push而是直接原地从上往下恢复堆序def build_heap(arr): n len(arr) for i in range((n - 2) // 2, -1, -1): _sift_down(arr, n, i)注意这里循环的起始点是(n - 2) // 2对应的是最后一个非叶子节点的下标。为什么不是n // 2因为数组从0开始存储时最后一个节点的父节点下标是(n-1-1)/2 (n-2)/2。这又是一个容易踩的边界细节。_sift_down在这里多接收两个参数数组本身和有效长度因为原地建堆时需要限定下沉的范围。这个细节在实现堆排序时会派上用场——排序过程中有效堆的长度会动态变小不能直接用len(arr)当作堆边界。原地建堆完成后数组就已经满足所有堆序条件。这个过程也有人叫“heapify”它在很多语言的标准库里都有封装。但面试或竞赛时有时需要自己动手写理解上面那个逆向下沉的循环比任何记忆都管用。5.3 Go语言里的container/heap是怎么用的Go标准库的container/heap包不直接提供一个Heap类型而是给了一个Interface要求你实现Len / Less / Swap / Push / Pop五个方法。这种设计方式对初学者有点绕但实际用惯了很顺手因为它允许你把某个已有的切片类型原地变成堆而不用复制数据。下面是一个最小示例将整数切片变为小顶堆type IntHeap []int func (h IntHeap) Len() int { return len(h) } func (h IntHeap) Less(i, j int) bool { return h[i] h[j] } func (h IntHeap) Swap(i, j int) { h[i], h[j] h[j], h[i] } func (h *IntHeap) Push(x interface{}) { *h append(*h, x.(int)) } func (h *IntHeap) Pop() interface{} { old : *h n : len(old) x : old[n-1] *h old[0 : n-1] return x }用法是heap.Init(h)建堆heap.Push(h, x)插入heap.Pop(h)弹出堆顶。这里有两个非常经典的坑。第一Less函数的实现决定了是大顶堆还是小顶堆h[i] h[j]是小顶堆改成h[i] h[j]就是大顶堆。第二自带的Pop方法实现时必须直接从末尾取元素不能从开头取——因为container/heap内部会先自己调整好结构把堆顶挪到末尾然后再调用你实现的Pop。如果你在Pop里错误地删除了开头元素整个逻辑就会错乱。我第一次被这个反直觉的设计坑到的时候排查了整整一个下午。另一个在工程中更常见的需求是“修改堆内某个元素的值”。Go标准库的heap接口没有直接暴露这个操作必须自己定位元素位置再结合上浮和下沉修复。很多扩展包为了解决这个问题设计了“索引堆”下面会单独讲。5.4 索引堆当堆里的元素需要被外部引用时基本的堆只能支持“插入”和“弹出堆顶”但如果我需要在堆中间修改某个特定元素的值呢比如Dijkstra算法里某个顶点的最短距离可能在堆里被更新多次。如果没有索引堆通常的办法是重复插入同一个顶点的新值并加一个标记跳过过期的旧条目。这种做法简单但会在堆里堆积大量“僵尸数据”导致性能下降和逻辑复杂。索引堆的思路是堆节点里不直接存储元素值而是存一个“索引号”这个索引领到真正的数据数组里。再加一个反向映射数组pos[索引] 堆中的位置这样给定一个索引我能O(1)找到它在堆里的位置更新值后就能精确地上浮或下沉。这个结构在工程中的典型应用场景包括Dijkstra算法需要频繁修改某个顶点的距离、带键值更新的优先队列、以及某些缓存淘汰算法。如果你只是面试或做题用“惰性删除”也就是过期标记法就够了但如果要在生产系统里处理数十万级别的更新索引堆是更严谨的方案。6. 堆的变体与进阶方向6.1 d叉堆牺牲一点内存换更快的下沉标准的二叉堆每个节点最多有两个孩子。d叉堆则允许每个节点有d个孩子比如3叉堆、4叉堆。d越大树的高度越小下沉时比较的次数变少但实现时每个节点需要维护d个子节点的最小/最大值交换和比较的成本会高一些。在实际场景中d叉堆的优势主要集中在底层缓存上较矮的树意味着访问路径更短对CPU缓存更友好。比如在磁盘调度的优先级队列里用4叉堆常常比二叉堆有更好的实际性能尽管理论上复杂度都是O(log n)。不过日常开发中二叉堆已经足够用d叉堆的收益体现在极端规模下。6.2 斐波那契堆与配对堆为“减少键值”而生斐波那契堆是另一种理论最优的堆变体。它的插入是O(1)、合并两个堆是O(1)、减少键值是均摊O(1)但代价是实现极其复杂。在很多图算法的理论上斐波那契堆能让整体复杂度进一步降低但由于实现复杂度和常数因子过大工程上很少真的使用它。配对堆则是一种更轻量、更实用的替代品。它的实现比斐波那契堆简单得多操作都是均摊O(log n)常被用在Dijkstra算法和某些图计算库中。如果需要实现一个支持高效“减少键值”的堆又不想被斐波那契堆的复杂度绑架配对堆是个不错的选择。6.3 大顶堆和小顶堆的互换Less函数的意义在Go的container/heap中堆的种类完全由Less函数决定。在Python的heapq中它是纯小顶堆想要大顶堆需要技巧要么存值时加负号变成负数要么包装成自定义对象并重载比较。Python的这个限制经常让人感觉别扭但加负号的方案其实也有它的用途——比如想按某个字段的最大值排序这个字段本身就是数字那取负后变成最小值问题这就和heapq无缝配合了。在C里标准库的priority_queue默认为大顶堆greaterint可以生成小顶堆。不同语言对堆的默认方向都不一样这是面试中经常被忽略的一个细节。写代码前先确认你语言里堆的默认方向否则很可能因为差一个比较符出现逻辑反转的bug。7. 常见问题与排查技巧实录7.1 数组越界多半是左右孩子边界判断出了问题如果你在下沉操作里只检查了左孩子而右孩子存在但没检查就会访问到不合法的数组索引如果你在右孩子判断时没有确认下标是否小于当前堆大小也可能越界。排查方法非常简单打印当前位置和左右孩子的下标对着值走一遍。我自己的习惯是永远把“边界判断”提前并集中在一个代码块里。比如left 2 * i 1 right 2 * i 2 if left n: break if right n: # 只有左孩子比较左孩子与当前即可 else: # 两个孩子都存在选更极端的一个这种提前分支的写法会让代码变得更冗长但边界逻辑会更直白排查问题也更省事。7.2 堆顶弹出后插入顺序错乱九成是下沉写错了很多开发者在第一次实现优先队列时弹出的堆顶值不是全局最大值或最小值这通常不是堆的构建问题而是下沉过程中没有及时更新比较基准。下沉时交换之后必须把i更新为交换后的子节点下标否则下一轮比较还在原来的位置比较堆序自然无法恢复。还有个小技巧下沉前先用一个临时变量存当前值之后比较时用临时变量和子节点比较找到正确位置后再一次性赋值这样可以省掉循环内部的连续交换。这在数据量大时确实能换来可感知的性能提升。7.3 堆大小和数组长度不一致很多场景下比如堆排序过程中有效堆的长度会小于等于数组总长度。如果你始终用len(arr)来当堆的边界那么排序过程中会比较到已经排好的尾部数据堆序会被彻底破坏。解决方法是在堆化函数里显式传入一个heap_size参数所有下沉判断都基于这个参数而不是数组总长度。这个坑在实现“原地优先级队列”“堆排序”“滑动窗口维护极值”时特别容易踩。我自己debug过几次之后现在的习惯是用一个结构体把堆的切片和长度封装在一起。7.4 深入排查用“最小堆”做“最大K个”为什么得到的是错误的答案如果你用小顶堆求前K个最大元素但最终结果完全不对试着检查一下你的比较方向。这个场景下小顶堆的堆顶是整个堆里最小的元素。新元素只有大于堆顶时才有资格替换如果你误写成“小于堆顶就替换”最后会得到最小的K个元素方向完全反了。还有一个特别隐蔽的问题如果K等于整个数据集大小用Top K算法会退化成对整个数据的一次全排序过程但堆本身的建堆复杂度还是O(n)。所以如果有“取全部”的场景直接排序可能更好。7.5 双堆实现中位数的平衡调整细节双堆维护中位数时最容易出错的是平衡逻辑。以“大顶堆存较小的一半小顶堆存较大的一半”为例我每次插入后会检查两边大小差。如果大顶堆比小顶堆多两个元素就把大顶堆堆顶移到小顶堆反之亦然。这里“差超过1才搬移”和“差为1就搬移”的区别很关键。如果差为1就搬移就会导致两个堆永远一个为空或总在互相搬移效率极差且逻辑极易乱。我个人建议把平衡函数单独抽出来def _rebalance(self): if len(self.max_heap) len(self.min_heap) 1: self.min_heap.push(self.max_heap.pop()) elif len(self.min_heap) len(self.max_heap) 1: self.max_heap.push(self.min_heap.pop())这样逻辑清晰不容易内联到插入里然后犯错。取中位数时如果两边数量相等中位数就是两个堆顶的平均值否则取多的那一边的堆顶。整个过程非常严谨。8. 时机与取舍什么时候该用堆什么时候不该用堆虽然是“取最值神器”但它绝不是所有场景的最优解。一个常被忽略的事实是堆的常数因子比较大因为每次操作都伴随着多路比较和数组之间的元素移动。如果数据规模比较小比如几十个元素直接用线性扫描取最大值或排序可能比堆更快。另一个容易被忽略的点是如果数据几乎不变只需要一次排序那直接sort()可能更合适。堆适合的是“动态增删”和“反复取极值”的场景而不是一次性查询。还有一点堆占用的空间虽然紧凑但频繁插入和弹出会导致GC压力在带垃圾回收的语言里。每次pop后把最后一个元素提前并做下沉实际上也是在制造数组元素的反复移动这本身开销也是不可忽略的。设计高吞吐优先队列时比起堆更倾向于“多层队列”或“时间轮”方案因为它们能避免频繁的内存操作。从我个人的实践经验来看最需要堆的场景排名是Top K问题、动态中位数、Dijkstra图和事件调度。如果你能把这四个场景的代码吃透堆就算真正入门了。9. 一个小技巧用堆实现“滑动窗口最大值”很多人知道用双端队列做滑动窗口最大值但很少有人知道也可以用“懒惰删除”的堆来做。思路是维护一个大顶堆里面存放值, 下标对。每次窗口右移堆里插入新元素取最大值时如果堆顶元素的下标已经超出了窗口左边界就把它弹掉重复直到堆顶在窗口内。弹出的过期元素是“懒惰删除”并不会影响正确性。这个方案虽然时间复杂度是O(n log n)比双端队列的O(n)稍差但它实现起来极其简单不容易在边界条件上出错特别适合在时间紧迫时快速写一个不折损正确性的方案。我自己在多次限时编码环境中用过这个思路都是优先保证逻辑正确后再优化常数。这也算是“堆”在工程实战里一个很实用的花式用法。回到最开始的问题这一路走下来你会发现堆的每一条性质、每一个操作、每一次边界判断最后都能落到“快取极值”这一个核心诉求上。理解了这一点堆就不再是那棵需要死记硬背的“树”了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进