JAVA 集合之 PriorityQueue

本文介绍了Java集合中的PriorityQueue,它基于堆数据结构实现,提供优先级排序功能。PriorityQueue默认按元素自然顺序排序,可自定义Comparator。文章详细分析了PriorityQueue的构造、成员变量、核心方法如add、peek和remove,并探讨了其在并发场景下的使用。

写在开头

​ 本文作为我学习 Java 集合 PriorityQueue 的一个记录与总结,如果文中有纰漏或者错误,请不吝赐教,共同进步!

PriorityQueue

一、PriorityQueue 介绍

1.1 PriorityQueue 概述
  1. PriorityQueue(优先队列) , 其底层的实现是**堆(heap)**这一数据结构
  2. PriorityQueue 维护底层数组长度的方式与 ArrayList 相同
  3. PriorityQueue 中元素默认按照元素的自然顺序排序,也可指定自定义的 Comparator
  4. PriorityQueue 中元素必须是彼此可比的且不能为 null
  5. PriorityQueue 是线程不安全的,在并发场景下应使用 PriorityBlockingQueue
1.2 PriorityQueue 底层数据结构

​ PriorityQueue 底层的数据结构是 堆(heap) , 其一般用数组来作为其实现,但是在逻辑上其更像一棵树,见下图,以数组实现的堆的特点为:设当前元素在数组中的下标为 n 则其左孩子为数组中下标为 [2n+1] 的元素,其右孩子为数组中下标为 [2(n+1)] 的元素。
在这里插入图片描述
在这里插入图片描述

​ 此外,堆的另一个特点是父节点和子节点的关系,特别的对于 PriorityQueue 来说其默认的堆是一个小根堆,其特点为父节点的元素值大于左右俩个子节点,根节点的元素值为整个堆的最小值,其能够在插入或删除时自动维护堆的性质。具体的实现可参考《算法导论》,其中有关于堆的详细介绍,在此就不再继续深入下去了。

二、PriorityQueue 源码分析

2.1 继承类与接口实现
public class PriorityQueue<E> extends AbstractQueue<E>
    implements java.io.Serializable
  • 继承类
    • AbstractQueue:队列的抽象类其实现了 Queue 结构,此类实现了队列的基本功能
  • 接口
    • Serializable 接口:使得 PriorityQueue 可序列化
2.2 成员变量
// PriorityQueue 第一次插入数据时底层数组的大小
private static final int DEFAULT_INITIAL_CAPACITY = 11;
// 底层数组
transient Object[] queue;
// PriorityQueue 的大小
private int size = 0;
// 元素顺序的比较器,若不指定则为 null,即使用元素的自然顺序进行排序
private final Comparator<? super E> comparator;
2.3 构造方法

在这里插入图片描述

​ 看起来很多但基本都是对PriorityQueue(int , Comparator<? super E>) 的不同调用因此,详细介绍一下此构造方法

public PriorityQueue(int initialCapacity,
                     Comparator<? super E> comparator) {
	// 保证初始化的数组大小至少为 1
    if (initialCapacity < 1)
        throw new IllegalArgumentException();
    // 初始化底层数组
    this.queue = new Object[initialCapacity];
    // 初始化 PriorityQueue 的比较器为自定义比较器
    this.comparator = comparator;
}

​ 此构造主要做了两件事情,1)初始化底层数组并指定其大小,2)将 PriorityQueue 的比较器指定为用户定义的比较器。此次有一个可有可无的小细节,在此暂且一提,如果阅读过我之前有关 ArrayList 的文章的话会发现,虽然底层都是基于数组,如果不指定具体的初始化长度,那么 PriorityQueue 会在初始化时直接初始化对应长度的数组,而 ArrayList 则会在第一次 add 时才初始化对应长度的数组。

2.4 核心方法

​ PriorityQueue 也是队列因此其核心方法同队列相同即:add(), peek(), remove()

2.4.1 add() 方法
  • add() 方法
public boolean add(E e) {
    // 调用了 offer
    return offer(e);
}
  • offer() 方法
public boolean offer(E e) {
        if (e == null)
            // PriorityQueue 不允许 null 
            throw new NullPointerException();
        modCount++;
        int i = size;
        if (i >= queue.length)
            // 如果 PriorityQueue.size() 超过了底层数组则调用 grow() 方法扩容
            grow(i + 1);
        size = i + 1;
        if (i == 0)
            queue[0] = e;
        else
            // siftUp 方法插入元素并维护堆特性
            siftUp(i, e);
        return true;
    }

​ 从上面两段代码可以发现有两个需要注意的点:

  1. add() 方法中调用了 offer() 方法,为什么要这样?
  2. offer() 方法中 使用了 grow() 方法增加数组长度,使用 siftUp() 方法来维护堆的特性,它们是如何实现的?

​ 有关第一个问题可以从父类 AbstractQueue 找到答案

    /**
     * Inserts the specified element into this queue if it is possible to 		 do so
     * immediately without violating capacity restrictions, returning
     * <tt>true</tt> upon success and throwing an IllegalStateException
     * if no space is currently available.
     *
     * <p>This implementation returns <tt>true</tt> if <tt>offer</tt> 		   succeeds,
     * else throws an <tt>IllegalStateException</tt>.
     */
	// 对于 add() 方法而言如果向 queue 中插入元素时队列已满则会抛出异常
    public boolean add(E e) {
        if (offer(e))
            return true;
        else
            throw new IllegalStateException("Queue full");
    }
	/**
     * Inserts the specified element into this queue if it is possible to 		 do
     * so immediately without violating capacity restrictions.
     * When using a capacity-restricted queue, this method is generally
     * preferable to {@link #add}, which can fail to insert an element only
     * by throwing an exception.
     */
	// offer 方法在 queue 满的情况下插入元素会直接返回 false 而不是抛出异常
    boolean offer(E e);

​ 简单阅读一下注释就可以发现 add() 和 offer() 的区别主要在于队列满的情况下的行为,add() 会抛出异常,而 offer() 只是返回 false,因此在有界队列 队列中应该使用 offer() 来添加元素,但是从 PriorityQueue 的源码可以发现,其 add() 和 offer() 并无此种差别,或者是 add() 的行为和 offer() 是一样的,我猜测是因为 PriorityQueue 是一个无界队列因此俩个方法的行为一致。

​ 有关第二个问题我们可以直接来看下源码:

  • grow()
// 可以看到 grow 方法与 ArrayList 的grow 方法极为相似,只在小容量扩容的大小方面有
// 细微差别
private void grow(int minCapacity) {
    int oldCapacity = queue.length;
    // 如果数组的容量较小直接扩容为原来的 2 倍
    // 如果数组的容量大于 64 则每次扩容为原来的 1.5 倍
    int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                                     (oldCapacity + 2) :
                                     (oldCapacity >> 1));
    // 如果扩容后的容量大于数组最大容量则将其变为数组最大容量
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    // 将原数组元素全部复制到扩容后长度的数组中
    queue = Arrays.copyOf(queue, newCapacity);
}

private static int hugeCapacity(int minCapacity) {
    if (minCapacity < 0) // overflow
        throw new OutOfMemoryError();
    //MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8
    return (minCapacity > MAX_ARRAY_SIZE) ?
        Integer.MAX_VALUE :
    MAX_ARRAY_SIZE;
}
  • siftUp()
private void siftUp(int k, E x) {
    // 判断是否有用户自定义比较器
    if (comparator != null)
        // 使用用户自定义比较器维持堆特性
        siftUpUsingComparator(k, x);
    else
        // 使用元素的自然排序维持堆特性
        siftUpComparable(k, x);
}

// 是否使用比较器只影响元素的顺序而不影响内在逻辑因此此出分析不使用比较器的
// Params: k 插入元素的位置,一般即数组的末尾
//         x 需要插入的元素
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;
}

​ shiftUp 的方法可以在插入元素的同时保持堆的特性,就如其方法名,通过不断的比较并上移插入位置以得到最终的插入目标位置,恕本人做不来动画不然可以更清楚的展示整个过程 ╮(╯▽╰)╭。

2.4.2 peek() 方法
public E peek() {
    // 如果队列为空则返回 null 否则返回队列的第一个值
    return (size == 0) ? null : (E) queue[0];
}
2.4.3 remove() 方法
  • remove() 方法
public boolean remove(Object o) {
    // 得到需要移除的元素的下标
    int i = indexOf(o);
    // 若数组中没有目标元素则 return false
    if (i == -1)
        return false;
    else {
        // 根据下标移除目标元素
        removeAt(i);
        return true;
    }
}
  • indexOf()
// 很简单,遍历数组找到目标元素的下表并返回,若数组中没有目标元素则返回 -1
private int indexOf(Object o) {
    if (o != null) {
        for (int i = 0; i < size; i++)
            if (o.equals(queue[i]))
                return i;
    }
    return -1;
}
  • removeAt()
private E removeAt(int i) {
    // assert i >= 0 && i < size;
    modCount++;
    int s = --size;
    // 如果 i 是数组的最后一个元素则直接删除,因为其不会影响堆特性
    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;
}

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) {
        // 下面代码段至 c = queue[child = right]; 为止在得到 left 和 right中
        // 小的那个节点
        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];
        // 比较较小的子元素与 key 如果 key 更大则 k 位置的元素下沉
        // 此处即要被删除的元素下沉
        if (key.compareTo((E) c) <= 0)
            break;
        queue[k] = c;
        k = child;
    }
    // 当 key 大于较小的子节点元素时将 key 放置在那个子节点上
    queue[k] = key;
}

​ 光看上面的文字可能有些迷惑,接下来用图来讲述一下这个 remove() 的过程。下图是我们示例队列的当前状态:

在这里插入图片描述

​ 此时我们需要删除红圈的元素,其值为 21,根据源码我们知道我们会取数组的最后一个元素来作为 moved 变量的值即图中黄圈标出的元素其值为 11。OK,经过 siftDown 过程以后我们的队列会变成下图这样:

在这里插入图片描述

需要末尾元素变成了 null 等待 GC 的回收,而原来红框处的元素被替换成了 11,这时候就发现问题了,下沉已经完成但是显然这种状态是不符合堆性质的因为 11 小于其父节点的值 20,由此我们还需要进行一次 siftUp 才能保证堆的性质,最后结果如图:

在这里插入图片描述

三、总结

  1. PriorityQueue 的底层实现是堆,更准确的说应该是二叉堆
  2. PriorityQueue 能够使用自定义比较器,默认情况下其是一个小根堆
  3. PriorityQueue 通过 siftUpsiftDown 来维持堆的性质
  4. PriorityQueue 中的元素必须能够彼此比较且不支持 null

后记:PriorityQueue 的简单应用

本节通过一道 leetcode 题目:前 K 个高频元素 来展示一下 PriorityQueue 的一个应用场景,当然除了 PriorityQueue 还可以用快速选择来解答此题。

class Solution {
    public int[] topKFrequent(int[] nums, int k) {
        // 使用 HashMap 来记录每个数字出现的次数
        HashMap<Integer, Integer> numCount = new HashMap<>();
        // 使用 PriorityQueue 来保存出现频率前 K 多的元素
        // 直接指定初始化长度为 k, 并指定比较器按照 HashMap 的 value 进行比较
        PriorityQueue<Integer> queue = new PriorityQueue<>(k, Comparator.comparingInt(numCount::get));
        for(int num : nums){
            numCount.put(num, numCount.getOrDefault(num, 0) + 1);
        }

        for (Map.Entry<Integer,Integer> entry: numCount.entrySet()){
            if(queue.size() < k){
                queue.add(entry.getKey());
            }else if (queue.size() == k){
                // 如果频率大于顶端元素则将顶端元素弹出
                // 使用小根堆,堆顶为出现频率排名 k 的元素
                if(entry.getValue() > numCount.get(queue.peek())){
                    queue.poll();
                    queue.add(entry.getKey());
                }
            }
        }
        int[] res = new int[k];
        for(int i = 0; i < k; i++){
            res[i] = queue.poll();
        }
        return res;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值