手把手教会你学会优先级队列的使用和底层逻辑

发布于:2022-10-17 ⋅ 阅读:(507) ⋅ 点赞:(0)

目录

概念

堆的存储方式

堆的性质:

小根堆

大根堆

数组还原为二叉树

堆的模拟实现

创建堆:

创建堆的复杂度:O(n)

堆的插入

堆的删除

易错题

PriorityQueue集合类源码剖析

构造方法:

 总结

PriorityQueue特性:

三种做题时的写法:

PriorityQueue的扩容机制



 

📌————本章重点————📌

🔗 堆的存储方式

🔗数组还原为二叉树

🔗堆的模拟实现

🔗PriorityQueue集合类源码剖析

🔗PriorityQueue特性

🔗PriorityQueue的扩容机制


 ✨————————————✨

概念

        前言:在数据结构中,栈的出入操作是先进后出,队列的出入操作是先进先出,但这些固定的出入顺序无法满足所有业务需求。比如:我们在手机上打游戏时,突然接到来电,在以前老版本操作系统的手机上,它会直接中断游戏接通来电,但在现在新操作系统的手机上,系统会在不中断游戏的同时让我们选择是否接通来电。这种活生生的例子就用到了优先级这一思想。

        首先:优先级队列其底层使用了这种数据结构,其次,堆又是在完全二叉树的基础上创建的。

        我们先来看堆的名词解释:

        如果有一个关键码的集合K = {k0,k1, k2,…,kn-1},把它的所有元素按完全二叉树的顺序存储方式存储 在一个一维数组中,并满足:Ki <= K2i+1 且 Ki<= K2i+2 (Ki >= K2i+1 且 Ki >= K2i+2) i = 0,1,2…,则称为 小堆(或大堆)。将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆。

        便于理解什么是堆,我们继续往下看~


堆的存储方式:

简言之:现有一维数组 arr = { 27,15,19,18,28,34,65,49,25,37 };

从下标0开始遍历该数组,每拿出一个元素,按照完全二叉树的层序遍历将其依次放入每一个子树节点处,这样便形成了堆。

如下图:

 那为什么优先级又体现在何处呢?请继续往下看~


堆的性质:

        上面我们已经学会了堆的存储方式,假设现在已经建立了一颗完全二叉树,那么该二叉树就必须满足以下性质,才能被称为是优先级队列:

  • 堆是一颗完全二叉树;

    由于每个根节点的值是与数组对应的,可想而知,如果这棵树不是完全二叉树,那必然会导致后面的新增或删除元素是,导致数组中存放空值,使内存被浪费。
     
  • 堆中的某个节点的值总数不大于或不小于其父节点的值;
    无论是整棵树还是没一颗子树,它们都是大根堆或小根堆(具体介绍在下面)。
     
  • 对于每棵树而言,其左子树的值与右子树的值之间没有必然的大小关系;
    只强调根节点要大于或小于所有子树节点的值,而左子树和右子树的值大小关系不必在意;

小根堆:

父节点的值小于子树节点的值:

大根堆:

父节点的值大于子树节点的值:

数组还原为二叉树:

借助数组的索引 i ,我们可以用以下总结用来与树中的节点来对应:

  1. 如果 i = 0,则 i 表示的节点为整棵树的根节点;
  2. 如果 i 不为 0,则 i 节点的双亲节点为 (i - 1) / 2;
  3. 如果 2 * i + 1 小于节点个数,则节点 i 的的左孩子下标为 2 * i + 1,否则没有左孩子;
  4. 如果 2 * i + 2 小于节点个数,则节点 i 的右孩子下标为 2 * i + 2,否则没有右孩子;

 以上结论结合图解才能更加直观的理解。


堆的模拟实现

        上面我们已经通过图解学会了堆的存储,下面我们将模拟实现堆的基本功能。

创建堆:

思路:假设要创建一个大根堆

        1.堆的数值与数组的值对应,就需要一个数组elem;其次需要一个计数器usedSize,保存目前树中节点个数,以便后续插入或删除找到所需节点;

        2.实例化出一个堆对象时,需要将往外面需要的数值赋值给elem,那么再来一个initElem方法;

public class TestHeap {
    public int[] elem;
    public int usedSize;

    //给数组一个默认容量
    private static final int DEFAULT_SIZE = 10;
    public TestHeap() {
        elem = new int[DEFAULT_SIZE];
    }

    public void initElem(int[] array) {
        for (int i = 0; i < array.length; i++) {
            elem[i] = array[i];
            usedSize++;
        }
    }

}

        3.创建队就是将已知数组的元素插入到树的节点中的过程,但堆中的值比数组的要求更苛刻,要满足大根堆的要求,那我们就会想到,先直接将数组的所有元素直接按照顺序方式来创建成一颗完全二叉树,再向下调整每一棵子树的节点,直到每颗子树都满足大根堆,最终整棵树就是一个大根堆了;

        4.向下调整:(最关键的细节)

        先对最后一颗子树进行操作 ——> 拿到其父节点和子树节点中值最大的那一个 ——> 比较,如果不符合大根堆,就交换 ——> 继续向后比遍历这棵树,重复上述比较过程,直到每颗子树走到结束位置 ——> 当每颗子树都被调整成大根堆以后,整棵树就是大根堆了。

最详细的调整过程需要结合代码和下图进行推理:

    /**
     * 创建大根堆
     * 复杂度:O(N)
     */
    public void createHeap() {
        for (int parent = (usedSize-1-1)/2; parent >= 0 ; parent--) {
            shiftDown(parent,usedSize);
        }

    }
    private void shiftDown(int parent,int len) {
        int child = 2 * parent + 1;
        while(child < len) {
            //开始准备比较
            //1.chile 是左子树节点,还要看它和右子树节点谁大,用大的来和parent比较
            //所有要先看是否有有节点,没有的话就直接拿左子树节点去比较
            if(child+1 < len && elem[child] < elem[child+1]) {
                child++;
            }
            //此时的child一定是当前子树上最大的节点
            //现在才可以开始比较
            if(elem[child] > elem[parent]) {
                int temp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = temp;
                //一次换完后还要看后面的节点是否也需要比较
                parent = child;
                child = 2 * parent + 1;
            }
            // 如果本来就是大根堆,那么这支树上就不需要交换
            else {
                break;
            }
        }
    }

创建堆的复杂度:O(n)

 推导过程:以满二叉树为例,假设每个节点都需要调整,这样就是最复杂的情况。

堆的插入:

        对于堆的数组而言,插入元素即简单的尾插,但对于堆的二叉树而言,要时刻保持树是大根堆,就需要每次新增元素后向上调整根的大小;

思路:依然是大根堆

        1.先检查数组是否需要扩容,插入新元素;

        2.拿到最后一颗子树的最后一个节点,再拿到其父节点,两个节点值比较,不符合大根堆就交换,再向上检查,直到整棵树的根节点结束;

    /**
     * 插入元素 复杂度:O(logN)
     * 对于自带的数组:肯定是尾插
     * 对于树:插入后要保证依然是大根堆,因此要向上调整
     */
    public void offer(int val) {
        //满了要扩容
        if(isFull()) {
            elem = Arrays.copyOf(this.elem,2 * this.elem.length);
        }
        //未满 - 直接插入
        elem[usedSize] = val;
        //插入后要向上调整
        shiftUp(usedSize);
        //调整完
        usedSize++;

    }
    private void shiftUp(int child) {
        int parent = (child - 1) / 2;
        while (parent >= 0) {
            if(elem[child] > elem[parent]) {
                int temp = elem[child];
                elem[child] = elem[parent];
                elem[parent] = temp;
                //交换完后 - 向上走
                child = parent;
                parent = (child - 1) / 2;
            } else {
                break;
            }
        }

    }
    private boolean isFull() {
        return usedSize == elem.length;
    }


堆的删除:

思路:堆的删除,即弹出堆顶第一个元素;

        1.先将树中下标为0的节点的值与树最后一个节点的值交换;

        2.交换完从整棵树的根节点开始向下调整,同上,依然要保证树是大根堆;

/**
     * 删除元素
     * 先让下标为0的根节点数值与整棵树最后一个节点的数组交换
     * 交换后 - 向下调整
     */
    public int pop() {
        //判断是否为空
        if(isEmpty()) {
            return -1;
        }
        //先交换
        int temp = elem[0];
        elem[0] = elem[usedSize - 1];
        elem[usedSize - 1] = temp;
        //交换完 - 向下调整
        usedSize--;
        shiftDown(0,usedSize);
        return temp;

    }
    private boolean isEmpty() {
        return usedSize == 0;
    }

 易错点:

usedSize--只有一次,因为下次插入元素时会直接将80覆盖掉。

 

易错题:

已知小根堆为8,15,10,21,34,16,12,删除关键字8之后需重建堆,在此过程中,关键字之间的比较次数是()
A: 1         B: 2         C: 3         D: 4


PriorityQueue集合类源码剖析

  •  PriorityQueue即优先级队列,其实现了Queue接口,所以具有Queue索具有的方法;
  •  优先级队列有PriorityQueue和PriorityBlockingQueue之分,前者是线程不安全的,后者是线程安全的;

构造方法:

PriorityQueue类的构造方法有七种:

以下面这段代码为例展开源码剖析:

 

PriorityQueue<Integer> priorityQueue = new PriorityQueue<>();

1.第一次调用构造方法:

 

 priorityQueue.offer(10);

2.第一次offer元素:

 

priorityQueue.offer(30);

3.第二次offer元素:

 

 

4.除了上述方法,我们还可以自己设置数组的默认长度,调用第②种构造方法:

 

 

5. 如果实例化时传入我们的比较器,就会调用第③种构造方法:

()比较器的写法在下面的“特性”中有介绍)

 

 

7.给堆传入Collection接口下的结构:

                对应的构造方法:

 

8.关于其他构造方法的时候,大家感兴趣可以自行阅读源码学习,如果这里的源码剖析有不明白的地方,可以学习完下面的“特性”后再回过来就一定可以完全理解。

 总结:

1.第一次调用构造方法实例化出一个PriorityQueue对象时,相当于new一个长度为默认值的数组;

2.如果调用构造方法时,传入一个数值,就会调用初始化数组的构造方法;

   如果调用构造方法时,传入一个比较器,就会调用实例化比较器的构造方法;

3.插入元素:

        如果是第一次插入:直接放在0下标的位置;

        如果不是第一次插入,并且没有传入比较器,那么首先要保证传入的对象是可比较的,其次offer方法会自动new一个可比较的key对象,再根据向上调整,最后选择合适位置放入元素;

        如果不是第一次插入,并且传入了比较器,那么说明插入的数据是可比较的,因此offer方法会直接根据向上调整选择合适的位置放入元素;

4.优先使用比较器来比较;

 

PriorityQueue特性:

  • PriorityQueue 本身实现的是小根堆:


 

  • PriorityQueue中放置的元素必须是能够比较大小的,否则会抛ClassCastExcepyion异常;

        我们可以自己创建一个Student来,这个类是我们自己写的,并不具备比较功能,因此春给堆后就会抛异常;

 

  • 可以通过实现Comparable接口,重写CompareTo方法,传入我们的自定义类型:

         如果要变成大根堆,只需要交换CompareTo方法的比较规则;


 

  • 如果传入对象是语法中现有的类型,如Integer类、String类等,我们不能改变其源码中的比较规则,那怎么才能让它们变成大根堆呢?
    方法:实现比较器

 

        

 

三种做题时的写法:

1.匿名内部类:

 2.lambda表达式(JDK8语法):与上面等价

3.第三种也与上面的等价:

PriorityQueue的扩容机制:

 

 


网站公告

今日签到

点亮在社区的每一天
去签到