大家好,我是 lucifer。今天给大家带来的是《堆》专题。先上下本文的提纲,这个是我用 mindmap 画的一个脑图,之后我会继续完善,将其他专题逐步完善起来。
大家也可以使用 vscode blink-mind 打开源文件查看,里面有一些笔记可以点开查看。源文件可以去我的公众号《力扣加加》回复脑图获取,以后脑图也会持续更新更多内容。vscode 插件地址:https://marketplace.visualstudio.com/items?itemName=awehook.vscode-blink-mind
堆确实是一个难度不低的专题。从官方的难度标签来看,堆的题目一共才 42 道,困难度将近 50%。没有对比就没有伤害,树专题困难度只有不到 10%。
从通过率来看,一半以上的题目平均通过率在 50% 以下。作为对比, 树的题目通过率在 50% 以下的只有不到三分之一。
不过大家不要太有压力。lucifer 给大家带来了一个口诀一个中心,两种实现,三个技巧,四大应用,我们不仅讲实现和原理,更讲问题的背景以及套路和模板。
文章里涉及的模板大家随时都可以从我的力扣刷题插件 leetcode-cheatsheet 中获取。
https://leetcode-solution-leetcode-pp.gitbook.io/leetcode-solution/thinkings/heap
堆其实就是一种数据结构,数据结构是为了算法服务的,那堆这种数据结构是为哪种算法服务的?它的适用场景是什么? 这是每一个学习堆的人第一个需要解决的问题。 在什么情况下我们会使用堆呢?堆的原理是什么?如何实现一个堆?别急,本文将一一为你揭秘。
在进入正文之前,给大家一个学习建议 - 先不要纠结堆怎么实现的,咱先了解堆解决了什么问题。当你了解了使用背景和解决的问题之后,然后当一个调包侠,直接用现成的堆的 api 解决问题。等你理解得差不多了,再去看堆的原理和实现。我就是这样学习堆的,因此这里就将这个学习经验分享给你。
为了对堆的使用场景进行说明,这里我虚拟了一个场景。
下面这个例子很重要, 后面会反复和这个例子进行对比。
问题描述
假如你是一个排队挂号系统的技术负责人。该系统需要给每一个前来排队的人发放一个排队码(入队),并根据先来后到的原则进行叫号(出队)。
除此之外,我们还可以区分了几种客户类型, 分别是普通客户, VIP 客户 和 至尊 VIP 客户。
我该如何设计我的系统才能满足需求,并获得较好的扩展性?
初步的解决方案
如果不同的客户使用不同的窗口。那么我们可以设计三个队列,分别存放正在排队的三种人。这种设计满足了题目要求,也足够简单。
如果我们只有一个窗口,所有的病人需要使用同一个队列,并且同样的客户类型按照上面讲的先到先服务原则,但是不同客户类型之间可能会插队。
可以插队怎么办?
这种算法很好地完成了我们的需求,复杂度相当不错。不过事情还没有完结,这一次我们又碰到新的产品需求:
这样就有了”插队“的情况了。该怎么办呢?一个简单的做法是,将其插入到正确位置,并重新调整后面所有人的排队位置。
如下图是插入一个 1:30 开始排队的普通客户的情况。
如果队列使用数组实现, 上面插队过程的时间复杂度为 O ( N ) O(N) O(N),其中 N N N 为被插队的队伍长度。如果队伍很长,那么调整的次数明显增加。
不过我们发现,本质上我们就是在维护一个有序列表,而使用数组方式去维护有序列表的好处是可以随机访问,但是很明显这个需求并不需要这个特性。如果使用链表去实现,那么时间复杂度理论上是 O ( 1 ) O(1) O(1),但是如何定位到需要插入的位置呢?朴素的思维是遍历查找,但是这样的时间复杂度又退化到了 O ( N ) O(N) O(N)。有没有时间复杂度更好的做法呢?答案就是本文的主角优先队列。
上面说了链表的实现核心在于查找也需要 O ( N ) O(N) O(N),我们可以优化这个过程吗?实际上这就是优先级队列的链表实现,由于是有序的,我们可以用跳表加速查找,时间复杂度可以优化到 O ( l o g N ) O(logN) O(logN)。
其实算法界有很多类似的问题。比如建立数据库索引的算法,如果给某一个有序的列添加索引,不能每次插入一条数据都去调整所有的数据吧(上面的数组实现)?因此我们可以用平衡树来实现,这样每次插入可以最多调整 ( O ( l o g N ) ) (O(logN)) (O(logN))。优先队列的另外一种实现 - 二叉堆就是这个思想,时间复杂度也可以优化到 O ( l o g N ) O(logN) O(logN)
本文只讲解常见的二叉堆实现,对于跳表和红黑树不再这里讲。 关于优先队列的二叉堆实现,我们会在后面给大家详细介绍。这里大家只有明白优先队列解决的问题是什么就可以了。
堆的两个核心 API 是 push 和 pop。
大家先不考虑它怎么实现的,你可以暂时把 ta 想象成一个黑盒,提供了两个 api:
push: 推入一个数据,内部怎么组织我不管。对应我上面场景里面的排队和插队。
pop: 弹出一个数据,该数据一定是最小的,内部怎么实现我不管。对应我上面场景里面的叫号。
这里的例子其实是小顶堆。而如果弹出的数据一定是最大的,那么对应的实现为大顶堆。
借助这两个 api 就可以实现上面的需求。
# 12:00 来了一个普通的顾客(push)
heapq.heappush(normal_pq, '12:00')
# 12:30 来了一个普通顾客(push)
heapq.heappush(normal_pq, '12:30')
# 13:00 来了一个普通顾客(push)
heapq.heappush(normal_pq, '13:00')
# 插队(push)。时间复杂度可以达到 O(logN)。如何做到先不管,我们先会用就行,具体实现细节后面再讲。
heapq.heappush(normal_pq, '12: 20')
# 叫号(pop)。12:00 来的先被叫到。需要注意的是这里的弹出时间复杂度也变成了 O(logN),这或许就是幸福的代价吧。
heapq.heappop(normal_pq)
上面这个场景单纯使用数组和链表都可以满足需求,但是使用其他数据结构在应对”插队“的情况表现地会更好。
具体来说:
简单总结下就是,堆就是动态帮你求极值的。当你需要动态求最大或最小值就就用它。而具体怎么实现,复杂度的分析我们之后讲,现在你只要记住使用场景,堆是如何解决这些问题的以及堆的 api 就够了。
上面通过一个例子带大家了解了一下优先队列。那么在接下来讲具体实现之前,我觉得有必要回答下一个大家普遍关心的问题,那就是优先队列是队列么?
很多人觉得队列和优先队列是完全不同的东西,就好像 Java 和 JavaScript 一样,我看了很多文章都是这么说的。
而我不这么认为。实际上,普通的队列也可以看成是一个特殊的优先级队列, 这和网上大多数的说法优先级队列和队列没什么关系有所不同。我认为队列无非就是以时间这一变量作为优先级的优先队列,时间越早,优先级越高,优先级越高越先出队。
大家平时写 BFS 的时候都会用到队列来帮你处理节点的访问顺序。那使用优先队列行不行?当然可以了!我举个例子:
思路
我们可以使用 BFS 来做一次层次遍历,并且每一层我们都从右向左遍历,这样层次遍历的最后一个节点就是树左下角的节点。
常规的做法是使用双端队列(就是队列)来实现,由于队列的先进先出原则很方便地就能实现层次遍历的效果。
代码
对于代码看不懂的同学,可以先不要着急。等完整读完本文之后再回过头看会容易很多。下同,不再赘述。
Python Code:
这里是引用
class Solution:
def findBottomLeftValue(self, root: TreeNode) -> int:
if root is None:
return None
queue = collections.deque([root])
ans = None
while queue:
size = len(queue)
for _ in range(size):
ans = node = queue.popleft()
if node.right:
queue.append(node.right)
if node.left:
queue.append(node.left)
return ans.val
实际上, 我们也可以使用优先队列的方式,思路和代码也几乎和上面完全一样。
class Solution:
def findBottomLeftValue(self, root: TreeNode) -> int:
if root is None:
return None
queue = []
# 堆存储三元组(a,b,c),a 表示层级,b 表示节点编号(以完全二叉树的形式编号,空节点也编号),c 是节点本身
heapq.heappush(queue, (1, 1, root))
ans = None
while queue:
size = len(queue)
for _ in range(size):
level, i, node = heapq.heappop(queue)
ans = node
if node.right:
heapq.heappush(queue, (level + 1, 2 * i + 1, node.right))
if node.left:
heapq.heappush(queue, (level + 1, 2 * i + 2, node.left))
return ans.val
所有使用队列的地方,都可以使用优先队列来完成,反之却不一定。
既然优先队列这么厉害,那平时都用优先队列不就行了?为啥使用队列的地方没见过别人用堆呢?最核心的原因是时间复杂度更差了。
比如上面的例子,本来入队和出队都可是很容易地在 O ( 1 ) O(1) O(1) 的时间完成。而现在呢?入队和出队的复杂度都是 O ( l o g N ) O(logN) O(logN),其中 N 为当前队列的大小。因此在没有必要的地方使用堆,会大大提高算法的时间复杂度,这当然不合适。说的粗俗一点就是脱了裤子放屁。
不过 BFS 真的就没人用优先队列实现么?当然不是!比如带权图的最短路径问题,如果用队列做 BFS 那就需要优先队列才可以,因为路径之间是有权重的差异的,这不就是优先队列的设计初衷么。使用优先队列的 BFS 实现典型的就是 dijkstra 算法。
这再一次应征了我的那句话队列就是一种特殊的优先队列而已。特殊到大家的权重就是按照到来的顺序定,谁先来谁的优先级越高。在这种特殊情况下,我们没必须去维护堆来完成,进而获得更好的时间复杂度。
堆的问题核心点就一个,那就是动态求极值。动态和极值二者缺一不可。
求极值比较好理解,无非就是求最大值或者最小值,而动态却不然。比如要你求一个数组的第 k 小的数,这是动态么?这其实完全看你怎么理解。而在我们这里,这种情况就是动态的。
如何理解上面的例子是动态呢?
你可以这么想。由于堆只能求极值。比如能求最小值,但不能直接求第 k 小的值。
那我们是不是先求最小的值,然后将其出队(对应上面例子的叫号)。然后继续求最小的值,这个时候求的就是第 2 小了。如果要求第 k 小,那就如此反复 k 次即可。
这个过程,你会发现数据是在动态变化的,对应的就是堆的大小在变化。
接下来,我们通过几个例子来进行说明。
有一堆石头,每块石头的重量都是正整数。
每一回合,从中选出两块 最重的 石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:
如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块石头。返回此石头的重量。如果没有石头剩下,就返回 0。
示例:
输入:[2,7,4,1,8,1]
输出:1
解释:
先选出 7 和 8,得到 1,所以数组转换为 [2,4,1,1,1],
再选出 2 和 4,得到 2,所以数组转换为 [2,1,1,1],
接着是 2 和 1,得到 1,所以数组转换为 [1,1,1],
最后选出 1 和 1,得到 0,最终数组转换为 [1],这就是最后剩下那块石头的重量。
提示:
1 <= stones.length <= 30
1 <= stones[i] <= 1000
思路
题目比较简单,直接模拟即可。需要注意的是,每次选择两个最重的两个石头进行粉碎之后,最重的石头的重量便发生了变化。这会影响到下次取最重的石头。简单来说就是最重的石头在模拟过程中是动态变化的。
这种动态取极值的场景使用堆就非常适合。
当然看下这个数据范围1 <= stones.length <= 30 且 1 <= stones[i] <= 1000,使用计数的方式应该也是可以的。
Java Code:
import java.util.PriorityQueue;
public class Solution {
public int lastStoneWeight(int[] stones) {
int n = stones.length;
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(n, (a, b) -> b - a);
for (int stone : stones) {
maxHeap.add(stone);
}
while (maxHeap.size() >= 2) {
Integer head1 = maxHeap.poll();
Integer head2 = maxHeap.poll();
if (head1.equals(head2)) {
continue;
}
maxHeap.offer(head1 - head2);
}
if (maxHeap.isEmpty()) {
return 0;
}
return maxHeap.poll();
}
}
编写一段程序来查找第 n 个超级丑数。
超级丑数是指其所有质因数都是长度为 k 的质数列表 primes 中的正整数。
示例:
输入: n = 12, primes = [2,7,13,19]
输出: 32
解释: 给定长度为 4 的质数列表 primes = [2,7,13,19],前 12 个超级丑数序列为:[1,2,4,7,8,13,14,16,19,26,28,32] 。
说明:
1 是任何给定 primes 的超级丑数。
给定 primes 中的数字以升序排列。
0 < k ≤ 100, 0 < n ≤ 10^6, 0 < primes[i] < 1000 。
第 n 个超级丑数确保在 32 位有符整数范围内。
思路:
这道题看似和动态求极值没关系。其实不然,让我们来分析一下这个题目。
我们可以实现生成超级多的丑数,比如先从小到大生成 N 个丑数,然后直接取第 N 个么?
拿这道题来说, 题目有一个数据范围限制 0 < n ≤ 10^6,那我们是不是预先生成一个大小为 1 0 6 10^6 106 的超级丑数数组,这样我们就可通过 O ( 1 ) O(1) O(1) 的时间获取到第 N 个超级丑数了。
首先第一个问题就是时间和空间浪费。我们其实没有必要每次都计算所有的超级丑数,这样的预处理空间和时间都很差。
第二个问题是,我们如何生成 1 0 6 10^6 106 以为的超级丑数呢?
通过丑数的定义,我们能知道超级丑数一定可以写出如下形式。
if primes = [a,b,c,…]
then f(ugly) = a * x1 * b * x2 * c * x3 …
其中 x1,x2,x3 均为正整数。
不妨将问题先做一下简化处理。考虑题目给的例子:[2,7,13,19]。
我们可以使用四个指针来处理。直接看下代码吧:
public class Solution {
public int solve(int n) {
int ans[]=new int[n+5];
ans[0]=1;
int p1=0,p2=0,p3=0,p4=0;
for(int i=1;i<n;i++){
ans[i]=Math.min(ans[p1]*2,Math.min(ans[p2]*7,Math.min(ans[p3]*13,ans[p4]*19)));
if(ans[i]==ans[p1]*2) p1++;
if(ans[i]==ans[p2]*7) p2++;
if(ans[i]==ans[p3]*13) p3++;
if(ans[i]==ans[p3]*19) p4++;
}
return ans[n-1];
}
}
这个技巧我自己称之为多路归并(实现想不到什么好的名字),我也会在后面的三个技巧也会对此方法使用堆来优化。
由于这里的指针是动态的,指针的数量其实和题目给的 primes 数组长度一致。因此实际上,我们可以使用记忆化递归的形式来完成,递归体和递归栈分别维护一个迭代变量即可。而这道题其实可以看出是一个状态机,因此使用动态规划来解决是符合直觉的。而这里,介绍一种堆的解法,相比于动态规划,个人认为更简单和符合直觉。
关于状态机,我这里有一篇文章原来状态机也可以用来刷 LeetCode?,大家可以参考一下哦。
实际上,我们可以动态维护一个当前最小的超级丑数。找到第一个, 我们将其移除,再找下一个当前最小的超级丑数(也就是全局第二小的超级丑数)。这样经过 n 轮,我们就得到了第 n 小的超级丑数。这种动态维护极值的场景正是堆的用武之地。
有没有觉得和上面石头的题目很像?
以题目给的例子 [2,7,13,19] 来说。
将 [2,7,13,19] 依次入堆。
出堆一个数字,也就是 2。这时取到了第一个超级丑数。
接着将 2 和 [2,7,13,19] 的乘积,也就是 [4,14,26,38] 依次入堆。
如此反复直到取到第 n 个超级丑数
上面的正确性是毋庸置疑的,由于每次堆都可以取到最小的,每次我们也会将最小的从堆中移除。因此取 n 次自然就是第 n 大的超级丑数了。
堆的解法没有太大难度,唯一需要注意的是去重。比如 2 * 13 = 26,而 13 * 2 也是 26。我们不能将 26 入两次堆。解决的方法也很简单:
用哪种方法不用多说了吧?
Java Code:
class Solution {
public int nthSuperUglyNumber(int n, int[] primes) {
PriorityQueue<Long> queue=new PriorityQueue<>();
int count = 0;
long ans = 1;
queue.add(ans);
while (count < n) {
ans=queue.poll();
while (!queue.isEmpty() && ans == queue.peek()) {
queue.poll();
}
count++;
for (int i = 0; i < primes.length ; i++) {
queue.offer(ans * primes[i]);
}
}
return (int)ans;
}
}
ans 初始化为 1 的作用相当于虚拟头,仅仅起到了简化操作的作用
堆的中心就一个,那就是动态求极值。
而求极值无非就是最大值或者最小值,这不难看出。如果求最大值,我们可以使用大顶堆,如果求最小值,可以用最小堆。
而实际上,如果没有动态两个字,很多情况下没有必要使用堆。比如可以直接一次遍历找出最大的即可。而动态这个点不容易看出来,这正是题目的难点。这需要你先对问题进行分析, 分析出这道题其实就是动态求极值,那么使用堆来优化就应该被想到。类似的例子有很多,我也会在后面的小节给大家做更多的讲解。
上面简单提到了堆的几种实现。这里介绍两种常见的实现,一种是基于链表的实现- 跳表,另一种是基于数组的实现 - 二叉堆。
使用跳表的实现,如果你的算法没有经过精雕细琢,性能会比较不稳定,且在数据量大的情况下内存占用会明显增加。 因此我们仅详细讲述二叉堆的实现,而对于跳表的实现,仅讲述它的基本原理,对于代码实现等更详细的内容由于比较偏就不在这里讲了。
跳表也是一种数据结构,因此 ta 其实也是服务于某种算法的。
跳表虽然在面试中出现的频率不大,但是在工业中,跳表会经常被用到。力扣中关于跳表的题目只有一个。但是跳表的设计思路值得我们去学习和思考。 其中有很多算法和数据结构技巧值得我们学习。比如空间换时间的思想,比如效率的取舍问题等。
上面提到了应付插队问题是设计堆应该考虑的首要问题。堆的跳表实现是如何解决这个问题的呢?
我们知道,不借助额外空间的情况下,在链表中查找一个值,需要按照顺序一个个查找,时间复杂度为 O ( N ) O(N) O(N),其中 N 为链表长度。
当链表长度很大的时候, 这种时间是很难接受的。 一种常见的的优化方式是建立哈希表,将所有节点都放到哈希表中,以空间换时间的方式减少时间复杂度,这种做法时间复杂度为 O ( 1 ) O(1) O(1),但是空间复杂度为 O ( N ) O(N) O(N)。
为了防止链表中出现重复节点带来的问题,我们需要序列化节点,再建立哈希表,这种空间占用会更高,虽然只是系数级别的增加,但是这种开销也是不小的 。更重要的是,哈希表不能解决查找极值的问题,其仅适合根据 key 来获取内容。
为了解决上面的问题,跳表应运而生。
如下图所示,我们从链表中每两个元素抽出来,加一级索引,一级索引指向了原始链表,即:通过一级索引 7 的 down 指针可以找到原始链表的 7 。那怎么查找 10 呢?
注意这个算法要求链表是有序的。
我们可以:
通过现在一级跳表中搜索到 7,发现下一个 18 大于 10 ,也就是说我们要找的 10 在这两者之间。
通过 down 指针回到原始链表,通过原始链表的 next 指针我们找到了 10。
这个例子看不出性能提升。但是如果元素继续增大, 继续增加索引的层数,建立二级,三级。。。索引,使得链表能够实现二分查找,从而获得更好的效率。但是相应地,我们需要付出额外空间的代价。
一个游戏有 10 关。如果我想要玩第 5 关的某一个地方,那么我可以直接从第五关开始,这样要比从第一关开始快。我们甚至可以在每一关同时设置很多的存档。这样我如果想玩第 5 关的某一个地方,也可以不用从第 5 关的开头开始,而是直接选择离你想玩的地方更近的存档,这就相当于跳表的二级索引。
跳表的时间复杂度和空间复杂度不是很好分析。由于时间复杂度 = 索引的高度 * 平均每层索引遍历元素的个数,而高度大概为 l o g n logn logn,并且每层遍历的元素是常数,因此时间复杂度为 l o g n logn logn,和二分查找的空间复杂度是一样的。
空间复杂度就等同于索引节点的个数,以每两个节点建立一个索引为例,大概是 n/2 + n/4 + n/8 + … + 8 + 4 + 2 ,因此空间复杂度是 O ( n ) O(n) O(n)。当然你如果每三个建立一个索引节点的话,空间会更省,但是复杂度不变。
理解了上面的内容,使用跳表实现堆就不难了。
大家如果想检测自己的实现是否有问题,可以去力扣的1206. 设计跳表 检测。
接下来,我们看下一种更加常见的实现 - 二叉堆。
二叉堆的实现,我们仅讲解最核心的两个操作: heappop(出堆) 和 heappush(入堆)。对于其他操作不再讲解,不过我相信你会了这两个核心操作,其他的应该不是难事。
实现之后的使用效果大概是这样的:
h = min_heap()
h.build_heap([5, 6, 2, 3])
h.heappush(1)
h.heappop() # 1
h.heappop() # 2
h.heappush(1)
h.heappop() # 1
h.heappop() # 3
本质上来说,二叉堆就是一颗特殊的完全二叉树。它的特殊性只体现在一点,那就是父节点的权值不大于儿子的权值(小顶堆)。
由于父节点的权值不大于儿子的权值(小顶堆),那么很自然能推导出树的根节点就是最小值。这就起到了堆的取极值的作用了。
那动态性呢?二叉堆是怎么做到的呢?
假如,我将树的根节点出堆,那么根节点不就空缺了么?我应该将第二小的顶替上去。怎么顶替上去呢?一切的一切还是那句话父节点的权值不大于儿子的权值(小顶堆)。
如果仅仅是删除,那么一个堆就会变成两个堆了,问题变复杂了。
一个常见的操作是,把根结点和最后一个结点交换。但是新的根结点可能不满足 父节点的权值不大于儿子的权值(小顶堆)。
如下图,我们将根节点的 2 和尾部的数字进行交换后,这个时候是不满足堆性质的。
这里 Java 语言为例,讲述一下代码的编写。其他语言的二叉堆实现可以去我的刷题插件 leetcode-cheatsheet 中获取。插件的获取方式在公众号力扣加加里,回复插件即可。
import java.util.Arrays;
import java.util.Comparator;
/**
* 用完全二叉树来构建 堆
* 前置条件 起点为 1
* 那么 子节点为 i <<1 和 i<<1 + 1
* 核心方法为
* shiftdown 交换下沉
* shiftup 交换上浮
*
* build 构建堆
*/
public class Heap {
int size = 0;
int queue[];
public Heap(int initialCapacity) {
if (initialCapacity < 1)
throw new IllegalArgumentException();
this.queue = new int[initialCapacity];
}
public Heap(int[] arr) {
size = arr.length;
queue = new int[arr.length + 1];
int i = 1;
for (int val : arr) {
queue[i++] = val;
}
}
public void shiftDown(int i) {
int temp = queue[i];
while ((i << 1) <= size) {
int child = i << 1;
// child!=size 判断当前元素是否包含右节点
if (child != size && queue[child + 1] < queue[child]) {
child++;
}
if (temp > queue[child]) {
queue[i] = queue[child];
i = child;
} else {
break;
}
}
queue[i] = temp;
}
public void shiftUp(int i) {
int temp = queue[i];
while ((i >> 1) > 0) {
if (temp < queue[i >> 1]) {
queue[i] = queue[i >> 1];
i >>= 1;
} else {
break;
}
}
queue[i] = temp;
}
public int peek() {
int res = queue[1];
return res;
}
public int pop() {
int res = queue[1];
queue[1] = queue[size--];
shiftDown(1);
return res;
}
public void push(int val) {
if (size == queue.length - 1) {
queue = Arrays.copyOf(queue, size << 1+1);
}
queue[++size] = val;
shiftUp(size);
}
public void buildHeap() {
for (int i = size >> 1; i > 0; i--) {
shiftDown(i);
}
}
public static void main(String[] args) {
int arr[] = new int[]{2,7,4,1,8,1};
Heap heap = new Heap(arr);
heap.buildHeap();
System.out.println(heap.peek());
heap.push(5);
while (heap.size > 0) {
int num = heap.pop();
System.out.printf(num + "");
}
}
}
堆的实现有很多。比如基于链表的跳表,基于数组的二叉堆和基于红黑树的实现等。这里我们详细地讲述了二叉堆的实现,不仅是其实现简单,而且其在很多情况下表现都不错,推荐大家重点掌握二叉堆实现。
对于二叉堆的实现,核心点就一点,那就是始终维护堆的性质不变,具体是什么性质呢?那就是 父节点的权值不大于儿子的权值(小顶堆)。为了达到这个目的,我们需要在入堆和出堆的时候,使用上浮和下沉操作,并恰当地完成元素交换。具体来说就是上浮过程和比它大的父节点进行交换,下沉过程和两个子节点中较小的进行交换,当然前提是它有子节点且子节点比它小。
关于堆化我们并没有做详细分析。不过如果你理解了本文的入堆操作,这其实很容易。因此堆化本身就是一个不断入堆的过程,只不过将时间上的离散的操作变成了一次性操作而已。