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

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)方法~~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容