深入理解循環隊列----循環數組實現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的主要優勢在於尾部添加元素,頭部出隊元素的效率是比較高的,內部使用位操作來判斷隊滿條件,效率相對有所提高,並且該結構使用動態擴容,所以對隊列長度也是沒有限制的。在具體情況下,適時選擇。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章