1.雙端隊列介紹
在介紹雙端隊列之前,我們需要先介紹隊列的概念。和棧相對應,在許多算法設計中,需要一種"先進先出(First Input First Output)"的數據結構,因而一種被稱為"隊列(Queue)"的數據結構被抽象了出來(因為現實中的隊列,就是先進先出的)。
隊列是一種線性表,將線性表的一端作為隊列的頭部,而另一端作為隊列的尾部。隊列元素從尾部入隊,從頭部出隊(尾進頭出,先進先出)。
雙端隊列(Double end Queue)是一種特殊的隊列結構,和普通隊列不同的是,雙端隊列的線性表兩端都可以進行出隊和入隊操作。當只允許使用一端進行出隊、入隊操作時,雙端隊列等價於一個棧;當限制一端只能出隊,另一端只能入隊時,雙端隊列等價於一個普通隊列。
簡潔起見,下述內容的"隊列"默認代表的就是"雙端隊列"。
2.雙端隊列ADT接口
/** * 雙端隊列 ADT接口 * */ public interface Deque<E>{ /** * 頭部元素插入 * */ void addHead(E e); /** * 尾部元素插入 * */ void addTail(E e); /** * 頭部元素刪除 * */ E removeHead(); /** * 尾部元素刪除 * */ E removeTail(); /** * 窺視頭部元素(不刪除) * */ E peekHead(); /** * 窺視尾部元素(不刪除) * */ E peekTail(); /** * @return 返回當前隊列中元素的個數 */ int size(); /** * 判斷當前隊列是否為空 * @return 如果當前隊列中元素個數為0,返回true;否則,返回false */ boolean isEmpty(); /** * 清除隊列中所有元素 * */ void clear(); /** * 獲得迭代器 * */ Iterator<E> iterator(); }
3.雙端隊列實現細節
3.1 雙端隊列基於數組的實現(ArrayDeque)
雙端隊列作為一個線性表,一開始也許會考慮能否像棧一樣,使用向量作為雙端隊列的底層實現。
但是仔細思考就會發現:在向量中,頭部元素的插入、刪除會導致內部元素的整體批量的移動,效率很差。而隊列具有"先進先出"的特性,對於頻繁入隊,出隊的隊列容器來說,O(n)時間復雜度的單位操作效率是無法容忍的。因此我們必須更進一步,從更為基礎的數組結構出發,實現我們的雙端隊列。
3.1.1 數組雙端隊列實現思路:
在進行代碼細節的展開之前,讓我們先來理解以下基本思路:
1.和向量一樣,雙端隊列在內部數組容量不足時,能和向量一樣動態的擴容。
2.雙端隊列內部維護着"頭部下標"、"尾部下標"。頭部下標指向的是隊列中第一位元素,尾部下標指向的是下一個尾部元素插入的位置。
從頭部下標起始,到尾部下標截止(左閉右開區間),連續保存着隊列中的全部元素。在元素出隊,入隊時,通過移動頭尾下標,進行隊列中元素的插入、刪除,從而避免了類似向量中大量內部元素的整體移動。
當頭部元素入隊時,頭部下標向左移動一位;頭部元素出隊時,頭部下標向右移動一位。
當尾部元素入隊時,尾部下標向右移動一位;尾部元素出隊時,尾部下標向左移動一位。
3.當元素下標的移動達到了邊界時,需要將數組從邏輯上看成一個環,其頭尾是相鄰的:
下標從數組第0位時,向左移動一位,會跳轉到數組的最后一位。
下標從數組最后一位時,向右移動一位,會跳轉到數組的第0位。
下標越界時的跳轉操作,在細節上是通過下標取模實現的。
3.1.2 隊列的基本屬性:
只有當隊列為空時,頭部節點和尾部節點的下標才會相等。
/** * 基於數組的 雙端隊列 * */ public class ArrayDeque<E> implements Deque<E>{ /** * 內部封裝的數組 * */ private Object[] elements; /** * 隊列默認的容量大小 * */ private static final int DEFAULT_CAPACITY = 16; /** * 擴容翻倍的基數 * */ private static final int EXPAND_BASE = 2; /** * 隊列頭部下標 * */ private int head; /** * 隊列尾部下標 * */ private int tail; /** * 默認構造方法 * */ public ArrayDeque() { //:::設置數組大小為默認 this.elements = new Object[DEFAULT_CAPACITY]; //:::初始化隊列 頭部,尾部下標 this.head = 0; this.tail = 0; } }
3.1.3 取模計算:
在jdk基於數組的雙端隊列實現中,強制保持內部數組容量為2的平方(初始化時容量為2的平方,每次自動擴容容量 * 2),因此其取模運算可以通過按位與(&)運算來加快計算速度。
取模運算在雙端隊列的基本接口實現中無處不在,相比jdk的雙端隊列實現,我們實現的雙端隊列實現更加原始,效率也較差。但相對的,我們的雙端隊列實現也較為簡潔和易於理解。在理解了基礎的實現思路之后,可以在這個初始版本的基礎上進一步優化。
/** * 取模運算 * */ private int getMod(int logicIndex){ int innerArrayLength = this.elements.length; //:::由於隊列下標邏輯上是循環的 //:::當邏輯下標小於零時 if(logicIndex < 0){ //:::加上當前數組長度 logicIndex += innerArrayLength; } //:::當邏輯下標大於數組長度時 if(logicIndex >= innerArrayLength){ //:::減去當前數組長度 logicIndex -= innerArrayLength; } //:::獲得真實下標 return logicIndex; }
取模運算時間復雜度:
取模運算中只是進行了簡單的整數運算,時間復雜度為O(1),而在jdk的雙端隊列實現中,使用位運算的取模效率還要更高。
3.1.4 基於數組的雙端隊列常用操作接口實現:
結合代碼,我們再來回顧一下前面提到的基本思路:
1. 頭部下標指向的是隊列中第一位元素,尾部下標指向的是下一個尾部元素插入的位置。
2. 頭部插入元素時,head下標左移一位;頭部刪除元素時,head下標右移一位。
尾部插入元素時,tail下標右移一位;尾部刪除元素時,tail下標左移一位。
3. 內部數組被看成是一個環,下標移動到邊界臨界點時,通過取模運算來計算邏輯下標對應的真實下標。
@Override public void addHead(E e) { //:::頭部插入元素 head下標左移一位 this.head = getMod(this.head - 1); //:::存放新插入的元素 this.elements[this.head] = e; //:::判斷當前隊列大小 是否到達臨界點 if(head == tail){ //:::內部數組擴容 expand(); } } @Override public void addTail(E e) { //:::存放新插入的元素 this.elements[this.tail] = e; //:::尾部插入元素 tail下標右移一位 this.tail = getMod(this.tail + 1); //:::判斷當前隊列大小 是否到達臨界點 if(head == tail){ //:::內部數組擴容 expand(); } } @Override @SuppressWarnings("unchecked") public E removeHead() { //:::暫存需要被刪除的數據 E dataNeedRemove = (E)this.elements[this.head]; //:::將當前頭部元素引用釋放 this.elements[this.head] = null; //:::頭部下標 右移一位 this.head = getMod(this.head + 1); return dataNeedRemove; } @Override @SuppressWarnings("unchecked") public E removeTail() { //:::獲得尾部元素下標(左移一位) int lastIndex = getMod(this.tail - 1); //:::暫存需要被刪除的數據 E dataNeedRemove = (E)this.elements[lastIndex]; //:::設置尾部下標 this.tail = lastIndex; return dataNeedRemove; } @Override @SuppressWarnings("unchecked") public E peekHead() { return (E)this.elements[this.head]; } @Override @SuppressWarnings("unchecked") public E peekTail() { //:::獲得尾部元素下標(左移一位) int lastIndex = getMod(this.tail - 1); return (E)this.elements[lastIndex]; }
隊列常用接口時間復雜度:
基於數組的隊列在訪問頭尾元素時,進行了一次取模運算獲得真實下標,由於數組的隨機訪問是常數時間復雜度(O(1)),因此隊列常用接口的時間復雜度都為O(1),效率很高。
3.1.5 擴容操作:
可以看到,在入隊插入操作結束后,會判斷當前隊列容量是否已經到達了臨界點。
前面提到,只有在隊列為空時,頭部下標才會和尾部下標重合;而當插入新的入隊元素之后,使得頭部下標等於尾部下標時,說明內部數組的容量已經達到了極限,需要進行擴容才能容納更多的元素。
我們舉一個簡單的例子來理解擴容操作:
尾部下標為2.頭部下標為3,隊列內的元素為頭部下標到尾部下標(左閉右開)中的元素排布為(1,2,3,4,5,6)。
目前隊列剛剛在下標為2處的尾部入隊元素"7"。尾部下標從2向右移動一位和頭部下標重合,此時隊列中元素排布為(1,2,3,4,5,6,7),此時需要進行一次擴容操作。
在擴容完成之后,我們希望讓隊列的元素在內部數組中排列的更加自然:
1. 隊列中元素的順序不變,依然是(1,2,3,4,5,6,7),內部數組擴容一定的倍數(兩倍)
2. 隊列中第一個元素將位於內部數組的第0位,隊列中的元素按照頭尾順序依次排列下去
擴容的大概思路:
1. 將"頭部下標"直至"當前內部數組尾部"的元素按照順序整體復制到新擴容數組的起始位置(紅色背景的元素)
2. 將"當前內部數組頭部"直至"尾部下標"的元素按照順序整體復制到新擴容數組中(位於第一步操作復制的數據區間之后)(藍色背景的元素)
擴容前:
擴容后:
擴容代碼的實現:
/** * 內部數組擴容 * */ private void expand(){ //:::內部數組 擴容兩倍 int elementsLength = this.elements.length; Object[] newElements = new Object[elementsLength * EXPAND_BASE]; //:::將"head -> 數組尾部"的元素 復制在新數組的前面 (tips:使用System.arraycopy效率更高) for(int i=this.head, j=0; i<elementsLength; i++,j++){ newElements[j] = this.elements[i]; } //:::將"0 -> head"的元素 復制在新數組的后面 (tips:使用System.arraycopy效率更高) for(int i=0, j=elementsLength-this.head; i<this.head; i++,j++){ newElements[j] = this.elements[i]; } //:::初始化head,tail下標 this.head = 0; this.tail = this.elements.length; //:::內部數組指向 新擴容的數組 this.elements = newElements; }
擴容操作時間復雜度:
動態擴容的操作由於需要進行內部數組的整體copy,其時間復雜度是O(n)。
但是站在全局的角度,動態擴容只會在入隊操作導致空間不足時偶爾的被觸發,整體來看,動態擴容的時間復雜度為O(1)。
3.1.6 其它接口實現:
@Override public int size() { return getMod(tail - head); } @Override public boolean isEmpty() { //:::當且僅當 頭尾下標相等時 隊列為空 return (head == tail); } @Override public void clear() { int head = this.head; int tail = this.tail; while(head != tail){ this.elements[head] = null; head = getMod(head + 1); } this.head = 0; this.tail = 0; } @Override public Iterator<E> iterator() { return new Itr(); }
3.1.7 基於數組的雙端隊列——迭代器實現:
迭代器從頭部元素開始迭代,直至尾部元素終止。
值得一提的是,雖然隊列的api接口中沒有提供直接刪除隊列中間(非頭部、尾部)的元素,但是迭代器的remove接口卻依然允許這種操作。由於必須要時刻保持隊列內元素排布的連續性,因此在刪除隊列中間的元素后,需要整體的移動其他元素。
此時,有兩種選擇:
方案一:將"頭部下標"到"被刪除元素下標"之間的元素整體向右平移一位
方案二:將"被刪除元素下標"到"尾部下標"之間的元素整體向左平移一位
我們可以根據被刪除元素所處的位置,計算出兩種方案各自需要平移元素的數量,選擇平移元素數量較少的方案,進行一定程度的優化。
隊列迭代器的remove操作中存在一些細節值得注意,我們使用一個簡單的例子來幫助理解:
1. 當前隊列在迭代時需要刪除元素"7"(紅色元素),采用方案一需要整體平移(1,2,3,4,5,6)六個元素,而方案二只需要整體平移(8,9,10,11,12)五個元素。因此采用平移元素更少的方案二,
2. 這時由於(8,9,10,11,12)五個元素被物理上截斷了,所以主要分三個步驟進行平移。
第一步: 先將靠近尾部的 (8,9)兩個元素整體向左平移一位(藍色元素)
第二步: 將內部數組頭部的元素(10),復制到內部數組的尾部(粉色元素)
第三部 : 將剩下的元素(11,12),整體向左平移一位(綠色元素)
remove操作執行前:
remove操作執行后:
迭代器代碼實現:
在remove操作中有多種可能的情況,由於思路相通,可以通過上面的舉例說明幫助理解。
/** * 雙端隊列 迭代器實現 * */ private class Itr implements Iterator<E> { /** * 當前迭代下標 = head * 代表遍歷從頭部開始 * */ private int currentIndex = ArrayDeque.this.head; /** * 目標終點下標 = tail * 代表遍歷至尾部結束 * */ private int targetIndex = ArrayDeque.this.tail; /** * 上一次返回的位置下標 * */ private int lastReturned; @Override public boolean hasNext() { //:::當前迭代下標未到達終點,還存在下一個元素 return this.currentIndex != this.targetIndex; } @Override @SuppressWarnings("unchecked") public E next() { //:::先暫存需要返回的元素 E value = (E)ArrayDeque.this.elements[this.currentIndex]; //:::最近一次返回元素下標 = 當前迭代下標 this.lastReturned = this.currentIndex; //:::當前迭代下標 向后移動一位(需要取模) this.currentIndex = getMod(this.currentIndex + 1); return value; } @Override public void remove() { if(this.lastReturned == -1){ throw new IteratorStateErrorException("迭代器狀態異常: 可能在一次迭代中進行了多次remove操作"); } //:::刪除當前迭代下標的元素 boolean deleteFromTail = delete(this.currentIndex); //:::如果從尾部進行收縮 if(deleteFromTail){ //:::當前迭代下標前移一位 this.currentIndex = getMod(this.currentIndex - 1); } //:::為了防止用戶在一次迭代(next調用)中多次使用remove方法,將lastReturned設置為-1 this.lastReturned = -1; } /** * 刪除隊列內部數組特定下標處的元素 * @param currentIndex 指定的下標 * @return true 被刪除的元素靠近尾部 * false 被刪除的元素靠近頭部 * */ private boolean delete(int currentIndex){ Object[] elements = ArrayDeque.this.elements; int head = ArrayDeque.this.head; int tail = ArrayDeque.this.tail; //:::當前下標 之前的元素個數 int beforeCount = getMod(currentIndex - head); //:::當前下標 之后的元素個數 int afterCount = getMod(tail - currentIndex); //:::判斷哪一端的元素個數較少 if(beforeCount < afterCount){ //:::距離頭部元素較少,整體移動前半段元素 //:::判斷頭部下標 是否小於 當前下標 if(head < currentIndex){ //:::小於,正常狀態 僅需要復制一批數據 //:::將當前數組從"頭部下標"開始,整體向右平移一位,移動的元素個數為"當前下標 之前的元素個數" System.arraycopy(elements,head,elements,head+1,beforeCount); }else{ //:::不小於,說明存在溢出環 需要復制兩批數據 //:::將數組從"0下標處"的元素整體向右平移一位,移動的元素個數為"從0到當前下標之間的元素個數" System.arraycopy(elements,0,elements,1,currentIndex); //:::將數組最尾部的數據設置到頭部,防止被覆蓋 elements[0] = elements[(elements.length-1)]; //:::將數組尾部的數據整體向右平移一位 System.arraycopy(elements,head,elements,head+1,(elements.length-head-1)); } //:::釋放被刪除元素的引用 elements[currentIndex] = null; //:::頭部下標 向右移動一位 ArrayDeque.this.head = getMod(ArrayDeque.this.head + 1); //:::沒有刪除尾部元素 返回false return false; }else{ //:::距離尾部元素較少,整體移動后半段元素 //:::判斷尾部下標 是否小於 當前下標 if(currentIndex < tail){ //:::小於,正常狀態 僅需要復制一批數據 //:::將當前數組從"當前"開始,整體向左平移一位,移動的元素個數為"當前下標 之后的元素個數" System.arraycopy(elements,currentIndex+1,elements,currentIndex,afterCount); }else{ //:::不小於,說明存在溢出環 需要復制兩批數據 //:::將數組從"當前下標處"的元素整體向左平移一位,移動的元素個數為"從當前下標到數組末尾的元素個數-1 ps:因為要去除掉被刪除的元素" System.arraycopy(elements,currentIndex+1,elements,currentIndex,(elements.length-currentIndex-1)); //:::將數組頭部的元素設置到末尾 elements[elements.length-1] = elements[0]; //:::將數組頭部的數據整體向左平移一位,移動的元素個數為"從0到尾部下標之間的元素個數" System.arraycopy(elements,1,elements,0,tail); } //:::尾部下標 向左移動一位 ArrayDeque.this.tail = getMod(ArrayDeque.this.tail - 1);
//:::刪除了尾部元素 返回true return true; } } }
3.2 基於鏈表的鏈式雙端隊列
和向量不同,雙向鏈表在頭尾部進行插入、刪除操作時,不需要額外的操作,效率極高。
因此,我們可以使用之前已經封裝好的的雙向鏈表作為基礎,輕松的實現一個鏈式結構的雙端隊列。限於篇幅,就不繼續展開了,有興趣的讀者可以嘗試自己完成這個任務。
4.雙端隊列性能
空間效率:
基於數組的雙端隊列:數組空間結構非常緊湊,效率很高。
基於鏈表的雙端隊列:由於鏈式結構的節點存儲了相關聯的引用,空間效率比數組結構稍低。
時間效率:
對於雙端隊列常用的出隊、入隊操作,由於都是在頭尾處進行操作,數組隊列和鏈表隊列的執行效率都非常高(時間復雜度(O(1)))。
需要注意的是,由於雙端隊列的迭代器remove接口允許刪除隊列中間部位的元素,而刪除中間隊列元素的效率很低(時間復雜度O(n)),所以在使用迭代器remove接口時需要謹慎。
5.雙端隊列總結
至此,我們實現了一個基礎的、基於數組的雙端隊列。要想更近一步的學習雙端隊列,可以嘗試着閱讀jdk的java.util.ArrayDeque類並且按照自己的思路嘗試着動手實現一個雙端隊列。我個人認為,如果事先沒有一個明確的思路,直接去硬看源代碼,很容易就陷入細節之中無法自拔,"不識廬山真面目,只緣生在此山中"。
希望這篇博客能夠讓讀者更好的理解雙端隊列,更好的理解自己所使用的數據結構,寫出更高效,易維護的程序。
博客的完整代碼在我的 github上:https://github.com/1399852153/DataStructures ,存在許多不足之處,請多多指教。