优先级队列是常用的数据结构之一,今天就来解析一下底层源码是如何实现的
知识准备
在谈优先级队列之前,先来大概陈述一下,队列大家都知道,先入先出,每一个元素在队列中排好队,然后按照顺序进行出队,那么优先级队列是怎么回事呢?优先级顾名思义,就是给每个元素加上了优先级,元素在进入队列后,并不是先入先出了,而是需要根据元素的优先级,每次出队都是优先级大的元素出队。
在jdk的源码中,优先级队列是通过堆来进行实现的,相信了解堆排序的朋友理解起来会更加容易,堆又是什么呢?堆有大根堆和小根堆之分,大根堆就是优先级越高,出队就优先,小根堆相反。其实堆就是二叉树,只不过有一条性质:以小根堆来说,小根堆的根节点要比两个孩子节点的优先级低,画一幅图来理解一下:
上图就是一个小根堆,3的孩子7和4都比3要小,7的孩子20和12都比7小,这就是小根堆,理解起来是不是很容易呢
使用介绍
在剖析源码之前,首先来讲一下如何去使用,jdk已经为我们提供好了很多接口,使用的时候,只需要new出来调用接口即可,下面介绍一下这些接口
- public boolean add(E e) 增加元素,底层直接调用offer方法
- public boolean offer(E e) 增加元素
- public boolean remove(Object o) 删除元素
- public E peek() 获得堆顶元素,也就是二叉树的根节点
- public E poll() 获得堆顶元素,获得后会删除堆顶元素
这里先告诉大家,该数据结构的底层是数组实现的,那么乱序插入一些数字后,数组的情况是怎样的?我按照20,15,12,7,4,3,5的顺序插入,那么按照性质 ,插入后是怎么样的呢?
通过这些下标逻辑上转为一棵树,如下图:
和预想的一样,完全符合小根堆的性质,那么如何实现的呢,就要通过源码解析来深度理解一下了
源码解析
继承结构
继承了抽象的队列,实现了序列化接口
数据结构
摘选了四个重要的属性
//1.数组
transient Object[] queue; // non-private to simplify nested class access
/**
* The number of elements in the priority queue.
*/
//2.元素个数
private int size = 0;
/**
* The comparator, or null if priority queue uses elements'
* natural ordering.
*/
//3.比较器
private final Comparator<? super E> comparator;
/**
* The number of times this priority queue has been
* <i>structurally modified</i>. See AbstractList for gory details.
*/
//4.版本号
transient int modCount = 0; // non-private to simplify nested class access
- 刚才在上面也已经提到过了,该queue的底层是数组实现,说的就是Object数组queue
- size用来记录队列中元素的个数
- 比较器就是用来进行优先级的比较,是实现优先级队列的关键
- 版本号用来保证安全操作,防止在迭代过程中修改数据,快速失败机制就是通过该属性来完成
构造函数
这里我们就看最主要的构造函数:
无非就是初始化数组,然后初始化了构造器,如果用户没有传入,则使用默认的大小,构造器为null
增加操作
在增加元素的过程中,势必会影响根堆的性质,那么如何保证该性质的呢?一起来看一下
刚才在上面也介绍了,add和offer方法都可以用来增加元素,先看一下add方法:
可以看到,add方法底层直接调用了offer方法
offer方法:
public boolean offer(E e) {
//如果传入的元素为空,抛异常
if (e == null)
throw new NullPointerException();
modCount++;
int i = size;
//如果当前元素个数大于了数组的大小,则扩容
if (i >= queue.length)
grow(i + 1);
size = i + 1;
//如果插入的是第一个元素,也就是根,那么直接赋值,如果不是,向上调整
if (i == 0)
queue[0] = e;
else
//向上调整
siftUp(i, e);
return true;
}
offer方法的逻辑比较简单,grow方法是扩容方法,后面进行解析,这里先关注增加元素的逻辑,如果插入的是第一个元素,那么直接就放入0号位置,如果不是需要向上调整,调用siftUp函数
siftUp函数:
siftUp的逻辑:如果用户传入了比较器,使用siftUpUsingComparator调用比较器进行比较,如果没有,调用siftUpComparable方法,使用类默认实现的Comparable接口来进行比较,两个方法的逻辑是一样的,我们看一下siftUpComparable方法
siftUpComparable方法:
private void siftUpComparable(int k, E x) {
//获取类的Comparable接口
Comparable<? super E> key = (Comparable<? super E>) x;
//向上调整,还没有调整到根就一直向上调整
while (k > 0) {
//找到k的父节点下标parent
int parent = (k - 1) >>> 1;
//通过下标拿到父节点e
Object e = queue[parent];
//比较k和父节点,如果k大了就不再往上调整
if (key.compareTo((E) e) >= 0)
break;
//把父节点放到k的位置,k指向父节点,这个过程其实就是把key拿出来,如果父节点大
//就往下覆盖,父节点往上移,还大,就继续把父节点覆盖下来,直到父节点小了
queue[k] = e;
k = parent;
}
//再把key拿回来
queue[k] = key;
}
首先需要明白的有两点:
- PriorityQueue维护的是一个小根堆
- 通过子节点拿到父节点的下标的方法是:( 子节点的下标-1)/2,而上面用了位运算>>>1,>>>即表示除以 2的意思
删除操作
删除操作调用的remove方法,一起看一下remove方法
indexof方法是定位元素在数组中的下标,如果数组中有该元素,那么返回该元素的位置下标,如果没有该元素,则返回-1。如果i等于-1,则该元素不存在,直接返回false,如果存在,调用removeAt()方法:
private E removeAt(int i) {
// assert i >= 0 && i < size;
modCount++;
//s为数组中最后一个元素的下标
int s = --size;
//如果删除的是最后一个元素的下标,那么不需要调整,直接把最后一个元素的下标置为null
if (s == i) // removed last element
queue[i] = null;
else {
//进行调整,moved为数组中的最后一个元素
E moved = (E) queue[s];
//把最后一个元素置为空
queue[s] = null;
//向下调整,该函数会先假设把最后一个元素放到删除的位置,然后从该位置开始向下调整
//这个函数下面会详细讲解,这里理解大体做了什么即可
siftDown(i, moved);
//如果是第一种情况,也就是尝试的放成功了,那只是向下来说没有问题,向上还不能保证
//所以需要向上调整
if (queue[i] == moved) {
siftUp(i, moved);
//如果删除的位置不等于最后一个元素了,那么说明moved向上调整了,返回最后一个元素
//这里的返回值其实没有任何作用,在上面可以看到removeAt方法的返回值就没有被使用
if (queue[i] != moved)
return moved;
}
}
return null;
}
siftDown:
和向上调整相同,这里还是只解析一个函数的逻辑:
private void siftDown(int k, E x) {
//根据是否有比较器,调用相应的函数
if (comparator != null)
siftDownUsingComparator(k, x);
else
siftDownComparable(k, x);
}
@SuppressWarnings("unchecked")
private void siftDownComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>)x;
//向下调整的界限是第一个叶子节点
int half = size >>> 1; // loop while a non-leaf
while (k < half) {
//拿到左孩子下标
int child = (k << 1) + 1; // assume left child is least
Object c = queue[child];
//拿到右孩子下标
int right = child + 1;
//比较左右孩子,谁小谁为c
if (right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
c = queue[child = right];
//比较c和最后一个元素,也就是把最后一个元素插入到要删除的位置
//不断的向下调整
if (key.compareTo((E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = key;
}
获得堆顶元素
peek:
逻辑很简单,如果元素个数等于0,返回空,不等于0返回堆顶元素,也就是数组的第一个元素
出队
public E poll() {
if (size == 0)
return null;
int s = --size;
modCount++;
E result = (E) queue[0];
E x = (E) queue[s];
queue[s] = null;
if (s != 0)
siftDown(0, x);
return result;
}
这个方法和remove方法的逻辑很类似,就是删除节点,而且该节点是数组的第一个元素,所以只需要把最后一个元素补到第一个元素,然后向下调整即可。
扩容
private void grow(int minCapacity) {
//原来的容量
int oldCapacity = queue.length;
// Double size if small; else grow by 50%
//原来容量小于64:扩容后=原来*2+2
//原来容量不小于64:扩容后=原来*3
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
// overflow-conscious code
//如果大于最大默认容量
if (newCapacity - MAX_ARRAY_SIZE > 0)
//该函数的功能见下面
newCapacity = hugeCapacity(minCapacity);
//将原来的数据复制到新数组中
queue = Arrays.copyOf(queue, newCapacity);
}
private static int hugeCapacity(int minCapacity) {
//如果扩容后的容量小于0,那么说明在移位的过程中出现了溢出,抛异常
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
//如果不大于最大容量,那么直接为最大容量,如果大于,则为Integer的最大值
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}