計算機程序的思惟邏輯 (48) - 剖析ArrayDeque

本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連接 html

前面咱們介紹了隊列Queue的兩個實現類LinkedListPriorityQueue,LinkedList還實現了雙端隊列接口Deque,Java容器類中還有一個雙端隊列的實現類ArrayDeque,它是基於數組實現的。java

咱們知道,通常而言,因爲須要移動元素,數組的插入和刪除效率比較低,但ArrayDeque的效率卻很是高,它是怎麼實現的呢?本節咱們就來詳細探討。算法

咱們首先來看ArrayDeque的用法,而後來分析其實現原理,最後總結分析其特色。編程

用法

ArrayDeque實現了Deque接口,同LinkedList同樣,它的隊列長度也是沒有限制的,在LinkedList一節咱們介紹過Deque接口,這裏簡要回顧一下。數組

Deque擴展了Queue,有隊列的全部方法,還能夠看作棧,有棧的基本方法push/pop/peek,還有明確的操做兩端的方法如addFirst/removeLast等。bash

ArrayDeque有以下構造方法:微信

public ArrayDeque() public ArrayDeque(int numElements) public ArrayDeque(Collection<? extends E> c) 複製代碼

numElements表示元素個數,初始分配的空間會至少容納這麼多元素,但空間不是正好numElements這麼大,待會咱們會看其實現細節。數據結構

ArrayDeque能夠看作一個先進先出的隊列,好比:數據結構和算法

Queue<String> queue = new ArrayDeque<>();

queue.offer("a");
queue.offer("b");
queue.offer("c");

while(queue.peek()!=null){
    System.out.print(queue.poll() +" ");    
}
複製代碼

輸出爲:spa

a b c
複製代碼

也能夠將ArrayDeque看作一個先進後出、後進先出的棧,好比:

Deque<String> stack = new ArrayDeque<>();

stack.push("a");
stack.push("b");
stack.push("c");

while(stack.peek()!=null){
    System.out.print(stack.pop()+" ");    
}
複製代碼

輸出爲:

c b a 
複製代碼

還可使用其通用的操做兩端的方法,好比:

Deque<String> deque = new ArrayDeque<>();

deque.addFirst("a");
deque.offerLast("b");
deque.addLast("c");
deque.addFirst("d");

System.out.println(deque.getFirst()); //d
System.out.println(deque.peekLast()); //c
System.out.println(deque.removeFirst()); //d
System.out.println(deque.pollLast()); //c 
複製代碼

ArrayDeque的用法是比較簡單的,下面咱們來看其實現原理。

實現原理

內部組成

ArrayDeque內部主要有以下實例變量:

private transient E[] elements;
private transient int head;
private transient int tail;
複製代碼

elements就是存儲元素的數組。ArrayDeque的高效來源於head和tail這兩個變量,它們使得物理上簡單的從頭至尾的數組變爲了一個邏輯上循環的數組,避免了在頭尾操做時的移動。咱們來解釋下循環數組的概念。

循環數組

對於通常數組,好比arr,第一個元素爲arr[0],最後一個爲arr[arr.length-1]。但對於ArrayDeque中的數組,它是一個邏輯上的循環數組,所謂循環是指元素到數組尾以後能夠接着從數組頭開始,數組的長度、第一個和最後一個元素都與head和tail這兩個變量有關,具體來講:

  1. 若是head和tail相同,則數組爲空,長度爲0。
  2. 若是tail大於head,則第一個元素爲elements[head],最後一個爲elements[tail-1],長度爲tail-head,元素索引從head到tail-1。
  3. 若是tail小於head,且爲0,則第一個元素爲elements[head],最後一個爲elements[elements.length-1],元素索引從head到elements.length-1。
  4. 若是tail小於head,且大於0,則會造成循環,第一個元素爲elements[head],最後一個是elements[tail-1],元素索引從head到elements.length-1,而後再從0到tail-1。

咱們來看一些圖示。

第一種狀況,數組爲空,head和tail相同,以下所示:

第二種狀況,tail大於head,以下所示,都包含三個元素:

第三種狀況,tail爲0,以下所示:

第四狀況,tail不爲0,且小於head,以下所示:

理解了循環數組的概念,咱們來看ArrayDeque一些主要操做的代碼,先來看構造方法。

構造方法

默認構造方法的代碼爲:

public ArrayDeque() {
    elements = (E[]) new Object[16];
}
複製代碼

分配了一個長度爲16的數組。

若是有參數numElements,代碼爲:

public ArrayDeque(int numElements) {
    allocateElements(numElements);
}
複製代碼

不是簡單的分配給定的長度,而是調用了allocateElements,代碼爲:

private void allocateElements(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
    }
    elements = (E[]) new Object[initialCapacity];
}
複製代碼

這段代碼看上去比較複雜,但主要就是在計算應該分配的數組的長度initialCapacity,計算邏輯是這樣的:

  • 若是numElements小於MIN_INITIAL_CAPACITY,則分配的數組長度就是MIN_INITIAL_CAPACITY,它是一個靜態常量,值爲8。
  • 在numElements大於等於8的狀況下,分配的實際長度是嚴格大於numElements而且爲2的整數次冪的最小數。好比,若是numElements爲10,則實際分配16,若是numElements爲32,則爲64。

爲何要爲2的冪次數呢?咱們待會會看到,這樣會使得不少操做的效率很高。

爲何要嚴格大於numElements呢?由於循環數組必須時刻至少留一個空位,tail變量指向下一個空位,爲了容納numElements個元素,至少須要numElements+1個位置。

這段代碼的晦澀之處在於:

initialCapacity |= (initialCapacity >>>  1);
initialCapacity |= (initialCapacity >>>  2);
initialCapacity |= (initialCapacity >>>  4);
initialCapacity |= (initialCapacity >>>  8);
initialCapacity |= (initialCapacity >>> 16);

initialCapacity++;
複製代碼

這究竟在幹什麼?其實,它是在將initialCapacity左邊最高位的1複製到右邊的每一位,這種複製相似於病毒複製,是1傳二、2傳四、4傳8式的指數級複製,最後再執行initialCapacity++就能夠獲得比initialCapacity大且爲2的冪次方的最小的數。咱們在剖析包裝類(中)一節介紹過Integer的一些二進制操做,其中就有很是相似的代碼:

public static int highestOneBit(int i) {
    // HD, Figure 3-1
    i |= (i >>  1);
    i |= (i >>  2);
    i |= (i >>  4);
    i |= (i >>  8);
    i |= (i >> 16);
    return i - (i >>> 1);
}
複製代碼

算法描述都在Hacker's Delight這本書中。

看最後一個構造方法:

public ArrayDeque(Collection<? extends E> c) {
    allocateElements(c.size());
    addAll(c);
}
複製代碼

一樣調用allocateElements分配數組,隨後調用了addAll,而addAll只是循環調用了add,下面咱們來看add的實現。

從尾部添加

add方法的代碼爲:

public boolean add(E e) {
    addLast(e);
    return true;
}
複製代碼

addLast的代碼爲:

public void addLast(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[tail] = e;
    if ( (tail = (tail + 1) & (elements.length - 1)) == head)
        doubleCapacity();
}
複製代碼

將元素添加到tail處,而後tail指向下一個位置,若是隊列滿了,則調用doubleCapacity擴展數組。tail的下一個位置是:(tail + 1) & (elements.length - 1),若是與head相同,則隊列就滿了。

須要進行與操做是要保證索引在正確範圍,與(elements.length - 1)相與就能夠獲得下一個正確位置,是由於elements.length是2的冪次方,(elements.length - 1)的後幾位全是1,不管是正數仍是負數,與(elements.length - 1)相與都能獲得指望的下一個正確位置。

好比說,若是elements.length爲8,則(elements.length - 1)爲7,二進制爲0111,對於負數-1,與7相與,結果爲7,對於正數8,與7相與,結果爲0,都能達到循環數組中找下一個正確位置的目的。

這種位操做是循環數組中一種常見的操做,效率也很高,後續代碼中還會看到。

doubleCapacity將數組擴大爲兩倍,代碼爲:

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;
    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 = (E[])a;
    head = 0;
    tail = n;
}
複製代碼

分配一個長度翻倍的新數組a,將head右邊的元素拷貝到新數組開頭處,再拷貝左邊的元素到新數組中,最後從新設置head和tail,head設爲0,tail設爲n。

咱們來看一個例子,假設原長度爲8,head和tail爲4,如今開始擴大數組,擴大先後的結構以下圖所示:

add是在末尾添加,咱們再看在頭部添加的代碼。

從頭部添加

addFirst方法的代碼爲:

public void addFirst(E e) {
    if (e == null)
        throw new NullPointerException();
    elements[head = (head - 1) & (elements.length - 1)] = e;
    if (head == tail)
        doubleCapacity();
}
複製代碼

在頭部添加,要先讓head指向前一個位置,而後再賦值給head所在位置。head的前一個位置是:(head - 1) & (elements.length - 1)。剛開始head爲0,若是elements.length爲8,則(head - 1) & (elements.length - 1)的結果爲7。好比說,執行以下代碼:

Deque<String> queue = new ArrayDeque<>(7);
queue.addFirst("a");
queue.addFirst("b"); 
複製代碼

執行完後,內部結構會以下圖所示:

介紹完了添加,下面來看刪除。

從頭部刪除

removeFirst方法的代碼爲:

public E removeFirst() {
    E x = pollFirst();
    if (x == null)
        throw new NoSuchElementException();
    return x;
}
複製代碼

pollFirst的代碼爲:

public E pollFirst() {
    int h = head;
    E result = 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;
}
複製代碼

代碼比較簡單,將原頭部位置置爲null,而後head置爲下一個位置,下一個位置爲:(h + 1) & (elements.length - 1)

從尾部刪除

removeLast方法的代碼爲:

public E removeLast() {
    E x = pollLast();
    if (x == null)
        throw new NoSuchElementException();
    return x;
}
複製代碼

pollLast的代碼爲:

public E pollLast() {
    int t = (tail - 1) & (elements.length - 1);
    E result = elements[t];
    if (result == null)
        return null;
    elements[t] = null;
    tail = t;
    return result;
}
複製代碼

t爲最後一個位置,result爲最後一個元素,將該位置置爲null,而後修改tail指向前一個位置,最後返回原最後一個元素。

查看長度

ArrayDeque沒有單獨的字段維護長度,其size方法的代碼爲:

public int size() {
    return (tail - head) & (elements.length - 1);
}
複製代碼

經過該方法便可計算出size。

檢查給定元素是否存在

contains方法的代碼爲:

public boolean contains(Object o) {
    if (o == null)
        return false;
    int mask = elements.length - 1;
    int i = head;
    E x;
    while ( (x = elements[i]) != null) {
        if (o.equals(x))
            return true;
        i = (i + 1) & mask;
    }
    return false;
}
複製代碼

就是從head開始遍歷並進行對比,循環過程當中沒有使用tail,而是到元素爲null就結束了,這是由於在ArrayDeque中,有效元素不容許爲null。

toArray方法

toArray方法的代碼爲:

public Object[] toArray() {
    return copyElements(new Object[size()]);
}
複製代碼

copyElements的代碼爲:

private <T> T[] copyElements(T[] a) {
    if (head < tail) {
        System.arraycopy(elements, head, a, 0, size());
    } else if (head > tail) {
        int headPortionLen = elements.length - head;
        System.arraycopy(elements, head, a, 0, headPortionLen);
        System.arraycopy(elements, 0, a, headPortionLen, tail);
    }
    return a;
}
複製代碼

若是head小於tail,就是從head開始拷貝size()個,不然,拷貝邏輯與doubleCapacity方法中的相似,先拷貝從head到末尾的部分,而後拷貝從0到tail的部分。

原理小結

以上就是ArrayDeque的基本原理,內部它是一個動態擴展的循環數組,經過head和tail變量維護數組的開始和結尾,數組長度爲2的冪次方,使用高效的位操做進行各類判斷,以及對head和tail的維護。

ArrayDeque特色分析

ArrayDeque實現了雙端隊列,內部使用循環數組實現,這決定了它有以下特色:

  • 在兩端添加、刪除元素的效率很高,動態擴展須要的內存分配以及數組拷貝開銷能夠被平攤,具體來講,添加N個元素的效率爲O(N)。
  • 根據元素內容查找和刪除的效率比較低,爲O(N)。
  • 與ArrayList和LinkedList不一樣,沒有索引位置的概念,不能根據索引位置進行操做。

ArrayDeque和LinkedList都實現了Deque接口,應該用哪個呢?若是隻須要Deque接口,從兩端進行操做,通常而言,ArrayDeque效率更高一些,應該被優先使用,不過,若是同時須要根據索引位置進行操做,或者常常須要在中間進行插入和刪除,則應該選LinkedList。

小結

本節介紹了ArrayDeque的用法和實現原理,用法上,它實現了雙端隊列接口,能夠做爲隊列、棧、或雙端隊列使用,相比LinkedList效率要更高一些,實現原理上,它採用動態擴展的循環數組,使用高效率的位操做。

至此,關於隊列相關的容器類就介紹完了,咱們介紹了LinkedList, PriorityQueue和ArrayDeque。PriorityQueue和ArrayDeque都是基於數組的,但都不是簡單的數組,經過一些特殊的約束、輔助成員和算法,它們都能高效的解決一些特定的問題,這大概是計算機程序中使用數據結構和算法的一種藝術吧。

關於Map和Set,咱們介紹了兩種實現,一種基於哈希:HashMap/HashSet,另一種基於樹:TreeMap/TreeSet,下面兩節,咱們再來介紹兩種實現,它們有什麼特色呢?


未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心原創,保留全部版權。

相關文章
相關標籤/搜索