集合系列 Queue:PriorityQueue

PriorityQueue 是一個優先級隊列,其底層原理採用二叉堆實現。我們先來看看它的類聲明:

public class PriorityQueue<E> extends AbstractQueue<E>
    implements java.io.Serializable

PriorityQueue 繼承了 AbstractQueue 抽象類,具有隊列的基本特性。

二叉堆

由於 PriorityQueue 底層採用二叉堆來實現,所以我們有必要先介紹下二叉堆。

二叉堆從結構上來看其實就是一個完全二叉樹或者近似完全二叉樹。二叉堆的每個左子樹和右子樹都是一個二叉堆。當父節點總是大於或等於一個子節點的鍵值時稱其爲「最大堆」,當父節點總是小於或等於任何一個子節點的鍵值時稱其爲「最小堆」。

        最小堆                               最大堆
             1                                11                          
         /        \                        /        \ 
       2           3                    9           10
    /     \      /     \             /     \      /     \ 
   4      5     6       7           5      6     7      8
  / \     / \                      / \     / \
 8  9 10 11                       1   2   3   4 

在二叉堆上常見的操作有:插入、刪除,我們下面將詳細介紹這兩種操作。

插入

在二叉堆上插入節點的思路爲:在數組的末尾插入新節點,然後不斷上浮與父節點比較,直到找到合適的位置,使當前子樹符合二叉堆的性質。二叉堆的插入操作最壞情況下需要從葉子上移到根節點,所以其時間複雜度爲 O(logN)。

例如我們有下面這個最小堆,當我們插入一個值爲 6 的節點,其調整過程如下:

     最小堆                              
             1                                                       
         /        \                       
       5           7                  
    /     \      /     \             
   8      10   48     55         
  / \     / \                     
 11 9   15                     

在數組末尾插入新節點 6。

     最小堆                              
             1                                                       
         /        \                       
       5           7                  
    /     \      /     \             
   8      10   48     55         
  / \     / \                     
 11 9   15   6    

做上浮操作不斷與父節點比較,直到其大於等於父節點。首先,6 < 10,所以交換位置。

     最小堆                              
             1                                                       
         /        \                       
       5           7                  
    /     \      /     \             
   8     → 6   48     55         
  / \     / \                     
 11 9   15   10   

刪除

二叉堆刪除節點的思路爲:

  • 首先,如果刪除的是末尾節點,那麼直接刪除即可,不需要調整。
  • 接着,將刪除節點與末尾節點交換數據,之後刪除末尾節點,接着對刪除節點不斷做下沉操作。
  • 最後,繼續對刪除節點做上浮操作。

例如我們有下面這個最小堆,當我們刪除一個值爲 7 的節點,其調整過程如下:

            1                                                       
         /        \                       
       5           7                  
    /     \      /     \             
   8      10   48     55         
  / \     / \   / \                   
 11 9   15  16 50 52   

首先,將刪除節點與末尾節點交換數據,並刪除末尾節點。

              1                                                       
         /        \                       
       5           52                  
    /     \      /   \             
   8      10   48     55         
  / \     / \   / \                   
 11 9   15  16 50    

接着,對刪除節點(52)不斷做下沉操作。首先比較 52 與 48 和 55 的大小,將 52 與 48 交換。接着比較 52 與 50 的大小,將 52 與 50 交換。結果爲:

             1                                                       
         /        \                       
       5           48                  
    /     \      /   \             
   8      10    50     55         
  / \     / \   / \                   
 11 9   15  16 52  

最後,對刪除節點(15)不斷做上浮操作,結果爲:

             1                                                       
         /        \                       
       5          15                  
    /     \      /     \             
   8      10   48     55         
  / \     / \                     
 11 9 

這裏有一個細節,爲什麼做下沉操作之後,還需要做一次上浮操作呢?這是因爲我們無法確定末尾節點的值與刪除節點的父節點的大小關係。

在上面的例子中,我們刪除的節點是 7,刪除節點的父節點爲1,末尾節點是 52。因爲末尾節點和刪除節點在同一個子樹上,所以我們能夠確定刪除節點的父節點一定小於末尾節點,即 1 一定小於 52。所以我們不需要做上浮操作。

但是如果末尾節點與刪除節點並不是在一顆子樹上呢?此時我們無法判斷末尾節點與刪除節點父節點之間的大小關係,此時可能出現下面這種情況:

             1                                                       
         /        \                       
       5           230                  
    /     \      /   \             
   8      10   240     255         
  / \     / \   / \      / \           
 11 9   15  16 241 242 256 260 
/ \
27 33

此時如果我們刪除 255 節點,那麼刪除節點的父節點爲 230,末尾節點爲 33。此時末尾節點就小於刪除節點的父節點,需要做上浮操作。

原理

瞭解完二叉樹的插入、刪除原理,我們再來看看 PriorityQueue 的源碼就很簡單了。

類成員變量

PriorityQueue 一共有 7 個構造方法。

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) { 
    if (initialCapacity < 1)
        throw new IllegalArgumentException();
    this.queue = new Object[initialCapacity];
    this.comparator = comparator;
}
// 傳入集合初始值
public PriorityQueue(Collection<? extends E> c) {
    if (c instanceof SortedSet<?>) {
        SortedSet<? extends E> ss = (SortedSet<? extends E>) c;
        this.comparator = (Comparator<? super E>) ss.comparator();
        initElementsFromCollection(ss);
    }
    else if (c instanceof PriorityQueue<?>) {
        PriorityQueue<? extends E> pq = (PriorityQueue<? extends E>) c;
        this.comparator = (Comparator<? super E>) pq.comparator();
        initFromPriorityQueue(pq);
    }
    else {
        this.comparator = null;
        initFromCollection(c);
    }
}
// 傳入PriorityQueue初始值
public PriorityQueue(PriorityQueue<? extends E> c) {
    this.comparator = (Comparator<? super E>) c.comparator();
    initFromPriorityQueue(c);
}
// 傳入SortedSet初始值
public PriorityQueue(SortedSet<? extends E> c) {
    this.comparator = (Comparator<? super E>) c.comparator();
    initElementsFromCollection(c);
}

PriorityQueue 的構造方法比較多,但其功能都類似。如果傳入的是普通集合,那麼會將其數據複製,最後調用 heapify 方法進行二叉堆的初始化操作。但如果傳入的數據是 SortedSet 或 PriorityQueue 這些已經有序的數據,那麼就直接按照順序複製數據即可。

核心方法

對於 PriorityQueue 來說,其核心方法有:獲取、插入、刪除、擴容。

獲取

PriorityQueue 沒有查詢方法,取而代之的是獲取數據的 peek 方法。

public E peek() {
    return (size == 0) ? null : (E) queue[0];
}

如果隊列爲空,那麼返回 null 值,否則返回隊列的第一個元素(即最大或最小值)。
插入
PriorityQueue 的數據插入過程,其實就是往二叉堆插入數據的過程。

public boolean add(E e) {
    return offer(e);
}
    
public boolean offer(E e) {
    if (e == null)
        throw new NullPointerException();
    modCount++;
    int i = size;
    // 1.容量不夠,進行擴容
    if (i >= queue.length)
        grow(i + 1);
    size = i + 1;
    // 2.如果隊列爲空那麼直接插入第一個節點
    // 否則插入末尾節點後進行上浮操作
    if (i == 0)
        queue[0] = e;
    else
        siftUp(i, e);
    return true;
}

private void siftUp(int k, E x) {
    if (comparator != null)
        siftUpUsingComparator(k, x);
    else
        // 3.採用默認的比較器
        siftUpComparable(k, x);
}
    
private void siftUpComparable(int k, E x) {
    Comparable<? super E> key = (Comparable<? super E>) x;
    while (k > 0) {
        // 4.將插入節點與父節點比較
        // 如果插入節點大於等於父節點,那麼說明符合最小堆性質
        // 否則交換插入節點與父節點的值,一直到堆頂
        int parent = (k - 1) >>> 1;
        Object e = queue[parent];
        if (key.compareTo((E) e) >= 0)
            break;
        queue[k] = e;
        k = parent;
    }
    queue[k] = key;
}

插入的代碼最終的邏輯是在 siftUpComparable 方法中,而該方法其實就是我們上面所說二叉堆插入邏輯的實現。

刪除

PriorityQueue 的數據刪除過程,其實就是將數據從二叉堆中刪除的過程。

public boolean remove(Object o) {
    int i = indexOf(o);
    if (i == -1)
        return false;
    else {
        removeAt(i);
        return true;
    }
}
    
private E removeAt(int i) {
    // assert i >= 0 && i < size;
    modCount++;
    int s = --size;
    // 1.刪除的是末尾節點,那麼直接刪除即可
    if (s == i) // removed last element
        queue[i] = null;
    else {
        E moved = (E) queue[s];
        queue[s] = null;
        // 2.對刪除節點做下沉操作
        siftDown(i, moved);
        if (queue[i] == moved) {
            // 3.queue[i] == moved 表示刪除節點根本沒下沉
            // 意思是其就是該子樹最小的節點
            // 這種情況下就需要進行上浮操作
            // 因爲可能出現刪除節點父節點大於刪除節點的情況
            siftUp(i, moved);
            if (queue[i] != moved)
                return moved;
        }
    }
    return null;
}
    
private void siftDown(int k, E x) {
    if (comparator != null)
        siftDownUsingComparator(k, x);
    else
        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) {
        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];
        if (key.compareTo((E) c) <= 0)
            break;
        queue[k] = c;
        k = child;
    }
    queue[k] = key;
}

PriorityQueue 的刪除操作需要注意的點是其下沉之後,還需要根據條件做一次上浮操作。關於爲什麼要做上浮操作,上面講解二叉堆的時候已經提到了。
offer
因爲 PriorityQueue 是隊列,所以有 offer 操作。

對於 offer 操作來說,其實就是相當於往數組未插入數據,其邏輯細節我們在插入 add 方法中已經說到。

poll
因爲 PriorityQueue 是隊列,同樣會有 poll 操作。而 poll 操作其實就是彈出隊列頭結點,相當於刪除頭結點。

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

之前我們說過刪除節點的邏輯,即拿末尾節點值替代刪除節點,然後做下沉操作。但是這裏因爲刪除節點是根節點了,所以不需要做上浮操作。

擴容
當往隊列插入數據時,如果隊列容量不夠則會進行擴容操作。

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

PriorityQueue 的擴容非常簡單。如果原來的容量小於 64,那麼擴容爲原來的兩倍,否則擴容爲原來的 1.5 倍。

總結

PriorityQueue 的實現是建立在二叉堆之上的,所以弄懂二叉堆就相當於弄懂了 PriorityQueue。PriorityQueue 默認情況下是最小堆,我們可以改變傳入的比較器,使其成爲最大堆。

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