Java集合(七) Queue詳解

卡農Canon發表於2017-12-21

  在開始很重要的集合Map的學習之前,我們先學習一下集合Queue,主要介紹一下集合Queue的幾個重要的實現類。雖然它的內容不多,但它牽涉到了極其重要的資料結構:佇列。所以這次主要針對佇列這種資料結構的使用來介紹Queue中的實現類。

佇列

  佇列與棧是相對的一種資料結構。只允許在一端進行插入操作,而在另一端進行刪除操作的線性表。棧的特點是後進先出,而佇列的特點是先進先出。佇列的用處很大,但大多都是在其他的資料結構中,比如,樹的按層遍歷,圖的廣度優先搜尋等都需要使用佇列做為輔助資料結構。

單向佇列

  單向佇列比較簡單,只能向隊尾新增元素,從隊頭刪除元素。比如最典型的排隊買票例子,新來的人只能在佇列後面,排到最前邊的人才可以買票,買完了以後,離開隊伍。這個過程是一個非常典型的佇列。
  定義佇列的介面:

public interface Queue {
    public boolean add(Object elem); // 將一個元素放到隊尾,如果成功,返回true
    public Object remove(); // 將一個元素從隊頭刪除,如果成功,返回true
}
複製程式碼

  一個佇列只要能入隊,和出隊就可以了。這個佇列的介面就定義好了,具體的實現有很多種辦法,例如,可以使用陣列做儲存,可以使用連結串列做儲存。
  其實大家頁可以看一下JDK原始碼,在java.util.Queue中,可以看到佇列的定義。只是它是泛型的。基本上,Queue.java中定義的介面都是進隊,出隊。只是行為有所不同。例如,remove如果失敗,會丟擲異常,而poll失敗則返回null,但它倆其實都是從隊頭刪除元素。

雙向佇列

  如果一個佇列的頭和尾都支援元素入隊,出隊,那麼這種佇列就稱為雙向佇列,英文是Deque。大家可以通過java.util.Deque來檢視Deque的介面定義,這裡節選一部分:

public interface Deque<E> extends Queue<E> {
    /**
     * Inserts the specified element at the front of this deque if it is
     * possible to do so immediately without violating capacity restrictions,
     * throwing an {@code IllegalStateException} if no space is currently
     * available.  When using a capacity-restricted deque, it is generally
     * preferable to use method {@link #offerFirst}.
     *
     * @param e the element to add
     * @throws IllegalStateException if the element cannot be added at this
     *         time due to capacity restrictions
     * @throws ClassCastException if the class of the specified element
     *         prevents it from being added to this deque
     * @throws NullPointerException if the specified element is null and this
     *         deque does not permit null elements
     * @throws IllegalArgumentException if some property of the specified
     *         element prevents it from being added to this deque
     */
    void addFirst(E e);


    void addLast(E e);


    E removeFirst();

    E removeLast();
}
複製程式碼

  最重要的也就是這4個,一大段英文,沒啥意思,其實就是說,addFirst是向隊頭新增元素,如果不滿足條件就會拋異常,然後定義了各種情況下丟擲的異常型別。
  只要記住佇列是先進先出的資料結構就好了,今天不必要把這些東西都掌握,一步步來。

Queue

  Queue也繼承自Collection,用來存放等待處理的集合,這種場景一般用於緩衝、併發訪問。我們先看一下官方的定義和類結構:

/**
 * A collection designed for holding elements prior to processing.
 * Besides basic {@link java.util.Collection Collection} operations,
 * queues provide additional insertion, extraction, and inspection
 * operations.  Each of these methods exists in two forms: one throws
 * an exception if the operation fails, the other returns a special
 * value (either {@code null} or {@code false}, depending on the
 * operation).  The latter form of the insert operation is designed
 * specifically for use with capacity-restricted {@code Queue}
 * implementations; in most implementations, insert operations cannot
 * fail.
 */
複製程式碼

  意思大體說Queue是用於在處理之前儲存元素的集合。 除了基本的集合操作,佇列提供了額外的插入、提取和檢查操作。 每個方法都有兩種形式:一種是在操作失敗時丟擲一個異常,另一個則返回一個特殊值(根據操作的不同)(返回null或false)。 插入操作的後一種形式是專門為有容量限制的佇列實現而設計的; 在大多數實現中,插入操作不會失敗。

public interface Queue<E> extends Collection<E> {
    //插入(丟擲異常)
    boolean add(E e);
    //插入(返回特殊值)
    boolean offer(E e);
    //移除(丟擲異常)
    E remove();
    //移除(返回特殊值)
    E poll();
    //檢查(丟擲異常)
    E element();
    //檢查(返回特殊值)
    E peek();
}
複製程式碼

  可以看出Queue介面沒有什麼神祕面紗,都不需要揭開。不存在花裡胡哨,就只有這6個方法。額外的新增、刪除、查詢操作。
  值得一提的是,Queue是個介面,它提供的add,offer方法初衷是希望子類能夠禁止新增元素為null,這樣可以避免在查詢時返回null究竟是正確還是錯誤。實際上大多數Queue的實現類的確響應了Queue介面的規定,比如ArrayBlockingQueue,PriorityBlockingQueue等等。
  但還是有一些實現類沒有這樣要求,比如LinkedList。
  雖然 LinkedList 沒有禁止新增 null,但是一般情況下 Queue 的實現類都不允許新增 null 元素,為啥呢?因為poll(),peek()方法在異常的時候會返回 null,你新增了null 以後,當獲取時不好分辨究竟是否正確返回。

PriorityQueue

  PriorityQueue又叫做優先順序佇列,儲存佇列元素的順序不是按照及加入佇列的順序,而是按照佇列元素的大小進行重新排序。因此當呼叫peek()或pool()方法取出佇列中頭部的元素時,並不是取出最先進入佇列的元素,而是取出佇列的最小元素。
  我們剛剛才說到佇列的特點是先進先出,為什麼這裡就按照大小順序排序了呢?我們還是先看一下它的介紹,直接翻譯過來:

基於優先順序堆的無界的優先順序佇列。
PriorityQueue的元素根據自然排序進行排序,或者按佇列構建時提供的 Comparator進行排序,具體取決於使用的構造方法。
優先佇列不允許 null 元素。
通過自然排序的PriorityQueue不允許插入不可比較的物件。
該佇列的頭是根據指定排序的最小元素。
如果多個元素都是最小值,則頭部是其中的一個元素——任意選取一個。
佇列檢索操作poll、remove、peek和element訪問佇列頭部的元素。
優先佇列是無界的,但有一個內部容量,用於管理用於儲存佇列中元素的陣列的大小。
基本上它的大小至少和佇列大小一樣大。
當元素被新增到優先佇列時,它的容量會自動增長。增長策略的細節沒有指定。
複製程式碼

  一句話概括,PriorityQueue使用了一個高效的資料結構:堆。底層是使用陣列儲存資料。還會進行排序,優先將元素的最小值存到隊頭。

PriorityQueue的排序方式

  PriorityQueue中的元素可以預設自然排序或者通過提供的Comparator(比較器)在佇列例項化時指定的排序方式進行排序。關於自然排序與Comparator(比較器)可以參考我的上一篇文章Java集合(六) Set詳解的講解。所以這裡的用法就不復述了。
  需要注意的是,當PriorityQueue中沒有指定的Comparator時,加入PriorityQueue的元素必須實現了Comparable介面(元素是可以進行比較的),否則會導致 ClassCastException。

PriorityQueue本質

  PriorityQueue 本質也是一個動態陣列,在這一方面與ArrayList是一致的。看一下它的構造方法:

 public PriorityQueue(int initialCapacity) {
        this(initialCapacity, null);
    }

public PriorityQueue(Comparator<? super E> comparator) {
        this(DEFAULT_INITIAL_CAPACITY, comparator);
    }

public PriorityQueue(int initialCapacity,
                         Comparator<? super E> comparator) {
        // Note: This restriction of at least one is not actually needed,
        // but continues for 1.5 compatibility
        if (initialCapacity < 1)
            throw new IllegalArgumentException();
        this.queue = new Object[initialCapacity];
        this.comparator = comparator;
    }
複製程式碼
  • PriorityQueue呼叫預設的構造方法時,使用預設的初始容量(DEFAULT_IITIAL_CAPACITY = 11)建立一個PriorityQueue,並根據其自然順序來排序其元素(使用加入其中的集合元素實現的Comparable)。
  • 當使用指定容量的構造方法時,使用指定的初始容量建立一個 PriorityQueue,並根據其自然順序來排序其元素(使用加入其中的集合元素實現的Comparable)
  • 當使用指定的初始容量建立一個 PriorityQueue,並根據指定的比較器comparator來排序其元素。當新增元素到集合時,會先檢查陣列是否還有餘量,有餘量則把新元素加入集合,沒餘量則呼叫 grow()方法增加容量,然後呼叫siftUp將新加入的元素排序插入對應位置。
      除了這些,還要注意的是:
      1.PriorityQueue不是執行緒安全的。如果多個執行緒中的任意執行緒從結構上修改了列表, 則這些執行緒不應同時訪問 PriorityQueue 例項,這時請使用執行緒安全的PriorityBlockingQueue 類。
      2.不允許插入 null 元素。
      3.PriorityQueue實現插入方法(offer、poll、remove() 和 add 方法) 的時間複雜度是O(log(n)) ;實現 remove(Object) 和 contains(Object) 方法的時間複雜度是O(n) ;實現檢索方法(peek、element 和 size)的時間複雜度是O(1)。所以在遍歷時,若不需要刪除元素,則以peek的方式遍歷每個元素。
      4.方法iterator()中提供的迭代器並不保證以有序的方式遍歷PriorityQueue中的元素。

Deque

  Deque介面是Queue介面子介面。它代表一個雙端佇列。Deque介面在Queue介面的基礎上增加了一些針對雙端新增和刪除元素的方法。LinkedList也實現了Deque介面,所以也可以被當作雙端佇列使用。也可以看前面的 Java集合(四) LinkedList詳解來理解Deque介面。
  先瞄一眼類結構:

public interface Deque<E> extends Queue<E> {
    //從頭部插入(拋異常)
    void addFirst(E e);
    //從尾部插入(拋異常)
    void addLast(E e);
    //從頭部插入(特殊值)
    boolean offerFirst(E e);
    //從尾部插入(特殊值)
    boolean offerLast(E e);
    //從頭部移除(拋異常)
    E removeFirst();
    //從尾部移除(拋異常)
    E removeLast();
    //從頭部移除(特殊值)
    E pollFirst();
    //從尾部移除(特殊值)
    E pollLast();
    //從頭部查詢(拋異常)
    E getFirst();
    //從尾部查詢(拋異常)
    E getLast();
    //從頭部查詢(特殊值)
    E peekFirst();
    //從尾部查詢(特殊值)
    E peekLast();
    //(從頭到尾遍歷列表時,移除列表中第一次出現的指定元素)
    boolean removeFirstOccurrence(Object o);
    //(從頭到尾遍歷列表時,移除列表中最後一次出現的指定元素)
    boolean removeLastOccurrence(Object o);
    //都沒啥難度,不解釋了
    boolean add(E e);
    boolean offer(E e);
    E remove();
    E poll();
    E element();
    E peek();
    void push(E e);
    E pop();
    boolean remove(Object o);
    boolean contains(Object o);
    public int size();
    Iterator<E> iterator();
    Iterator<E> descendingIterator();

}
複製程式碼

  從上面的方法可以看出,Deque不僅可以當成雙端佇列使用,而且可以被當成棧來使用,因為該類中還包含了pop(出棧)、push(入棧)兩個方法。其他基本就是方法名後面加上“First”和“Last”表明在哪端操作。

ArrayDeque

  重頭戲來了,顧名思義,ArrayDeque使用陣列實現的Deque;底層是陣列,也是可以指定它的capacity,當然也可以不指定,預設長度是16,根據新增的元素個數,動態擴容。

迴圈佇列

  值得重點介紹的是,ArrayDeque是一個迴圈佇列。它的實現比較高效,它的思路是這樣:引入兩個遊標,head 和 tail,如果向佇列裡,插入一個元素,就把 tail 向後移動。如果從佇列中刪除一個元素,就把head向後移動。我們看一下示意圖:

Java集合(七) Queue詳解
  如果向佇列中插入D,那麼,佇列就會變成這樣:
Java集合(七) Queue詳解
  如果此時,從佇列的頭部把A刪除,那隻需要移動head指標即可:
Java集合(七) Queue詳解
  通過這種方式,就可以使元素出隊,入隊的速度加快了。那如果 tail 已經指向了陣列的最後一位怎麼辦呢?其實呀,只需要將tail重新指向陣列的頭就可以了。for example,tail已經指向陣列最後一位了,再插入一個元素,就會變成這樣:
Java集合(七) Queue詳解
  使用這種方式,就可以迴圈使用一個陣列來實現佇列了。
  這裡有一個程式設計上的小技巧,那就是,實現的時候,陣列的長度都是2的整數次冪,這樣,我們就可以使用(tail++)&(length-1)來計算tail的下一位。比如說:陣列長度是1024,2的10次方,如果tail已經指向了陣列的最後一位了,那我們就可以使用tail++,然後和1023求“與”,就得到了0,變成了陣列的第一項。

擴容

  所有的集合類都會面臨一個問題,那就是如果容器中的空間不夠了怎麼辦。這就涉及到擴容的問題。在前面我們已經說了,我們要保證陣列的長度都是2的整數次冪,那麼擴容的時候也很簡單,直接把原來的陣列長度乘以2就可以了。申請一個長度為原陣列兩倍的陣列,然後把資料拷貝進去就OK了。我們看一下具體程式碼:

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 = a;
        head = 0;
        tail = n;
    }
複製程式碼

  程式碼沒啥難度,先把長度擴一倍,(n<<1),再把資料拷到目標位置。只要把這兩個arraycopy方法看懂問題不大。

總個小結

  • 當 Deque 當做 Queue佇列使用時(FIFO),新增元素是新增到隊尾,刪除時刪除的是頭部元素
  • Deque 也能當Stack棧用(LIFO)。這時入棧、出棧元素都是在 雙端佇列的頭部進行。插一嘴:Stack過於古老,並且實現地非常不好,因此現在基本已經不用了,可以直接用Deque來代替Stack進行棧操作。
  • ArrayDeque不是執行緒安全的。 當作為棧使用時,效能比Stack好;當作為佇列使用時,效能比LinkedList好。
  • 最後,送上一個笑話。棧和佇列有什麼區別?吃多了拉就是佇列,吃多了吐就是棧。冬幕節快樂~
參考

資料結構(三):佇列
大話資料結構
Deque 雙端佇列

相關文章