走進 JDK 系列第 16 篇java
文章相關源碼: PriorityQueue.javagit
這是 Java 集合框架的第三篇文章了,前兩篇分別解析了 ArrayList 和 LinkedList,它們分別是基於動態數組和鏈表來實現的。今天來講說 Java 中的優先級隊列 PriorityQueue
,它是基於堆實現的,後面也會介紹堆的相關概念。github
PriorityQueue 是基於堆實現的無界優先級隊列。優先級隊列中的元素順序根據元素的天然序或者構造器中提供的 Comparator。不容許 null 元素,不容許插入不可比較的元素(未實現 Comparable)。它不保證線程安全,JDK 也提供了線程安全的優先級隊列 PriorityBlockingQueue
。api
劃個重點,基於堆實現的優先級隊列。首先來看一下什麼是隊列?什麼是堆?數組
隊列其實很好理解,它是一種特殊的線性表。好比食堂排隊打飯就是一個隊列,先排隊的人先打飯,後來的同窗在隊尾排隊,打到飯的同窗從對頭離開,這就是典型的 先進先出(FIFO)隊列。隊列通常會提供 入隊 和 出隊 兩個基本操做,入隊在隊尾進行,出隊在對頭進行。Java 中隊列的父類接口是 Queue
。咱們來看一下 Queue
的 uml 圖,給咱們提供了哪些基本方法:安全
add(E) :
在隊尾添加元素offer(E) :
在隊尾添加元素。在容量受限的隊列中和 add() 表現一致。remove() :
刪除並返回隊列頭,隊列爲空時拋出異常poll() :
刪除並返回隊列頭,隊列爲空時返回 nullelement():
返回隊列頭,但不刪除,隊列爲空時拋出異常peek() :
返回隊列頭,但不刪除,隊列爲空時返回 null基本也就是對出隊和入隊操做進行了細分。PriorityQueue
是一個優先級隊列,會按天然序或者提供的 Comparator
對元素進行排序,這裏使用的是堆排序,因此優先級隊列是基於堆來實現的。若是你瞭解堆的概念,就能夠跳過下一節了。若是你不知道什麼是堆,仔細閱讀下一節,否則是沒辦法理解 PriorityQueue
的源碼的。微信
堆實際上是一種特殊的二叉樹,它具有以下兩個特徵:框架
對於一個高度爲 k 的二叉樹,若是它的 0 到 k-1 層都是滿的,且最後一層的全部子節點都是在左邊那麼他就是徹底二叉樹。用數組實現的徹底二叉樹能夠很方便的根據父節點的下標獲取它的兩個子節點。下圖就是一個徹底二叉樹:函數
堆就是一個徹底二叉樹。頂部是最小元素的叫小頂堆,頂部是最大元素的叫大頂堆。PriorityQueue
是小頂堆。對照上面的堆結構,對於任意父節點,如下標爲 4
的節點 5 爲例,它的兩個子節點下標分別爲 2*4+1
和 2*4+2
。關於徹底二叉樹和堆,記住下面幾個結論,都是後面的源碼分析中要用到的:oop
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; // 最大容量
複製代碼
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()
。
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);
}
複製代碼
第二步就是堆化了。隊尾增長元素爲何要從新堆化呢?看下面這個圖:
左邊是一個堆,我要在隊尾添加一個元素 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()
是出隊操做,也就是移除隊隊頭元素。想一想一下,一個徹底二叉樹,你把堆頂移除了,它就不是一個徹底二叉樹了,也就沒辦法去堆化了。源碼中是這樣處理的,移除隊頭元素以後,暫時把隊尾元素移到隊頭,這樣它又是一個徹底二叉樹了,就能夠進行堆化了。下面這個圖更容易理解:
這裏的堆化操做很顯然應該是 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()
方法實現。
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 源碼解析,掃碼關注我吧!