查看原文
其他

隐藏在优先级队列后边的那个神秘结构

小孩子4919 我们都是小青蛙 2022-10-28

小贴士:本文中的图使用PS画的,真可谓杀鸡用牛刀~

PriorityQueue翻译成中文就是优先级队列。所谓优先级队列,就意味着一个优先级比较高的元素入队的话,可能并不是直接插到队尾,可以根据他的优先级等级插入到队中的某个地方,如果他的优先级特别特别高,就会直接被插入到队首。举个栗子可能更好理解,比如火车站排队的时候是军人优先的,如果一个兵哥哥来排队的话,他可以直接插到队首去买票,而不是像普通旅客一样从最后排起。

小贴士:当然现实中兵哥哥一般也不会用这种特权~

下边我们给个例子来看一下这个所谓的优先级队列怎么使用:

public class Test {

public static void main(String[] args) {
Queue<Integer> queue = new PriorityQueue<>();
queue.add(5); //第1步
queue.add(7); //第2步
queue.add(3); //第3步
queue.add(4); //第4步
queue.add(2); //第5步
queue.add(9); //第6步
queue.add(1); //第7步

Integer i = queue.poll(); //获取并移除一个元素
while (i != null) {
System.out.println(i);
i = queue.poll();
}
}
}

我们看一下输出结果:

1
2
3
4
5
7
9

从输出结果中我们可以看出来,虽然入队时的数字大小是随机的,但是出队时的数字大小是有序的,这是怎么做到的呢?因为引入了一种称之为二叉堆的东东。

二叉堆

其实,PriorityQueue内部使用了数组来实际存储数据,我们前边也说过,ArrayList内部也是用数组来存储数据的,让我们来对比一下这两个底层都用数组来存储元素的容器的元素添加过程。

先来看一下用ArrayList的方式来添加元素的过程:

List<Integer> list = new ArrayList<>();
list.add(5); //第1步
list.add(7); //第2步
list.add(3); //第3步
list.add(4); //第4步
list.add(2); //第5步
list.add(9); //第6步
list.add(1); //第7步

我们分步骤来看一下添加过程:



可以看到,ArrayList插入元素的方式很简单,直接往后怼就对了~

我们再看一下PriorityQueue容器的元素添加过程:

Queue<Integer> queue = new PriorityQueue<>();
queue.add(5); //第1步
queue.add(7); //第2步
queue.add(3); //第3步
queue.add(4); //第4步
queue.add(2); //第5步
queue.add(9); //第6步
queue.add(1); //第7步

在分析添加步骤之前,我们先看下添加完这些元素之后,内部的数组长什么样:



大家从结果图中可以看出来,我们的插入顺序是:5、7、3、4、2、9、1,而真正的数组顺序是1、3、2、7、4、9、5add方法肯定是搞了什么猫腻~ 其实之所以是1、3、2、7、4、9、5这样的数字序列,是因为这个数字序列就是下边这种抽象的逻辑结构的一个从上到下,从左到右的一个数字序列:



先看看这种抽象的逻辑结构有什么特点:

  1. 每一个圆圈代表一个节点,值为1的节点被称之为根节点

  2. 根节点外,每一个节点都有一个父节点,比如说值为1的节点是值为3的节点的父节点,值为9的节点的父节点是值为2的节点。

  3. 一个节点最多有两个子节点,比如值为1的节点的子节点就是值为32的两个节点。我们可以称在左边的子节点为左孩子,在右边的子节点为右孩子

  4. 在填充元素的时候,按照从上往下,从左往右的顺序依次填充,中间不能跳过某些节点。

    比方说下边这两个栗子就不是二叉堆



  5. 二叉堆分两种,第一种是大顶堆,就是父节点的值大于它的子节点的值。第二种是小顶堆,就是父节点的值小于它的子节点的值。我们上边的栗子中的二叉堆就是小顶堆

我们把这种抽象的逻辑结构称为二叉堆。它的提出只是为了我们人类方便的解决问题。它具有一个非常好的性质,那就是:对于小顶堆来说,根节点永远都是最小的节点。这样就非常利于我们优先级队列的实现喽。

我们一再强调二叉堆是一种抽象的逻辑结构,也就是说这只是给人来分析问题用的,至于这个逻辑结构具体使用链表还是数组去实际实现是另一回事儿。不过PriorityQueue采用了数组来实际存储这种结构的数据,我们马上来看数组是怎么实现这种逻辑结构的。

堆与数组的映射

按照从上到下,从左到右的顺序依次把二叉堆里边的元素填入数组中,比方说下边这个二叉堆结构:



根节点的元素就对应数组的0号下标的元素,值为3的节点就对应1号下标,值为6的节点就对应2号下标,以此类推。

有一点很重要,我们可以用数组的下标来确定父节点和孩子节点的相对关系,下标计算如下:

下标为i的节点的左孩子的下标:Left(i) = 2 * i + 1
下标为i的节点的右孩子的下标:Right(i) = 2 * i + 2
下标为i的节点的父节点的下标:Parent(i) = (i-1)/2

拿上图举例,比方说下标为1的节点,它的值是3

左孩子的下标:Left(1) = 2 * 1 + 1 = 3。右孩子的下标:Right(1) = 2 * 1 + 2 = 4。父节点的下标:Parent(1) = (1 - 1)/ 2 = 0。

PriorityQueue内使用的是小顶堆的结构,所以为了维持二叉堆的特点,每向堆内插入一个元素的时候,都要维持好父节点的值大与两个子节点的值,所以需要遵循这样的插入算法:

  1. 如果插入的节点值大于或等于它父节点的值,则插入成功。

  2. 如果插入的节点值小于它父节点的值,则需要把刚插入的这个节点和它父节点做交换。交换后继续和它上层的父节点做比较,重复这个规则。

现在回到上边的例子,我们将顺序为5、7、3、4、2、9、1的元素依次插入PriorityQueue优先级队列,画图分析一下这个过程:



为了维持小顶堆的特性,上图展示了插入元素时每一步的做的节点交换,橙黄色的节点表示需要交换。

看完了每一步的插入元素示意图之后,我们就知道了PriorityQueue中的内部数组的元素排列为什么不是插入时的顺序,而是1、3、2、7、4、9、5这样的数字序列了~有了这个二叉堆,大家知道为什么我们每次获取队头的时候都是返回最小的那个元素了吧,就是直接把二叉堆的根元素给拿出来了哈哈~

我们这里只是重点唠叨了它的实现原理,具体的代码就不写了,大家有空一定要自己把这个PriorityQueue去实现一遍,如果不会写的话查看一下源代码哈~

小青蛙历史文章:




长按关注小青蛙,都是干货喔




您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存