【数据结构】堆和堆的应用之优先级队列【详解篇6】

队列的几种变化

  • 普通的队列:先进先出
  • 带优先级的队列(名字叫队列,本质上是一个特殊二叉树–>堆):按照顺序进,出队列的时候出优先级最高的元素,如果优先级相同,再按照先进先出的方式。
  • 带类型的队列(消息队列):这里的类型指的不是int,String,业务上的类型,它和具体场景密切相关,即入队列按照原来的顺序入,出队列不是普通的先进先出,而是按照类型取数据,如果是相同类型的元素还是按照先进先出的规则。
  • 阻塞队列:它是线程安全版本的队列,具有一定的特性,即当队列为空,再去取元素就会发生阻塞;当队列为满,再去插入元素也会发生阻塞;
  • 无锁队列:线程安全版本的队列,不用锁就能保证线程安全(CAS).

二叉树的顺序存储

存储方式

二叉树的存储方式可以使用左右孩子表示法,也可以使用数组来表示一棵树。

  • 左右孩子表示法:每一个节点都记录左子树和右子树他的一个根节点引用,然后就能够把整棵树都能够获取到;
  • 使用数组保存二叉树结构的方式:即将二叉树用层序遍历方式放入数组中(需要存储空节点),不过它一般只适合表示完全二叉树,因为非完全二叉树会有空间的浪费。如下图:

image-20211017202217076

下标关系

画图分析下标关系规律:

总结:

  • 一个根节点的左右子树下标是相邻的
  • 通过数组来表示二叉树的一个重要规律:根据子树下标求根节点下标,统一减一除以2即可,不需要区分当前是左子树还是右子树。
  • 通过这个下标的规律才好去确定树形的结构,所以这个规律非常重要!!!

堆(heap)

堆,这里的堆是数据结构中的通用概念。

堆本质上是一个二叉树,满足以下几个条件:

1、堆逻辑上是一棵完全二叉树

2、对于树中的任意节点,满足根节点小于左右子树的值,叫做小堆(大根堆或最大堆);满足根节点大于左右子树的值,叫做大堆(大根堆或最大堆),一个堆如果是小堆,就不可能是大堆。

大小堆示例:

image-20211018084008031

3、堆通常是通过数组的形式来存储的,即堆物理上是保存在数组中

4、堆的最大用处就是能让我们快速找到一个树中的最大值或最小值(根节点),也可以说成是能帮我们高效的找出一个集合中的最大/最小的元素(堆顶元素),还能帮我们找到前K大或者前K小的元素,也就是经典的topK问题。

堆的核心操作

向上调整和向下调整:把一个不满足堆的结构调整成满足堆的结构(大堆或小堆)

1、从前往后遍历,是向上调整;

2、从后往前遍历,是向下调整。

向下调整

向下调整的操作实现思路:

  • 先设定根节点为当前节点
  • 然后找到当前节点的左右子树的值(这个值是通过下标来获取到的)
  • 比较左右子树的值,找出谁更小,使用child来标记更小的值
  • 比较child 和parent值谁大谁小
    • 如果child比parent小,不符合小堆的规则,就进行交换;
    • 如果child比parent大,符合小堆的规则,不需要交换,此时整个调整也就结束了。
    • 处理完一个节点之后,从当前child出发,循环刚才的过程。

基于向下调整实现建堆的操作的思路:

  • 借助向下调整,就可以把一个数组构建成堆。
  • 从倒数第一个非叶子节点开始,从后往前遍历数组,针对每个位置,依次向下调整即可;
  • 如:把这组数据建成小堆 9 5 2 7 3 6 8

image-20211018100556395

image-20211018100510429
上图过程分析:

​ 倒数第一个非叶子节点下标为2,然后针对这个子树进行向下调整,发现不需要调整就已经满足小堆了。
​ 再针对下标为1的节点调整,经过3和5交换,就调整完成
​ 再针对下标为0的节点调整,9和2交换,然后9和6交换。

代码实现:

package java2021_1010;

import java.lang.reflect.Array;
import java.util.Arrays;

public class Heap {
//操作-向下调整
    //size表示array中哪些元素是有效的堆元素,
    //左右子树都是堆,才能进行这样的调整
    public static void shiftDown(int[] array,int size,int index){
        int parent=index;
        int child=2 * parent +1;//根据parent下标找到左右子树
        while(child<size){
            //左右子树,找到较小值
            if (child + 1 < size && array[child + 1] < array[child]) {
                child=child+1;
            }
            //经过上面的比较,此时已经不知道child是左子树还是右子树了
            //只知道的是child下标一定对应左右子树最小值的下标
 
            //拿child位置的元素和parent位置的元素进行比较
            if(array[child]<array[parent]){
                //不符合小堆的规则,就交换父子节点
                int tmp=array[child];
                array[child]=array[parent];
                array[parent]=tmp;
            }else{
                //调整完毕,不需要继续了
                break;
            }
            //更新parent和child,处理下一层的数据
            parent=child;
            child=parent*2+1;
        }
    }
// 基于向下调整写一个建堆的方法,实现建堆操作
    public static void createHeap(int[] array,int size){
        for(int i=(size-1-1)/2;i>=0;i--){
            //size-1得到的是最后一个叶子节点,再-1/2就得到了最后一个叶子节点的父节点(这也就是倒数第一个非叶子节点了)
            shiftDown(array,size,i);
        }
    }
//测试
    public static void main(String[] args) {
        int[] array={9,5,2,7,3,6,8};
        createHeap(array,array.length);
        System.out.println(Arrays.toString(array));
    }
}

打印结果:
[2, 3, 6, 7, 5, 9, 8]

向上调整

跟向下调整思路差不多,只不过,向上调整是建大堆。

代码实现:

package java2021_1011;

import java.util.Arrays;

/**
 * Description:向上调整
 */
public class Heap2 {
   //操作-向上调整
    //int[] array:数组类型的参数
    //size表示array中哪些元素是有效的堆元素,即array中是堆的部分都是有效的堆元素
    //index:表示从哪个位置开始进行向上调整
    //左右子树都是堆,才能进行这样的调整
    public static void shiftUp(int[] array,int size,int index){
        int parent=index;
        int child=2 * parent +1;//根据父子节点的下标关系得出的结论,通过parent下标找到左右子树
        while(child<size){//这个条件的含义是看看parent有没有子节点
            //找左右子树较大的节点
            if (child + 1 < size && array[child + 1]>array[child]) {
                child=child+1;
            }
            //经过上面的比较,此时已经不知道child是左子树还是右子树了
            //只知道的是child下标一定对应左右子树最大值的下标

            //拿child位置的元素和parent位置的元素进行比较
            if(array[child]>array[parent]){
                //不符合大堆的规则,就交换父子节点
                int tmp=array[child];
                array[child]=array[parent];
                array[parent]=tmp;
            }else{
                //当前这个位置,已经符合堆的要求了,不需要继续调整了
                break;
            }
            //更新parent和child,处理下一层的数据
            parent=child;
            child=parent*2+1;
        }
    }
    // 基于向上调整写一个建堆的方法,实现建堆操作
    public static void createHeap(int[] array,int size){
        for(int i=(size-1-1)/2;i>=0;i--){
            //size-1得到的是最后一个叶子节点,再-1/2就得到了最后一个叶子节点的父节点(这也就是倒数第一个非叶子节点了)
            shiftUp(array,size,i);
        }
    }
    // 写一个main方法,在main方法中调用建堆的方法
    public static void main(String[] args) {
        int[] array={9,5,2,7,3,6,8};
        createHeap(array,array.length);
        System.out.println(Arrays.toString(array));
    }
}

打印结果
[9, 7, 8, 5, 3, 6, 2]

堆的应用

堆(优先队列)的基本操作

概念

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

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

内部原理

优先级队列的实现方式有很多,但最常见的是使用堆来构建

入队列操作

过程(以大堆为例):

  1. 首先按尾插方式放入数组
  2. 比较其和其双亲的值的大小,如果双亲的值大,则满足堆的性质,插入结束
  3. 否则,交换其和双亲位置的值,重新进行 2、3 步骤
  4. 直到根结点

出队列操作(优先级最高)

为了防止破坏堆的结构,删除时并不是直接将堆顶元素删除,而是用数组的最后一个元素替换堆顶元素,然后通过向下调整方式重新调整成堆.

返回队首元素(优先级最高)

返回堆顶元素即可.

代码实现:

package java2021_1011;

import java.util.Arrays;
/**
 * Created by Sun
 * Description:用(大)堆实现优先级队列
 使用向上调整和向下调整实现优先队列结构的操作,将取出的元素按照优先级从高到低依次出队列。
 *向上调整是在插入元素时用到的,向下调整是在删除元素时用到的
 * User:Administrator
 * Date:2021-10-18
 * Time:11:41
 */
public class MyPriorityQueue {
    //创建一个数组,array看起来是一个数组,其实应该是一个堆的结构
    private int[] array = new int[100];
    private int size = 0;

    //入队列操作
    public void offer(int x) {
        array[size] = x;//将元素插入到优先队列(数组)中
        size++;
        shiftUp(array, size - 1);//  把新加入的元素进行向上调整,调用向上调整操作
    }

    //向上调整操作
    private static void shiftUp(int[] array, int index) {
        int child = index;
        int parent = (child - 1) / 2;//已知child找parent使用(child-1)/2这个公式,就得到了父节点的下标了
        while (child > 0) {//如果child=0表示已经到达根节点了,到达根节点就不需要进行调整了,如果>0就需要继续调整
            if (array[parent] < array[child]) {//如果父节点小于子节点,就不符合大堆要求,二者交换调整
                int tmp = array[parent];
                array[parent] = array[child];
                array[child] = tmp;
            } else {
                //如果发现当前父节点比子节点大,这时就说明整个数组已经符合堆的结构了
                break;
            }
            //如果没有触发执行break,那么下次执行就需要更新child和parent
            child = parent;
            parent = (child - 1) / 2;
        }
    }

    //出队操作:需要删除队首元素,下标为0的元素就是队首元素,删掉的同时,也希望剩下的结构仍然是一个堆
    public int poll() {
        int oldValue = array[0];
        array[0] = array[size - 1];
        size--;
        shiftDown(array, size, 0);
        return oldValue;//需要把被删掉的元素返回出去
    }

    //向下调整操作
    //int[] array:数组类型的参数
    //size表示array中哪些元素是有效的堆元素,即array中是堆的部分都是有效的堆元素
    //index:表示从哪个位置开始进行向下调整
    //左右子树都是堆,才能进行这样的调整
    private static void shiftDown(int[] array, int size, int index) {
        int parent = index;
        int child = 2 * parent + 1;//根据父子节点的下标关系得出的结论,通过parent下标找到左右子树
        while (child < size) {//这个条件的含义是看看parent有没有子节点
            //找左右子树较小的节点
            if (child + 1 < size && array[child + 1] > array[child]) {
                child = child + 1;
            }
            //经过上面的比较,此时已经不知道child是左子树还是右子树了
            //只知道的是child下标一定对应左右子树最小值的下标

            //拿child位置的元素和parent位置的元素进行比较
            if (array[child] > array[parent]) {
                //不符合小堆的规则,就交换父子节点
                int tmp = array[child];
                array[child] = array[parent];
                array[parent] = tmp;
            } else {
                //当前这个位置,已经符合堆的要求了,不需要继续调整了
                break;
            }
            //更新parent和child,处理下一层的数据
            parent = child;
            child = parent * 2 + 1;
        }
    }
//取堆顶元素操作
    public int peek(){
        return array[0];
    }
//测试
    public boolean isEmpty(){
        return size==0;
    }
    public static void main(String[] args) {
        MyPriorityQueue queue=new MyPriorityQueue();//创建一个队列->向队列中添加几个元素
        queue.offer(9);//插入几个元素
        queue.offer(5);
        queue.offer(2);
        queue.offer(7);
        queue.offer(3);
        queue.offer(6);
        queue.offer(8);
        while(!queue.isEmpty()){//如果队列不为空就依次循环取出
            int cur= queue.poll();
            System.out.println(cur);
        }
    }
}

画图分析:

image-20211018200601389

其实不难发现,进行向上调整要比向下调整更简单一些,因为向上调整直接就比较父子节点即可。

画图分析:

image-20211018200002490

代码大致过程:

1、优先队列的创建MyPriorityQueue
2、向上调整操作shiftUp()-插入元素操作offer()(调用向上调整)
3、向下调整shiftDowm()-删除元素操作poll()(调用向下调整)
4、取堆顶元素 peek()
5、main方法测试->创建一个队列->向队列中添加几个元素

堆(优先队列),每次poll一个元素都是把优先级最高/最低的元素取出来,此时能帮我们解决topK问题,除此之外,如果poll N 次的话,就相当于对原来的序列进行了排序,这个过程就叫做堆排序。

从上述代码可以看出,借助优先队列同样也能够完成(排序)堆排序

java中的优先级队列

上面我们用堆实现了优先级队列,那么标准库中的优先级队列是怎么实现的呢?下面就来看看吧!

package java2021_1011;

import java.util.PriorityQueue;

/**
 * Created by Sun
 * Description:标准库中的优先级队列
 * User:Administrator
 * Date:2021-10-18
 * Time:14:51
 */
public class TestPriorityQueue {
    public static void main(String[] args) {
        PriorityQueue<Integer> queue=new PriorityQueue<>();
        queue.offer(9);//插入元素
        queue.offer(5);
        queue.offer(2);
        queue.offer(7);
        queue.offer(3);
        queue.offer(6);
        //如果队列不为空就依次循环取出
        while(!queue.isEmpty()){
            int cur=queue.poll();
            System.out.print(cur+" ");
        }
    }
}
打印结果:
2 3 5 6 7 9 

通过打印结果可以看出,标准库中的优先级队列里面默认搞的是小堆,所以每次取的就是一个比较小的元素。

堆的其他应用-TopK 问题

TopK问题:指的是在若干个元素中去找前K个最大或者是最小值

如:给定100亿个数字,找出其中前1000大的数字

两种不同的解决方案:

方案一:用一个数组保存刚才的这些数字,直接在这个数组上建大堆,循环1000次进行取栈顶元素+调整操作,就能得到前1000大的元素。

方案二:先取集合中的前1000个元素放到一个数组中,建立一个小堆(堆顶元素就是前1000大元素的守门员)。再一个一个遍历集合中的数字,依次和守门员进行比较,如果这个元素比守门员大,就把守门员删掉(调整堆),再把当前元素入堆(调整堆),当把所有的元素都遍历完之后,堆中的元素就是前1000大的元素。

举一个有关方案二的栗子:image-20211018154424979

经过思考,对于100亿个数字:

  • 方案一,内存可能放不下,因为按int(整数)来算,一个数字占4个字节的内存,100亿就是400亿个字节,8G的内存可以存放80亿字节,即1G内存可以存放10亿个字节,那400亿字节可能就需要,40G的内存空间,这个空间已经超过了普通家用电脑的内存了…,所以如果数字太多,这种方案,内存可能放不下。
  • 而方案二,是先把前1000个元素放到一个数组中,之后一个一个进,所以不用担心内存放不下的问题,而且方案二的运行效率也更高。

具体的过程可以使用时间复杂度的方法进行衡量,假设100亿记为N,1000记为M

  • 方案一要进行建堆操作,它要建的是N这么大的堆,建堆操作的时间复杂度就是O(N);循环1000次进行取栈顶元素+调整操作是M * (logN),所以方案一的时间复杂度是O(N)+O(M * logN);
  • 方案二也要进行建堆操作,不过它要建的是M这么大的堆,建堆操作的时间复杂度就是O(M);再一个一个遍历集合中的数字是N* logM,所以方案二的时间复杂度是O(M)+ O(N*logM);

那方案一和方案二谁的时间复杂度更高呢?在TopK问题中,一般N远远大于M,如果把M近似看做1的话

  • 方案一的时间复杂度是O(N)+O(M * logN)=O(N)+O(logN)=O(N)
  • 方案一的时间复杂度是O(M)+ O(N*logM)=O(M)+O(1)+O(N * 1)=O(N)
  • 从复杂度结果上看,方案一和方案二的最终结果差不多,但是呢,一般还是认为用方案二的效率更高一点,而且方案一如果是像100亿个数字的话,这么大,内存可能放不下!

所以,对于TopK问题要重点掌握及它的第二种方案。

一个典型的TopK问题

LeetCode-373查找和最小的K对数字

基本思路:
1、先通过排列组合获取到所有的数对;
2、再把数对放到优先队列中;
3、再从优先队列中取前K对数对即可;
由于优先队列是单个存放的,需要把数对放到一个类中,优先队列就保存这个类的对象即可。

代码实现:

package java2021_1011;

import java.util.ArrayList;
import java.util.List;
import java.util.PriorityQueue;

//先创建一个类,表示一个数对,这个数对里面包含两个成员变量n1,n2和一个sum变量
    class Pair implements Comparable<Pair>{//
        public int n1;
        public int n2;
        public int sum;
//提供构造方法
    public Pair(int n1, int n2) {
        this.n1 = n1;
        this.n2 = n2;
        this.sum=n1+n2;
    }
    @Override   //实现compareTo方法
    public int compareTo(Pair o) {//compareTo方法的返回值有三种情况,this是当前对象,other是它参数的对象
        //this 比 other小,返回<0的数字;
        //this 比 other大,返回>0的数字;
        //this 和 other相等,返回0;
        //此处直接用sum值来衡量Pair的大小,sum越大,Pair就越大,反之越小
        return this.sum-o.sum;
    }
}

public class InterfaceHeap {
        //返回值List<List<Integer>>的二维数组中,表示每一行是一个数对(即每一行只有两个元素)
        //一共应该有K行
    public List<List<Integer>> kSmallestPairs(int[] nums1, int[] nums2, int k) {
        List<List<Integer>> result=new ArrayList<>();
        //判断nums1和nums2是否符合要求
        if(nums1.length == 0 || nums2.length == 0 || k<=0){
            //非法操作直接返回
            return result;
        }
        //创建一个优先队列,类型为数对所对应的类
        //当前是需要前k小的元素,那就建立一个小堆
        PriorityQueue<Pair> queue=new PriorityQueue<>();
        //采取TopK的方案一来做
        //所有可能的数对都获取到并加入到队列中
        for(int i=0;i<nums1.length;i++){
            for(int j=0;j<nums2.length;j++){
                queue.offer(new Pair(nums1[i],nums2[j] ));
            }
        }
        //循环结束后,此时所有数对都在队列中,循环取出k个较小元素即可
        for(int i=0;i < k && !queue.isEmpty();i++){
            Pair cur=queue.poll();//取出队首元素,此时队首元素就应该是最小的值
            List<Integer> tmp=new ArrayList<>();
            tmp.add(cur.n1);
            tmp.add(cur.n2);
            result.add(tmp);//把这个最小值添加到result当中,
        }
        return result;
    }
}
为什么要让Pair实现Comparable接口?
/*答:如果想要对一个数组进行排序,可以直接使用java中的内置方法sort,但是sotr方法,默认是按照升序排序的如果想要降序排列或者按照其他更复杂的规则
* 进行排列就可以让当前这个数组里面的元素去实现Comparable接口,重写其中的compareTo方法就能够自定制比较规则,这里也是类似,要想把Pair
*放到优先队列里面是需要能够保证插入里面的元素得具备比较大小的功能,如果不实现Comparable接口,那么当前的Pair谁大谁小无法区分,
* 为了能够进一步的完成这个比较大小的操作,就需要让他去实现Comparable接口。
*/
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值