為什麼迴圈佇列要浪費一個儲存空間

雙子孤狼發表於2022-01-14

什麼是佇列

佇列和陣列,連結串列,棧一樣都是屬於線性資料結構,而佇列又和棧比較相似,都屬於操作受限的資料結構,其中棧是“後入先出”,而佇列是“先進先出”。

和棧一樣,佇列也僅有兩個基本操作:入隊和出隊(棧則是入棧和出棧)。往佇列中放元素稱之為入隊,往佇列中取元素稱之為出隊,然而相對於棧來說,佇列又會複雜一點。

佇列中通常需要定義兩個指標:headtail(當然,也有稱之為:frontrear)分別用來表示頭指標和尾指標。初始化佇列時,headtail 相等,當有元素入隊時,tail 指標往後移動一位,當有元素出隊時,head 指標往後移動一位。

隊空和隊滿

根據上面的佇列初始化和入隊出隊的過程,我們可以得到以下三個關係:

  • 初始化佇列時:head=tail=-1(這裡也可以設定為 0,看具體實現)。
  • 佇列滿時:tail=size-1(其中 size 為初始化佇列時佇列的大小)。
  • 佇列空時:head=tail(比如上圖中的第一和第三個佇列)。

佇列的實現

和棧一樣,佇列也可以通過陣列或者連結串列來實現,通過陣列來實現的佇列我們稱之為“順序佇列”,通過連結串列實現的佇列稱之為“鏈式佇列”。

陣列實現佇列

package com.lonely.wolf.note.queue;

/**
 * 基於陣列實現自定義單向佇列。FIFO(first in first out)先進先出
 * @author lonely_wolf
 * @version 1.0
 * @date 2021/12/26
 * @since jdk1.8
 */
public class MyQueueByArray<E> {
    public static void main(String[] args) {
        MyQueueByArray myQueue = new MyQueueByArray(3);
        System.out.println("佇列是否為空:" + myQueue.isEmpty());//輸出:true
        myQueue.enqueue(1);
        myQueue.enqueue(2);
        myQueue.enqueue(3);
        System.out.println("佇列是否已滿:" + myQueue.isFull());//輸出:true
        System.out.println("第1次出隊:" + myQueue.dequeue());//輸出:1
        System.out.println("第2次出隊:" + myQueue.dequeue());//輸出:2
        System.out.println("第3次出隊:" + myQueue.dequeue());//輸出:3
        System.out.println("佇列是否為空:" + myQueue.isEmpty());//輸出:true
        System.out.println("佇列是否已滿:" + myQueue.isFull());//輸出:true
    }

    private Object[] data;
    private int size;//佇列長度
    private int head;//佇列頭部
    private int tail;//佇列尾部

    public MyQueueByArray(int size) {//初始化
        this.size = size;
        data = new Object[size];
        head = -1;
        tail = -1;
    }

    /**
     * tail=size-1 表示佇列已滿
     * @return
     */
    public boolean isFull(){
        return tail == size - 1;
    }

    /**
     * head=tail表示佇列為空
     * @return
     */
    public boolean isEmpty(){
        return head == tail;
    }


    /**
     * 元素入隊,tail指標後移一位
     * @param e
     * @return
     */
    public boolean enqueue (E e){
        if (isFull()){
            return false;
        }
        data[++tail] = e;//tail 指標後移
        return true;
    }


    /**
     * 元素出丟
     * @return
     */
    public E dequeue (){
        if (isEmpty()){
            return null;
        }
        E e = (E)data[++head];//出隊
        return e;
    }
}

連結串列實現佇列

有了上面基於陣列實現的簡單佇列例子,基於連結串列實現佇列相信大家也能寫出來,如果用連結串列實現佇列,我們們同樣需要head 指標和 tail 指標。其中 head 指向連結串列的第一個結點,tail 指向最後一個結點。

入隊時:

tail.next = newNode;
tail = tail.next;

出隊時:

head = head.next;

假溢位問題

我們回到上面陣列實現的例子中,我們發現最後的兩個輸出語句兩個都是 true,也就是佇列又是空的又是滿的,這看起來是一個自相矛盾的事情卻在佇列裡面出現了,我們來看看這時候佇列的示意圖:

通過上圖中我們發現,因為 tail=size-1,所以佇列是滿的;而此時又同時滿足:head=tail,所以根據上面隊空的條件,我們又可以得到當前佇列是空的,也就是當前佇列是沒有元素的。這時候我們已經無法繼續入隊了,但是這時候佇列中的其實是有空間的,有空間卻不能被利用,這就是單向佇列的“假溢位”問題。

那麼如何解決這個問題呢?解決這個問題有兩個思路。

  • 思路一

前面我們學習陣列的時候提到了,當陣列中刪除一個元素,那麼這個位置之後的所有元素都需要往前移動一位,所以佇列中也是同理,假如我們用陣列來實現的話,當元素出隊時,我們把所有元素都往前移,不過這樣因為每次都需要搬移資料,導致入隊的時間複雜度是 O(n)

  • 思路二

因為搬移資料會影響到入隊效率,那麼如何不搬移資料又能將佇列的空閒空間利用起來呢?答案也很簡單,那就是當再次有新元素入隊時,我們把 tail 指標又重新移動到佇列的最開頭位置,這樣也能避免出現“假溢位”問題,同時時間複雜度仍然保持為 O(1)

迴圈佇列

上面提到的第二種思路來解決單向佇列的“假溢位”問題,實際上就構成了一個迴圈佇列。而上面單向佇列判斷當前佇列是否隊滿時僅需要滿足 tail=size-1 即可,但是迴圈佇列肯定不行,因為當 tail=size-1 時繼續新增元素,tail 可能可以移動到 size=0 的位置,所以如果要實現迴圈佇列最關鍵還是需要確定好隊空和隊滿的條件。

隊空和隊滿

在迴圈佇列中,當初始化佇列時,一般會將其設定為 0,而隊空條件仍然是 head=tail,迴圈佇列最關鍵的是如何確定隊滿條件。

下圖是初始化一個長度為 6 的佇列以及連續入隊 5 個元素後的迴圈佇列中 headtail 指標示意圖:

上圖中第二個佇列表示的是入隊 5 個元素之後 tail 指標的位置,這時候如果繼續入隊,那麼 tail 只能指向佇列開頭也就是元素 1 所在的位置,此時會得到下面這個示意圖的場景:

這時候發現 head=tail,和隊空時的條件衝突了,那麼如何解決這個問題呢?主要有三種解決辦法:

  1. (tail+1)%size=head 時就表示佇列已滿,這時候 tail 所在位置為空,所以會浪費一個空間(也就是第一幅圖中第二個佇列的場景就算佇列已滿)。
  2. 新增一個容量 capacity 欄位,並進行維護,當 capacity=size 表示佇列已滿。
  3. 和第二個辦法差不多,我們可以再維護一個標記,或者當 head=tail 時同步判斷當前位置是否有元素來判斷當前是隊空還是隊滿。

這三種思路我們發現都各有缺點:方法 1 會浪費一個空間;方法 2 每次入隊和出隊時候都需要維護 capacity 欄位;方法 3 就是不論佇列空或者佇列滿時都要多一次判斷方式。

相互比較之下,其實方法一的思路就是空間換時間,而另外兩種辦法當資料量併發量很大時,多一次判斷其實也是會對效能有所影響,常規的迴圈連結串列會採用方法 1 進行處理,也就是選擇浪費一個空間的方式。當然大家在實際開發過程中也可以自行斟酌。

實現迴圈佇列

下面我們就以方法 1 的思路基於陣列來實現一個迴圈佇列:

package com.lonely.wolf.note.queue;

/**
 *
 * 實現迴圈佇列
 * @author lonely_wolf
 * @version 1.0
 * @date 2021/12/26
 * @since jdk1.8
 */
public class MyCycleQueueByArray<E> {

    public static void main(String[] args) {
        MyCycleQueueByArray cycleQueue = new MyCycleQueueByArray(3);
        System.out.println("迴圈佇列是否為空:" + cycleQueue.isEmpty());//輸出:true
        System.out.println("1是否入隊成功:" + cycleQueue.enqueue(1));//輸出:true
        System.out.println("2是否入隊成功:" + cycleQueue.enqueue(2));//輸出:true
        System.out.println("3是否入隊成功:" + cycleQueue.enqueue(3));//輸出:false
        System.out.println("迴圈佇列是否已滿:" + cycleQueue.isFull());//輸出:true
        System.out.println("第1次出隊:" + cycleQueue.dequeue());//輸出:1
        System.out.println("第2次出隊:" + cycleQueue.dequeue());//輸出:2
        System.out.println("第3次出隊:" + cycleQueue.dequeue());//輸出:null,因為 3 入隊失敗
        System.out.println("迴圈佇列是否為空:" + cycleQueue.isEmpty());//輸出:true
        System.out.println("迴圈佇列是否已滿:" + cycleQueue.isFull());//輸出:false
    }

    private Object[] data;
    private int size;
    
    private int head;//佇列頭部
    private int tail;//佇列尾部

    public MyCycleQueueByArray(int size) {
        this.size = size;
        data = new Object[size];
        head = 0;
        tail = 0;
    }

    public boolean isFull(){
        return (tail + 1) % size == head;
    }

    public boolean isEmpty(){
        return head == tail;
    }

    /**
     * 入隊
     * @param e
     * @return
     */
    public boolean enqueue (E e){
        if (isFull()){
            return false;
        }
        data[tail] = e;
        tail =  (tail + 1) % size;//注意這裡不能直接 tail++,否則無法迴圈使用
        return true;
    }


    /**
     * 出隊
     * @return
     */
    public E dequeue (){
        if (isEmpty()){
            return null;
        }
        E e = (E)data[head];
        head =  (head + 1) % size;//head 也同樣不能直接 head++
        return e;
    }
}

這時候我們發現,雖然佇列空間有 3 個,但是實際上只能存放 2 個元素,而最後兩條輸出語句的輸出結果也說明了迴圈佇列不會出現單向佇列的“假溢位問題”。

佇列實戰

佇列其實在 Javajuc 中有廣泛應用,比如 AQS 等,在這裡,我們繼續來看一看佇列的相關演算法題來加深對佇列的理解。

兩個棧實現佇列

前面我們講棧的時候,用了兩個佇列來實現棧,這道題卻正好相反,是利用兩個棧來實現佇列。

LeetCode232 題:請你僅使⽤兩個棧實現先⼊先出佇列,佇列應當⽀持⼀般佇列⽀持的所有操作(push、pop、peek、empty)。

這道題目其實相比較之前兩個佇列實現棧還是會更簡單一點,因為棧是後入先出,所以我們只需要將入隊和出隊使用不同的棧就可以解決了。

具體解題思路為:將⼀個棧當作輸⼊棧,⽤於壓⼊(push)傳⼊的資料;另⼀個棧當作輸出棧,⽤於 pop(出隊) 和 peek(檢視佇列頭部元素) 操作。 每次 poppeek 時,若輸出棧為空則將輸⼊棧的全部資料依次彈出並壓⼊輸出棧,再將元素從輸出棧輸出。

具體程式碼實現為:

package com.lonely.wolf.note.queue;

import java.util.Stack;

/**
 * LeetCode 232
 * 請你僅使⽤兩個棧實現先⼊先出佇列。佇列應當⽀持⼀般佇列⽀持的所有操作(push、pop、peek、empty):
 *
 * void push(int x) 將元素 x 推到佇列的末尾
 * int pop() 從佇列的開頭移除並返回元素
 * int peek() 返回佇列開頭的元素
 * boolean empty() 如果佇列為空,返回 true ;否則,返回 false
 *
 * 說明:
 * 你只能使⽤標準的棧操作 —— 也就是隻有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。
 * 你所使⽤的語⾔也許不⽀持棧。你可以使⽤ list 或者 deque(雙端佇列)來模擬⼀個棧,只要是標準的棧操作即可。
 *
 * 解題思路
 * 將⼀個棧當作輸⼊棧,⽤於壓⼊ push 傳⼊的資料;另⼀個棧當作輸出棧,⽤於 pop 和 peek 操作。
 * 每次 pop 或 peek 時,若輸出棧為空則將輸⼊棧的全部資料依次彈出並壓⼊輸出棧,
 *
 * @author lonely_wolf
 * @version 1.0
 * @date 2021/12/26
 * @since jdk1.8
 */
public class MyQueueByTwoStack<Integer> {
    private Stack<Integer> inStack;//輸入棧
    private Stack<Integer> outStack;//輸出棧

    /**
     * 即入隊:enqueue 操作
     * @param e
     */
    public void push(Integer e){
        inStack.push(e);//壓入輸入棧
    }

    /**
     * 檢視並移除佇列頭部元素,即:出隊 dequeue 操作
     * @return
     */
    public Integer pop(){
        if (!outStack.isEmpty()){//輸出棧不為空則直接出棧
            return outStack.pop();
        }
        while (!inStack.isEmpty()){//輸出棧為空,則檢查輸入棧
            outStack.push(inStack.pop());//輸入棧不為空,則將其壓入輸出棧
        }
        if (!outStack.isEmpty()){//再次檢查輸出棧是否有元素出棧
            return outStack.pop();
        }
        return null;
    }

    /**
     * 檢視佇列頭部元素,相比較 pop,這裡只檢視元素,並不移除元素
     **/
    public Integer peek(){
        if (!outStack.isEmpty()){
            return outStack.peek();
        }
        while (!inStack.isEmpty()){
            outStack.push(inStack.pop());
        }
        if (!outStack.isEmpty()){
            return outStack.peek();
        }
        return null;
    }

    /**
     * 佇列是否為空
     * @return
     */
    public boolean empty(){
        return inStack.isEmpty() && outStack.isEmpty();
    }
}

總結

本文主要講述了佇列這種操作受限的資料結構,文中通過一個例子說明了單向連結串列為什麼會存在“假溢位“問題,並最終引出了迴圈連結串列,而迴圈連結串列的實現同樣有三種不同思路,並通過以空間換時間的方法基於陣列實現了一個簡單的迴圈連結串列。最後我們還講述瞭如何利用兩個棧來實現一個佇列。

相關文章