Queue之ArrayDeque原始碼解析
1 Queue
1.1 定義
前面講了Stack
是一種先進後出的資料結構:棧
,那麼對應的Queue
是一種先進先出(First In First Out
)的資料結構:佇列
對比一下Stack
,Queue
是一種先進先出的容器,它有兩個口,從一個口放入元素,從另一個口獲取元素。如果把棧比作一個木桶,那麼佇列就是一個管道。
佇列有兩個口,一個負責入隊另一個負責出隊,所以會有先進先出的效果。
當然我們說ArrayDeque
是一個雙向佇列,佇列的兩個口都可以入隊和出隊操作。再進一步說,其實ArrayDeque
public class ArrayDeque<E> extends AbstractCollection<E>
implements Deque<E>, Cloneable, Serializable
從ArrayDeque
的定義可以看到,它繼承AbstractCollection
,實現了Deque,Cloneable,Serializable
介面。Deque
介面在LinkedList
中見過,LinkedList
也是實現Deque
介面的。Deque
Queue
介面,那麼什麼是雙端佇列
,就是在佇列的同一端即可以入隊又可以出隊,所以Deque
既可以作為佇列又可以作為棧使用。但是今天這裡是講Queue
佇列,所以就只看單向佇列的一些原理和實現。
來看下Queue
介面:
public interface Queue<E> extends Collection<E> { // 增加一個元素到隊尾,如果佇列已滿,則丟擲一個IIIegaISlabEepeplian異常 boolean add(E e); // 新增一個元素到隊尾並返回true,如果佇列已滿,則返回false boolean offer(E e); // 移除並返回佇列頭部的元素,如果佇列為空,則丟擲一個NoSuchElementException異常 E remove(); // 移除並返問佇列頭部的元素,如果佇列為空,則返回null E poll(); // 返回佇列頭部的元素,如果佇列為空,則丟擲一個NoSuchElementException異常 E element(); // 返問佇列頭部的元素,如果佇列為空,則返回null E peek(); }
看到Queue
的定義,發現它和Stack
的方法是非常相似的。
但是ArrayDeque
並不是一個固定大小的佇列,每次佇列滿了就會進行擴容,除非擴容至超過int
的邊界,才會丟擲異常。所以這裡的add
和offer
幾乎是沒有區別的。
1.2 底層儲存
當然從ArrayDeque
的命名就可以看出底層是用陣列實現的(而LinkedList
則是用連結串列實現的佇列),來主要看一下ArrayDeque
// 底層用陣列儲存元素
private transient E[] elements;
// 佇列的頭部元素索引(即將pop出的一個)
private transient int head;
// 佇列下一個要新增的元素索引
private transient int tail;
// 最小的初始化容量大小,需要為2的n次冪
private static final int MIN_INITIAL_CAPACITY = 8;
這裡需要注意的是MIN_INITIAL_CAPACITY
,這個初始化容量必須為2的n次冪
。為什麼必須要是2的n次冪呢,HashMap
要求其底層陣列的初始容量必須為2的n次冪
。那麼ArrayDeque
這裡又是基於什麼考慮呢,我們下面再看。
tail
不是最後一個元素的索引,是下一個要新增的元素索引,也就是最後一個元素+1。
1.3 構造方法
/**
* 預設構造方法,陣列的初始容量為16
*/
public ArrayDeque() {
elements = (E[]) new Object[16];
}
/** * 使用一個指定的初始容量構造一個ArrayDeque
*/
public ArrayDeque( int numElements) {
allocateElements(numElements);
}
/** * 構造一個指定Collection集合引數的ArrayDeque
*/
public ArrayDeque(Collection<? extends E> c) {
allocateElements(c.size());
addAll(c);
}
/**
* 分配合適容量大小的陣列,確保初始容量是大於指定numElements的最小的2的n次冪
*/
private void allocateElements(int numElements) {
int initialCapacity = MIN_INITIAL_CAPACITY;
// 找到大於指定容量的最小的2的n次冪
// Find the best power of two to hold elements.
// Tests "<=" because arrays aren't kept full.
// 如果指定的容量小於初始容量8,則執行一下if中的邏輯操作
if (numElements >= initialCapacity) {
initialCapacity = numElements;
initialCapacity |= (initialCapacity >>> 1);
initialCapacity |= (initialCapacity >>> 2);
initialCapacity |= (initialCapacity >>> 4);
initialCapacity |= (initialCapacity >>> 8);
initialCapacity |= (initialCapacity >>> 16);
initialCapacity++;
if (initialCapacity < 0) // Too many elements, must back off
initialCapacity >>>= 1; // Good luck allocating 2 ^ 30 elements
}
elements = (E[]) new Object[initialCapacity];
}
我們來仔細分析一下,回想一下在HashMap
中分析過的,2的n次冪
和2的n次冪-1
的二進位制是什麼樣子的呢,再來看一下:
2^n
轉換為二進位制是什麼樣子呢:
2^1 = 10
2^2 = 100
2^3 = 1000
2^n = 1(n個0)
再來看下2^n-1
的二進位制是什麼樣子的:
2^1 - 1 = 01
2^2 - 1 = 011
2^3 - 1 = 0111
2^n - 1 = 0(n個1)
看下程式碼initialCapacity++
是什麼意思呢,就是說initialCapity+1
之後才是2的n次冪
,那麼此時的initialCapacity
是什麼呢?就是上面的2^n - 1(initialCapacity + 1 = 2^n)
,也就是說怎麼做到使initialCapacity
為2^n - 1
呢,那就上面的4次>>>
和|
操作了。
>>>
是無符號右移,意思就是將一個運算元轉換為二進位制後,將後n
位移除,高位補0
。舉個例子:11的二進位制1101,11 >>> 2就是:(1)將後兩位01移除,(2)高位補0,最後得0011。
|
是按位或操作,意思是把兩個運算元分別轉換為二進位制,如果兩個運算元的位都有1則為1,全為0則為0,舉個例子:兩個數8和9的二進位制分別為1000和1001,1000 | 1001 = 1001。
理解了>>>
和|
操作後,再來看下上面程式碼中的4個>>>
和|
是什麼意思,>>>
將一個數低位變為1,|
後,最後整個數的二進位制都變為1。
舉個例子:如果initialCapacity=9,9轉換為二進位制為:1001,那麼經過第一輪>>>1後為:100,然後1001 | 100 = 1101;經過第二輪>>>2後變為:0011,然後1101 | 0011 = 1111,1111轉換為10進位制+1後等於16(2^4),到此經過這一系列的操作就完成獲取大於指定容量最小的2的n次冪。如果給定的initialCapacity夠大的話,最終將變為1111111111111111111111111111111(31位1),當然最後為了防止溢位(initialCapacity<0),將initialCapacity右移1位變成2的30次方,那麼什麼時候initialCapacity
會小於0呢,那就是當initialCapacity
作為int值<<1越界後。
其實在HashMap
中也有這麼一個目的的操作,只不過其程式碼不是這麼實現的,它是通過一個迴圈,每次迴圈只右移1位。來回憶一下:
// 確保容量為2的n次冪,是capacity為大於initialCapacity的最小的2的n次冪
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
那麼這兩種方法有什麼區別呢?HashMap
中的這種寫法更容量理解,而ArrayDeque
中的效果更高(最多經過4次位移和或操作+1次加一操作)。
1.4 入隊(新增元素到隊尾)
/** * 增加一個元素,如果佇列已滿,則丟擲一個IIIegaISlabEepeplian異常
*/
public boolean add(E e) {
// 呼叫addLast方法,將元素新增到隊尾
addLast(e);
return true;
}
/** * 新增一個元素
*/
public boolean offer(E e) {
// 呼叫offerLast方法,將元素新增到隊尾
return offerLast(e);
}
/**
* 在隊尾新增一個元素
*/ public boolean offerLast(E e) {
// 呼叫addLast方法,將元素新增到隊尾
addLast(e);
return true;
}
/**
* 將元素新增到隊尾
*/
public void addLast(E e) {
// 如果元素為null,咋丟擲空指標異常
if (e == null)
throw new NullPointerException();
// 將元素e放到陣列的tail位置
elements[tail ] = e;
// 判斷tail和head是否相等,如果相等則對陣列進行擴容
if ( (tail = (tail + 1) & ( elements.length - 1)) == head)
// 進行兩倍擴容
doubleCapacity();
}
這裡,( (tail = (tail + 1) & ( elements.length - 1)) == head)
這句程式碼是關鍵,為什麼會這樣寫呢。正常的新增元素後應該是將tail+1
對不對,但是佇列的刪除和新增是不在同一端的,什麼意思呢,我們畫個圖看一下。
我們假設佇列的初始容量是8,初始佇列添加了4個元素A、B、C、D,分別在陣列0、1、2、3
的下標位置,如左圖,此時的head
對應陣列下標0,tail對應陣列下標4。當佇列經過一系列的入隊和出隊後,就會變成右圖的樣子,此時的head對應陣列下標3,tail對應陣列下標7 。那麼問題來了,如果這個時候再增加一個元素到陣列下標7的位置,此時理論上tail+1=8,也就是已經越界,需要對陣列進行擴容了,但是我們看下陣列0、1、2的位置由於出隊操作,這三個位置是空的,如果此時就進行擴容會造成空間的浪費。
我們回想一下ArrayList
為了減少空間浪費,它是怎麼做的呢,是通過陣列copy
,每次刪除元素都會將被刪除元素索引後面位置的元素向前移動一位。但是這樣做又造成了效率不高。怎麼辦呢,能不能換一種思路,我們可以把陣列想象成為一個首尾相連的環
,陣列的第一個位置索引0的位置和陣列的最後一個位置索引length-1的位置是挨在一起的(雙向連結串列)。
需要注意的是head
不是陣列的第一個位置索引0
,tail
也不是陣列的最後一個位置索引length-1
,head
和tail
實際上是一個指標,隨著出隊和入隊操作不斷的移動。如果tail
移動到length-1
之後,如果陣列的第一個位置0
沒有元素,那麼需要將tail指向0
,依次向後指向。此時當tail
如果等於head
的時候會有兩種情況,一個是空佇列,另一個就是佇列將要滿了(只有tail處還有空位置),只要判斷佇列將要滿了的時候,就進行陣列擴容。
再來回憶下2的n次冪
和2的n次冪-1
轉換成二進位制後的樣子:
2^n轉換為二進位制是什麼樣子呢:
2^1 = 10
2^2 = 100
2^3 = 1000
2^n = 1(n個0)
再來看下2^n-1的二進位制是什麼樣子的:
2^1 - 1 = 01
2^2 - 1 = 011
2^3 - 1 = 0111
2^n - 1 = 0(n個1)
會發現什麼,如果(2^n) & (2^n-1) = 0
對不對,舉個例子,2^3=8
和2^3 - 1=7
,8和7的二進位制分別為1000和0111,1000 & 0111 = 0000,也就是0嘛。
現在再來看這段程式碼( (tail = (tail + 1) & ( elements.length - 1)) == head)
是不是開始理解了,(tail + 1) & ( elements.length - 1)
,當tail
等於length-1
的時候也就是(2^n) & (2^n-1)
,此時將結果0賦值給tail,也就是這個時候tail指向了0,印證了前面我們的說法。那麼如果tail
不是陣列的最後一個位置的索引的時候呢,比如tail=5,那麼5 & (elements.length - 1)實際上就等於5對不對,因為tail永遠不會大於length的,所以當tail不等於length-1的時候,(tail + 1) & ( elements.length - 1)
的結果就是tail+1
(我們在HashMap中分析過h & (2^n - 1)
就相當於h % 2^n
)。
所以從這裡看,我們就可以將ArrayDeque看做是一個雙向迴圈佇列,之所以這裡用"看做"這個詞,是因為這裡只是程式碼邏輯上"環",而非儲存結構上的"環"。
至此,我們終於明白( (tail = (tail + 1) & ( elements.length - 1)) == head)
這句程式碼的意義,我們再來總結下這句程式碼的效果:(1)將tail+1操作,(2)如果tail+1已經越界,則將tail賦值為0,(3)當tail和head指向同一個索引時,則說明需要進行擴容。既然是需要擴容,那麼我們就來看看具體是怎麼擴容的吧。
/** * 陣列將要滿了的時候(tail==head)將,陣列進行2倍擴容
*/ private void doubleCapacity() {
// 驗證head和tail是否相等
assert head == tail;
int p = head ;
// 記錄陣列的長度
int n = elements .length;
// 計算head後面的元素個數,這裡沒有采用jdk中自帶的英文註釋right,是因為所謂佇列的上下左右,只是我們看的方位不同而已,如果上面畫的圖,這裡就應該是left而非right
int r = n - p; // number of elements to the right of p
// 將陣列長度擴大2倍
int newCapacity = n << 1;
// 如果此時長度小於0,則丟擲IllegalStateException異常,什麼時候newCapacity會小於0呢,前面我們說過了int值<<1越界
if (newCapacity < 0)
throw new IllegalStateException( "Sorry, deque too big" );
// 建立一個長度是原陣列大小2倍的新陣列
Object[] a = new Object[newCapacity];
// 將原陣列head後的元素都拷貝值新陣列
System. arraycopy(elements, p, a, 0, r);
// 將原陣列head前的元素都拷貝到新陣列
System. arraycopy(elements, 0, a, r, p);
// 將新陣列賦值給elements
elements = (E[])a;
// 重置head為陣列的第一個位置索引0
head = 0;
// 重置tail為陣列的最後一個位置索引+1((length - 1) + 1)
tail = n;
}
這裡需要清除,為什麼要進行兩次陣列copy,因為陣列被head
分成了兩段。。。後面有元素,前面也有元素。。。
1.5 出隊(移除並返回隊頭元素)
/** * 移除並返回佇列頭部的元素,如果佇列為空,則丟擲一個NoSuchElementException異常
*/
public E remove() {
// 呼叫removeFirst方法,移除隊頭的元素
return removeFirst();
}
/**
* @throws NoSuchElementException {@inheritDoc}
*/
public E removeFirst() {
// 呼叫pollFirst方法,移除並返回隊頭的元素
E x = pollFirst();
// 如果佇列為空,則丟擲NoSuchElementException異常
if (x == null)
throw new NoSuchElementException();
return x;
}
/**
* 移除並返問佇列頭部的元素,如果佇列為空,則返回null
*/
public E poll() {
// 呼叫pollFirst方法,移除並返回隊頭的元素
return pollFirst();
}
public E pollFirst() {
int h = head ;
// 取出陣列隊頭位置的元素
E result = elements[h]; // Element is null if deque empty
// 如果陣列隊頭位置沒有元素,則返回null值
if (result == null)
return null;
// 將陣列隊頭位置置空,也就是刪除元素
elements[h] = null; // Must null out slot
// 將head指標往前移動一個位置
head = (h + 1) & (elements .length - 1);
// 將隊頭元素返回
return result;
}
pollFirst
中的 (h + 1) & (elements . length - 1)
相比已經不用再具體解釋了吧,不懂的看看上面的解釋吧,當然這是為了處理臨界的情況。
1.6 返回隊頭元素(不刪除)
/** * 返回佇列頭部的元素,如果佇列為空,則丟擲一個NoSuchElementException異常
*/
public E element() {
// 呼叫getFirst方法,獲取隊頭的元素
return getFirst();
}
/**
* @throws NoSuchElementException {@inheritDoc}
*/
public E getFirst() {
// 取得陣列head位置的元素
E x = elements[head ];
// 如果陣列head位置的元素為null,則丟擲異常
if (x == null)
throw new NoSuchElementException();
return x;
}
/** * 返回佇列頭部的元素,如果佇列為空,則返回null
*/ public E peek() {
// 呼叫peekFirst方法,獲取隊頭的元素
return peekFirst();
}
public E peekFirst() {
// 取得陣列head位置的元素並返回
return elements [head]; // elements[head] is null if deque empty
}
ArrayDeque
作為Queue
的操作方法,主要的難點則在於要把ArrayDeque
看成一個雙向迴圈佇列,head
和tail
指標是如何移動的,又是如果做到環
的,如果還不是很明白一定要對照圖解多看幾遍,並動手做一下位移和或操作。
當然ArrayDeque
作為一個雙向佇列還有一些Deque
特有的方法,以及作為Stack
的一些方法