1. 程式人生 > >演算法一看就懂之「 佇列 」

演算法一看就懂之「 佇列 」

演算法的系列文章中,之前咱們已經聊過了「 陣列和連結串列 」、「 堆疊 」,今天咱們再來繼續看看「 佇列 」這種資料結構。「 佇列 」和「 堆疊 」比較類似,都屬於線性表資料結構,並且都在操作上受到一定規則約束,都是非常常用的資料型別,咱們掌握得再熟練也不為過。

一、「 佇列 」是什麼?

佇列(queue)是一種先進先出的、操作受限的線性表。

佇列這種資料結構非常容易理解,就像我們平時去超市買東西,在收銀臺結賬的時候需要排隊,先去排隊的就先結賬出去,排在後面的就後結賬,有其他人再要過來結賬,必須排在隊尾不能在隊中間插隊。

「 佇列 」資料結構就是這樣的,先進入佇列的先出去,後進入佇列的後出去。必須從隊尾插入新元素,佇列中的元素只能從隊首出,這也就是「 佇列 」操作受限制的地方了。

與堆疊類似,佇列既可以用 「 陣列 」 來實現,也可以用 「 連結串列 」 來實現。

下面主要介紹一下目前用的比較多的幾種「 佇列 」型別:

  • 順序佇列

  • 鏈式佇列

  • 迴圈佇列

  • 優先佇列

下面來依次瞭解一下:

  1. 用陣列實現的佇列,叫做 順序佇列:

    用陣列實現的思路是這樣的:初始化一個長度為n的陣列,建立2個變數指標front和rear,front用來標識隊頭的下標,而rear用來標識隊尾的下標。因為佇列總是從對頭取元素,從隊尾插入資料。因此我們在操作這個佇列的時候通過移動front和rear這兩個指標的指向即可。初始化的時候front和rear都指向第0個位置。

    當有元素需要入隊的時候,首先判斷一下佇列是否已經滿了,通過rear與n的大小比較可以進行判斷,如果相等則說明佇列已滿(隊尾沒有空間了),不能再插入了。如果不相等則允許插入,將新元素賦值到陣列中rear指向的位置,然後rear指標遞增加一(即向後移動了一位),不停的往佇列中插入元素,rear不停的移動,如圖:


    當佇列裝滿的時候,則是如下情況:


    當需要做出隊操作時,首先要判斷佇列是否為空,如果front指標和rear指標指向同一個位置(即front==rear)則說明佇列是空的,無法做出隊操作。如果佇列不為空,則可以進行出隊操作,將front指標所指向的元素出隊,然後front指標遞增加一(即向後移動了一位),加入上圖的隊列出隊了2個元素:


    所以對於陣列實現的佇列而言,需要用2個指標來控制(front和rear),並且無論是做入隊操作還是出隊操作,front或rear都是往後移動,並不會往前移動。入隊的時候是rear往後移動,出隊的時候是front往後移動。出隊和入隊的時間複雜度都是O(1)的。

  2. 用連結串列實現的佇列,叫做 鏈式佇列:

    用連結串列來實現也比較簡單,與陣列實現類似,也是需要2個指標來控制(front和rear),如圖:


    當進行入隊操作時,讓新節點的Next指向rear的Next,再讓rear的Next指向新節點,最後讓rear指標向後移動一位(即rear指標指向新節點),如上圖右邊部分。

    當進行出隊操作時,直接將front指標指向的元素出隊,同時讓front指向下一個節點(即將front的Next賦值給front指標),如上圖左邊部分。

  3. 迴圈佇列

    迴圈佇列是指佇列是前後連成一個圓圈,它以迴圈的方式去儲存元素,但還是會按照佇列的先進先出的原則去操作。迴圈佇列是基於陣列實現的佇列,但它比普通資料實現的佇列帶來的好處是顯而易見的,它能更有效率的利用陣列空間,且不需要移動資料。

    普通的陣列佇列在經過了一段時間的入隊和出隊以後,尾指標rear就指向了陣列的最後位置了,沒法再往佇列裡插入資料了,但是陣列的前面部分(front的前面)由於舊的資料曾經出隊了,所以會空出來一些空間,這些空間就沒法利用起來,如圖:


    當然可以在陣列尾部已滿的這種情況下,去移動資料,把資料所有的元素都往前移動以填滿前面的空間,釋放出尾部的空間,以便尾部還可以繼續插入新元素。但是這個移動也是消耗時間複雜度的。

    而迴圈佇列就可以天然的解決這個問題,下面是迴圈佇列的示意圖:


    迴圈佇列也是一種線性資料結構,只不過它的最後一個位置並不是結束位。對於迴圈佇列,頭指標front始終指向佇列的前面,尾指標rear始終指向佇列的末尾。在最初階段,頭部和尾部的指標都是指向的相同的位置,此時佇列是空的,如圖:


    當有新元素要插入到這個迴圈佇列的時候(入隊),新元素就會被新增到隊尾指標rear指向的位置(rear和tail這兩個英文單詞都是表示隊尾指標的,不同人喜歡的叫法不一樣),並且隊尾指標就會遞增加一,指向下一個位置,如圖:

    當需要做出隊操作時,直接將頭部指標front指向的元素進行出隊(我們常用 front 或 head 英文單詞來表示頭部指標,憑個人喜好),並且頭部指標遞增加一,指向下一個位置,如圖:


    上圖中,D1元素被出隊列了,頭指標head也指向了D2,不過D1元素的實際資料並沒有被刪除,但即使沒有刪除,D1元素也不屬於佇列中的一部分了,佇列只承認隊頭和隊尾之間的資料,其它資料並不屬於佇列的一部分。

    當繼續再往佇列中插入元素,當tail到達佇列的尾部的時候:


    tail的下標就有重新變成了0,此時佇列已經真的滿了。

    不過此處有個知識點需要注意,在上述佇列滿的情況下,其實還是有一個空間是沒有儲存資料的,這是迴圈佇列的特性,只要佇列不為空,那麼就必須讓head和tail之間至少間隔一個空閒單元,相當於浪費了一個空間吧。

    假如此時我們將佇列中的D2、D3、D4、D5都出隊,那佇列就又有空間了,我們又可以繼續入隊,我們將D9、D10入隊,狀態如下:


    此時,頭指標的下標已經大於尾指標的下標了,這也是正式迴圈佇列的特性導致的。

    所以可以看到,整個佇列的入隊和出隊的過程,就是頭指標head和尾指標tail互相追趕的過程,如果tail追趕上了head就說明隊滿了(前提是相隔一個空閒單元),如果head追趕上了tail就說明佇列空了。

    因此迴圈佇列中,判斷佇列為空的條件是:head==tail。

    判斷佇列為滿的情況就是:tail+1=head(即tail的下一個是head,因為前面說了不為空的情況下兩者之間需相隔一個單元),不過如果tail與head正好一個在隊頭一個在隊尾(即tail=7,head=0)的時候,佇列也是滿的,但上述公式就不成立了,因此正確判斷隊滿的公式應該是:(tail+1)%n=head

  4. 優先佇列

    優先佇列(priority Queue)是一種特殊的佇列,它不遵守先進先出的原則,它是按照優先順序出佇列的。分為最大優先佇列(是指最大的元素優先出隊)和最小優先佇列(是指最小的元素優先出隊)。

    一般用堆來實現優先佇列,在後面講堆的文章裡我會詳細再講,這裡瞭解一下即可。

二、「 佇列 」的演算法實踐?

我們看看經常涉及到 佇列 的 演算法題(來源leetcode):

演算法題1:使用棧實現佇列的下列操作:
    push(x) -- 將一個元素放入佇列的尾部。
    pop() -- 從佇列首部移除元素。
    peek() -- 返回佇列首部的元素。
    empty() -- 返回佇列是否為空。

解題思路:堆疊是FILO先進後出,佇列是FIFO先進先出,要使用堆疊來實現佇列的功能,可以採用2個堆疊的方式。堆疊A和堆疊B,當有元素要插入的時候,就往堆疊A裡插入。當要移除元素的時候,先將堆疊A裡的元素依次出棧放入到堆疊B中,再從堆疊B的頂部出資料。如此便基於2個堆疊實現了先進先出的原則了。

class MyQueue {

    private Stack<Integer> s1 = new Stack<>();
    private Stack<Integer> s2 = new Stack<>();
    private int fornt;


    /** Initialize your data structure here. */
    public MyQueue() {

    }

    /** Push element x to the back of queue. */
    public void push(int x) {
        if(s1.empty()) fornt = x;
        s1.push(x);
    }

    /** Removes the element from in front of queue and returns that element. */
    public int pop() {
        if(s2.empty()){
            while(!s1.empty()){
                s2.push(s1.pop());
            }
        }
         return s2.pop();
    }

    /** Get the front element. */
    public int peek() {
        if(s2.empty()){
            return fornt;
        }
        return s2.peek();
    }

    /** Returns whether the queue is empty. */
    public boolean empty() {
        return s1.empty()&&s2.empty();
    }
}   

入棧的時間複雜度為O(1),出棧的時間複雜度為O(1)


演算法題2:使用佇列來實現堆疊的下列操作:
    push(x) -- 元素 x 入棧
    pop() -- 移除棧頂元素
    top() -- 獲取棧頂元素
    empty() -- 返回棧是否為空

解題思路:由於需要使用FIFO的佇列模擬出FILO的堆疊效果,因此需要使用2個佇列來完成,佇列A和佇列B,當需要進行入棧操作的時候,直接往佇列A中插入元素。當需要進行出棧操作的時候,先將佇列A中的前n-1個元素依次出隊移動到佇列B中,這樣佇列A中剩下的最後一個元素其實就是我們所需要出棧的元素了,將這個元素出隊即可。

class MyStack {

    private Queue<Integer> q1 = new LinkedList<>();
    private Queue<Integer> q2 = new LinkedList<>();
    int front;

    /** Initialize your data structure here. */
    public MyStack() {

    }

    /** Push element x onto stack. */
    public void push(int x) {
        q1.add(x);
        front = x;
    }

    /** Removes the element on top of the stack and returns that element. */
    public int pop() {
        while(q1.size()>1){
            front = q1.remove();
            q2.add(front);
        }
        int val = q1.remove();
        Queue<Integer> temp = q2;
        q2 = q1;
        q1 = temp;
        return val;
    }

    /** Get the top element. */
    public int top() {
        return front;
    }

    /** Returns whether the stack is empty. */
    public boolean empty() {
        return q1.size()==0;
    }
}

入棧的時間複雜度為O(1),出棧的時間複雜度為O(n)

這道題其實還有另一個解法,只需要一個佇列就可以做到模擬出堆疊,思路就是:當需要進行入棧操作的時候,先將新元素插入到佇列的隊尾中,再將這個佇列中的其它元素依次出隊,佇列的特性當然是從隊頭出隊了,但是出來的元素再讓它們從隊尾入隊,這樣依次進行,留下剛才插入的新元素不動,這個時候,這個新元素其實就被頂到了隊頭了,新元素入棧的動作就完成了。當需要進行出棧操作的時候,就直接將佇列隊頭元素出隊即是了。
思路已經寫出來了,程式碼的話就留給大家練習了哦。

以上,就是對資料結構「 佇列 」的一些思考。

碼字不易啊,喜歡的話不妨轉發朋友吧。