Java 基礎(六)集合原始碼解析 Queue

diamond_lin發表於2017-09-28

Queue

Queue繼承自 Collection,我們先來看看類結構吧,程式碼量比較少,我直接貼程式碼了

public interface Queue<E> extends Collection<E> {
    boolean add(E var1);
    boolean offer(E var1);
    E remove();
    E poll();
    E element();
    E peek();
}複製程式碼

從方法名上不太好猜每個方法的作用,我們直接來看 API 吧

~ 丟擲異常 返回特殊值
插入 add(e) offer(e)
移除 remove() poll()
檢查 element() peek()

好像就除了對增刪查操作增加了一個不丟擲異常的方法,沒什麼特點吧,我們繼續看描述~

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

就描述了這三組方法的區別,那麼以後我操作佇列儘量用不丟擲異常的方法總行了吧。另外也沒看出什麼名堂,那麼佇列這個介面到底是規範了什麼行為?我記得佇列好像是一種資料常用的結構,我們來看看百度百科的定義吧

佇列是一種特殊的線性表,特殊之處在於它只允許在表的前端(front)進行刪除操作,而在表的後端(rear)進行插入操作,和棧一樣,佇列是一種操作受限制的線性表。進行插入操作的端稱為隊尾,進行刪除操作的端稱為隊頭。

看了百度百科的描述,才知道佇列規範了集合只允許在表前端刪除,在表後端插入。這不就是 FIFO 嘛~~

什麼是 FIFO?

FIFO 是英語 first in first out 的縮寫。先進先出,想象一下,在車輛在通過不允許超車的隧道時,是不是先進入隧道的車輛最先出隧道。

FIFO 有什麼用?

這個問題我回答不了,佇列只是一種資料結構,在某些特定的場合,用佇列實現效率會比較高。

Queue 的抽象實現類

AbstractQueue 是Queue 的抽象實現類,和Lst、Set 的抽象實現類一樣,AbstractQueue 也繼承自 AbstractCollection。
AbstractQueue 實現的方法不多,主要就 add、remove、element 三個方法的操作失敗丟擲了異常。

Queue 的實現類

PriorityQueue 直接繼承自 AbstractQueue,並且除序列號介面外,沒實現任何介面,大概算是最忠誠的 Queue 實現類吧。照慣例,我們先來看看 API 介紹。

一個基於優先順序堆的無界優先順序佇列。優先順序佇列的元素按照其自然順序進行排序,或者根據構造佇列時提供的 Comparator 進行排序,具體取決於所使用的構造方法。優先順序佇列不允許使用 null 元素。依靠自然順序的優先順序佇列還不允許插入不可比較的物件.
此佇列的頭 是按指定排序方式確定的最小 元素。如果多個元素都是最小值,則頭是其中一個元素——選擇方法是任意的。佇列獲取操作 poll、remove、peek 和 element 訪問處於佇列頭的元素。
優先順序佇列是無界的,但是有一個內部容量,控制著用於儲存佇列元素的陣列大小。它通常至少等於佇列的大小。隨著不斷向優先順序佇列新增元素,其容量會自動增加。無需指定容量增加策略的細節。

進佇列的資料還要進行排序,每次取都是取到元素最小值,尼瑪,說好的 FIFO 呢?好吧,我暫且當這是一個取出時有順序的佇列,看起來和昨天學的 TreeSet 功能差不多哈。

PriorityQueue 叫優先佇列,即優先把元素最小值存到隊頭。想象一下,使用PriorityQueue去管理一個班的學生,根據可以年齡、成績、身高設定好對應的 Comparator ,然後就能自動從小到大排序呢。哈哈哈~

我們先來看一下 PriorityQueue 的實現吧~

類成員變數如下~

public class PriorityQueue<E> extends AbstractQueue<E> implements Serializable {
    private static final long serialVersionUID = -7720805057305804111L;
    private static final int DEFAULT_INITIAL_CAPACITY = 11;
    transient Object[] queue;
    private int size;
    private final Comparator<? super E> comparator;
    transient int modCount;
    private static final int MAX_ARRAY_SIZE = 2147483639;
}複製程式碼

沒錯,基於陣列的實現,也能找到 grow 擴容方法,少了 List 的各種方法,Queue 的方法我們前面也看了。那麼我們就之前去看他是怎麼實現優先佇列的~

思考一下,既然是陣列實現,又能按元素大小順序去取出,那麼肯定是在新增元素的時候做的排序,直接把對應的元素值大小的元素新增到對應的位置。那麼我們就從 add 方法看起吧~~

public boolean add(E var1) {
    return this.offer(var1);
}

public boolean offer(E var1) {
    if(var1 == null) {
        throw new NullPointerException();
    } else {
        ++this.modCount;
        int var2 = this.size;
        if(var2 >= this.queue.length) {
            this.grow(var2 + 1);
        }

        this.size = var2 + 1;
        if(var2 == 0) {
            this.queue[0] = var1;
        } else {
            this.siftUp(var2, var1);
        }

        return true;
    }
}
private void siftUp(int childIndex) {
    E target = elements[childIndex];
    int parentIndex;
    while (childIndex > 0) {
        parentIndex = (childIndex - 1) / 2;
        E parent = elements[parentIndex];
        if (compare(parent, target) <= 0) {
            break;
        }
        elements[childIndex] = parent;
        childIndex = parentIndex;
    }
    elements[childIndex] = target;
}複製程式碼

上面的方法呼叫都很簡單,我就不寫註釋了,add 呼叫 offer 新增元素,如果集合裡面的元素個數不為零,則呼叫 siftUp 方法把元素插入合適的位置。

敲黑板~~接下來的東西我看了老半天才看明白。有點吃力

注意了,siftUp裡面的演算法有點奇怪,我一開始還以為是二分插入法,然而並不是。

首先,我們這裡走進了一個誤區,PriorityQueue 雖然是一個優先佇列,能夠滿足我們剛剛說的需求,把一個班的學生按年齡大小順序取出來,但是在記憶體中(陣列中)的儲存卻並不是按照從小到大的順序儲存的,但是一直 poll,是能夠按照元素從小到大的順去取出結果。

這裡我做了一個小測試。

PriorityQueue<Integer> integers = new PriorityQueue<>();
integers.add(8);                                        
integers.add(6);                                       
integers.add(5);                                       複製程式碼

已知 PriorityQueue 用陣列儲存,大家猜猜我這樣存進佇列的三個數子是怎樣儲存的?
一開始我以為是5、6、8的順序,但是 debug 的時候看到 PriorityQueue 裡面儲存資料陣列裡面的存放順序是5、8、6.why?

然後我呼叫下面這個方法列印~

while (!integers.isEmpty()) {              
    Log.e("_____", integers.poll() + "~~");
}                                          複製程式碼

結果是5、6、8.這他媽就尷尬了。

然後怎麼辦~去找度娘唄。。。

好了,開始解析~~

不知道大家記不記得一種資料結構叫二叉樹,這裡就是使用了二叉樹的思路,所以比較難理解。

首先,這裡使用的是一種特殊的二叉樹:1.父節點永遠小於子節點,2.優先填滿第 n 層樹枝再填 n+1 層樹枝。也就是說,陣列裡面的5、8、6是這樣儲存的

依次新增元素8、6、5.
  5                            
 / \    
8   6    
    ‖
    ∨
陣列角標位置
  0
 / \
1   2複製程式碼

這樣能理解了吧,再回過頭去看siftUp方法,捋一下新增元素的過程。

  • 新增8
    沒什麼好說的,直接新增一個元素到到陣列[0]即可,二叉樹新增一個頂級節點

  • 新增5
    首先把[1]的位置賦值給5,使得陣列中的元素為{8,5}
    然後執行siftUp(1)方法(1是剛剛插入元素5的角標)

      siftUp方法首先獲取5的父節點,判斷5是否小於父節點。
      如果小於,則交換位置繼續比較祖父節點
      如果大於或者已經到頂級節點,結束。複製程式碼

    siftUp方法後,陣列變為{5,8}

  • 新增6
    重複上面的動作,陣列變為{5,8,6}

問:如果此時新增數字7,陣列的順序是多少?
思考一下3分鐘~~

好,3分鐘過去了,結果是{5,7,6,8}
為什麼會這樣?拿著數字7代入到上面的方法中去算呀,首先8在陣列中的角標是3,3要去和父節點比,求父節點的公式是(3-1)/2 = 1.於是父節點的角標是1,7<8,因此交換位置,此時角標1還有父節點 (1-1)/2 = 0,再比較7和5,7>5,滿足大於父節點條件,結束。

好了,現在應該明白了吧~~~沒明白再回過頭去理解一遍。
接下來,我們來看迴圈呼叫 poll() 方法是怎樣從{5,8,6}的陣列中按照從小到大的順序取出5、6、8.
我們來看 poll()方法

public E poll() {
    if (isEmpty()) {
        return null;
    }
    E result = elements[0];
    removeAt(0);
    return result;
}
private void removeAt(int index) {
    size--;
    E moved = elements[size];
    elements[index] = moved;
    siftDown(index);
    elements[size] = null;
    if (moved == elements[index]) {
        siftUp(index);
    }
}
private void siftDown(int rootIndex) {
    E target = elements[rootIndex];
    int childIndex;
    while ((childIndex = rootIndex * 2 + 1) < size) {
        if (childIndex + 1 < size
                    && compare(elements[childIndex + 1], elements[childIndex]) < 0) {
            childIndex++;
        }
        if (compare(target, elements[childIndex]) <= 0) {
            break;
        }
        elements[rootIndex] = elements[childIndex];
        rootIndex = childIndex;
    }
    elements[rootIndex] = target;
}複製程式碼

這是 api23 裡面 PriorityQueue 的方法,和 Java8 略有不同,但實現都是一樣的,只是方法看起來好理解一些。

首先 poll 方法取出了陣列角標0的值,這點不用質疑,因為角標0對應二叉樹的最高節點,也就是最小值。

然後在 removeAt 方法裡面把陣列的最後一個元素覆蓋了第0個元素,再是將最後一個元素置空,好,到了這裡,進入第二個關鍵點了,黑板敲起來~~

這裡在賦值之後呼叫了 siftDown(0);
我們來看 siftDown()方法~
這個方法從0角標(最頂級父節點)開始,先判斷左右子節點,取較小的那個一,和父節點比較,然後再對比左右子節點。根據我們這裡二叉樹的特點,最終能取到最小的那個元素放到頂級父節點,保證下一次 poll能取到當前集合最小的元素。具體程式碼不帶著讀了~~

ok,PriorityQueue 看完了。

#Deque
剛剛我們一直在找 FIFO 的集合,找到個 PriorityQueue,然而並不是。
然後我們繼續找唄,發現了 Queue 有一個子介面Deque

來看看 API 文件的定義~

一個線性 collection,支援在兩端插入和移除元素。名稱 deque 是“double ended queue(雙端佇列)”的縮寫,通常讀為“deck”。大多數 Deque 實現對於它們能夠包含的元素數沒有固定限制,但此介面既支援有容量限制的雙端佇列,也支援沒有固定大小限制的雙端佇列。

此介面定義在雙端佇列兩端訪問元素的方法。提供插入、移除和檢查元素的方法。每種方法都存在兩種形式:一種形式在操作失敗時丟擲異常,另一種形式返回一個特殊值(null 或 false,具體取決於操作)。插入操作的後一種形式是專為使用有容量限制的 Deque 實現設計的;在大多數實現中,插入操作不能失敗。

嗯~就是一個首尾插入刪除操作都直接的介面。

我們剛剛說了 Queue 遵循 FIFO 規則,當有了 Deque,我們還能實現 LIFO(後進先出)。反正像先進後出、後進先出都能在 Deque 的實現類上做到,具體看各位 Coder 們怎麼操作了。

總結一下 Deque 的方法~

~~-- __第一個元素(頭部)..... _最後一個元素(尾部)
~ 丟擲異常 特殊值 丟擲異常 特殊值
插入 addFirst(e) offerFirst(3) addLast(e) offerLast(3)
移除 removeFirst() pollFirst() removeLast() pollLast()
檢查 getFirst() peekFirst() getLast() peekLast()

__特麼的,MD 語法不支援這種不對齊表格

如果想用作 LIFO 佇列,應優先使用此介面,而不是遺留的 Stack 類。在將雙端佇列用作堆疊時,元素被推入雙端佇列的開頭並從雙端佇列開頭彈出。堆疊方法完全等效於 Deque 方法,如下表所示:

堆疊方法 等效 Deque 方法
push(e) addFirst(e)
pop() removeFirst()
peek() peekFirst()

就醬紫吧,也沒什麼特別的,我個人不太喜歡這個介面,我覺得這個介面規範的行為有點多,不符合介面隔離原則和單一職能原則。

接下來我們就去看看 Deque 的實現類吧。

看兩個具有代表性的類吧,第一個是基於陣列實現的 ArrayQeque,第二個是基於連結串列實現的LinkedList。

LinkedList

前面 List 的時候我們看過 LinkedList,LinkedList 繼承自AbstractList,同時也實現了 List 介面,因此這是一個很全能的類。一句話描述就是:基於連結串列結構實現的陣列,同時又支援雙向佇列操作。

還記得之前在 List 結尾留的一個思考題麼:怎樣用連結串列的結構快速實現棧功能LinkedListStack?

public class LinkedListStack extends LinkedList{
    public LinkedListStack(){
        super();
    }

    @Override
    public void push(Object o) {
        super.push(o);
    }

    @Override
    public Object pop() {
        return super.pop();
    }

    @Override
    public Object peek() {
        return super.peek();
    }

    @Override
    public boolean isEmpty() {
        return super.isEmpty();
    }

    public int search(Object o){
        return indexOf(o);
    }
}複製程式碼

吶,這裡給出了實現,其實什麼都沒做,就是呼叫了父類方法。這個類只是看起來結構清晰的實現了 LIFO,但是由於繼承自 LinkedList,還是可以呼叫 addFirst 等各種“非法操作方法”,這就是我說的不理解 Java 為什麼要這樣設計,還推薦使用 Deque 替換棧實現。專案實際開發中,同學們要使用棧結構直接用 LinkedList就行了,我這裡 LinkedListStack 只是便於大家理解 LinkedList 也可以用作棧集合。

ArrayDeque

照慣例先看 API 定義~

Deque介面的大小可變陣列的實現。陣列雙端佇列沒有容量限制;它們可根據需要增加以支援使用。它們不是執行緒安全的;在沒有外部同步時,它們不支援多個執行緒的併發訪問。禁止 null 元素。此類很可能在用作堆疊時快於 Stack,在用作佇列時快於 LinkedList。

感覺 ArrayDeque 才是一個正常的 Deque 實現類,ArrayDeque 直接繼承自 AbstractCollection,實現了Deque介面。

類部實現和 ArrayList 一樣都是基於陣列,當頭尾下標相等時,呼叫doubleCapacity()方法,執行翻倍擴容操作。

頭尾操作是什麼鬼?我們都知道ArrayDeque 是雙向列表,就是可以兩端一起操作的列表。因此使用了兩個指標 head 和tail 來儲存當前頭尾的 index,一開始預設都是0角標,當新增一個到尾的時候,tail先加1,再把值存放到 tail 角標的陣列裡面去。
那麼 addFirst 是怎麼操作的呢?head 是0,新增到-1的角標上面去?其實不是的,這裡 你可以把這個陣列當成是一個首尾相連的連結串列,head 是0的時候 addFirst 實際上是把值存到了陣列最後一個角標裡面去了。即: 當 head 等於0的時候 head - 1 的值 陣列.length - 1,程式碼實現如下。

如圖,這是我如下程式碼的執行新增60時 debug

ArrayDeque<Integer> integers = new ArrayDeque<>();
integers.addLast(8);
integers.addFirst(60);複製程式碼

然後當head == tail的時候表示陣列用滿了,需要擴容,就執行doubleCapacity擴容,這裡的擴容和 ArrayList 的程式碼差不多,就不去分析了。

總結

凡是牽涉到需要使用 FIFO 或者 LIFO 的資料結構時,推薦使用 ArrayDeque,LinkedList 也行,還有 get(index)方法~~

相關文章