上篇文章,我们使用PriorityQueue解决了TopK问题,其中有个神奇的操作就是,当从PriorityQueue中插入或者删除一个元素时,他总能通过一定的方式调整,使得堆顶的元素是这个PriorityQueue的最值,这一节我们就来研究一下PriorityQueue底层是用什么存储的数据,又是怎么调整数据,使得其满足以上特性。
预备知识
如何把一个数组想象为完全二叉树呢?
其实这个数组就是完全二叉树层序遍历的结果。如果我们把完全二叉树的节点按照层序遍历的顺序,依次标记为0, 1, 2, 3, …, n,会发现,标号为i的节点,正是数组下标为i的值。
堆的定义:n个元素的序列{k0,k1,k2,ki,…,kn}当且仅当满足下关系时,称之为堆。
k i ≤ k 2 i + 1 且 k i ≤ k 2 i + 2 或 者 k i ≥ k 2 i + 1 且 k i ≥ k 2 i + 2 k_i \leq k_{2i+1}\ 且\ k_i \leq k_{2i+2}\ \ \ 或者\ \ \ k_i \geq k_{2i+1}\ 且\ k_i \geq k_{2i+2} ki≤k2i+1 且 ki≤k2i+2 或者 ki≥k2i+1 且 ki≥k2i+2
堆的性质:
- 堆总是一颗完全二叉树
- 父节点的值总是小于等于(或者大于等于)其两个子节点的值
有一点值得注意,我们并不限制左右孩子的大小,就是说左孩子并不一定要小于等于(或者大于等于)右孩子!
一、底层的数据结构
二话不说,我们直接打开Java8的源码:
public class PriorityQueue<E> extends AbstractQueue<E>
implements java.io.Serializable {
private static final int DEFAULT_INITIAL_CAPACITY = 11;
transient Object[] queue; // non-private to simplify nested class access
private int size = 0;
private final Comparator<? super E> comparator;
}
-
DEFAULT_INITIAL_CAPACITY字段
创建一个队列,默认情况具有11个元素;
-
queue字段
是个
Object
类型的数组(嗯?为什么是Object类型,而不是E类型呢???)transient
关键字是表示这个字段不会被序列化,很多说法是出于数据安全考虑(比如说密码) -
size字段
用于记录当前队列里有多少个数据
-
comparator字段
比较规则。将根据这个字段指定的比较规则,对数据进行排序;
如果不指定比较规则,将采用
E
类型自然大小(这个翻译感觉怪怪的)作为排序规则;
二、如何构造PriorityQueue
通过PriorityQueue
的成员变量,我们已经大致猜到了,会有那些构造函数,比如没有任何参数的构造函数、只指定初始大小的构造函数、只指定比较规则的构造函数、即指定初始大小,又指定比较规则的构造函数、拷贝构造函数(其实这是C++的概念,习惯这么叫了),或者通过其他集合创建一个PriorityQueue
。
当通过集合创建PriorityQueue
时,我们看个例子:
public PriorityQueue(Collection<? extends E> c) {
if (c instanceof SortedSet<?>) {
SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
this.comparator = (Comparator<? super E>) ss.comparator();
initElementsFromCollection(ss);
}
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
,首先通过Collection
的toArray
方法将元素转为数组,存储在数据成员queue
里,之后更新数据成员size
;如果集合类型不是SortedSet
和PriorityQueue
的实例,还会调用heapify()
方法调整数据成员queue
中的元素,使其满足堆的特性。
private void heapify() {
for (int i = (size >>> 1) - 1; i >= 0; i--)
siftDown(i, (E) queue[i]);
}
在heapify()
方法内部,我们发现是循环调用siftDown()
方法,从某个节点开始向下调整。
我们看下默认的向下调整是如何实现的:
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;
if (right < size &&
((Comparable<? super E>) c).compareTo((E) queue[right]) > 0)
c = queue[child = right];
if (key.compareTo((E) c) <= 0)
break;
queue[k] = c;
k = child;
}
queue[k] = key;
}
从左右孩子中选取一个较小的,与父节点比较,如果父节点比 较小的哪个孩子 还小,就用子节点的值覆盖父节点的值(我们用key保存了入参的值,不用担心被覆盖),然后从较小孩子位置,继续向下调整,直到调整到最后一个非叶子节点的下一个位置。如果不存在交换,直接退出循环,此时k标记的是哪个孩子节点位置,也是key的合适位置,将key放到k的位置上,这次向下调整就完成了。
回到我们heapify()
方法,有两点需要我们注意:
-
开始调整的位置?
最后一个非叶子节点的位置(想想完全二叉树的性质!),只有这样,才能保证其有左右孩子;
回顾我们向上调整的过程,最后一个非叶子节点的值,极有可能改变。
经过一次调整,最后一个非叶子节点的值,就是这颗子树的最小值(因为最多只有两个孩子,经过两次比较,一定可以得到一个最小的)。
-
为什么要循环调整?
经过一次调整,我们可以让非叶子节点的值,调整为这颗子树的最小值,如此向着根节点依次调整,就可以将这颗完全二叉树调整为堆。
再声明一次,将数组调整为堆之后,并不能保证数组是有序的,只能保证第一个元素是这个数组里最小的。
向下调整是堆最重要的操作之一!!
三、向PriorityQueue插入元素,会发生什么
PriorityQueue
提供了两种方法来插入元素,分别是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;
}
-
判空检查
如果插入元素为空,会抛
NPE
; -
判断空间是否充足
如果不充足,将会扩容。元素个数小于64,双倍扩容;否则容量增加50%。
先申请空间,再拷贝元素。
-
插入元素
如果是个空的
PriorityQueue
,直接插入;如果非空,调用
siftUp()
方法向上调整;
private void siftUpComparable(int k, E x) {
Comparable<? super E> key = (Comparable<? super E>) x;
while (k > 0) {
int parent = (k - 1) >>> 1;
Object e = queue[parent];
if (key.compareTo((E) e) >= 0)
break;
queue[k] = e;
k = parent;
}
queue[k] = key;
}
在插入元素之前,保存元素的queue
数据成员已经满足堆的特性,在插入元素时只要经过适当的调整,使得其继续满足堆的性质即可。数据成员size
的大小,既可以表示当前数组有多少元素,同时可以标记下一个元素插入的位置(也许这个位置并不一定适合待插入元素,但是我们一定会将一个元素放到这个位置)。
从最后一个叶子节点开始,如果待插入元素的值比父节点的值小,就将父节点的值搬到子节点(此时父节点也许就是一个合适的位置来放置待插入元素,但是我们需要继续判断),于是循环进入父节点,继续寻找合适的插入位置,直到根节点(一定会有一个合适的插入位置),插入元素。
四、删除元素
PriorityQueue
提供了poll()
方法,在获取队头元素(队列的第一个元素,在在里就是堆顶元素)的同时,删除该元素;于此之外,还提供了remove()
和removeEq()
方法来删除元素,他们的区别是:
-
remove方法
删除值相等(equals方法)的第一个元素
-
removeEq方法
删除相等(
==
)的某个对象,一般是iterator.remove(object)
-
相同点
他们底层都调用
removeAt
方法
private E removeAt(int i) {
// assert i >= 0 && i < size;
modCount++;
int s = --size;
if (s == i) // removed last element
queue[i] = null;
else {
E moved = (E) queue[s];
queue[s] = null;
siftDown(i, moved);
if (queue[i] == moved) {
siftUp(i, moved);
if (queue[i] != moved)
return moved;
}
}
return null;
}
记录最后一个元素,然后将其删除,再通过siftDown()
方法从i位置向下调整,而poll()
方法,从0位置开始向下调整。
五、其他操作
-
peek方法
只读取元素,不移除元素,如果
PriorityQueue
为空,将返回null
。 -
contains方法
判断
PriorityQueue
是否包含值相等的元素,是,返回true,否则返回false; -
iterator方法
返回一个迭代器,方便循环遍历(感觉没卵用!)
-
size方法
返回
PriorityQueue
中,有多少元素; -
clear方法
将
PriorityQueue
清空,是直接把底层数组设置为null
,同时把size
设置为0,不是移除数组的所有元素,也就是说,底层容量变了。