1. 程式人生 > >自己動手實現java資料結構(四)雙端佇列

自己動手實現java資料結構(四)雙端佇列

自己動手實現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 ,存在許多不足之處,請多多指教。