走进 JDK 之 PriorityQueue

简介: 这是 Java 集合框架的第三篇文章了,前两篇分别解析了 ArrayList 和 LinkedList,它们分别是基于动态数组和链表来实现的。今天来说说 Java 中的优先级队列 PriorityQueue,它是基于堆实现的,后面也会介绍堆的相关概念。

概述


image.png

PriorityQueue 是基于堆实现的无界优先级队列。优先级队列中的元素顺序根据元素的自然序或者构造器中提供的 Comparator。不允许 null 元素,不允许插入不可比较的元素(未实现 Comparable)。它不保证线程安全,JDK 也提供了线程安全的优先级队列 PriorityBlockingQueue

划个重点,基于堆实现的优先级队列。首先来看一下什么是队列?什么是堆?


队列

队列其实很好理解,它是一种特殊的线性表。比如食堂排队打饭就是一个队列,先排队的人先打饭,后来的同学在队尾排队,打到饭的同学从对头离开,这就是典型的 先进先出(FIFO)队列。队列一般会提供 入队出队 两个基本操作,入队在队尾进行,出队在对头进行。Java 中队列的父类接口是 Queue。我们来看一下 Queue 的 uml 图,给我们提供了哪些基本方法:

image.png


  • add(E) : 在队尾添加元素
  • offer(E) : 在队尾添加元素。在容量受限的队列中和 add() 表现一致。
  • remove() : 删除并返回队列头,队列为空时抛出异常
  • poll() : 删除并返回队列头,队列为空时返回 null
  • element(): 返回队列头,但不删除,队列为空时抛出异常
  • peek() : 返回队列头,但不删除,队列为空时返回 null


基本也就是对出队和入队操作进行了细分。PriorityQueue 是一个优先级队列,会按自然序或者提供的 Comparator 对元素进行排序,这里使用的是堆排序,所以优先级队列是基于堆来实现的。如果你了解堆的概念,就可以跳过下一节了。如果你不知道什么是堆,仔细阅读下一节,不然是没办法理解 PriorityQueue 的源码的。


堆其实是一种特殊的二叉树,它具备如下两个特征:

  • 堆是一个完全二叉树
  • 堆中每个节点的值都必须小于等于(或者大于等于)其子节点的值


对于一个高度为 k 的二叉树,如果它的 0 到 k-1 层都是满的,且最后一层的所有子节点都是在左边那么他就是完全二叉树。用数组实现的完全二叉树可以很方便的根据父节点的下标获取它的两个子节点。下图就是一个完全二叉树:

image.png


堆就是一个完全二叉树。顶部是最小元素的叫小顶堆,顶部是最大元素的叫大顶堆。PriorityQueue 是小顶堆。对照上面的堆结构,对于任意父节点,以下标为 4 的节点 5 为例,它的两个子节点下标分别为 2*4+12*4+2。关于完全二叉树和堆,记住下面几个结论,都是后面的源码分析中要用到的:

  • 没有子节点的节点叫做叶子节点
  • 下标为 n 的父节点的两个左右子节点的下标分别是 2*n+12*n+2


这就是用数组来构建堆的好处,根据下标就可以快速构建堆结构。堆就先说到这里,记住优先级队列 PriorityQueue 是基于堆实现的队列,堆是一个完全二叉树。下面就根据 PriorityQueue 的源码对堆的操作进行深入解析。


源码解析


类声明

public class PriorityQueue<E> extends AbstractQueue<E>
    implements java.io.Serializable { }
复制代码


成员变量

private static final long serialVersionUID = -7720805057305804111L;
private static final int DEFAULT_INITIAL_CAPACITY = 11; // 默认初始容量
transient Object[] queue; // 存储队列元素的数组
private int size = 0; // 队列元素个数
private final Comparator<? super E> comparator; 
transient int modCount = 0; // fail-fast
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8; // 最大容量
复制代码


  • PriorityQueue 使用数组 queue 来存储元素,默认初始容量是 11,最大容量是 Integer.MAX_VALUE - 8
  • comparator 若为 null,则按照元素的自然序来排列。
  • modCount 用来提供 fail-fast 机制。


构造函数

PriorityQueue 的构造函数有 7 个,可以分为两类,提供初始元素和不提供初始元素。先来看看不提供初始元素的构造函数:

/*
 * 创建初始容量为 11 的优先级队列,元素按照自然序
 */
public PriorityQueue() {
    this(DEFAULT_INITIAL_CAPACITY, null);
}
/*
 * 创建指定初始容量的优先级队列,元素按照自然序
 */
public PriorityQueue(int initialCapacity) {
    this(initialCapacity, null);
}
/*
 * 创建初始容量为 11 的优先级队列,元素按照按照给定 comparator 排序
 */
public PriorityQueue(Comparator<? super E> comparator) {
    this(DEFAULT_INITIAL_CAPACITY, comparator);
}
/*
 * 创建指定初始容量的优先级队列,元素按照按照给定 comparator 排序
 */
public PriorityQueue(int initialCapacity,
                        Comparator<? super E> comparator) {
    if (initialCapacity < 1)
        throw new IllegalArgumentException();
    this.queue = new Object[initialCapacity];
    this.comparator = comparator;
}
复制代码


这一类构造函数都很简单,直接给 queuecomparator 赋值即可。对于给定初始元素的构造函数就没有这么简单了,因为给定的初始集合并不一定满足堆的结构,我们需要将其构造成堆,这个过程称之为 堆化


PriorityQueue 可以直接根据 SortedSetPriorityQueue 来构造堆,由于初始集合本来就是有序的,所以无需进行堆化。如果构造器参数是任意 Collection,那么就可能需要堆化了。


public PriorityQueue(Collection<? extends E> c) {
    if (c instanceof SortedSet<?>) { // 直接使用 SortedSet 的 comparator
        SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
        this.comparator = (Comparator<? super E>) ss.comparator();
        initElementsFromCollection(ss);
    }
    else if (c instanceof PriorityQueue<?>) { // 直接使用 PriorityQueue 的 comparator
        PriorityQueue<? extends E> pq = (PriorityQueue<? extends E>) c;
        this.comparator = (Comparator<? super E>) pq.comparator();
        initFromPriorityQueue(pq);
    }
    else {
        this.comparator = null;
        initFromCollection(c); // 需要堆化
    }
}
复制代码


我们来看看堆化的具体过程:

private void initFromCollection(Collection<? extends E> c) {
    initElementsFromCollection(c); // 将 c copy 一份直接赋给 queue
    heapify(); // 堆化
}
复制代码


private void heapify() {
    for (int i = (size >>> 1) - 1; i >= 0; i--)
        siftDown(i, (E) queue[i]); // 自上而下堆化
}
复制代码


堆化的逻辑很短,但是内容很丰富。堆化其实用两种,shiftDown() 是自上而下堆化,shiftUp() 是自下而上堆化。这里使用的是 shiftDown。从上面的代码中你可以看出从哪一个结点开始堆化的吗?并不是从最后一个节点开始堆化,而是从最后一个非叶子节点开始的。还记得什么是叶子节点吗,没有子节点的节点就是叶子节点。所以,对所有非叶子节点进行堆化,就足以处理所有节点了。那么最后一个非叶子节点的下标是多少呢,如果想不出来可以翻到上面的堆的示意图,答案就是 size/2 - 1,源码中使用了无符号移位操作代替了除法。


再来看看 shiftDown() 的具体逻辑:

/*
 * 自上而下堆化,保证 x 小于等于子节点或者 x 是一个叶子结点
 */
private void siftDown(int k, E x) {
    if (comparator != null)
        siftDownUsingComparator(k, x);
    else
        siftDownComparable(k, x);
}
复制代码


x 是要插入的元素,k 是要填充的位置。根据 comparator 是否为空调用不同的方法。这里以 comparator 不为 null 为例:

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) { // 堆的非叶子节点的个数总是小于 half 的。当 k 是叶子节点的时候,直接交换即可
        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) // 比较 x 和子节点
            break; // x 比子节点大,直接跳出循环
        queue[k] = c; // 若 x 比子节点小,和子节点交换
        k = child; // 此时 k 等于 child,继续和子节点比较
    }
    queue[k] = key;
}
复制代码


逻辑比较简单。PriorityQueue 是一个小顶堆,父节点总是小于等于子节点。对于每一个非叶子节点,将它和自己的两个左右子节点进行比较,若父节点比两个子节点都大,就要将这个父节点下沉,下沉之后再继续和子节点比较,直到该父节点比两个子节点都小,或者这个父节点已经是叶子结点,没有子节点了。这样循环往复,自上而下的堆化就完成了。


方法


看完了构造函数,我们来看看 PriorityQueue 提供的方法。既然是队列,那就肯定有入队和出队操作。先来看看入队方法 add()


add(E e)

public boolean add(E e) {
    return offer(e);
}
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;
}
复制代码


add() 方法会调用 offer() 方法,它们都是在队尾增加一个元素。offer() 过程可以分为两步:自动扩容堆化


优先队列也支持自动扩容,但其扩容逻辑和 ArrayList 不同,ArrayList 是直接扩容至原来的 1.5 倍。而 PriorityQueue 根据当前队列大小的不同有不同的表现。

private void grow(int minCapacity) {
    int oldCapacity = queue.length;
    // Double size if small; else grow by 50%
    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);
}
复制代码


  • 原队列大小小于 64 时,直接翻倍再 +2
  • 原队列大小大于 64 时,增加 50%

第二步就是堆化了。队尾增加元素为什么要重新堆化呢?看下面这个图:

image.png

左边是一个堆,我要在队尾添加一个元素 4,如果这样直接加在队尾,还是一堆吗?显然不是的了,因为 4 比 5 小,却排在了 5 的下面。所以这时候就需要堆化了。前面介绍过 shiftDown, 这里还可以自上而下堆化吗?显然不行,因为在队尾添加节点,这个节点肯定是叶子节点,它已经位于最下面一层了,没有子节点了。这就要使用另一种堆化方法,自下而上堆化。拿 4 和其父节点比较,发现 4 比 5 小,和父节点交换,这时候 4 就处在 下标为 2 的位置了。再和父节点比较,发现 4 比 1 大,不交换,结束堆化。这时候 4 就找到自己在堆中正确的位置了。对应源代码中的 shiftUp() 方法:


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) {
        int parent = (k - 1) >>> 1; // 找到 k 位置的父节点
        Object e = queue[parent];
        if (comparator.compare(x, (E) e) >= 0) // 比较 x 与父节点值的大小
            break; // x 比父节点大,直接跳出循环
        queue[k] = e; // 若 x 比父节点小,交换元素
        k = parent; // 此时 k 等于 parent,继续和父节点比较
    }
    queue[k] = x;
}
复制代码


根据下标 k 就可以找到 k 位置的父节点,这也是前面介绍堆的时候给出的结论。那么其插入操作的时间复杂度是多少呢?这和堆的高度相关,最好时间复杂度就是 O(1),不需要交换元素,最坏时间复杂度是 O(log(n)),因为堆的高度是 log(n),最坏情况就是一路交换到堆顶。平均时间复杂度也就是 O(log(n))


说完了入队,下面看一下出队。


poll()

poll() 是出队操作,也就是移除队队头元素。想想一下,一个完全二叉树,你把堆顶移除了,它就不是一个完全二叉树了,也就没办法去堆化了。源码中是这样处理的,移除队头元素之后,暂时把队尾元素移到队头,这样它又是一个完全二叉树了,就可以进行堆化了。下面这个图更容易理解:

image.png

这里的堆化操作很显然应该是 shiftDown() 了,自上而下堆化。

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 也支持 remove 任意位置的节点,通过 remove() 方法实现。


remove()

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]; // moved 是队尾元素
        queue[s] = null;
        siftDown(i, moved); // 将队尾元素插入 i 位置,再自上而下堆化
        if (queue[i] == moved) {
            siftUp(i, moved); // moved 没有往下交换,仍然在 i 位置处,此时需要再自下而上堆化以保证堆的正确性
            if (queue[i] != moved)
                return moved;
        }
    }
    return null;
}
复制代码


如果是删除队尾,直接删除皆可以了。但如果是删除中间某个节点,就会在堆中形成一个空洞,不再是完全二叉树。其实和 poll 的处理方式一致,将队尾节点暂时填充到删除的位置,形成完全二叉树再进行堆化。

image.png

这里的堆化过程和 poll 有一些不一致。首先进行 shiftDown(),自上而下堆化。shiftDown() 完成之后比较 queue[i] == moved,如果不相等,说明节点 i 向下交换了,它找到了自己的位置。但是如果相等,则说明节点 i 没有向下交换,也就是节点 i 的值比它的子节点都要小。但这并不能说明它一定比它的父节点大。所以,这种情况还需要再自下而上堆化,以保证可以完全符合堆的特性。


总结


说了半天 PriorityQueue ,其实都是在说堆。如果你对堆很熟悉的话,PriorityQueue 的源码很好理解。当然不熟悉也没关系,借着源码正好可以学习一下堆的基本概念。最后简单总结一下优先队列:

  • PriorityQueue 是基于堆的,堆是一个特殊的完全二叉树,它的每一个节点的值都必须小于等于(或大于等于)其子树中每个节点的值
  • PriorityQueue 的出队和入队操作时间复杂度都是 O(log(n)),仅与堆的高度有关
  • PriorityQueue 初始容量为 11,支持动态扩容。容量小于 64 时,扩容一倍。大于 64 时,扩容 0.5 倍
  • PriorityQueue 不允许 null 元素,不允许不可比较的元素
  • PriorityQueue 不是线程安全的,PriorityBlockingQueue 是线程安全的

PriorityQueue 就说到这里了。下一篇应该会写 Set 相关。



相关文章
|
数据采集 算法 Java
JDK源码之PriorityQueue解析
JDK源码之PriorityQueue解析
JDK源码之PriorityQueue解析
|
3月前
|
Java
安装JDK18没有JRE环境的解决办法
安装JDK18没有JRE环境的解决办法
381 3
|
5月前
|
Oracle Java 关系型数据库
入职必会-开发环境搭建41-Linux软件安装-安装JDK
本文介绍了在Linux系统中下载和安装JDK
189 3
入职必会-开发环境搭建41-Linux软件安装-安装JDK
|
4月前
|
Oracle Java 关系型数据库
Mac安装JDK1.8
Mac安装JDK1.8
774 4
|
4月前
|
Java 关系型数据库 MySQL
"解锁Java Web传奇之旅:从JDK1.8到Tomcat,再到MariaDB,一场跨越数据库的冒险安装盛宴,挑战你的技术极限!"
【8月更文挑战第19天】在Linux上搭建Java Web应用环境,需安装JDK 1.8、Tomcat及MariaDB。本指南详述了使用apt-get安装OpenJDK 1.8的方法,并验证其版本。接着下载与解压Tomcat至`/usr/local/`目录,并启动服务。最后,通过apt-get安装MariaDB,设置基本安全配置。完成这些步骤后,即可验证各组件的状态,为部署Java Web应用打下基础。
65 1
|
5月前
|
Java Linux
Linux复制安装 jdk 环境
Linux复制安装 jdk 环境
115 3
|
1月前
|
Oracle Java 关系型数据库
安装 JDK 时应该注意哪些问题
选择合适的JDK版本需考虑项目需求与兼容性,推荐使用LTS版本如JDK 17或21。安装时注意操作系统适配,配置环境变量PATH和JAVA_HOME,确保合法使用许可证,并进行安装后测试以验证JDK功能正常。
53 1
|
1月前
|
IDE Java 编译器
开发 Java 程序一定要安装 JDK 吗
开发Java程序通常需要安装JDK(Java Development Kit),因为它包含了编译、运行和调试Java程序所需的各种工具和环境。不过,某些集成开发环境(IDE)可能内置了JDK,或可使用在线Java编辑器,无需单独安装。
70 1
|
4月前
|
Java 开发工具
开发工具系列 之 同一个电脑上安装多个版本的JDK
这篇文章介绍了如何在一台电脑上安装和配置多个版本的JDK,包括从官网下载所需JDK、安装过程、配置环境变量以及如何查看和切换当前使用的JDK版本,并提到了如果IDEA和JDK版本不兼容时的解决方法。
开发工具系列 之 同一个电脑上安装多个版本的JDK
|
2月前
|
Oracle Java 关系型数据库
jdk17安装全方位手把手安装教程 / 已有jdk8了,安装JDK17后如何配置环境变量 / 多个不同版本的JDK,如何配置环境变量?
本文提供了详细的JDK 17安装教程,包括下载、安装、配置环境变量的步骤,并解释了在已有其他版本JDK的情况下如何管理多个JDK环境。
1435 0