【数据结构 Java 版】堆和优先级队列(超详解)

x33g5p2x  于2021-11-09 转载在 Java  
字(6.5k)|赞(0)|评价(0)|浏览(398)

在上一章 《二叉树的实现(超多图、超详解)》 中,我们介绍了二叉树的存储分为: 类似于链表的链式存储顺序存储。链式存储在上一节介绍过了,今天将从二叉树的顺序存储过度到堆的实现。

1. 二叉树的顺序存储

1.1 存储方式

二叉树的顺序存储:
是通过层序遍历的方式将二叉树存入数组

注意:

顺序存储一般只使用于完全二叉树,因为非完全二叉树会造成空间浪费

1.2 下标关系

由于二叉树存储在数组中,父亲节点和孩子节点的下标有以下关系:

  • 已知父亲节点的下标 i: 左孩子节点下标为 2i+1;右孩子节点下标为:2i+2
  • 已知孩子节点的下标 i: 父亲节点下标为 (i-1)/2

1.3 顺序存储和堆的关系

其实二叉树的顺序存储,即将一棵完全二叉树通过层序遍历的方式存入数组中,这种方式主要的用法其实就是今天的主角堆。堆的底层就是一棵完全二叉树,并且是存储在数组中的。

2. 堆

2.1 概念

  • 堆的逻辑上: 是一棵完全二叉树
  • 堆的物理上: 是保存在数组中

堆的特类:

  • 大根堆: 满足任意节点的值都大于其子树中节点的值(也叫大堆或最大堆)
  • 小根堆: 满足任意节点的值都小于其子树中节点的值(也叫小队或最小堆)

大根堆示例图:

小根堆示例图:

注意:
只有整个二叉树当中的每棵子树都是大堆/小堆时,整棵树才是大堆/小堆

堆的基本作用:

通过上图演示大根堆和小根堆,我们很清晰的知道,堆的最基本的作用就是:快速找到集合中的最值

2.2 堆的创建(以大根堆为例)

步骤:

  1. 既然我们知道堆的物理底层是一个数组,那么我们就可以先创建一个类,写成这样
  1. public class TestHeap {
  2. public int[] elem;
  3. public int usedSize;
  4. public TestHeap(){
  5. this.elem=new int[10];
  6. }
  7. }
  1. 我们得到了一个堆的类,接下来我们来实现这个类的最基本的操作,创建一个堆(这里以大根堆为例)
  1. // 创建一个大根堆
  2. public void createHeap(int[] array){
  3. }
  1. 该方法中我们可以首先把需要的数据直接存储在 elem 中,之后直接对 elem 进行操作
  1. // 创建一大根个堆
  2. public void createHeap(int[] array){
  3. // 首先把需要的数据直接存放在 elem 中,一会建堆时直接操作 elem
  4. for(int i=0;i<array.length;i++){
  5. this.elem[i]=array[i];
  6. this.usedSize++;
  7. }
  8. }
  • 接下来我们要解决怎么将存入的无规则的数据,转换成我们需要的大根堆的存储的顺序。

  • 先看一个已经在 elem 中存放了数据的示例图,它的逻辑结构和物理结构如下:

  • 接下来通过向下调整的操作,从 elem 中的最后一个值,即最后一个节点开始,找到它的父节点,再找到这个父节点的左右子树的较大的节点,如果它比父节点的值还大就进行交换。完成以后再继续向前依次进行该操作。注意: 父节点与孩子节点若交换,以孩子节点为跟节点的子树要判断是否还事一个大根堆。

  • 我们再通过子节点如果是 i,那么父节点就是 (i-1)/2 的关系,将这棵树的子节点和父节点关联起来

实现代码:

  1. // 创建一大根个堆
  2. public void createHeap(int[] array){
  3. // 首先把需要的数据直接存放在 elem 中,一会建堆时直接操作 elem
  4. for(int i=0;i<array.length;i++){
  5. this.elem[i]=array[i];
  6. this.usedSize++;
  7. }
  8. // 从最后一个节点的父亲节点开始对其进行向下操纵
  9. for(int parent=(this.usedSize-2)/2;parent>=0;parent--){
  10. shiftDown(parent);
  11. }
  12. }
  13. public void shiftDown(int parent){
  14. int child=2*parent+1;
  15. // 进入该循环说明你有左孩子,进行向下调整操作
  16. while (child<this.usedSize){
  17. if(child+1<this.usedSize&&this.elem[child]<this.elem[child+1]){
  18. child++;
  19. }
  20. // child 所保存的下标就是左右孩子的最大值
  21. if(this.elem[parent]<this.elem[child]){
  22. int tmp=this.elem[parent];
  23. this.elem[parent]=this.elem[child];
  24. this.elem[child]=tmp;
  25. parent=child;
  26. child=2*parent+1;
  27. }else {
  28. // 该子树的向下调整操作结束
  29. break;
  30. }
  31. }
  32. }

2.3 创建堆的时间复杂度(易错)

错误计算方式(时间复杂度为:O(Nlog(N))):

  • shiftDown 方法中: 最坏的情况就是从根节点开始要一直往下调整到叶子节点。通过性质我们知道:具有 n 个节点的完全二叉树的深度为 log2(n+1) 向上取整。故该方法时间复杂度为:O(log(N))
  • createHeap 方法中: 由于被向下调整的父节点的个数为 N/2,所以最终时间复杂度可以估算为:O(Nlog(N))

正确计算方式(时间复杂度为:O(N)):

  • 假设这棵树的高度为 h,并且是一棵满二叉树(最坏情况)
  • 从第1层到第 h 层,每层的节点个数分别为:20、21、22、…、2h-2、2h-1
  • 从第1层到第 h 层,每层需要向下调整的高度为:h-1、h-2、h-3、…、1、0
  • 由于最后一层我们不需要调整,只要调整第1层到倒数第1层即可,故得到时间复杂度的数学公式为:T(n) = 20 * (h-1) + 21* (h-2) + 22 * (h-3) + … + 2h-2 * 1,其中 n 为节点总数
  • 再通过错位相减法,我们可以将 T(n) 简化为:T(n) = 2h - 1 - n
  • 由于 n = 2h - 1,故 T(n) = n - log2(n+1) ,估算为时间复杂度为:O(N)

3. 堆的应用(1)优先级队列

3.1 概念

实际应用:
在很多应用中,我们通常需要按照优先情况对待处理对象进行处理,比如首先处理优先级最高的对象,然后处理次高的对象。最简单的一个例子就是,在手机上玩游戏时,如果有来电,那么系统应该优先处理进来的电话。

优先级队列:

在这种情况下,我们的数据结构应该提供两个最基本的操作:一个是返回最高优先级,另一个是添加新的对象。这种数据结构就是优先级队列(Priority Queue)

3.2 内部原理

  • 优先级队列的实现方式有很多,但最常见的方式是:使用堆来构建
  • 我们知道堆有两个特类:大根堆和小根堆。而这两个堆的根节点肯定是优先级最高的,故我们如果构建的这个大根堆或小根堆,不管是入队列还是出队列,在操作结束后,这个堆仍然是大根堆或小根堆。那么这个堆就一直保持着优先级的特点。

3.3 入队列操作

思想(以大根堆为例):

  1. 将新元素尾查到数组中
  2. 通过向上调整的方式,比较它与父亲节点的值的大小,如果更大则交换
  3. 如果不交换则符合大根堆特点,则完成入队列;如果交换,则将父节点变为孩子节点,再找到新的孩子节点的父节点进行重复操作直到孩子节点下标为0或父节点小标小于0结束

实现代码:

  1. // 判断堆是否为空
  2. public boolean isFull(){
  3. return this.usedSize==this.elem.length;
  4. }
  5. // 入堆操纵
  6. public void push(int val){
  7. if(isFull()){
  8. this.elem= Arrays.copyOf(this.elem,2*this.elem.length);
  9. }
  10. this.elem[this.usedSize++]=val;
  11. shiftUp(this.usedSize-1);
  12. }
  13. // 向上调整
  14. public void shiftUp(int child){
  15. int parent=(child-1)/2;
  16. while(parent>=0) {
  17. if (this.elem[parent] < this.elem[child]) {
  18. int tmp = this.elem[parent];
  19. this.elem[parent] = this.elem[child];
  20. this.elem[child] = tmp;
  21. child = parent;
  22. parent = (child - 1) / 2;
  23. } else {
  24. break;
  25. }
  26. }
  27. }

3.4 出队列操作

思想(以大根堆为例):

  • 由于大根堆的堆顶元素一定是优先级最高的,故我们将它进行出队列操作。
  • 但是为了防止破坏堆的结构,删除时并不是直接将堆顶元素删除,而是用数组的最后一个元素替换堆顶元素,然后通过向下调整的方式重新调整堆

实现代码:

  1. // 判断堆是否为空
  2. public boolean isEmpty(){
  3. return this.usedSize==0;
  4. }
  5. // 出队列
  6. public int poll(){
  7. if(isEmpty()){
  8. throw new RuntimeException("队列空,不能进行出队列操作");
  9. }
  10. int val=this.elem[0];
  11. this.elem[0]=this.elem[this.usedSize-1];
  12. this.usedSize--;
  13. // 通过向下调整
  14. shiftDown(0);
  15. return val;
  16. }

3.5 返回队首元素操作

思想(以大根堆为例):
直接返回堆顶元素

实现代码:

  1. // 返回队首元素
  2. public int peek(){
  3. if(isEmpty()){
  4. throw new RuntimeException("堆为空");
  5. }
  6. return this.elem[0];
  7. }

4. 堆的应用(2)TopK 问题

4.1 介绍

TopK 问题是在一组数据当中,找到前 k 个最大的或最小的数据。

示例:

如我们有一百个数据,我们要得到前十个最大或最小的元素

4.2 方法

处理 TopK 问题的方法有很多,如:简单的排序、堆、分治法、减治法等等。

今天将用堆来处理这个 TopK 问题

4.3 思路

下面思路以找到前 k 个最大的元素为例,如果是找前 k 个最小的几个元素,则做法相反

  • 关于优先级的问题,我们往往就想到用大根堆或者小根堆。
  • 而要找到最大的几个元素,第一想法应该是大堆,但是如果我们用大堆的话,我们就要存储含100个数据的大堆,并且每找到一个最大的元素就出堆,再通过向上调整。故最先出堆的10个元素就是最大的10个数据,但时间复杂度其实为:O(N*log(N))
  • 如果我们反向思考,使用小堆的话,我们就可以先用小堆存储前10个数据,再将后面的90个数据依次与小堆的堆顶元素(即最小元素)比较。如果大于堆顶元素,则将堆顶元素出堆,再通过向上调整将该堆再变成一个小堆,然后新的数据补上空缺的位置。故到最后,最小的元素其实都通过堆顶出堆了,剩下的就是10个最大的元素,时间复杂度为:O(N*log(k))
  • 而 k 比 N 小的多,故用小堆去找前 k 个最大的元素更好

4.4 实现代码

下面代码以找到前 k 个最大的元素为例,如果是找前 k 个最小的几个元素,则做法相反

  1. // 找到前 k 个最大的元素
  2. public static int[] topK(int[] array, int k){
  3. // 默认建小堆
  4. PriorityQueue<Integer> minHeap=new PriorityQueue<>();
  5. for(int i=0;i<array.length;i++){
  6. if(i<k){
  7. minHeap.offer(array[i]);
  8. }else {
  9. if (array[i] > minHeap.peek()) {
  10. minHeap.poll();
  11. minHeap.offer(array[i]);
  12. }
  13. }
  14. }
  15. int[] elem=new int[k];
  16. for(int i=k-1;i>=0;i--){
  17. elem[i]=minHeap.poll();
  18. }
  19. return elem;
  20. }

4.5 拓展(找第 k 大/小的元素)

思路:

  • 找第 k 大的元素: 建小堆,得到前 k 个最大的元素,堆顶元素就是第 k 大的元素
  • 找第 k 小的元素: 建大堆,得到前 k 个最小的元素,堆顶元素就是第 k 小的元素

5. 堆的应用(3)堆排序

如果我们要使用堆去对一些数据进行排序,那么我们该怎么做呢?

5.1 思路

这里以从小到大排序为例,如果要从大到小的话以下思路就是反的
首先我们明确是建一个大根堆,然后堆顶元素就是最大的,然后我们将堆顶元素与堆尾元素交换,此时最后一个元素就是最大的元素,然后通过向上调整,保证堆顶元素为最大的,然后与堆的倒数第二个元素进行交换,这样我们就可以在每次交换中使从最末尾的元素开始依次变小的排序。

5.2 实现代码

这里以从小到大排序为例,如果要从大到小的话以下思路就是反的

  1. public static void heapSort(int[] array){
  2. // 下面将默认的大堆改成小堆,并且使用了匿名内部类的方式
  3. PriorityQueue<Integer> maxHeap=new PriorityQueue<>(new Comparator<Integer>() {
  4. @Override
  5. public int compare(Integer o1, Integer o2) {
  6. return o2-o1;
  7. }
  8. });
  9. for(int i=0;i<array.length;i++){
  10. maxHeap.offer(array[i]);
  11. }
  12. int end=array.length-1;
  13. while(end>=0){
  14. int val=maxHeap.poll();
  15. array[end]=val;
  16. end--;
  17. }
  18. }

6. 练习题——查找和最小的 k 对数字

题目(OJ 链接):
给定两个以升序排列的整数数组 nums1 和 nums2 , 以及一个整数 k 。

定义一对值 (u,v),其中第一个元素来自 nums1,第二个元素来自 nums2 。

请找到和最小的 k 个数对 (u1,v1), (u2,v2) … (uk,vk) 。

代码:

  1. class Solution {
  2. public List<List<Integer>> kSmallestPairs(int[] nums1, int[] nums2, int k) {
  3. PriorityQueue<List<Integer>> maxHeap=new PriorityQueue<>(new Comparator<List<Integer>>(){
  4. @Override
  5. public int compare(List<Integer> o1, List<Integer> o2){
  6. return (o2.get(0)+o2.get(1))-(o1.get(0)+o1.get(1));
  7. }
  8. });
  9. for(int i=0;i<Math.min(k,nums1.length);i++){
  10. for(int j=0;j<Math.min(k,nums2.length);j++){
  11. List<Integer> list=new ArrayList<>();
  12. if(maxHeap.size()<k){
  13. list.add(nums1[i]);
  14. list.add(nums2[j]);
  15. maxHeap.offer(list);
  16. }else{
  17. int top=maxHeap.peek().get(0)+maxHeap.peek().get(1);
  18. if((nums1[i]+nums2[j])<top){
  19. maxHeap.poll();
  20. list.add(nums1[i]);
  21. list.add(nums2[j]);
  22. maxHeap.offer(list);
  23. }
  24. }
  25. }
  26. }
  27. List<List<Integer>> minKList=new ArrayList<>();
  28. for(int i=0;i<k && !maxHeap.isEmpty();i++){
  29. minKList.add(maxHeap.poll());
  30. }
  31. return minKList;
  32. }
  33. }

相关文章