基本定义
PriorityQueue 是一个基于优先级堆实现的优先级队列,具体来说是通过完全二叉树实现的,因此可以通过数组作为其底层实现。队列中的元素默认按照其自然顺序(小顶堆)进行排列,或者是根据调用构建方法时传入的比较器 Comparator
对内部元素进行排序。优先级队列的元素不允许为 null,也不允许插入一个不可比较的对象,因为有可能在排序时导致类型转换异常(ClassCastException),因此不传入比较器时,元素需要本身就已经实现 Comparable
接口。
优先级队列是无边界的,但是由于它的本质是一个数组,所以还是会给数组定义一个容量值。其默认初始大小为 11,随着元素的增加,队列的大小会自动扩张。回想一下用数组表示完全二叉树的方式,实际上遍历数组就等于对二叉树进行层序遍历(从根节点开始,依次每层从左到右访问每个元素),因此父子节点下标会满足以下关系:
- left = parent * 2 + 1
- right = parent * 2 + 2
- parent = (node - 1) / 2
这个的 node 指的是任意非根节点的下标。
方法解析
它继承了 AbstractQueue
抽象类以及实现了 Queue
接口,实现与队列对应的一些基本方法。
add() 和 offer()
在 PriorityQueue中,add() 方法内部是调用的 offer() 方法,因此两个方法的业务实现没什么区别,都是向队尾(数组末尾)插入元素。
public boolean offer(E e) {
if (e == null)
// 不允许插入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;
}
由于新插入的元素可能会破坏小顶堆的性质(有序性、数组容量等),因此每次执行插入操作,都会进行必要的调整。插入元素时,队列的调整主要是自低向上的,具体的实现参考堆的相关资料。
数据扩容的源码实现如下:
private void grow(int minCapacity) {
int oldCapacity = queue.length;
// 原来容量小于64时,扩容时翻倍;否则扩大0.5倍
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
// MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
queue = Arrays.copyOf(queue, newCapacity);
}
如果扩容后的新容量大于预定义的数组最大容量,那么则将数组扩容至最大为 Integer.MAX_VALUE
的大小,其值为
2
31
−
1
2^{31} - 1
231−1。若插入元素过多,可能会导致数组越界,抛出异常。
element() 和 peek()
两者的实现逻辑相同,都是获取但不删除队列头的元素,也就是返回数组下标为 0 的那个元素。区别在于当方法执行失败时,element() 方法抛出异常,而 peek() 方法返回null。实现代码非常简单,具体如下:
public E peek() {
return (size == 0) ? null : (E) queue[0];
}
remove() 和 poll()
两者的实现逻辑相同,都是获取并删除队列头的元素。区别在于当方法执行失败时,remove() 方法抛出异常,而 poll() 方法返回null。由于删除操作会改变队列的结构,为了维护堆的性质,需要进行必要的调整。删除元素时,队列的调整是自顶向下的,先删除堆顶元素,再将队尾元素置于对头,然后自顶向下开始调整。具体实现参考堆的相关资料。基本逻辑代码如下:
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;
并发安全性
作为一个集合类,PriorityQueue 同样提供了遍历元素用的迭代器方法,但是这些方法并不能保证能按照特定的顺序对元素进行遍历。如果想要保证遍历的顺序,建议将其转化成数组形式,再进行排序后访问,即调用方法 Arrays.sort(pg.toArray())
。还需要注意的是,这个类的实现并不是线程安全的,不能在多线程下访问同一个优先级队列对象。有需要的话,应该选用 java.util.concurrent.PriorityBlockingQueue
。
经典场景 - Top K问题
Top K 问题是指,从给定的大量数据中寻找最大(或者最小)的前 K 个数据。使用优先级队列,能很好地解决这个问题。假设现在有 100 个数据,我们需要从中找出最大的 10 个数据,那么我们可以先使用前 10 个数据构建一个最小优先级队列。注意,寻找最大的前 K 个数据是构建一个最小优先级队列(小顶堆),反之亦然。然后从剩余的数据中依次取出一个,都与队列头的元素进行比较。若大于队列头的元素,则将队列头元素删除,并将该元素添加到队列中;若小于队头元素,则将该元素丢弃掉。当所有数据都遍历完成后,最后优先级队列中剩下的 10 个元素就是最大的 10 个元素。
之所以使用小顶堆来寻找最大的前 K 个数据,是为了充分利用小顶堆的特性。小顶堆的根节点,也就是队列头的元素,必然是当前优先级队列中最小的元素,为了获取较大的 K 个数据,只需要拿新元素与队列头元素比较即可;反之亦然。而新元素加入队列之后,优先级队列会马上根据元素的大小关系,重新整理队列,因此减少了开发人员维护其有序性的时间成本。
常用的比较器重写方式(如整型数比较):
void fun() {
// 升序,小顶堆
PriorityQueue<Integer> smallQueue = new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
});
// 降序,大顶堆
PriorityQueue<Integer> largeQueue = new PriorityQueue<>(new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
});
}
实际上默认的排序方式就是代表升序的小顶堆,此时就可以不用重新比较方法。