演算法一看就懂之「 佇列 」
演算法的系列文章中,之前咱們已經聊過了「 陣列和連結串列 」、「 堆疊 」,今天咱們再來繼續看看「 佇列 」這種資料結構。「 佇列 」和「 堆疊 」比較類似,都屬於線性表資料結構,並且都在操作上受到一定規則約束,都是非常常用的資料型別,咱們掌握得再熟練也不為過。
一、「 佇列 」是什麼?
佇列(queue)是一種先進先出的、操作受限的線性表。
佇列這種資料結構非常容易理解,就像我們平時去超市買東西,在收銀臺結賬的時候需要排隊,先去排隊的就先結賬出去,排在後面的就後結賬,有其他人再要過來結賬,必須排在隊尾不能在隊中間插隊。
「 佇列 」資料結構就是這樣的,先進入佇列的先出去,後進入佇列的後出去。必須從隊尾插入新元素,佇列中的元素只能從隊首出,這也就是「 佇列 」操作受限制的地方了。
與堆疊類似,佇列既可以用 「 陣列 」 來實現,也可以用 「 連結串列 」 來實現。
下面主要介紹一下目前用的比較多的幾種「 佇列 」型別:
-
順序佇列
-
鏈式佇列
-
迴圈佇列
-
優先佇列
下面來依次瞭解一下:
-
用陣列實現的佇列,叫做 順序佇列:
用陣列實現的思路是這樣的:初始化一個長度為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個指標來控制(front和rear),如圖:
當進行入隊操作時,讓新節點的Next指向rear的Next,再讓rear的Next指向新節點,最後讓rear指標向後移動一位(即rear指標指向新節點),如上圖右邊部分。
當進行出隊操作時,直接將front指標指向的元素出隊,同時讓front指向下一個節點(即將front的Next賦值給front指標),如上圖左邊部分。
-
迴圈佇列
迴圈佇列是指佇列是前後連成一個圓圈,它以迴圈的方式去儲存元素,但還是會按照佇列的先進先出的原則去操作。迴圈佇列是基於陣列實現的佇列,但它比普通資料實現的佇列帶來的好處是顯而易見的,它能更有效率的利用陣列空間,且不需要移動資料。
普通的陣列佇列在經過了一段時間的入隊和出隊以後,尾指標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
-
優先佇列
優先佇列(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)
這道題其實還有另一個解法,只需要一個佇列就可以做到模擬出堆疊,思路就是:當需要進行入棧操作的時候,先將新元素插入到佇列的隊尾中,再將這個佇列中的其它元素依次出隊,佇列的特性當然是從隊頭出隊了,但是出來的元素再讓它們從隊尾入隊,這樣依次進行,留下剛才插入的新元素不動,這個時候,這個新元素其實就被頂到了隊頭了,新元素入棧的動作就完成了。當需要進行出棧操作的時候,就直接將佇列隊頭元素出隊即是了。
思路已經寫出來了,程式碼的話就留給大家練習了哦。
以上,就是對資料結構「 佇列 」的一些思考。
碼字不易啊,喜歡的話不妨轉發朋友吧。