1. 程式人生 > >深入理解迴圈佇列----迴圈陣列實現ArrayDeque

深入理解迴圈佇列----迴圈陣列實現ArrayDeque

     我們知道佇列這種資料結構的物理實現方式主要還是兩種,一種是鏈佇列(自定義節點類),另一種則是使用陣列實現,兩者各有優勢。此處我們將要介紹的迴圈佇列其實是佇列的一種具體實現,由於一般的陣列實現的佇列結構在頻繁出隊的情況下,會產生假溢位現象,導致陣列使用效率降低,所以引入迴圈佇列這種結構。本文將從以下兩個大角度介紹迴圈佇列這種資料結構:

  • 迴圈陣列實現迴圈佇列
  • Java中具體實現容器類ArrayDeque

一、迴圈佇列
     為了深刻體會到迴圈佇列這個結構優於非迴圈佇列的地方,我們將首先介紹陣列實現的非迴圈佇列結構。佇列這種資料結構,無論你是用連結串列實現,還是用陣列實現,它都是要有兩個指標分別指向隊頭和隊尾。在我們陣列的實現方式中,用兩個int型變數用於記錄隊頭和隊尾的索引。

這裡寫圖片描述

一個佇列的初始狀態,head和tail都指向初始位置(索引為0處)。head永遠指向該佇列的隊頭元素,tail則指向該佇列最後一個元素的下一位置,當有入隊操作時:

這裡寫圖片描述
這裡寫圖片描述

當有出隊操作時:

這裡寫圖片描述

當遇到出隊操作時,head會移向下一元素位置。當然,對於這種方式入隊和出隊,隊空的判斷條件顯然是head=tail,隊滿的判斷條件是tail=array.length(陣列最後一個位置的下一位置)。顯然,這種結構最致命的缺陷就是,tail只知道向後移動,一旦到達陣列邊界就認為隊滿,但是佇列可能時刻在出隊,也就是前面元素都出隊了,tail也不知道。例如:

這裡寫圖片描述

此時tail判斷隊滿,我們暫時認為資源利用是可以接受的,但是如果接下來不斷髮生出隊操作:

這裡寫圖片描述

此時tail依然通過判斷,認為隊滿,不能入隊,這時陣列的利用率我們是不能接受的,這樣浪費很大。所以,我們引入迴圈佇列,tail可以通過mode陣列的長度實現迴歸初始位置,下面我們具體來看一下。

按照我們的想法,一旦tail到達陣列邊界,那麼可以通過與陣列長度取模返回初始位置,這種情況下判斷隊滿的條件為tail=head

這裡寫圖片描述

此時tail的值為8,取模陣列長度8得到0,發現head=tail,此時認為佇列滿員。這是合理的,但是我們忽略了一個重要的點,判斷隊空的條件也是head=tail,那麼該怎麼區分是隊空還是隊滿呢?解決辦法是,空出佇列中一個位置,如果(tail+1)%array.length=head,我們就認為隊滿,下面說明其合理性。

上面遇到的問題是,tail指向了隊尾的後一個位置,也就是新元素將要被插入的位置,如果該位置和head相等了,那麼必然說明當前狀態已經不能容納一個元素入隊(間接的說明隊滿)。因為這種情況是和隊空的判斷條件是一樣的,所以我們選擇捨棄一個節點位置,tail指向下一個元素的位置,我們使用tail+1判斷下一個元素插入之後,是否還能再加入一個元素,如果不能了說明佇列滿,不能容納當前元素入隊(其實還剩下一個空位置),看圖:

這裡寫圖片描述

tail通過取模,迴歸到初始位置,我們判斷tail+1是否等於head,如果等於說明隊滿,不允許入隊操作,當然這是犧牲了一個節點位置來實現和判斷隊空的條件進行區分。上述文字基本完成了隊迴圈佇列的理論介紹,下面我們看在Java中對該資料結構的具體實現是怎樣的。

二、雙端佇列實現類ArrayDeque
     ArrayDeque中主要有以下幾個屬性域:

transient Object[] elements;
transient int head;
transient int tail;
private static final int MIN_INITIAL_CAPACITY = 8;

elements就是我們上述介紹用於儲存佇列中每個節點,不過在ArrayDeque中該陣列長度是沒有限制的,採用一種動態擴容機制實現動態擴充陣列容量。head和tail分別代表著頭指標和尾指標。MIN_INITIAL_CAPACITY 代表著建立一個佇列的最小容量,具體使用情況在下文詳細介紹。現在我們看下它的幾個建構函式:

public ArrayDeque() {
    elements = new Object[16];
}
public ArrayDeque(int numElements) {
    allocateElements(numElements);
}

如果沒有指定顯式傳入elements的長度,則預設16。如果顯式傳入一個代表elements的長度的變數,那麼會呼叫allocateElements做一些簡單的處理,並不會簡單的將你傳入的引數用來構建elements,它會獲取最接近numElements的2的指數值,比如:numElements等於20,那麼elements的長度會為32,numElements為11,那麼對應elements的長度為16。但是如果你傳入一個小於8的引數,那麼會預設使用我們上述介紹的靜態屬性值作為elements的長度。至於為什麼這麼做,因為這麼做會大大提高我們在入隊時候的效率,我們等會兒會看到。

入隊操作
由於ArrayDeque實現了Deque,所以它是一個雙向佇列,支援從頭部或者尾部新增節點,由於內部操作類似,我們只簡單介紹從尾部新增入隊操作。涉及以下一些函式:

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

public boolean offerLast(E e) {
    addLast(e);
    return true;
}

public boolean add(E e) {
    addLast(e);
    return true;
}

顯然,主要的方法還是addLast,其實有人可能會疑問,為什麼要這麼多重複的方法呢?其實,雖然我們這個ArrayDeque它實現了雙端佇列,並且我們本篇主要把他當做佇列來研究,其實該類完全可以作為棧或者一些其他結構來使用,所以提供了一些其他的方法,但本質上還是某幾個方法。此處我們主要研究下addLast這個方法,該方法首先將你要新增的元素入隊,然後通過這條語句判斷隊是否已滿:

if ( (tail = (tail + 1) & (elements.length - 1)) == head)

這條語句的判斷條件還是比較難理解的,我們之前在構造elements元素的時候,說過它的長度一定是2的指數級,所以對於任意一個2的指數級的值減去1之後必然所有位全為1,例如:8-1之後為111,16-1之後1111。而對於tail來說,當tail+1小於等於elements.length - 1,兩者與完之後的結果還是tail+1,但是如果tail+1大於elements.length - 1,兩者與完之後就為0,回到初始位置。這種判斷佇列是否滿的方式要遠遠比我們使用符號%直接取模高效,jdk優雅的設計從此可見一瞥。接著,如果佇列滿,那麼會呼叫方法doubleCapacity擴充容量,

private void doubleCapacity() {
    assert head == tail;
    int p = head;
    int n = elements.length;
    int r = n - 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 = a;
    head = 0;
    tail = n;
}

該方法還是比較容易理解的,首先會獲取到原陣列長度,擴大兩倍構建一個空陣列,接下來就是將原陣列中的內容移動到新陣列中,下面通過截圖演示兩次移動過程:

這裡寫圖片描述

這是一個滿隊狀態,假如我們現在還需要入隊,那麼久需要擴容,擴容結果如下:

這裡寫圖片描述

其實兩次移動陣列,第一次將head索引之後的所有元素移動到新陣列中,第二次將tail到head之間的所有元素移動到新陣列中。實際上,就是在移動的時候對原來的順序進行了調整。對於addFirst只不過是將head向前移動一個位置,然後新增新元素。

出隊操作
出隊操作和入隊一樣,具有著多個不同的方法,但是內部呼叫的還是一個pollFirst方法,我們主要看下該方法的具體實現即可:

public E pollFirst() {
    int h = head;
    @SuppressWarnings("unchecked")
    E result = (E) elements[h];
    if (result == null)
        return null;
    elements[h] = null;
    head = (h + 1) & (elements.length - 1);
    return result;
}

該方法很簡單,直接獲取陣列頭部元素即可,然後head往後移動一個位置。這是出隊操作,其實刪除操作也是一種出隊,內部還是呼叫了pollFirst方法:

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

其他的一些操作
我們可以通過getFirst()或者peekFirst()獲取隊頭元素(不刪除該元素,只是檢視)。toArray方法返回內部元素的陣列形式。

public Object[] toArray() {
    return copyElements(new Object[size()]);
}

還有一些利用索引或者值來檢索具體節點的方法,由於這些操作並不是ArrayDeque的優勢,此處不再贅述了。

至此,有關ArrayDeque的簡單原理已經介該紹完了,ArrayDeque的主要優勢在於尾部新增元素,頭部出隊元素的效率是比較高的,內部使用位操作來判斷隊滿條件,效率相對有所提高,並且該結構使用動態擴容,所以對佇列長度也是沒有限制的。在具體情況下,適時選擇。