走進 JDK 之 PriorityQueue
走進 JDK 系列第 16 篇
文章相關原始碼: PriorityQueue.java
這是 Java 集合框架的第三篇文章了,前兩篇分別解析了ArrayList 和LinkedList,它們分別是基於動態陣列和連結串列來實現的。今天來說說 Java 中的優先順序佇列 PriorityQueue
,它是基於堆實現的,後面也會介紹堆的相關概念。
概述

PriorityQueue 是基於堆實現的無界優先順序佇列。優先順序佇列中的元素順序根據元素的自然序或者構造器中提供的 Comparator。不允許 null 元素,不允許插入不可比較的元素(未實現 Comparable)。它不保證執行緒安全,JDK 也提供了執行緒安全的優先順序佇列 PriorityBlockingQueue
。
劃個重點,基於堆實現的優先順序佇列。首先來看一下什麼是佇列?什麼是堆?
佇列
佇列其實很好理解,它是一種特殊的線性表。比如食堂排隊打飯就是一個佇列,先排隊的人先打飯,後來的同學在隊尾排隊,打到飯的同學從對頭離開,這就是典型的 先進先出(FIFO)佇列 。佇列一般會提供 入隊 和 出隊 兩個基本操作,入隊在隊尾進行,出隊在對頭進行。Java 中佇列的父類介面是 Queue
。我們來看一下 Queue
的 uml 圖,給我們提供了哪些基本方法:

add(E) : offer(E) : remove() : poll() : element(): peek() :
基本也就是對出隊和入隊操作進行了細分。 PriorityQueue
是一個優先順序佇列,會按自然序或者提供的 Comparator
對元素進行排序,這裡使用的是堆排序,所以優先順序佇列是基於堆來實現的。如果你瞭解堆的概念,就可以跳過下一節了。如果你不知道什麼是堆,仔細閱讀下一節,不然是沒辦法理解 PriorityQueue
的原始碼的。
堆
堆其實是一種特殊的二叉樹,它具備如下兩個特徵:
- 堆是一個完全二叉樹
- 堆中每個節點的值都必須小於等於(或者大於等於)其子節點的值
對於一個高度為 k 的二叉樹,如果它的 0 到 k-1 層都是滿的,且最後一層的所有子節點都是在左邊那麼他就是完全二叉樹。用陣列實現的完全二叉樹可以很方便的根據父節點的下標獲取它的兩個子節點。下圖就是一個完全二叉樹:

堆就是一個完全二叉樹。頂部是最小元素的叫小頂堆,頂部是最大元素的叫大頂堆。 PriorityQueue
是小頂堆。對照上面的堆結構,對於任意父節點,以下標為 4
的節點 5 為例,它的兩個子節點下標分別為 2*4+1
和 2*4+2
。關於完全二叉樹和堆,記住下面幾個結論,都是後面的原始碼分析中要用到的:
- 沒有子節點的節點叫做葉子節點
- 下標為
n
的父節點的兩個左右子節點的下標分別是2*n+1
和2*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; } 複製程式碼
這一類建構函式都很簡單,直接給 queue
和 comparator
賦值即可。對於給定初始元素的建構函式就沒有這麼簡單了,因為給定的初始集合並不一定滿足堆的結構,我們需要將其構造成堆,這個過程稱之為 堆化 。
PriorityQueue 可以直接根據 SortedSet
和 PriorityQueue
來構造堆,由於初始集合本來就是有序的,所以無需進行堆化。如果構造器引數是任意 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%
第二步就是堆化了。隊尾增加元素為什麼要重新堆化呢?看下面這個圖:

左邊是一個堆,我要在隊尾新增一個元素 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()
是出隊操作,也就是移除隊隊頭元素。想想一下,一個完全二叉樹,你把堆頂移除了,它就不是一個完全二叉樹了,也就沒辦法去堆化了。原始碼中是這樣處理的,移除隊頭元素之後,暫時把隊尾元素移到隊頭,這樣它又是一個完全二叉樹了,就可以進行堆化了。下面這個圖更容易理解:

這裡的堆化操作很顯然應該是 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
的處理方式一致,將隊尾節點暫時填充到刪除的位置,形成完全二叉樹再進行堆化。

這裡的堆化過程和 poll
有一些不一致。首先進行 shiftDown()
,自上而下堆化。 shiftDown()
完成之後比較 queue[i] == moved
,如果不相等,說明節點 i 向下交換了,它找到了自己的位置。但是如果相等,則說明節點 i 沒有向下交換,也就是節點 i 的值比它的子節點都要小。但這並不能說明它一定比它的父節點大。所以,這種情況還需要再自下而上堆化,以保證可以完全符合堆的特性。
總結
說了半天 PriorityQueue ,其實都是在說堆。如果你對堆很熟悉的話,PriorityQueue 的原始碼很好理解。當然不熟悉也沒關係,藉著原始碼正好可以學習一下堆的基本概念。最後簡單總結一下優先佇列:
O(log(n))
PriorityQueue 就說到這裡了。下一篇應該會寫 Set
相關。
文章首發微信公眾號: 秉心說
, 專注 Java 、 Android 原創知識分享,LeetCode 題解。
更多 JDK 原始碼解析,掃碼關注我吧!
