1. 程式人生 > 實用技巧 >ArrayDeque API 與演算法分析

ArrayDeque API 與演算法分析

ArrayDeque 是雙端佇列的動態陣列實現,可以當作棧和佇列來使用。作為棧時,它的效率比 Stack 更高,作為佇列時,效率比 LinkedList 更高。ArrayDeque 大部分操作的時間複雜度都是 O(1)。本文將介紹 ArrayDeque 的核心 API 以及其內部實現的演算法。

資料結構

ArrayDeque 的資料結構非常簡單,包含一個用於儲存資料的 Object 陣列和兩個分別指向佇列頭和尾的索引。

transient Object[] elements; // non-private to simplify nested class access
transient int head;
transient int tail;

構造方法

ArrayDeque 有 3 個構造方法。

ArrayDeque()

ArrayDeque() 方法很簡單,僅僅是建立了一個長度為 16 的陣列 elements。即 ArrayDeque 的容量為 16。

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

ArrayDeque(int numElements)

此方法傳入一個整數 numElements,elements 陣列的長度取決於引數 numElements,但 elemenets 的長度不是直接取 numElements,而是通過 calculateSize(int numElements) 計算之後的值。

public ArrayDeque(int numElements) {
    allocateElements(numElements);
}

private void allocateElements(int numElements) {
    elements = new Object[calculateSize(numElements)];
}

calculateSize(int) 是一個靜態工具方法,傳入一個整數 numElements,返回一個恰好大於 numElements 且為 2 的整數次方的值。注意是大於,例如:輸入 4,輸出的為 8。這裡的演算法很巧妙,先通過 5 次位操作將 numElements 最高位左側的二進位制位全部置為 1,再加 1 得到返回值。

事實上 elements 陣列在 ArrayDeque 的整個生命週期中長度都為 2 的整數次方。

    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; // 這裡的位操作關注 numElements 的最高位即可
            initialCapacity |= (initialCapacity >>>  1);
            initialCapacity |= (initialCapacity >>>  2);
            initialCapacity |= (initialCapacity >>>  4);
            initialCapacity |= (initialCapacity >>>  8);
            initialCapacity |= (initialCapacity >>> 16); // 最高位左側的二進位制位已經全部為 1
            initialCapacity++; // 得到一個大於 numElements 且恰好為 2 的整數次冪的數

            if (initialCapacity < 0)   // 檢查是否溢位
                initialCapacity >>>= 1;// Good luck allocating 2 ^ 30 elements
        }
        return initialCapacity;
    }

ArrayDeque(Collection<? extends E> c)

此方法可以傳入一個集合,集合中的元素會按照集合中元素的迭代順序新增到 ArrayDeque 的尾部。呼叫的為 addLast(e) 方法。

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

核心 API

ArrayDeque 的 API 是圍繞如何在一個動態迴圈陣列進行的一系列操作展開。動態體現在 ArrayDeque 的容量是可以動態擴充套件的,迴圈體現在 elements 陣列在邏輯上首尾相連線的(物理上 elements 是一個數組,頭是 0 ,尾是 elements.length-1)。一系列操作指的是雙端佇列的操作。

下面通過繪製 ArrayDeque 內部儲存結構的方式描述其核心 API 和演算法。

例項化

ArrayDeque<Character> deque= new ArrayDeque();

例項化時 head 和 tail 指標均指向了索引 0 位置。head 總是從右往左迴圈移動,tail 總是從左往右迴圈移動。頭部新增的下一個元素總是存放在 head 下一個移向的索引位置,尾部新增的下一個元素總是存放在當前 tail 所在的位置。

尾部新增 addLast(e)

deque.addLast('A');

addLast(e) 方法往雙端佇列的尾部新增元素,將元素放入 elements[tail],tail 往右移動一個位置。如果已經 tail 超出了索引範圍,則指向 0 。迴圈利用陣列。需要注意的是 ArrayDeque 不能存放 null 元素,不過 Deque 的另一實現 LinkedList 卻可以。

JDK 原始碼使用了高效的位操作實現了這個邏輯。

    public void addLast(E e) {
        if (e == null) // ArrayDeque 不能存放元素 null
            throw new NullPointerException();
        elements[tail] = e;
        // 因為 elements.length 總是 2 的整數次方,所以 elements.length - 1 所有二進位制位均為 1,
        // 若 tail+1 < elements.length,則 (tail + 1) & (elements.length - 1) 結果即為 tail+1;
        // 若 tail+1 == elements.length,表示下標越界,則 (tail + 1) & (elements.length - 1) 值為 0
        // 這裡可以解釋為什麼長度要設定為 2 的整數次方,目的是為了能夠高效地進行位操作
        // 後面還判斷了是否等於 head,相等則表示 elements 陣列放滿了,此時觸發擴容
        if ( (tail = (tail + 1) & (elements.length - 1)) == head)
            doubleCapacity(); // this.elements 陣列長度擴容為原來的兩倍
    }

頭部新增 addFirst(e)

deque.addFirst('B');

與尾部新增類似,不過移動的是 head 指標,head 指標從往左迴圈移動 1 個位置,放入元素 elements[head](移動指標和放入元素的順序與 addLast() 相反)。與 addLast(e) 類似,原始碼中同樣使用了高效的位操作來進行移動,進行了擴容判斷。

獲取頭部元素 getFirst() / peekFirst() 和獲取尾部元素 getLast() / peekLast()

deque.getFirst(); // 返回 B
deque.getLast(); // 返回 B
deque.peekFirst(); // 返回 A
deque.peekLast(); // 返回 A

從前面尾部新增元素可知,雙端佇列中有元素的前提下,head 總是指向剛剛往雙端佇列頭部新增的元素的位置,tail 總是指向存放下一個往雙端佇列新增元素的位置。因此,getFirst() / peekFirst() 返回的是 head 位置的元素,而 getLast() / peekLast() 返回的是上一次 tail 所在元素的位置。

其中 getXXX() 會對獲取的值進行判斷,若為空,則丟擲 NoSuchElementException。前面提到,ArrayDeque 不能存放 null 元素,因此值為空的另一層意思是這個 ArrayDeque 沒有存放元素。而 peekXXX() 不會對返回的值進行判斷,若獲取到 null 則表示這個雙端佇列中沒有元素。可以根據需求來選擇相應的 API。

    public E getFirst() {
        @SuppressWarnings("unchecked")
        E result = (E) elements[head];
        if (result == null)
            throw new NoSuchElementException();
        return result;
    }

    /**
     * @throws NoSuchElementException {@inheritDoc}
     */
    public E getLast() {
        @SuppressWarnings("unchecked")
        E result = (E) elements[(tail - 1) & (elements.length - 1)];
        if (result == null)
            throw new NoSuchElementException();
        return result;
    }

    public E peekFirst() {
        // elements[head] is null if deque empty
        return (E) elements[head];
    }

    @SuppressWarnings("unchecked")
    public E peekLast() {
        return (E) elements[(tail - 1) & (elements.length - 1)];
    }

雙端佇列中還有若干 API 與上面提到的 API 等價,例如:peek() 與 peekFirst()。更多說明可以參考這篇:Java 雙端佇列介面 Deque

移除元素 removeFirst() / pollFirst() 和 removeLast() / pollLast()

上面兩組 API 中,removeFirst() 呼叫了 pollFirst(),removeLast() 呼叫了 pollLast(),只是增加了返回值為空時丟擲異常的內容。

與新增元素相反,移除元素時,pollFirst() 先獲取當前元素,然後移動 head 指標往右迴圈移動 1 個位置;pollLast() 則是先向左迴圈移動 1 個位置,再獲取元素。這裡需要注意,head 指標和 tail 指標在 ArrayDeque 有元素的時候才移動,沒有元素的時候不會移動。

    public E pollFirst() {
        int h = head;
        @SuppressWarnings("unchecked")
        E result = (E) elements[h];
        // Element is null if deque empty
        if (result == null) // 返回值 null,不移動 head
            return null;
        elements[h] = null;     // 這一句看起來是多餘的,但它的目的是讓陣列 elements 不再引用移除的物件,這樣 GC 就能夠將物件回收了。
        head = (h + 1) & (elements.length - 1); // head 迴圈右移 1 個位置
        return result;
    }
deque.pollLast(); // 返回 'A',tail 往左迴圈移動 1 個位置,head 不動

deque.pollLast(); // 返回 'B',tail 往左迴圈移動 1 個位置,head 不動

deque.pollFirst(); // 返回 null, tail 和 head 均不動

擴容

不斷呼叫 addFirst(e) 和 addLast(e) 往裡面增加元素,當元素增加到 elements 陣列存放不下(即雙端佇列有元素的情況下 head == tail) 時,ArrayDeque 的容量會自動增加 1 倍。

deque.addLast('A');
deque.addLast('B');
...

deque.addLast('M');

elements 陣列達到如下狀態,此時還差 1 個元素就滿了。

再往裡新增一個元素。

deque.addFirst('L');

呼叫 deque.add('L') 時,元素 'L' 先放入到陣列中,tail 往右迴圈移動 1 個位置。然後進行判斷,發現 tail 等於 head,觸發擴容。擴容過程在 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); // p=head 以及其右邊部分先放到新陣列左側
        System.arraycopy(elements, 0, a, r, p); // p=head 左邊部分尾隨。
        elements = a;
        head = 0;
        tail = n;
    }

小結

ArrayDeque 在不考慮擴容的情況下,ArrayDeque 頭部和尾部操作都僅僅是移動一下索引,效率極高,時間複雜度為 O(1)。

ArrayDeque 的實現中沒有執行緒同步操作,因此 ArrayDeque 是非執行緒安全的,併發訪問一個 ArrayDeque 可能導致錯誤。

ArrayDeque 儲存結構是一個 Object 陣列,陣列的長度總是 2 的整數次方,這是 ArrayDeque 某些程式碼能夠進行位操作的基礎。