大家好,我是小彭。
在上一篇文章裡,我們聊到了基於連結串列的 Queue 和 Stack 實現 —— LinkedList。那麼 Java 中有沒有基於陣列的 Queue 和 Stack 實現呢?今天我們就來聊聊這個話題。
小彭的 Android 交流群 02 群已經建立啦,掃描文末二維條碼進入~
思維導圖:
在資料結構上,LinkedList 不僅實現了與 ArrayList 相同的 List 介面,還實現了 Deque 介面,而我們今天要講的 ArrayDeque 就是實現於 Deque 介面的動態陣列。
Deque 介面表示一個雙端佇列(Double Ended Queue),允許在佇列的首尾兩端操作,所以既能實現佇列行為,也能實現棧行為。
Queue 的 API 可以分為 2 類,區別在於方法的拒絕策略上:
拒絕策略 | 拋異常 | 返回特殊值 |
---|---|---|
入隊(隊尾) | add(e) | offer(e) |
出隊(隊頭) | remove() | poll() |
觀察(隊頭) | element() | peek() |
Java 沒有提供標準的棧介面(很好奇為什麼不提供),而是放在 Deque 介面中:
拒絕策略 | 拋異常 | 等價於 |
---|---|---|
入棧 | push(e) | addFirst(e) |
出棧 | pop() | removeFirst() |
觀察(棧頂) | peek() | peekFirst() |
除了標準的佇列和棧行為,Deque 介面還提供了 12 個在兩端操作的方法:
拒絕策略 | 拋異常 | 返回值 |
---|---|---|
增加 | addFirst(e)/ addLast(e) | offerFirst(e)/ offerLast(e) |
刪除 | removeFirst()/ removeLast() | pollFirst()/ pollLast() |
觀察 | getFirst()/ getLast() | peekFirst()/ peekLast() |
1、資料結構: 在資料結構上,ArrayDeque 和 LinkedList 都實現了 Java Deque 雙端佇列介面。但 ArrayDeque 沒有實現了 Java List 列表介面,所以不具備根據索引位置操作的行為;
2、執行緒安全: ArrayDeque 和 LinkedList 都不考慮執行緒同步,不保證執行緒安全;
3、底層實現: 在底層實現上,ArrayDeque 是基於動態陣列的,而 LinkedList 是基於雙向連結串列的。
在遍歷速度上: ArrayDeque 是一塊連續記憶體空間,基於區域性性原理能夠更好地命中 CPU 快取行,而 LinkedList 是離散的記憶體空間對快取行不友好;
在操作速度上: ArrayDeque 和 LinkedList 的棧和佇列行為都是 O(1) 時間複雜度,ArrayDeque 的入棧和入隊有可能會觸發擴容,但從均攤分析上看依然是 O(1) 時間複雜度;
額外記憶體消耗上: ArrayDeque 在陣列的頭指標和尾指標外部有閒置空間,而 LinkedList 在節點上增加了前驅和後繼指標。
我們知道棧和佇列都是 「操作受限」 的線性表:棧是 LIFO,限制在表的一端入棧和出棧。而佇列是 FIFO,限制在表的一端入隊,在另一端出隊。棧和佇列既可以用陣列實現,也可以用連結串列實現:
在上一篇文章裡,我們提到了 LinkedList 的多面人生,它即作為 List 的鏈式表,又作為 Queue 的鏈式佇列,又作為 「Stack」 的鏈式棧,功能很全面。相較之下,ArrayList 卻只作為實現了 List 的順序表,為什麼呢?
這是因為在陣列上同時實現 List 和 Queue 時,無法平衡這兩個行為的效能矛盾。具體來說:ArrayList 不允許底層資料有空洞,所有的有效資料都會 「壓縮」 到底層陣列的首部。所以,使用 ArrayList 開發棧的結構或許合適,可以在陣列的尾部運算元據。但使用 ArrayList 開發佇列就不合適,因為在陣列的首部入隊或出隊需要搬運資料。
使用陣列實現棧相對容易,因為棧結構的操作被限制在陣列的一端,所以我們可以選擇陣列的尾部作為棧頂,並且使用一個 top 指標記錄棧頂位置:
對於出棧而言,時間複雜度總是 O(1),但是對於入棧而言,卻不一定。因為當陣列的空間不足(top == n)時,就需要擴容和搬運資料來容納新的資料。此時,時間複雜度就從 O(1) 退化到 O(n)。
對於這種大部分操作時間複雜度很低,只有個別情況下時間複雜度會退化,而且這些操作之間存在很強烈的順序關係的情況,就很適合用 「均攤時間複雜度分析」 了:
假設我們的擴容策略是將陣列擴大到舊陣列的 2 倍,用均攤分析法:
可以看到,在每次搬運 K 個次數後,隨後的 K - 1 次入棧操作就只是簡單的 O(1) 操作,K 次入棧操作涉及到 K 個資料搬運和 K 次賦值操作。那我們從整體看,如果把複雜度較高的 1 次入棧操作的耗時,均攤到其他複雜度較低的操作上,就等於說 1 次入棧操作只需要搬運 1 個資料和 1 次賦值操作,所以入棧的均攤時間複雜度就是 O(1)。
入棧的均攤時間複雜度分析
使用陣列實現佇列相對複雜,我們需要一個隊頭指標和一個隊尾指標:
對於出隊而言,時間複雜度總是 O(1)。對於入隊而言,當 tail == n
時,就需要擴容和搬運資料來容納新的資料,我們用均攤分析法得出均攤時間複雜度依然是 O(1),就不重複了。
但是我們發現,棧的 top == n
表示棧空間不足,擴容沒有問題,而佇列的 tail == n
卻不一定表示佇列空間不足。因為入隊和出隊發生在不同方向,有可能出現 tail == n
但隊頭依然有非常多剩餘空間的情況。此時,擴容顯得沒有必要。
擴容顯得沒有必要
那麼,怎麼避免沒有必要的擴容和資料搬移呢?—— 迴圈陣列。
我們在邏輯上將陣列的首尾相連,當 tail == n
時,如果陣列頭部還有空閒位置,我們就把 tail 指標調整到陣列頭部,在陣列頭部新增資料。我們下面要分析的 ArrayDeque 資料結構,就是採用了迴圈陣列的實現。
使用迴圈陣列後,佇列空和佇列滿的判斷條件會發生變化:
理解了使用陣列實現棧和佇列的思路後,下面再來分析 ArrayDeque 的實現原理,就顯得遊刃有餘了。
這一節,我們來分析 ArrayDeque 中主要流程的原始碼。
head
和 tail
指標指向陣列的 「隊頭位置」 和 「隊尾位置」,需要注意 tail 隊尾指標實際上是指向隊尾最後一個有效元素的下一位。ArrayDeque 的屬性很好理解的,不出意外的話又有小朋友出來舉手提問了: