【Java 集合类】PriorityQueue 类源码分析

基本定义

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 2311。若插入元素过多,可能会导致数组越界,抛出异常。

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;
        }
    });
}

实际上默认的排序方式就是代表升序的小顶堆,此时就可以不用重新比较方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值