深入淺出分析 PriorityQueue

一、摘要

在前幾篇文章中,咱們瞭解到,Queue 的實現類有 ArrayDeque、LinkedList、PriorityQueue。

在上一章節中,陸續的介紹到 ArrayDeque 和 LinkedList 的數據結構和算法實現,今天咱們來介紹一下** PriorityQueue 這個類,一個特殊的優先級隊列**。如果有理解不當之處,歡迎指正。

二、簡介

PriorityQueue 並沒有直接實現 Queue接口,而是通過繼承 AbstractQueue 類來實現 Queue 接口一些方法,在 Java 定義中,PriorityQueue 是一個基於優先級的無界優先隊列。

通俗的說,添加到 PriorityQueue 隊列裏面的元素都經過了排序處理,默認按照自然順序,也可以通過 Comparator 接口進行自定義排序。

優先隊列的作用是保證每次取出的元素都是隊列中權值最小的。

如果猿友們瞭解過 TreeMap 的實現,會發現 PriorityQueue 排序實現與之類似。

PriorityQueue 是採用樹形結構來描述元素的存儲,具體說是通過完全二叉樹實現一個小頂堆,在物理存儲方面,PriorityQueue 底層通過數組來實現元素的存儲。

在上圖中,我們給每個元素的下標做了標註,足夠細心的你會發現,數組下標,存在以下關係:

leftNo = parentNo * 2 + 1
rightNo = parentNo * 2 + 2
parentNo = (currentNo -1) / 2

各個參數具體含義如下:

  • parentNo:表示父節點下標;
  • leftNo:表示子元素左節點下標;
  • rightNo:表示子元素右節點下標;
  • currentNo:表示當前元素節點下標;

通過上述三個公式,可以輕易計算出某個節點的父節點以及子節點的下標。這也就是爲什麼可以直接用數組來存儲元素實現二叉樹結構的原因。

2.1、源碼介紹

PriorityQueue 源碼定義如下:

public class PriorityQueue<E> extends AbstractQueue<E>
    implements java.io.Serializable {
    
    /**默認容量爲11*/
    private static final int DEFAULT_INITIAL_CAPACITY = 11;

    /**隊列容器*/
    transient Object[] queue;

    /**隊列長度*/
    private int size = 0;

    /**比較器,爲null使用自然排序*/
    private final Comparator<? super E> comparator;

    ......
}

從定義中可以得出,PriorityQueue 有3個比較核心的變量屬性,內容如下:

  • queue:表示存放元素的數組
  • comparator:表示比較器對象,如果爲空,使用自然排序
  • size:表示隊列長度

我們再來看看 PriorityQueue 類的構造方法,PriorityQueue 構造方法分兩類,一種是默認初始化、另一種是傳入 Comparator 接口比較器,內容如下:

默認初始化,使用自然排序方式進行插入,源碼如下:

public PriorityQueue() {
    //默認數組長度爲11,傳入比較器爲null
    this(DEFAULT_INITIAL_CAPACITY, null);
}

調用的方法,源碼如下:

public PriorityQueue(int initialCapacity, Comparator<? super E> comparator) {
    //初始化容量小於 1,拋異常
    if (initialCapacity < 1)
        throw new IllegalArgumentException();
    this.queue = new Object[initialCapacity];
    this.comparator = comparator;
}

自定義比較器初始化,使用 comparator 接口比較器作爲參數傳入,源碼如下:

public PriorityQueue(Comparator<? super E> comparator) {
    //傳入比較器 comparator
    this(DEFAULT_INITIAL_CAPACITY, comparator);
}

這兩者初始化方式,咱們在下文會一一講到。

在介紹 PriorityQueue 實現的方法之前,咱們瞭解到,Queue 接口定義有如下方法:

同樣的 PriorityQueue 也實現了這些方法,PriorityQueue 方法雖然定義的很多,但無非就是對容器進行添加、刪除、查詢操作,下面我們分別來看看各個操作方法的實現過程。

三、常見方法介紹

3.1、添加方法

PriorityQueue 的添加方法有 2 種,分別是add(E e)offer(E e),兩者語義相同,都是向優先隊列中插入元素,只是Queue接口規定二者對插入失敗時的處理不同,前者在插入失敗時拋出異常,後則返回false

3.1.1、offer 方法

offer 方法圖解實現流程如下:

新加入的元素可能會破壞小頂堆的性質,在 c、d 兩步會進行調整。

offer 方法的實現,源碼如下:

public boolean offer(E e) {
    //不允許放入null元素
    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;
}

值得注意的是,插入元素不能爲null,否則報空指針異常!

當數組空間不足時,會進行擴容,擴容函數grow()類似於ArrayList裏的grow()函數,就是再申請一個更大的數組,並將原數組的元素複製過去,源碼如下:

private void grow(int minCapacity) {
    int oldCapacity = queue.length;
    //如果舊數組容量小於64,新容量爲 oldCapacity *2 +2
    //如果大於64,新容量爲 oldCapacity + oldCapacity * 0.5
    int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                                     (oldCapacity + 2) :
                                     (oldCapacity >> 1));
    //判斷是否超過最大容量值,設置最高容量值
    if (newCapacity - MAX_ARRAY_SIZE > 0)
        newCapacity = hugeCapacity(minCapacity);
    //複製數組元素
    queue = Arrays.copyOf(queue, newCapacity);
}

從源碼中可以看出,在計算新容量的時候,如果舊數組的容量小於64,新數組容量爲舊容量的2➕2;反之,新數組容量的擴容係數爲50%

我們再來看看siftUp(i, e)這個方法,當插入的元素不是頂部位置,會進行內容排序調整,siftUp(i, e)方法就是起到這個作用,源碼如下:

private void siftUp(int k, E x) {
    //如果使用比較器,採用比較器進行比較
    if (comparator != null)
        siftUpUsingComparator(k, x);
    else
        //沒有比較器,採用自然排序
        siftUpComparable(k, x);
}

默認調整方式的實現,源碼如下:

private void siftUpComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    while (k > 0) {
        //parentNo = (nodeNo-1)/2
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        //默認自然排序,從小到大
        if (key.compareTo((E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = key;
}

自定義比較器的實現,調整方式,源碼如下:

private void siftUpUsingComparator(int k, E x) {
    while (k > 0) {
        //parentNo = (nodeNo-1)/2
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        //調用比較器的比較方法
        if (comparator.compare(x, (E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = x;
}

默認的插入規則中,新加入的元素可能會破壞小頂堆的性質,因此需要進行調整。

調整的過程爲:從尾部下標的位置開始,將加入的元素逐層與當前點的父節點的內容進行比較並交換,直到滿足父節點內容都小於子節點的內容爲止。

當然,也可以依靠自定義比較器,實現自定排序規則。

3.1.2、add 方法

add方法,就比較簡單了,直接調用了offer方法,返回false拋異常,源碼如下:

public boolean add(E e) {
    if (offer(e))
        return true;
    else
        throw new IllegalStateException("Queue full");
}
3.1.3 使用方式
  • 自然排序
public static void main(String[] args) {
    PriorityQueue<Integer> queue = new PriorityQueue<>();
    System.out.println("插入的數據");
    //隨機添加兩位數
    for (int i = 0; i < 10; i++) {
        Integer num = new Random().nextInt(90) + 10;
        System.out.print(num + ",");
        queue.offer(num);
    }

    System.out.println("\n輸出後的數據");
    while (true){
        Integer result = queue.poll();
        if(result == null){
            break;
        }
        System.out.print(result + ",");
    }
}

輸出結果:

插入的數據
53,97,66,58,69,10,72,27,18,16,
輸出後的數據
10,16,18,27,53,58,66,69,72,97,
  • 自定義排序
public static void main(String[] args) {
    PriorityQueue<Integer> customeQueue = new PriorityQueue<>(new Comparator<Integer>() {
        @Override
        public int compare(Integer o1, Integer o2) {
            //按照大到小排序
            return o2.compareTo(o1);
        }
    });
    System.out.println("插入的數據");
    //隨機添加兩位數
    for (int i = 0; i < 10; i++) {
        Integer num = new Random().nextInt(90) + 10;
        System.out.print(num + ",");
        customeQueue.offer(num);
    }

    System.out.println("\n輸出後的數據");
    while (true){
        Integer result = customeQueue.poll();
        if(result == null){
            break;
        }
        System.out.print(result + ",");
    }
}

輸出結果:

插入的數據
66,39,28,54,56,66,54,77,10,97,
輸出後的數據
97,77,66,66,56,54,54,39,28,10,

3.2、刪除方法

PriorityQueue 的刪除方法有 2 種,分別是remove()poll(),兩者語義也完全相同,都是獲取並刪除隊首元素,區別是當方法失敗時前者拋出異常,後者返回null。由於刪除操作會改變隊列的結構,爲維護小頂堆的性質,需要進行必要的調整。

3.2.1、poll 方法

offer 方法圖解實現流程如下:

刪除的元素可能會破壞小頂堆的性質,在 b、 c、d 三步會進行調整。

poll 方法的實現,源碼如下:

public E poll() {
    if (size == 0)
        return null;
    int s = --size;
    modCount++;
    //0下標處的那個元素就是最小的那個
    E result = (E) queue[0];
    E x = (E) queue[s];
    queue[s] = null;
    if (s != 0)
        //調整
        siftDown(0, x);
    return result;
}

調整過程與插入的調整過程有些相反!

首先記錄數組頭部的下標,並用最後一個元素的內容替換數組頭部的元素,之後調用siftDown()方法對堆進行調整,最後返回數組頭部的元素。

siftDown(int k, E x)方法的實現,源碼內容如下:

private void siftDown(int k, E x) {
    //判斷是否有自定義比較器
    if (comparator != null)
        siftDownUsingComparator(k, x);
    else
        siftDownComparable(k, x);
}

與插入的調整類似,首先判斷是否有自定義的比較器,如果沒有,按照默認的方式進行調整,反之,根據自定義比較器的排序規則進行調整。

默認調整方式,函數siftDownComparable(k, x),源碼如下:

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裏,並用child記錄其下標
        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;//然後用c取代原來的值
        k = child;
    }
    queue[k] = key;
}

自定義調整方式,函數siftDownUsingComparator(k, x),源碼如下:

private void siftDownUsingComparator(int k, E x) {
    int half = size >>> 1;
    while (k < half) {
        //首先找到左右孩子中較小的那個,記錄到c裏,並用child記錄其下標
        int child = (k << 1) + 1;
        Object c = queue[child];
        int right = child + 1;
        if (right < size &&
            comparator.compare((E) c, (E) queue[right]) > 0)
            c = queue[child = right];
        if (comparator.compare(x, (E) c) <= 0)
            break;
        queue[k] = c;//然後用c取代原來的值
        k = child;
    }
    queue[k] = x;
}

默認的刪除調整中,首先獲取頂部下標和最尾部的元素內容,從頂部的位置開始,將尾部元素的內容逐層向下與當前點的左右子節點中較小的那個交換,直到判斷元素內容小於或等於左右子節點中的任何一個爲止。

如果有自定義比較器,使用自定義比較器中的排序算法來進行交換。

思路是一樣的,只是排序比較算法不一樣而已!

3.2.2、remove 方法

remove 方法實現比較簡單,直接調用了poll()方法,返回空值拋異常,源碼如下:

public E remove() {
    E x = poll();
    if (x != null)
        return x;
    else
        //返回空值,拋異常
        throw new NoSuchElementException();
}

3.3、查詢方法

PriorityQueue 的查詢方法有 2 種,分別是element()和peek(),兩者語義也完全相同,都是獲取但不刪除隊首元素,也就是隊列中權值最小的那個元素,二者唯一的區別是當方法失敗時前者拋出異常,後者返回null

因爲是數組結構,所以查詢的時間複雜度log(1),根據小頂堆的性質,堆頂那個元素就是全局最小的那個,直接返回數組下標爲0即可返回隊首元素!

3.3.1、peek 方法

peek 方法圖解實現流程如下:

peek 方法實現,直接返回數組下標爲0的元素,源碼如下:

public E peek() {
    return (size == 0) ? null : (E) queue[0];
}
3.3.2、element 方法

element 方法實現也比較簡單,直接調用了peek()方法,如果返回空值拋異常,源碼如下:

public E element() {
    E x = peek();
    if (x != null)
        return x;
    else
        //返回空值,拋異常
        throw new NoSuchElementException();
}

四、總結

在 Java 中 PriorityQueue 是一個使用數組結構來存儲元素的優先隊列,雖然它也實現了Queue接口,但是元素存取並不是先進先出,而是通過一個二叉小頂堆實現的,默認底層使用自然排序規則給插入的元素進行排序,也可以使用自定義比較器來實現排序,每次取出的元素都是隊列中權值最小的。

同時需要注意的是,PriorityQueue 不能插入null,否則報空指針異常!

五、參考

1、JDK1.7&JDK1.8 源碼

2、知乎 - CarpenterLee -深入理解Java PriorityQueue

作者:炸雞可樂
原文出處:www.pzblog.cn

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章