自己動手實現java數據結構(四)雙端隊列


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 ,存在許多不足之處,請多多指教。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM