1. 程式人生 > >佇列(queue)及其儲存結構和特點詳解

佇列(queue)及其儲存結構和特點詳解

什麼是佇列?佇列就是一個隊伍。佇列和棧一樣,由一段連續的儲存空間組成,是一個具有自身特殊規則的資料結構。我們說棧是後進先出的規則,佇列剛好與之相反,是一個先進先出(FIFO,First In First Out)或者說後進後出(LILO,Last In Last Out)的資料結構。想象一下,在排隊時是不是先來的就會先從隊伍中出去呢?

佇列是一種受限的資料結構,插入操作只能從一端操作,這一端叫作隊尾;而移除操作也只能從另一端操作,這一端叫作隊頭。針對上面購買奶茶隊伍的例子,排在收銀員一端的就是隊頭,而新來的人則要排到隊尾。

我們將沒有元素的佇列稱為空隊,也就是在沒人要購買奶茶時,就沒人排隊了。往佇列中插入元素的操作叫作入隊,相應地,從佇列中移除元素的操作叫作出隊

一般而言,佇列的實現有兩種方式:陣列連結串列。這裡又提到了連結串列,我們暫時先不做講解。用陣列實現佇列有兩種方式,一種是順序佇列,一種是迴圈佇列。這兩種佇列的儲存結構及特點在之後進行介紹。

用陣列實現佇列,若出現佇列滿了的情況,則這時就算有新的元素需要入隊,也沒有位置。此時一般的選擇是要麼丟掉,要麼等待,等待的時間一般會有程式控制。

佇列的儲存結構

順序佇列的儲存結構如圖 1 所示。


圖 1 順序佇列的儲存結構
順序佇列會有兩個標記,一個是隊頭位置(head),一個是下一個元素可以插入的隊尾位置(tail)。一開始兩個標記都指向陣列下標為 0 的位置,如圖 2 所示。


圖 2 順序佇列的初始情況
在插入元素之後,tail 標記就會加1,比如入隊三個元素,分別為 A、B、C,則當前標記及儲存情況如圖 3 所示。


圖 3 順序佇列入隊三個元素的儲存情況
當前 head 為 0 時,tail 為 3。接下來進行出隊操作,出隊一個元素,head 指向的位置則加 1。比如進行一次出隊操作之後,順序佇列的儲存情況如圖 4 所示。


圖 4 順序隊列出隊一個元素之後的儲存情況
因此,在順序佇列中,佇列中元素的個數我們可以用 tail 減去 head 計算。當 head 與 tail 相等時,佇列為空隊,當 tail 達到陣列長度,也就是佇列儲存之外的位置時,說明這個佇列已經無法再容納其他元素入隊了。空間是否都滿了?並沒有,由於兩個標記都是隻增不減,所以兩個標記最終都會到陣列的最後一個元素之外,這時雖然陣列是空的,但也無法再往佇列里加入元素了。

當佇列中無法再加入元素時,我們稱之為“上溢”;當順序佇列還有空間卻無法入隊時,我們稱之為“假上溢”;如果空間真的滿了,則我們稱之為“真上溢”;如果佇列是空的,則執行出隊操作,這時佇列裡沒有元素,不能出隊,我們稱之為“下溢
”,就像奶茶店根本沒人排隊,收銀員也就沒法給別人開出消費單了。

怎麼解決順序佇列的“假上溢”問題呢?這時就需要採用迴圈隊列了。

當順序隊列出現假上溢時,其實陣列前端還有空間,我們可以不用把標記指向陣列外的地方,只需要把這個標記重新指向開始處就能解決。想象一下這個陣列首尾相接,成為一個圈。儲存結構還是之前提到的,在一個數組上。此時,如果當前佇列中元素的情況如圖 5 所示:

圖 5 當 tail 超出陣列位置時,會被重新標記為 0
那麼在入隊 E、F 元素時,儲存結構會如圖 6 所示。


圖 6 繼續入隊 E、F 元素
一般而言。我們在對 head 或者 tail 加 1 時,為了方便,可直接對結果取餘數組長度,得到我們需要的陣列長度。另外由於順序佇列存在“假上溢”的問題,所以在實際使用過程中都是使用迴圈佇列來實現的。

但是迴圈佇列中會出現這樣一種情況:當佇列沒有元素時,head 等於 tail,而當佇列滿了時,head 也等於 tail。為了區分這兩種狀態,一般在迴圈佇列中規定佇列的長度只能為陣列總長度減 1,即有一個位置不放元素。因此,當 head 等於 tail 時,說明佇列為空隊,而當 head 等於(tail+1)%length 時(length 為陣列長度),說明隊滿。

下面為順序列隊的程式碼實現:
package me.irfen.algorithm.ch02;
public class ArrayQueue {
    private final Object[] items;
    private int head = 0;
    private int tail = 0;
    /**
     * 初始化佇列
     * @param capacity 佇列長度
     */
    public ArrayQueue(int capacity) {
        this.items = new Object[capacity];
    }

    /**
     * 入隊
     * @param item
     * @return
     */
    public boolean put(Object item) {
        if (head == (tail + 1) % items.length) {
            // 說明隊滿
            return false;
        }
        items[tail] = item;
        tail = (tail + 1) % items.length; // tail標記向後移動一位
        return true;
    }

    /**
     * 獲取佇列頭元素,不出隊
     * @return
     */
    public Object peek() {
        if (head == tail) {
            // 說明隊空
            return null;
        }
        return items[head];
    }

    /**
     * 出隊
     * @return
     */
    public Object poll() {
        if (head == tail) {
            // 說明隊空
            return null;
        }
        Object item = items[head];
        items[head] = null; // 把沒用的元素賦空值,當然不設定也可以,反正標記移動了,之後會被覆蓋
        head = (head + 1) % items.length; // head標記向後移動一位
        return item;
    }
   
    public boolean isFull() {
     return head == (tail + 1) % items.length;
    }
   
    public boolean isEmpty() {
     return head == tail;
    }

    /**
     * 佇列元素數
     * @return
     */
    public int size() {
        if (tail >= head) {
            return tail - head;
        } else {
            return tail + items.length - head;
        }
    }
   
}
接下來通過測試程式碼驗證前面程式碼的正確性:
package me.irfen.algorithm.ch02;
public class ArrayQueueTest {
public static void main(String[] args) {
    ArrayQueue queue = new ArrayQueue(4);
    System.out.println(queue.put("A")); // true
    System.out.println(queue.put("B")); // true
    System.out.println(queue.put("C")); // true
    System.out.println(queue.put("D")); // false
 
    System.out.println(queue.isFull());// true,當前佇列已經滿了,並且D元素沒有入隊成功
    System.out.println(queue.size()); // 3,佇列中有三個元素
 
    System.out.println(queue.peek()); // A,獲取隊頭元素,不出隊
    System.out.println(queue.poll()); // A
    System.out.println(queue.poll()); // B
    System.out.println(queue.poll()); // C
 
    System.out.println(queue.isEmpty()); // true,當前佇列為空隊
}
}
在上面的程式碼中儘管宣告的長度是 4,但是隻能放入 3 個元素,這裡是通過在初始化陣列時多設定一個位置來解決問題的;也可以通過增加一個變數來記錄元素的個數去解決問題,不需要兩個標記去確定是隊空還是隊滿,元素也能放滿而不用空出一位了。

佇列的特點

佇列的特點也是顯而易見的,那就是先進先出。出隊的一頭是隊頭,入隊的一頭是隊尾。當然,佇列一般來說都會規定一個有限的長度,叫作隊長(chang)。

佇列的適用場景

佇列在實際開發中是很常用的。在一般程式中會將佇列作為緩衝器或者解耦使用。下面舉幾個例子具體說明佇列的用途。

解耦,即當一個專案發展得比較大時,必不可少地要拆分各個模組。為了儘可能地讓各個模組獨立,則需要解耦,即我們常聽說的高內聚、低耦合。如何對各模組進行解耦?其中一種方式就是通過訊息佇列。

1) 某品牌手機線上秒殺用到的佇列

現在,某個品牌的手機推出新型號,想要購買就需要上網預約,到了開搶時間就得趕緊開啟網頁守著,瘋狂地重新整理頁面,瘋狂地點選搶購按鈕。一般在每次秒殺中提供的手機只有幾千部。假設有兩百萬人搶購,則從開搶的這一秒開始,兩百萬人都開始向伺服器傳送請求。如果伺服器能直接處理請求,把搶購結果立刻告訴使用者,同時為搶購成功的使用者生成訂單,讓使用者付款購買手機,則這對伺服器的要求很高,很難實現。那麼該怎麼解決呢?解決方法是:在接收到每個請求之後,把這些請求按順序放入佇列的隊尾中,然後提示使用者“正在排隊中……”,接下來使用者開始排隊;而在這個佇列的另一端,也就是隊頭,會有一些伺服器在處理,根據先後順序告知使用者搶購結果。

這就出現了搶購手機時,搶購介面稍後才告訴我們搶購結果的情況。我有個朋友在搶購成功之後,搶購介面提示他稍後去訂單中檢視結果,當下檢視訂單卻沒有發現新訂單,其實是因為他的請求已經進入了伺服器處理的佇列,伺服器處理完之後才會為他生成訂單。

注:這種方式也叫作非同步處理。非同步與同步是相對的。同步是在一個呼叫執行完成之後,等待呼叫結束返回;而非同步不會立刻返回結果,返回結果的時間是不可預料的,在另一端的伺服器處理完成之後才有結果,如何通知執行的結果又是另一回事。

2) 生產者和消費者模式

有個非常有名的設計模式,叫作生產者和消費者模式。這個設計模式就像有一個傳送帶,生產者在傳送帶這頭將生產的貨物放上去,消費者在另一頭逐個地將貨物從傳送帶上取下來。這個設計模式的實現原理也比較簡單,即存在一個佇列,若干個生產者同時向佇列中新增元素,然後若干個消費者從佇列中獲取元素。

這時參考奶茶店的例子,每個購買奶茶的人就是一個生產者,依次進入第 1 個佇列中,收銀員就是一個消費者(假設這個收銀員稱為消費者 A),負責“消費”佇列中的購買者,讓購買者逐個從佇列中出來。通過提供給購買者帶有編號的一張小票,讓購買者進入了第 2 個佇列。此時收銀員在第 2 個佇列中又作為生產者出現。

第 2 個佇列的消費者是誰?是製作奶茶的店員,這裡稱之為消費者 B。而一般規模較大的奶茶店,製作奶茶的店員會較多,假設有兩人以上,即消費者 B 比消費者 A 多。此時第 2 個佇列就起到了緩衝的作用,達到了平衡的效果。排隊付款一般較快,等待制作奶茶一般較慢,因此需要安排較多的製作奶茶的店員。

因此對於生產者和消費者的設計模式來說,有一點非常重要,那就是生產的速度要和消費的速度持平。如果生產得太快,而消費得太慢,那麼佇列會很長。而對於計算機來說,佇列太長所佔用的空間也會較大。