数据结构与算法(4)——优先队列和堆

前言:题图无关,接下来开始简单学习学习优先队列和堆的相关数据结构的知识;
前序文章:
- 数据结构与算法(1)——数组与链表(https://www.jianshu.com/p/7b93b3570875)
- 数据结构与算法(2)——栈和队列(https://www.jianshu.com/p/5087c751cb42)
- 数据结构与算法(3)——树(二叉、二叉搜索树)(https://www.jianshu.com/p/4ef1f50d45b5)
1|0什么是优先队列?
听这个名字就能知道,优先队列也是一种队列,只不过不同的是,优先队列的出队顺序是按照优先级来的;在有些情况下,可能需要找到元素集合中的最小或者最大元素,可以利用优先队列ADT来完成操作,优先队列ADT是一种数据结构,它支持插入和删除最小值操作(返回并删除最小元素)或删除最大值操作(返回并删除最大元素);
这些操作等价于队列的enQueue
和deQueue
操作,区别在于,对于优先队列,元素进入队列的顺序可能与其被操作的顺序不同,作业调度是优先队列的一个应用实例,它根据优先级的高低而不是先到先服务的方式来进行调度;

如果最小键值元素拥有最高的优先级,那么这种优先队列叫作升序优先队列(即总是先删除最小的元素),类似的,如果最大键值元素拥有最高的优先级,那么这种优先队列叫作降序优先队列(即总是先删除最大的元素);由于这两种类型时对称的,所以只需要关注其中一种,如升序优先队列;
1|1优先队列ADT
下面操作组成了优先队列的一个ADT;
1.优先队列的主要操作
优先队列是元素的容器,每个元素有一个相关的键值;
insert(key, data)
:插入键值为key的数据到优先队列中,元素以其key进行排序;deleteMin/deleteMax
:删除并返回最小/最大键值的元素;getMinimum/getMaximum
:返回最小/最大剑指的元素,但不删除它;
2.优先队列的辅助操作
第k最小/第k最大
:返回优先队列中键值为第k个最小/最大的元素;大小(size)
:返回优先队列中的元素个数;堆排序(Heap Sort)
:基于键值的优先级将优先队列中的元素进行排序;
1|2优先队列的应用
- 数据压缩:赫夫曼编码算法;
- 最短路径算法:Dijkstra算法;
- 最小生成树算法:Prim算法;
- 事件驱动仿真:顾客排队算法;
- 选择问题:查找第k个最小元素;
- 等等等等....
1|3优先队列的实现比较
实现 | 插入 | 删除 | 寻找最小值 |
---|---|---|---|
无序数组 | 1 | n | n |
无序链表 | 1 | n | n |
有序数组 | n | 1 | 1 |
有序链表 | n | 1 | 1 |
二叉搜索树 | logn(平均) | logn(平均) | logn(平均) |
平衡二叉搜索树 | logn | logn | logn |
二叉堆 | logn | logn | 1 |
2|0堆和二叉堆
2|1什么是堆
堆是一颗具有特定性质的二叉树,堆的基本要求就是堆中所有结点的值必须大于或等于(或小于或等于)其孩子结点的值,这也称为堆的性质;堆还有另一个性质,就是当 h > 0 时,所有叶子结点都处于第 h 或 h - 1 层(其中 h 为树的高度,完全二叉树),也就是说,堆应该是一颗完全二叉树;

在下面的例子中,左边的树为堆(每个元素都大于其孩子结点的值),而右边的树不是堆(因为5大于其孩子结点2)

2|2二叉堆
在二叉堆中,每个结点最多有两个孩子结点,在实际应用中,二叉堆已经足够满足需求,因此接下来主要讨论二叉最小堆和二叉最大堆;
堆的表示:在描述堆的操作前,首先来看堆是怎样表示的,一种可能的方法就是使用数组,因为堆在形式上是一颗完全二叉树,用数组来存储它不会浪费任何空间,例如下图:

用数组来表示堆不仅不会浪费空间还具有一定的优势:
- 每个结点的左孩子为下标i的2倍:
left child(i) = i * 2
;每个结点的右孩子为下标i的2倍加1:right child(i) = i * 2 + 1
- 每个结点的父亲结点为下标的二分之一:
parent(i) = i / 2
,注意这里是整数除,2和3除以2都为1,大家可以验证一下; - 注意:这里是把下标为0的地方空出来了的,主要是为了方便理解,如果0不空出来只需要在计算的时候把i值往右偏移一个位置就行了(也就是加1,大家可以试试,下面的演示也采取这样的方式);
二叉堆的相关操作
堆的基本结构
向堆中添加元素和Sift Up
当插入一个元素到堆中时,它可能不满足堆的性质,在这种情况下,需要调整堆中元素的位置使之重新变成堆,这个过程称为堆化(heapifying);在最大堆中,要堆化一个元素,需要找到它的父亲结点,如果不满足堆的基本性质则交换两个元素的位置,重复该过程直到每个结点都满足堆的性质为止,下面我们来模拟一下这个过程:
下面我们在该堆中插入一个新的元素26:

我们通过索引(上面的公式)可以很容易地找到新插入元素的父亲结点,然后比较它们的大小,如果新元素更大则交换两个元素的位置,这个操作就相当于把该元素上浮了一下:

重复该操作直到26到了一个满足堆条件的位置,此时就完成了插入的操作:

对应的代码如下:
取出堆中的最大元素和Sift Down
如果理解了上述的过程,那么取出堆中的最大元素(堆顶元素)将变得容易,不过这里运用到一个小技巧,就是用最后一个元素替换掉栈顶元素,然后把最后一个元素删除掉,这样一来元素的总个数也满足条件,然后只需要把栈顶元素依次往下调整就好了,这个操作就叫做Sift Down(下沉):

用最后元素替换掉栈顶元素,然后删除最后一个元素:

然后比较其孩子结点的大小:

如果不满足堆的条件,那么就跟孩子结点中较大的一个交换位置:

重复该步骤,直到16到达合适的位置:

完成取出最大元素的操作:

对应的代码如下:
Replace 和 Heapify
Replace这个操作其实就是取出堆中最大的元素之后再新插入一个元素,常规的做法是取出最大元素之后,再利用上面的插入新元素的操作对堆进行Sift Up操作,但是这里有一个小技巧就是直接使用新元素替换掉堆顶元素,之后再进行Sift Down操作,这样就把两次O(logn)的操作变成了一次O(logn):
Heapify翻译过来就是堆化的意思,就是将任意数组整理成堆的形状,通常的做法是遍历数组从0开始添加创建一个新的堆,但是这里存在一个小技巧就是把当前数组就看做是一个完全二叉树,然后从最后一个非叶子结点开始进行Sift Down操作就可以了,最后一个非叶子结点也很好找,就是最后一个结点的父亲结点,大家可以验证一下:

从22这个节点开始,依次开始Sift Down操作:

重复该过程直到堆顶元素:



完成堆化操作:

将n个元素逐个插入到一个空堆中,算法复杂度是O(nlogn),而heapify的过程,算法复杂度为O(n),这是有一个质的飞跃的,下面是代码:
2|3基于堆的优先队列
首先我们的队列仍然需要继承我们之前将队列时候声明的哪个接口Queue
,然后实现这个接口中的方法就可以了,之类简单写一下:
2|4Java中的PriorityQueue
在Java中也实现了自己的优先队列java.util.PriorityQueue
,与我们自己写的不同之处在于,Java中内置的为最小堆,然后就是一些函数名不一样,底层还是维护了一个Object类型的数组,大家可以戳戳看有什么不同,另外如果想要把最小堆变成最大堆可以给PriorityQueue传入自己的比较器,例如:
3|0LeetCode相关题目整理
3|123. 合并K个排序链表

参考答案:(85ms)
3|2215. 数组中的第K个最大元素

我的答案:(75ms)
参考答案:(5ms)
还看到一个简单粗暴的,也是服了:(4ms)
而且我随机生成了一个100万数据的随机数组,来测试这个简单粗暴的方法的效率,发现当数据量上去之后,排序这个操作变得繁琐,我自己测试的时候,上面三个方法,第三个大概比第一个(我自己写的方法)多花仅4倍的时间;
3|3239. 滑动窗口最大值(类似剑指Offer面试题59)

参考答案:(88ms)
参考答案2:(9ms)
3|4264. 丑数 II(剑指Offer面试题49)

参考答案:(7ms)
如果采用逐个判断每个整数是不是丑数的解法,直观但不够高效,所以我们就需要换一种思路,我的第一反应就是这其中一定有什么规律,但是尝试着找了一下,没找到...看了看答案才幡然醒悟,前面提到的算法之所以效率低,很大程度上是因为不管一个数是不是丑数,我们都要对它进行计算,接下来我们试着找到一种只计算丑数的方法,而不在非丑数的整数上花费时间,根据丑数的定义,丑数应该是另一个丑数乘以2、3或者5的结果(1除外),因此,我们可以创建一个数组,里面的数字是排好序的丑数,每个丑数都是前面的丑数乘以2、3或者5得到的,也就是上面的算法了..
3|5295.数据流的中位数(剑指Offer面试题41)

参考答案:(219ms)
思路:这道题的实现思路有很多,比如我们可以在插入的时候就将每个元素插入到正确的位置上,这样返回中位数的时候就会是一个O(1)的操作,下面列举一张表来说明不同实现的复杂度具体是多少:
数据结构 | 插入的时间复杂度 | 得到中位数的时间复杂度 |
---|---|---|
没有排序的数组 | O(1) | O(n) |
排序的数组 | O(n) | O(1) |
排序的链表 | O(n) | O(1) |
二叉搜索树 | 平均O(logn),最差O(n) | 平均O(logn),最差O(n) |
AVL树 | O(logn) | O(logn) |
最大堆和最小堆 | O(logn) | O(logn) |

如何快速从一个数据容器中找出最大数呢?我们可以使用最大堆来实现这个数据容器,因为堆顶的元素就是最大的元素;同样我们可以使用最小堆来快速找出一个数据容器中最小数。因此按照这个思路我们就可以使用一个最大堆实现左边的数据容器,使用一个最小堆实现右边的数据容器,但是需要注意的是这两个容器的大小差值不能超过1;
3|6347. 前K个高频元素(类似剑指Offer面试题40)

参考答案:(131ms)
3|7692. 前K个高频单词

参考答案:(72ms)
这道题类似于上面的第347题,但是问题出在返回的顺序上,需要自己来定义一个比较器来排序..然后也学到一个写法,就是上面的第一个for循环里,
getOrDefault()
方法,get√..
参考答案2:(91ms)
这个解法就有点儿类似于上面的347题,其实是大同小异,就是自己不会灵活使用比较器而已,学习到了学习到了√...
3|8简单总结
今天算是很有收获的一天,因为这两种数据结构都是自己特别不熟悉的,特别是在刷了一些LeetCode相关题目之后,对这两种数据有了很不一样的认识,特别是堆的应用,这是一种特别适合用来找第k小/大的特殊的数据结构,并且在Java中居然有直接的实现,这可太棒了,而且今天的效率还算挺高的,满足;
欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz_javaweb
分享自己的Java Web学习之路以及各种Java学习资料
__EOF__

本文链接:https://www.cnblogs.com/wmyskxz/p/9301021.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?