棧、佇列、雙端佇列都是非常經典的資料結構。和連結串列、陣列不同,這三種資料結構的抽象層次更高。它只描述了資料結構有哪些行為,而並不關心資料結構內部用何種思路、方式去組織。
本篇博文重點關注這三種資料結構在java中的對應設計,並且對ArrayDeque的原始碼進行分析。
<!– more –>
概念
先來簡單回顧下大學時的資料結構知識。
- 什麼是棧?資料排成一個有序的序列,只能從一個口彈出資料或加入資料。即後進先出(LIFO)。
- 什麼是佇列?資料同樣排成一個有序的序列,資料只能在隊尾加入,在隊頭彈出。即先進先出(FIFO)。
- 什麼是雙端佇列?資料同樣排成一個有序的序列,只能從前後兩個口插入或刪除資料。結合了棧和佇列的特點。
這三樣東西都可以通過陣列或連結串列來實現。從這種表述就能發現,似乎連結串列和陣列比這三個更“偏底層”。
仔細思考不難發現,棧、佇列、雙端佇列僅僅是描述了介面行為,是一種抽象資料型別;而陣列、連結串列則描述的是資料的具體在記憶體中的組織方式。
java中棧、佇列、雙端佇列
java中的棧
public
class Stack<E> extends Vector<E> {
/* */
}
java的確有一個叫做Stack
的類,它繼承自Vector
。
個人以為,jdk的這種設計不是很妥當。前面分析過,Stack從概念上是一種抽象資料型別,可以有多種實現方式。因此,將其設計為介面更為合適。
jdk的這種設計導致:
- Stack只有陣列這一種實現方式,沒有辦法改用其它的實現方式。
- Stack繼承自Vector,耦合太緊,同時擁有Vector的大量不屬於Stack模型的方法,破壞隱藏。
此外,Vector本身現在已經不建議使用了。
而且,jdk自己也說了,Stack這個類,設計的不好,不推薦使用:
* <p>A more complete and consistent set of LIFO stack operations is
* provided by the {@link Deque} interface and its implementations, which
* should be used in preference to this class. For example:
* Deque<Integer> stack = new ArrayDeque<Integer>();}</pre>
好在Deque像是棧和佇列的組合,也能當棧使用。因此,在java中,有棧的使用需求時,使用Deque代替。
而且,偶然間在jdk中看到這樣一個工具函式Collections.asLifoQueue
:
public static <T> Queue<T> asLifoQueue(Deque<T> deque) {
return new AsLIFOQueue<>(deque);
}
它將Deque包裝成一個Lifo的佇列。LIFO?那不就是棧麼!也就是說,得到的雖然是Queue介面,但是行為是LIFO。
java中的佇列
public interface Queue<E> extends Collection<E> {
/* ... */
}
jdk中佇列的設計沒有什麼問題,是一個介面。
雖然名字叫Queue,但是這個jdk中Queue介面指代的範圍更廣。從它的子介面及實現類來看,有這樣幾種含義:
- FIFO佇列。也就是資料結構中的先進先出佇列。
- 優先佇列。也就是資料結構中的大頂堆或小頂堆。
- 阻塞佇列。也是佇列,只不過某些方法在沒有元素時或隊滿時會阻塞,併發中使用的一種結構。
再來看它的幾種實現:
- FIFO佇列。FIFO佇列的實現其實是按照Deque實現的了,有LinkedList和ArrayDeque。
- 優先佇列。PriorityQueue。
- 阻塞佇列。這個和併發關係更大,這裡先不談。
java中的雙端佇列
雙端佇列的定義也是介面:
public interface Deque<E> extends Queue<E> {
/* ... */
}
Deque也是Queue,Deque也能當Queue用,沒有太多額外開銷。所以jdk沒有單獨實現Queue。
Deque有兩種實現類:
- LinkedList。也就是連結串列,java的連結串列同時實現了Deque。
- ArrayDeque。Deque的陣列實現。為什麼不在ArrayList中一把實現Deque介面?
也很簡單,實現方式不同。
Deque也有阻塞佇列版本的實現,這裡也先不談。
ArrayDeque原始碼分析
實現思路
我先來總結下ArrayDeque的實現思路。
首先,ArrayDeque內部是擁有一個內部陣列用於儲存資料。
其次,假設採用簡單的方案,即佇列陣列按順序在陣列裡排開,那麼:
- 由於ArrayDeque的兩端都能增刪資料,那麼把資料插入到佇列頭部也就是陣列頭部,會造成O(N)的時間複雜度。
- 假設只再隊尾加入而只從隊頭刪除,隊頭就會空出越來越多的空間。
那麼該怎麼實現?也很簡單。將物理上的連續陣列迴繞,形成邏輯上的一個 環形結構。即a[size – 1]的下一個位置是a[0].
之後,使用頭尾指標標識佇列頭尾,在佇列頭尾增刪元素,反映在頭尾指標上就是這兩個指標繞著環賽跑。
這個是大體思路,具體的還有一些細節,後面程式碼裡分析:
- head和tail的具體概念是如何界定?
- 如果判斷隊滿和隊空?
- 陣列滿了怎麼辦?
屬性
先來看內部屬性。elements域就是儲存資料的原生陣列。
head和tail分別分別為頭尾指標。
transient Object[] elements; // non-private to simplify nested class access
transient int head;
transient int tail;
建構函式
public ArrayDeque() {
elements = new Object[16];
}
public ArrayDeque(int numElements) {
allocateElements(numElements);
}
private void allocateElements(int numElements) {
elements = new Object[calculateSize(numElements)];
}
- 如果沒有指定內部陣列的初始大小,預設為16.
- 如果指定了內部陣列的初始大小,則通過
calculateSize
函式二次計算出大小。
來看calculateSize
函式:
private static final int MIN_INITIAL_CAPACITY = 8;
private static int calculateSize(int numElements) {
int initialCapacity = MIN_INITIAL_CAPACITY;
// Find the best power of two to hold elements.
// Tests "<=" because arrays aren`t kept full.
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
}
return initialCapacity;
}
- 如果小於8,那麼大小就為8.
- 如果大於等於8,則按照2的冪對齊。
入隊
看兩個入隊方法:
public void addFirst(E e) {
if (e == null)
throw new NullPointerException();
elements[head = (head - 1) & (elements.length - 1)] = e;
if (head == tail)
doubleCapacity();
}
public void addLast(E e) {
if (e == null)
throw new NullPointerException();
elements[tail] = e;
if ( (tail = (tail + 1) & (elements.length - 1)) == head)
doubleCapacity();
}
addFirst
是從隊頭插入,addLast
是從隊尾插入。
從該程式碼能夠分析出head和tail指標的含義:
- head指標指向的是隊頭元素的位置,除非佇列為空。
- tail指標指向的是隊尾元素後一格的位置,即尾後指標。
因此:
- 如果佇列沒有滿,tail指向的是空位置,head指向的是隊頭元素,永遠不可能一樣。
- 但是當佇列滿時,tail迴繞會追上head,當tail等於head時,表示佇列滿了。
理清楚了這一點,上面的程式碼也就十分容易理解了:
- 對應位置插入位置,移動指標。
- 當tail和head相等時,擴容。
最後,這句:
(head - 1) & (elements.length - 1)
曾經在《原始碼|jdk原始碼之HashMap分析(二)》中分析過,假如被餘數是2的冪次方,那麼模運算就能夠優化成按位與運算。
也即相當於:
(head - 1) % elements.length
出隊
public E pollFirst() {
int h = head;
@SuppressWarnings("unchecked")
E result = (E) elements[h];
// Element is null if deque empty
if (result == null)
return null;
elements[h] = null; // Must null out slot
head = (h + 1) & (elements.length - 1);
return result;
}
public E pollLast() {
int t = (tail - 1) & (elements.length - 1);
@SuppressWarnings("unchecked")
E result = (E) elements[t];
if (result == null)
return null;
elements[t] = null;
tail = t;
return result;
}
出隊的程式碼很顯然,不多解釋。
擴容
private void doubleCapacity() {
assert head == tail;
int p = head;
int n = elements.length;
int r = n - p; // number of elements to the right of p
int newCapacity = n << 1;
// 擴容後的大小小於0(溢位),也即佇列最大應該是2的30次方
if (newCapacity < 0)
throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
System.arraycopy(elements, p, a, 0, r);
System.arraycopy(elements, 0, a, r, p);
elements = a;
head = 0;
tail = n;
}
擴容的實現為按 兩倍 擴容原陣列,將原數倍拷貝過去。
其中值得注意的是對陣列大小溢位的處理。
迭代器
之前《原始碼|jdk原始碼之LinkedList與modCount欄位》中分析過,
容器的實現中,所有修改過容器結構的操作都需要修改modCount
欄位。
這樣迭代器迭代過程中,通過前後比對該欄位來判斷容器是否被動過,及時丟擲異常終止迭代以免造成不可預測的問題。
不過,在ArrayDeque的插入方法中並沒有修改modeCount欄位。從ArrayDeque的迭代器的實現中可以看出來:
private class DeqIterator implements Iterator<E> {
/**
* Index of element to be returned by subsequent call to next.
*/
private int cursor = head;
/**
* Tail recorded at construction (also in remove), to stop
* iterator and also to check for comodification.
*/
private int fence = tail;
}
原來,ArrayDeque直接使用了head和tail頭尾指標,就能判斷出迭代過程中是否發生了變化。