堆、堆排序&PriorityQueue源码分析
前言
有一个数据流a[],每时每刻都在发生着数据的插入、删除,如何实时动态的获取该数据流的前K大元素呢?另外一个数据流b[],每时每刻数据也发生着变化,如何实时获取该数据流的中位数呢?除了插入排序、快速排序、归并排序等几种常见的排序算法外,还有一种排序叫做堆排序,你知道它是如何实现的么?带着这些问题我们先来看一下什么是堆
什么是堆
堆是一种特殊的二叉树,必须满足以下两点:
1.必须是一个完全二叉树
2.堆中的每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值
第一点中什么是完全二叉树呢?完全二叉树就是除了最后一层,其他层的节点个数都是满的,最后一层的叶子节点都靠左排列。如果叶子节点也都是满的那它就是一棵满二叉树。
第二点堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。实际上,我们还可以换一种说法,堆中每个节点的值都大于等于(或小于等于)其左右子节点的值。如果每个节点的值都大于等于其左右子节点的话我们就叫做大顶堆,如果每个节点的值都小于等于其左右子节点则叫做小顶堆,如下图所示:
了解了堆的结构后那么我们该如何来实现、存储一个堆呢?鉴于堆是一种完全二叉树,它是一种特殊的二叉树是由规律可循的,我们可以用数组来表示一个堆。用数组的话就不需要存储左右子节点的指针了,非常节省存储空间。那我们该如何查找某个节点的左右子节点以及父节点呢?通过数组下标就可以找到,如下图所示,数组中下标为i的节点其左子节点的下标就是i *2+1,右子节点的下标就是i *2+2,父节点的下标为(i-1)/2
知道了堆的存储结构后,我们接下来看看堆有哪些常用的操作,最核心的操作就是删除堆顶元素和插入一个元素,另外初始化一个堆的时候会涉及到堆化,将一个无规则的数组经过一系列的比较交换最终变成一个堆的过程我们就叫做堆化。当删除堆顶元素、插入一个元素后,数组就不满足堆的定义规则了,这时候也要进行一次堆化操作。那堆化具体是如何实现的呢,它的时间、空间复杂度又是多少呢,接下来我们结合jdk中PriorityQueue的源码来讲解一下这个过程。
PriorityQueue源码分析
PriorityQueue是jdk自带的一个优先级队列,顾名思义,它本质上也是一个队列,但是跟一般的队列不一样,它不是先进先出,而是按照优先级来的,优先级最高的元素最先出队。如何来实现一个优先级队列呢?底层就是使用了堆,优先级最高的元素就是堆顶元素,出队就是删除堆顶元素,入队就是往堆中插入一个元素。所以PriorityQueue本质上就是一个堆,我们可以分析一下它的源码来学习一下堆的实现过程。以下分析中我们假设构建的是一个小顶堆
属性字段
PriorityQueue底层使用了数组queue来存储堆元素,还有一个比较器comparator,用来进行堆化时元素大小的比较
//默认初始化大小
private static final int DEFAULT_INITIAL_CAPACITY = 11;
//用来构建堆的数组,
//从下标0开始,i结点的左孩子为2*i+1,右孩子为2*i+2
//transient修饰不参与序列化,没有用private修饰方便内部类访问
transient Object[] queue;
//堆中元素的数量
private int size = 0;
//比较器,用来排序元素,如果没有指定的话使用元素的自然顺序
//指定这个比较器可以用来定制大顶堆、小顶堆
//因为我们假设了是一个小顶堆,所以comparator.compare(a,b)>0的话就是a>b
private final Comparator<? super E> comparator;
//修改次数
transient int modCount = 0;
构造函数
如果初始化的时候从外部传入了一个集合Collection,那么就需要将传入的集合元素构建成一个堆(堆化)
//默认构造函数
public PriorityQueue() {
this(DEFAULT_INITIAL_CAPACITY, null);
}
//指定初始容量
public PriorityQueue(int initialCapacity) {
this(initialCapacity, null);
}
//传入比较器
public PriorityQueue(Comparator<? super E> comparator) {
this(DEFAULT_INITIAL_CAPACITY, comparator);
}
//同时传入初始容量、比较器
public PriorityQueue(int initialCapacity,
Comparator<? super E> comparator) {
// Note: This restriction of at least one is not actually needed,
// but continues for 1.5 compatibility
if (initialCapacity < 1)
throw new IllegalArgumentException();
this.queue = new Object[initialCapacity];
this.comparator = comparator;
}
//初始化时传入外部集合
public PriorityQueue(Collection<? extends E> c) {
//SortedSet类型的集合处理
if (c instanceof SortedSet<?>) {
SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
//取外部集合中的比较器
this.comparator = (Comparator<? super E>) ss.comparator();
//初始化堆元素,SortedSet是有序集合不需要进行堆化,queue数组按照元素原来的顺序排序
initElementsFromCollection(ss);
}
//传入另外一个PriorityQueue
else if (c instanceof PriorityQueue<?>) {
PriorityQueue<? extends E> pq = (PriorityQueue<? extends E>) c;
//取外部PriorityQueue中的比较器
this.comparator = (Comparator<? super E>) pq.comparator();
//初始化堆元素,不需要堆化因为传入的就是一个PriorityQueue
initFromPriorityQueue(pq);
}
else {
//把c当作Collection类型进行初始化,
this.comparator = null;
//这里需要进行首次建堆
initFromCollection(c);
}
}
//传入一个外部PriorityQueue
public PriorityQueue(PriorityQueue<? extends E> c) {
this.comparator = (Comparator<? super E>) c.comparator();
//初始化堆元素,不需要堆化因为传入的就是一个PriorityQueue
initFromPriorityQueue(c);
}
private void initFromPriorityQueue(PriorityQueue<? extends E> c) {
if (c.getClass() == PriorityQueue.class) {
//取外部PriorityQueue的元素直接赋值给queue
//这里不需要进行堆化,因为传进来的本身就是一个堆
this.queue = c.toArray();
this.size = c.size();
} else {
//否则使用把c当作Collection类型进行初始化,其他元素添加进来需要堆化
initFromCollection(c);
}
}
//传入一个外部SortedSet
public PriorityQueue(SortedSet<? extends E> c) {
this.comparator = (Comparator<? super E>) c.comparator();
//初始化堆元素,SortedSet是有序集合不需要进行堆化,queue数组按照元素原来的顺序排序
initElementsFromCollection(c);
}
private void initElementsFromCollection(Collection<? extends E> c) {
Object[] a = c.toArray();
// 数组a的实际类型如果不是Object[]的话
// 将他转化成Object[]
if (a.getClass() != Object[].class)
a = Arrays.copyOf(a, a.length, Object[].class);
int len = a.length;
//保证不存在空元素
if (len == 1 || this.comparator != null)
for (int i = 0; i < len; i++)
if (a[i] == null)
throw new NullPointerException();
//初始化数组
this.queue = a;
this.size = a.length;
}
我们重点看一下初始化外部集合元素后,构建堆的方法
private void initFromCollection(Collection<? extends E> c) {
//先初始化数组元素
initElementsFromCollection(c);
//再将数组构建成一个堆
heapify();
}
//构造一个堆
private void heapify() {
// (size >>> 1) - 1就是size/2-1,表示最后一个非叶子节点
//从最后一个非叶子节点开始逐个向上遍历
for (int i = (size >>> 1) - 1; i >= 0; i--)
//将每个非叶子节点向下堆化
siftDown(i, (E) queue[i]);
}
private void siftDown(int k, E x) {
if (comparator != null)
//使用比较器向下堆化
siftDownUsingComparator(k, x);
else
//使用元素的自然顺序向下堆化
siftDownComparable(k, x);
}
//开始向下堆化,k:开始节点的位置,x:开始节点的元素
private void siftDownUsingComparator(int k, E x) {
//half用来区分叶子节点和非叶子节点
//k>=half表示叶子节点,k<half表示非叶子节点
int half = size >>> 1;
//如果是叶子节点的话就不需要向下比较了,因为已经没有子节点了
while (k < half) {
//k*2+1,表示左子节点下标
int child = (k << 1) + 1;
//左子节点
Object c = queue[child];
//右子节点下标
int right = child + 1;
//存在右子节点 && c>right(这里我们假设c>right大于0,你也可以自己定制比较器规则构建大顶堆)
if (right < size &&
comparator.compare((E) c, (E) queue[right]) > 0)
//right位置的节点赋值给c,c现在是记录了两个子节点中值小的那个节点
c = queue[child = right];
//如果x<=c的话就不需要向下堆化了,它已经满足小顶堆的定义了
if (comparator.compare(x, (E) c) <= 0)
break;
//否则的话值小的子节点放到父节点的位置
queue[k] = c;
//父节点移到两子节点中值小的那个位置继续向下判断直到叶子节点
k = child;
}
//最后,k就是x节点向下堆化完成后的正确位置
queue[k] = x;
}
//按元素的自然顺序堆化
private void siftDownComparable(int k, E x) {
//使用自然顺序比较的话元素必须实现Comparable接口
Comparable<? super E> key = (Comparable<? super E>)x;
//如果节点位置大于等于half的话,说明是叶子节点
int half = size >>> 1;
//一直比较到最后一个非叶子节点,因为叶子节点没有子节点就不需要比较了
while (k < half) {
//左子节点的索引下标
int child = (k << 1) + 1;
//左子节点
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变成了子节点的位置下标并继续向下堆化
k = child;
}
//最后,k就是节点x堆化完后正确的位置
queue[k] = key;
}
这里说明一下为什么k < half表示的是非叶子节点,half=size/2,我们可以用反证法简单证明一下,k < half表示的是非叶子节点,那么也就是说k1>=half表示的是叶子节点,我们取k1的最小值也就是第一个叶子节点half,假设它不是叶子节点那么就会存在子节点,左子节点下标为half*2+1,也就是size+1,已经超出了size所以这个假设不成立。下图展示了构建堆的过程:
接下来我们来分析一下建堆的时间、空间复杂度。在上面的代码中我们只对下标从0到size/2-1的元素进行堆化,剩余的叶子节点是不需要堆化。所以实际堆化的节点是从倒数第二层开始的,每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点的高度k成正比。如下图所示,我们只需要将每个节点的高度求和,得出的就是建堆的时间复杂度,其中叶子节点的高度是0,不需要计算。
将每一个非叶子节点的高度求和,就是下面的公式:
我们将S1乘以2得到S2,再将S2与S1错位相减得到S
S的中间部分是一个等比数列,可以利用等比数列的求和公式计算,最终的结果就是下面图中的样子
完全二叉树的高度h=log2N(以2为底的对数),带入公式就可得S=O(n),所以建堆的时间复杂度是O(n)。在堆化的过程中只需要固定的几个变量不需要额外的存储空间,所以空间复杂度是O(1)
往队列添加元素
和一般的队列不一样,它在最后一个位置添加元素后还需要进一步堆化以保证堆的合法性。新添加进来的元素一步步向上堆化。除此之外,如果数组长度不够用了也会进行扩容
//往队列(堆)中添加元素
public boolean add(E e) {
return offer(e);
}
//添加元素
public boolean offer(E e) {
//不能为空
if (e == null)
throw new NullPointerException();
//修改次数加1
modCount++;
//元素添加的位置,最后一个空位置上
int i = size;
//说明数组不够用了,需要扩容
if (i >= queue.length)
grow(i + 1);
//size加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的话,扩大一倍加2,否则的话扩大50%
int newCapacity = oldCapacity + ((oldCapacity < 64) ?
(oldCapacity + 2) :
(oldCapacity >> 1));
//如果新的容量超出了扩容的最大长度,需要做特殊处理那是因为
//这里的newCapacity有可能是负数,因为最高位1的话就是负数
//当Integer.MAX_VALUE+1的时候最高位就变成了1,负数
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
//定义新的数组对象,大小为newCapacity,并把原先的元素搬到新数组中
queue = Arrays.copyOf(queue, newCapacity);
}
//处理整数精度溢出的场景
private static int hugeCapacity(int minCapacity) {
//当minCapacity超出Integer.MAX_VALUE时就变成了负数
//抛出oom异常
if (minCapacity < 0)
throw new OutOfMemoryError();
//否则的话就取MAX_ARRAY_SIZE或者Integer.MAX_VALUE
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
再来看一下向上堆化的具体操作
//向上堆化,k开始堆化的节点位置,x开始堆化的节点
private void siftUp(int k, E x) {
if (comparator != null)
//使用比较器进行堆化
siftUpUsingComparator(k, x);
else
//使用元素的自然顺序向上堆化
siftUpComparable(k, x);
}
private void siftUpUsingComparator(int k, E x) {
//向上堆化直到头节点
while (k > 0) {
//x节点的父节点索引
int parent = (k - 1) >>> 1;
//父节点
Object e = queue[parent];
//如果x节点大于等于父节点的话,符合小顶堆的定义,堆化结束
if (comparator.compare(x, (E) e) >= 0)
break;
//否则,把父节点移到子节点x位置上
queue[k] = e;
//k变成父节点的位置继续向上比较
k = parent;
}
//最后k就是x节点堆化后的正确位置
queue[k] = x;
}
private void siftUpComparable(int k, E x) {
//元素的自然顺序比较的话,该元素必须实现了Comparable接口
Comparable<? super E> key = (Comparable<? super E>) x;
//向上堆化直到头结点
while (k > 0) {
//父节点下标位置
int parent = (k - 1) >>> 1;
//父节点
Object e = queue[parent];
//如果k大于等于父节点的话,符合堆的定义,结束堆化
if (key.compareTo((E) e) >= 0)
break;
//否则把父节点移到子节点k的位置上
queue[k] = e;
//k变成父节点的位置,继续向上比较
k = parent;
}
//最后,k就是节点x堆化后正确的位置
queue[k] = key;
}
如下图所示:
下面我们分析一下添加元素的时间复杂度,添加元素的时候总是添加在最后一个节点,然后再向上比较堆化,比较的次数不会超过树的高度h,而树的高度h不会超过log2N(以2为底的对数),所以时间复杂度就是O(logN)
删除元素
如果删除的是堆顶元素,那么就是获取了队列中优先级最高的那个节点。删除某个元素后,为了保持堆的完整性,我们需要把最后一个元素放到删除节点的位置,然后在该位置向下堆化或向上堆化以保证堆的合法性。
我们先来看一下获取堆顶元素,有两个方法,一个是获取不删除peek方法,一个是获取并删除poll方法
//获取堆顶元素,不删除
public E peek() {
return (size == 0) ? null : (E) queue[0];
}
//弹出堆顶元素
public E poll() {
if (size == 0)
return null;
int s = --size;
//修改次数加1
modCount++;
//堆顶元素
E result = (E) queue[0];
//最后一个元素
E x = (E) queue[s];
//清空最后一个位置
queue[s] = null;
//如果堆中元素大于1个,那么我们就清空最后一个位置
//并把最后一个节点移动到堆顶,然后再从堆顶向下堆化
if (s != 0)
siftDown(0, x);
return result;
}
再来看一下删除任一元素的操作
//删除某个元素
public boolean remove(Object o) {
//先遍历查找元素的位置
int i = indexOf(o);
//没找到返回失败
if (i == -1)
return false;
else {
//执行删除
removeAt(i);
return true;
}
}
//删除内存地址相同的节点
boolean removeEq(Object o) {
for (int i = 0; i < size; i++) {
//通过内存地址比较
if (o == queue[i]) {
removeAt(i);
return true;
}
}
return false;
}
//根据下标删除元素
private E removeAt(int i) {
//修改次数加1
modCount++;
//size减1
int s = --size;
//删除最后一个位置的元素
//这里就不需要调整堆结构了
if (s == i)
queue[i] = null;
else {
//删除非最后一个节点的话就需要调整堆结构了
//这里的意思是要删除i位置的节点我们就先删除
//最后一个节点moved,然后再把最后一个节点移到i位置上
//然后再从i位置开始向下堆化
E moved = (E) queue[s];
queue[s] = null;
siftDown(i, moved);
//如果moved还是在i的位置上,说明向下堆化没有成功
//即子节点都是大于moved节点的,这个时候我们还需要向上堆化
//因为父节点也可能会存在比moved大的情况
//但是如果moved向下堆化了,则说明有子节点小于moved
//既然子节点中都有小于moved的,那么父节点中肯定都是小于moved的
//就不需要再向上堆化了
if (queue[i] == moved) {
//向上堆化
siftUp(i, moved);
if (queue[i] != moved)
return moved;
}
}
其中向上堆化、向下堆化的操作原理和上面的图示一样,就不再演示了。时间复杂度和添加元素一样,比较的次数不会超过树的高度h,所以时间复杂度还是O(logN)
序列化与反序列化
//序列化,ObjectOutputStream#writeObject方法会调用到该方法
private void writeObject(java.io.ObjectOutputStream s)
throws java.io.IOException {
//先将非static、非transient修饰的字段写入stream
s.defaultWriteObject();
//写入数组长度+1,也不知道它写入要干嘛
s.writeInt(Math.max(2, size + 1));
//最后将每个元素单独写入stream
for (int i = 0; i < size; i++)
s.writeObject(queue[i]);
}
//反序列化,ObjectInputStream#readObject会调用到该方法
private void readObject(java.io.ObjectInputStream s)
throws java.io.IOException, ClassNotFoundException {
// 先读入非static、非transient修饰的字段
s.defaultReadObject();
//对应于序列化时的writeInt
//读入的值没有保存,直接跳过
s.readInt();
SharedSecrets.getJavaOISAccess().checkArray(s, Object[].class, size);
//初始化数组
queue = new Object[size];
//把元素挨个反序列到数组中
for (int i = 0; i < size; i++)
queue[i] = s.readObject();
//其实按原先的顺序反序列化的话还是原先的那个堆
//以防万一元素发生了改变,再进行一次堆化
heapify();
}
解答开篇几个问题
如何用一个堆来实现排序呢 主要分两步,首先我们要建堆,将一个无规则的数组构建成一个堆,时间复杂度前面分析过了是O(n),然后我们只要依次删除堆顶元素并把它放在数组末尾(size<数组长度),最后形成的数组就是有序排列了。因为堆顶是最大(最小)的元素,每次取出堆顶元素后都会进行一次堆化,时间复杂度是O(logN),那么n个元素依次取出来的时间复杂度就是O(NlogN)
如何实时动态的获取数据流的前K大元素呢,我们可以维护一个大小为K的小顶堆,然后顺序遍历数据流与堆顶元素比较,如果大于堆顶元素那就删除堆顶元素并把该元素添加进堆中。以后有数据添加进数据流中时都先和堆顶元素比较,整个堆中存储的就是前k大元素,这样我们就可以实时返回数据流的前k大元素了。
如何实时动态的获取某一数据流的中位数呢中位数就是处在中间位置的那个数。如果数据的个数是奇数,那么第size/2+1个数据就是中位数;如果个数是偶数的话,那么处在中间位置的两个数(size/2、size/2+1)都是中位数,这时我们可以随意取一个作为中位数。对于动态的数据中位数是一直在变的,我们不可能每次都排序一遍然后再取中间的数。借助堆这种数据结构,我们不需要排序,就可以实时高效的实现求中位数操作了。我们只需要维护一个大顶堆、一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,并且小堆顶中的数据都大于大顶堆中的数据。
也就是说,大顶堆中存储的是前半部分的数据,如果是偶数的话就存储size/2个数据,奇数的话就存储size/2+1个数据,小堆顶中存储剩余的部分,这样大顶堆的堆顶元素就是我们要的中位数。
但是前面也说了,这个数据流是一直在动态变化的,如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则我们就将这个新数据插入到小顶堆。这个时候可能就有可能出现,两个堆中的数据个数不符合前面的约定:数据个数是偶数的话大顶堆中存储size/2个数据,奇数的话就存储size/2+1个数据。这个时候我们就可以从一个堆中不停的将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。