本人的源码阅读主要聚焦于类的使用场景,一般只在java层面进行分析,没有深入到一些native方法的实现。并且由于知识储备不完整,很可能出现疏漏甚至是谬误,欢迎指出共同学习
本文基于corretto-17.0.9源码,参考本文时请打开相应的源码对照,否则你会不知道我在说什么
简介
在JCF相关基础类接口/抽象类源码阅读中介绍Queue接口时说过:
虽然名义上是队列,但不一定是FIFO,也可以是优先队列,也可以是LIFO。总而言之Queue只是提供“入队”和“出队”的语义,而出入队顺序并不作规定,即在给定入队顺序下,出队顺序由子类定义
这篇要介绍的就是优先队列PriorityQueue,先出队的元素的是当前队列中“最小的元素”,这里"最小"可以是1小于2这种数值间的比较,也可以是自定义的对复杂类之间的比较方法,比如长方形类优先队列,面积最小者为最小的元素:
class Retangle {
public int length; // 长
public int width; // 宽
// 计算面积
public int area() { return length * width; }
}
var pq = new PriorityQueue((x, y) -> {
x.area() - y.area();
});
模型
从这篇开始,更换一下分析代码的风格,不一头扎进代码,代码只是实现抽象模型的具体实现,因此应该先搞懂模型,换句话说就是先搞懂原理,自上而下地分析。
优先队列是用堆这个数据结构来实现的,首先堆可以看成是一颗完全二叉树,每个节点存储单个元素,并且根据父节点是否大于子节点可以分为 最大堆 和 最小堆,比如一个最大堆(为了简单起见,元素就是普通的整数类型):
![image-20240114161757407](https://i-blog.csdnimg.cn/blog_migrate/e8cb79babc04136ddebf71a858f617e2.png)
可以看到每个节点都比自己的子节点大。这样一来,根节点为堆的最大节点。而最小堆则是每个节点小于等于自己的子节点,根节点为堆的最小节点。
因此,回到我们的优先队列:先出队的元素的是当前队列中“最小的元素”。因此不难得知优先队列是对最小堆的封装,每次出队的元素就是最小堆的根节点即可,而入队的时候,将新节点添加到树的末尾(保持其还是一颗完全二叉树),然后逐渐将该节点上移使其保持堆的特性。
代码分析
成员变量
public class PriorityQueue<E> extends AbstractQueue<E> implements java.io.Serializable {
// 堆
transient Object[] queue;
// 元素个数
int size;
// 用户自定义比较器
private final Comparator<? super E> comparator;
// 帮助迭代器检测结构性修改
transient int modCount;
}
其中size和modCount不用再说。
queue就是堆这个数据结构,哎,堆不是二叉树吗,为什么可以用数组来存?因为堆不光是二叉树,还是一个完全二叉树,因此可以用数组来存储,下标为k的节点的两个字节点下标分别为k*2+1和k*2+2。
comparator是用户自定义的比较器,用于元素类型不是可比较的类型比如整数的时候,用户可以自己定义元素之间的大小关系,又或者比如类型就是整数,但不想要默认的最小堆,而是每次出队最大的元素,也可以通过用户自定义比较器做到。
方法
首先看构造方法,主要分析用集合来构造的方法:
public PriorityQueue(Collection<? extends E> c) {
// 用SotedSet合初始化
if (c instanceof SortedSet<?>) {
SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
this.comparator = (Comparator<? super E>) ss.comparator();
initElementsFromCollection(ss);
}
// 用PriorityQueue初始化
else if (c instanceof PriorityQueue<?>) {
PriorityQueue<? extends E> pq = (PriorityQueue<? extends E>) c;
this.comparator = (Comparator<? super E>) pq.comparator();
initFromPriorityQueue(pq);
}
// 普通初始化
else {
this.comparator = null;
initFromCollection(c);
}
}
最简单的是用PriorityQueue初始化,拷贝成员变量就行了。其次是用SortedeSet初始化,借助其toArray拷贝到底层数组。最后是用其他的集合初始化,也是toArray拷贝到底层数组,但是由于其他的集合toArray得到的数组不一定是最小堆,因此需要heapify将其调整为最小堆,即建堆:
private void heapify() {
final Object[] es = queue;
int n = size, i = (n >>> 1) - 1;
final Comparator<? super E> cmp;
// 将节点下移调整到适合的位置。根据元素的自然顺序,元素必须已实现Comparable接口
if ((cmp = comparator) == null)
for (; i >= 0; i--)
siftDownComparable(i, (E) es[i], es, n);
// 将节点下移调整到适合的位置。使用用户自定义的Comparator
else
for (; i >= 0; i--)
siftDownUsingComparator(i, (E) es[i], es, n, cmp);
}
heapify是建堆的实现,其思想为:从下至上、从右至左地处理所有的非叶子节点,比如在一开始举例子的图中,处理的顺序是节点80、90。
![image-20240114161757407](https://i-blog.csdnimg.cn/blog_migrate/e8cb79babc04136ddebf71a858f617e2.png)
对应到代码中就是:
// 初始化i为下标最大的非叶子节点
int n = size, i = (n >>> 1) - 1;
// 遍历所有非叶子节点
for (; i >= 0; i--)
siftDownUsingComparator(i, (E) es[i], es, n, cmp);
具体到每个节点的处理过程如下:为了维护最小堆的特性,当前的非叶子节点x若大于任一子节点,将其与较小的子节点交换,不断重复这个过程,总体来看就相当于不断把x下移,当不大于任一子节点的时候,或者已经没有子节点(成为了叶节点)则处理完成。由于Comparable与Comparator的建堆都是一样的,只是API的使用上有点区别,因此我们只分析Comparator,即siftDownUsingComparator函数即可,分析都写在注释里:
// 实现思路:将节点x与子节点比较,如果比任一子节点大,那么与较小子节点交换(x下移),重复这个步骤直到不比子节点大或者成为叶子节点
private static <T> void siftDownUsingComparator(int k, T x, Object[] es, int n, Comparator<? super T> cmp) {
// half为叶子节点的最小下标,即非叶子节点的最大下标+1
int half = n >>> 1;
// 只需要处理非叶子节点
while (k < half) {
int child = (k << 1) + 1;
Object c = es[child];
int right = child + 1;
// 选择较小的子节点来比较
if (right < n && cmp.compare((T) c, (T) es[right]) > 0)
c = es[child = right];
if (cmp.compare(x, (T) c) <= 0)
break;
// 如果较小子节点比父节点x小的话,将该子节点放到父节点的位置,即x下移
es[k] = c;
k = child;
}
// 此时k已经处于叶节点的位置或者x已经比k位置的两个子节点都小
es[k] = x;
}
这个建堆的过程时间复杂度为O(n),n为元素的个数,即底层数组queue.length。这个时间复杂度有点意思,第一眼看上去似乎是O(nlogn),因为处理的节点大致为n/2个,并且节点最坏情况下会发生logn次交换,即树高logn。但其实并不是,比如logn这个就有问题,高度为i的节点最多只会交换logi而不是logn。建堆具体的复杂度分析可以参考,讲得很清晰明了:堆排序中建堆过程时间复杂度O(n)怎么来的? - SCVTheDefect的回答 - 知乎。
优先队列作为一个队列,最重要的方法莫过于入队和出队,先看下入队。入队其实就是把新元素放到数组末尾,然后再调用函数将数组重新调整为最小堆:
public boolean offer(E e) {
if (e == null)
throw new NullPointerException();
modCount++;
int i = size;
// 扩容
if (i >= queue.length)
grow(i + 1);
// 插入元素到数组尾部并调整为最小堆
siftUp(i, e);
size = i + 1;
return true;
}
首先优先队列用数组存储堆,扩容肯定是需要的,看过其他基于数组的JCF类比如ArrayList、ArrayDeque等就知道,扩容主要在于扩多少,这里是如果原容量较小就扩为原来的2倍,否则就1.5倍,扩容代码没什么好分析,就不看了。
主要看一下siftUp函数:
private void siftUp(int k, E x) {
if (comparator != null)
// 将节点上移调整到适合的位置。使用Comparator
siftUpUsingComparator(k, x, queue, comparator);
else
// 将节点上移调整到合适的位置。使用Comparable
siftUpComparable(k, x, queue);
}
注意之前建堆的过程中是让节点下移(siftDown)这里是上移(siftUp),上移的目的是当节点与其父节点之间违反了最小堆的特性时需要进行调整,为了维护最小堆:如果父节点比该节点大的话,将其与父节点交换,重复这个过程,总体来看相当于把这个节点不断上移,直到父节点不比该节点大或者这个节点已经是根节点(根节点没有父节点),则处理完成,我们依然选择Comparator版本来分析。
private static <T> void siftUpUsingComparator(int k, T x, Object[] es, Comparator<? super T> cmp) {
// 如果k已经为0说明为根节点,没有节点
while (k > 0) {
// 获取父节点
int parent = (k - 1) >>> 1;
Object e = es[parent];
// 如果父节点不比该节点则处理完成,退出循环
if (cmp.compare(x, (T) e) >= 0)
break;
// 与父节点交换(父节点下移)
es[k] = e;
k = parent;
}
es[k] = x;
}
ok,再分析下出队,由于是数组是最小堆,因此根节点就是最小节点,把它取走出队,然后把数组最后一个元素补上来作为新的根节点,然后siftDown这个新补上来的节点,不断下移调整维护最小堆特性。下移之前介绍过,目的是当该节点与子节点之间违反最小堆特性的时候进行调整:
public E poll() {
final Object[] es;
final E result;
// 如果队列不空,取出根节点作为返回结果
if ((result = (E) ((es = queue)[0])) != null) {
modCount++;
final int n;
final E x = (E) es[(n = --size)];
es[n] = null;
// 如果取走根节点后,队列不为空,则将数组调整为堆
if (n > 0) {
final Comparator<? super E> cmp;
if ((cmp = comparator) == null)
siftDownComparable(0, x, es, n);
else
siftDownUsingComparator(0, x, es, n, cmp);
}
}
return result;
}
Queue接口的出入队方法基本分析完了,下面来看看Collection接口的remove方法:
public boolean remove(Object o) {
int i = indexOf(o);
if (i == -1)
return false;
else {
removeAt(i);
return true;
}
}
先用indexOf遍历元素定位被删除元素的下标,然后调用removeAt删除该节点。removeAt还是比较好理解的:如果删除的不是末尾节点的话,将节点删除后用末尾节点补上,然后调整该节点以维护最小堆:
E removeAt(int i) {
final Object[] es = queue;
modCount++;
int s = --size;
if (s == i)
es[i] = null;
else {
// 如果不是删除末尾节点,那么将末尾节点补到被删除节点的位置,用moved保存,下一步作维护最小堆的调整
E moved = (E) es[s];
es[s] = null;
// moved可能大于子节点,下移调整
siftDown(i, moved);
// 如果已经下移了,那么就不用再调整
// 否则,moved可能小于父节点,需要上移进行调整
if (es[i] == moved) {
siftUp(i, moved);
// 如果上移了,那么上移完成后moved所在下标小于i,返回moved
if (es[i] != moved)
return moved;
}
}
// 返回null表示i-1以及之前的节点都没变
return null;
}
难理解的地方在于返回值,第一眼看上去就是如果补上的节点会上移的话,就返回该节点否则返回null,就很奇怪为什么要这样设计。看了注释之后才知道:
Removes the ith element from queue. Normally this method leaves the elements at up to i-1, inclusive, untouched. Under these circumstances, it returns null.
一般情况下当移除第i个节点后,补上的末尾节点不会被上移,那么最终该节点的下标>=i。这种情况下返回null。
Occasionally, in order to maintain the heap invariant, it must swap a later element of the list with one earlier than i. Under these circumstances, this method returns the element that was previously at the end of the list and is now at some position before i.
但如果补上的节点需要上移的话,则该节点最终的下标<i。这种情况下返回该节点。
This fact is used by iterator.remove so as to avoid missing traversing elements.
这样设计是为了迭代器在遍历元素的时候,调用remove某个元素后,避免因为补上的末尾节点被上移到 i 之前的位置而没被遍历到,因此需要返回该节点并保存,保证之后会被遍历到。
再补充下,下面分析迭代器的时候会知道,它是直接按照下标顺序遍历数组,而remove可能会造成还没被遍历到的节点被移动到已经被遍历的下标,但为了保持最小堆特性又不得不这样做,因此迭代器用ArrayDeque保存这种在迭代过程中被上移的节点,以保证其最后会被遍历到。从这个角度也能知道优先队列迭代器的迭代顺序是不确定的,没有特定的顺序。
至此,PriorityQueue的各个方法已经分析得差不多,还有一个bulkRemove在ArrayList源码阅读中已经分析过,在这里的实现只能说是大同小异,不再分析。
接下来简单看一下迭代器。
private final class Itr implements Iterator<E> {
// 指向下一个遍历到元素的指针
private int cursor;
// 指向刚被遍历的元素的的指针
private int lastRet = -1;
// 保存还未被遍历但被移动到了已遍历下标的元素
private ArrayDeque<E> forgetMeNot;
// 功能与lastRet一样,但是是与forgetMeNot配合使用,保存上一个被遍历到forgetMeNot的元素
private E lastRetElt;
// 期望的修改次数,用于检测并发修改
private int expectedModCount = modCount;
}
cursor、lastRet、expectedModCount不用再解释。
forgetMeNot这个集合保存由于remove导致还没被遍历的元素,被移动到了当前cursor之前的位置,保证他们最终会被遍历到(详见removeAt的分析)。
迭代器的方法实现没什么特别的,不分析了。
总结
PriorityQueue实现了优先队列,其实际上是对最小堆这个数据结构的封装,由于堆是根据元素之间的大小关系来构建的,用户可以通过实现Comparable接口或传入Comparator对象自定义元素类之间的大小关系,一般情况下推荐使用Comparator,侵入性更低。建堆复杂度O(n),出入队操作复杂度为O(logn)。