算法的系列文章中,之前咱們已經聊過了「 數組和鏈表 」、「 堆棧 」,今天咱們再來繼續看看「 隊列 」這種數據結構。「 隊列 」和「 堆棧 」比較類似,都屬於線性表數據結構,並且都在操作上受到一定規則約束,都是非常常用的數據類型,咱們掌握得再熟練也不為過。
一、「 隊列 」是什么?
隊列(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)
這道題其實還有另一個解法,只需要一個隊列就可以做到模擬出堆棧,思路就是:當需要進行入棧操作的時候,先將新元素插入到隊列的隊尾中,再將這個隊列中的其它元素依次出隊,隊列的特性當然是從隊頭出隊了,但是出來的元素再讓它們從隊尾入隊,這樣依次進行,留下剛才插入的新元素不動,這個時候,這個新元素其實就被頂到了隊頭了,新元素入棧的動作就完成了。當需要進行出棧操作的時候,就直接將隊列隊頭元素出隊即是了。
思路已經寫出來了,代碼的話就留給大家練習了哦。
以上,就是對數據結構「 隊列 」的一些思考。
碼字不易啊,喜歡的話不妨轉發朋友吧。😊
本文原創發布於微信公眾號「 不止思考 」,歡迎關注。涉及 思維認知、個人成長、架構、大數據、Web技術 等。