隊列是咱們開發中經常使用到的一種數據結構,它與棧的結構類似。然而棧是后進先出,而隊列是先進先出,說的專業一點就是FIFO。在生活中到處都可以找到隊列的,最常見的就是排隊,吃飯排隊,上地鐵排隊,其他就不過多舉例了。
隊列的模型
在數據結構中,和排隊這種場景最像的就是數組了,所以我們的隊列就用數組去實現。在排隊的過程中,有兩個基本動作就是入隊和出隊,入隊就是從隊尾插入一個元素,而出隊就是從隊頭移除一個元素。基本的模型我們可以畫一個簡圖:
看了上面的模型,我們很容易想到使用數組去實現隊列,
- 先定義一個數組,并確定數組的長度,我們暫定數組長度是5,而上面圖中的長度是一樣的;
- 再定義兩個數組下標,front和tail,front是隊頭的下標,每一次出隊的操作,我們直接取front下標的元素就可以了。tail是隊尾的下標,每一次入隊的操作,我們直接給tail下標的位置插入元素就可以了。
我們看一下具體的過程,初始狀態是一個空的隊列,
隊頭下標和隊尾下標都是指向數組中的第0個元素,現在我們插入第一個元素“a”,如圖:
數組的第0個元素賦值“a”,tail的下標+1,由指向第0個元素變為指向第1個元素。這些變化我們都要記住啊,后續在編程實現的過程中,每一個細節都不能忽略。然后我們再做一次出隊操作:
第0個元素“a”在數組中移除了,并且front下標+1,指向第1個元素。
這些看起來不難實現啊,不就是給數組元素賦值,然后下標+1嗎?但是我們想一想極端的情況, 我們給數組的最后一個元素賦值后,數組的下標怎么辦?
tail如果再+1,就超越了數組的長度了呀,這是明顯的越界了。同樣front如果取了數組中的最后一個元素,再+1,也會越界。這怎么辦呢?
循環數組
我們最開始想到的方法,就是當tail下標到達數組的最后一個元素的時候,對數組進行擴容,數組的長度又5變為10。這種方法可行嗎?如果一直做入隊操作,那么數組會無限的擴容下去,占滿磁盤空間,這是我們不想看到的。
另外一個方法,當front或tail指向數組最后一個元素時,再進行+1操作,我們將下標指向隊列的開頭,也就是第0個元素,形成一個循環,這就叫做循環數組。那么這里又引申出一個問題,我們的下標怎么計算呢?
- 數組的長度是5;
- tail當前的下標是4,也就是數組的最后一個元素;
- 我們給最后一個元素賦值后,tail怎么由數組的最后一個下標4,變為數組的第一個下標0?
這里我們可以使用取模來解決:tail = (tail + 1) % mod
,模(mod)就是我們的數組長度5,我們可以試一下,tail當前值是4,套入公式計算得到0,符合我們的需求。我們再看看其他的情況符不符合,假設tail當前值是1,套入公式計算得出2,也相當于是+1操作,沒有問題的。只有當tail+1=5時,才會變為0,這是符合我們的條件的。那么我們實現隊列的方法就選用循環數組,而且數組下標的計算方法也解決了。
隊列的空與滿
隊列的空與滿對入隊和出隊的操作是有影響的,當隊列是滿的狀態時,我們不能進行入隊操作,要等到隊列中有空余位置才可以入隊。同樣當隊列時空狀態時,我們不能進行出隊操作,因為此時隊列中沒有元素,要等到隊列中有元素時,才能進行出隊操作。那么我們怎么判斷隊列的空與滿呢?
我們先看看隊列空與滿時的狀態:
空時的狀態就是隊列的初始狀態,front和tail的值是相等的。
滿時的狀態也是front==tail,我們得到的結論是,front==tail時,隊列不是空就是滿,那么到底是空還是滿呢?這里我們要看看是什么操作導致的front==tail,如果是入隊導致的front==tail,那么就是滿;如果是出隊導致的front==tail,那就是空。
手擼代碼
好了,隊列的模型以及基本的問題都解決了,我們就可以手擼代碼了,我先把代碼貼出來,然后再給大家講解。
public class MyQueue<T> {
//循環數組
private T[] data;
//數組長度
private int size;
//出隊下標
private int front =0;
//入隊下標
private int tail = 0;
//導致front==tail的原因,0:出隊;1:入隊
private int flag = 0;
//構造方法,定義隊列的長度
public MyQueue(int size) {
this.size = size;
data = (T[])new Object[size];
}
/**
* 判斷對隊列是否滿
* @return
*/
public boolean isFull() {
return front == tail && flag == 1;
}
/**
* 判斷隊列是否空
* @return
*/
public boolean isEmpty() {
return front == tail && flag == 0;
}
/**
* 入隊操作
* @param e
* @return
*/
public boolean add(T e) {
if (isFull()) {
throw new RuntimeException("隊列已經滿了");
}
data[tail] = e;
tail = (tail + 1) % size;
if (tail == front) {
flag = 1;
}
return true;
}
/**
* 出隊操作
* @return
*/
public T poll() {
if (isEmpty()) {
throw new RuntimeException("隊列中沒有元素");
}
T rtnData = data[front];
front = (front + 1) % size;
if (front == tail) {
flag = 0;
}
return rtnData;
}
}
在類的開始,我們分別定義了,循環數組,數組的長度,入隊下標,出隊下標,還有一個非常重要的變量flag,它表示導致front==tail的原因,0代表出隊,1代表入隊。這里我們初始化為0,因為隊列初始化的時候是空的,而且front==tail,這樣我們判斷isEmpty()
的時候也是正確的。
接下來是構造方法,在構造方法中,我們定義了入參size
,也就是隊列的長度,其實就是我們循環數組的長度,并且對循環數組進行了初始化。
再下面就是判斷隊列空和滿的方法,實現也非常的簡單,就是依照上一小節的原理。
然后就是入隊操作,入隊操作要先判斷隊列是不是已經滿了,如果滿了,我們進行報錯,不進行入隊的操作。有的同學可能會說,這里應該等待,等待隊列有空位了再去執行。這種說法是非常正確的,我們先把最基礎的隊列寫完,后面還會再完善,大家不要著急。下面就是對循環數組的tail元素進行賦值,賦值后,使用我們的公式移動tail下標,tail到達最后一個元素時,通過公式計算,可以回到第0個元素。最后再判斷一下,這個入隊操作是不是導致了front==tail,如果導致了,就將flag置為1。
出隊操作和入隊操作類似,只不過是取值的步驟,這里不給大家詳細解釋了。
我們做個簡單的測試吧,
public static void main(String[] args) {
MyQueue<String> myQueue = new MyQueue<>(5);
System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
myQueue.add("a");
System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
myQueue.add("b");
myQueue.add("c");
myQueue.add("d");
myQueue.add("e");
System.out.println("isFull:"+myQueue.isFull()+" isEmpty:"+myQueue.isEmpty());
myQueue.add("f");
}
我們定義長度是5的隊列,分別加入a b c d e f
6個元素,并且看一下空和滿的狀態。
打印日志如下:
isFull:false isEmpty:true
isFull:false isEmpty:false
isFull:true isEmpty:false
Exception in thread "main" java.lang.RuntimeException: 隊列已經滿了
at org.example.queue.MyQueue.add(MyQueue.java:29)
at org.example.queue.MyQueue.main(MyQueue.java:82)
空和滿的狀態都是對的,而且再插入f元素的時候,報錯了”隊列已經滿了“,是沒有問題的。出隊的測試這里就不做了,留個小伙伴們去做吧。
并發與等待
隊列的基礎代碼已經實現了,我們再看看有沒有其他的問題。對了,第一個問題就是并發,我們多個線程同時入隊或者出隊時,就會引發問題,那么怎么辦呢?其實也很簡單,加上synchronized
關鍵字就可以了,如下:
/**
* 入隊操作
* @param e
* @return
*/
public synchronized boolean add(T e) {
if (isFull()) {
throw new RuntimeException("隊列已經滿了");
}
data[tail] = e;
tail = (tail + 1) % size;
if (tail == front) {
flag = 1;
}
return true;
}
/**
* 出隊操作
* @return
*/
public synchronized T poll() {
if (isEmpty()) {
throw new RuntimeException("隊列中沒有元素");
}
T rtnData = data[front];
front = (front + 1) % size;
if (front == tail) {
flag = 0;
}
return rtnData;
}
這樣入隊出隊操作就不會有并發的問題了。下面我們再去解決上面小伙伴提出的問題,就是入隊時,隊列滿了要等待,出隊時,隊列空了要等待,這個要怎么解決呢?這里要用的wait()
和notifyAll()
了,再進行編碼前,我們先理清一下思路,
- 目前隊列的長度是5,并且已經滿了;
- 現在要向隊列插入第6個元素,插入時,判斷隊列滿了,要進行等待
wait()
; - 此時有一個出隊操作,隊列有空位了,此時應該喚起之前等待的線程,插入元素;
相反的,出隊時,隊列是空的,也要等待,當隊列有元素時,喚起等待的線程,進行出隊操作。好了,擼代碼,
/**
* 入隊操作
* @param e
* @return
*/
public synchronized boolean add(T e) throws InterruptedException {
if (isFull()) {
wait();
}
data[tail] = e;
tail = (tail + 1) % size;
if (tail == front) {
flag = 1;
}
notifyAll();
return true;
}
/**
* 出隊操作
* @return
*/
public synchronized T poll() throws InterruptedException {
if (isEmpty()) {
wait();
}
T rtnData = data[front];
front = (front + 1) % size;
if (front == tail) {
flag = 0;
}
notifyAll();
return rtnData;
}
之前我們拋異常的地方,統一改成了wait()
,而且方法執行到最后進行notifyAll()
,喚起等待的線程。我們進行簡單的測試,
public static void main(String[] args) throws InterruptedException {
MyQueue<String> myQueue = new MyQueue<>(5);
new Thread(() -> {
try {
System.out.println(myQueue.poll());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
myQueue.add("a");
}
測試結果沒有問題,可以正常打印"a"。這里只進行了出隊的等待測試,入隊的測試,小伙伴們自己完成吧。
if還是while
到這里,我們手擼的消息隊列還算不錯,基本的功能都實現了,但是有沒有什么問題呢?我們看看下面的測試程序,
public static void main(String[] args) throws InterruptedException {
MyQueue<String> myQueue = new MyQueue<>(5);
new Thread(() -> {
try {
System.out.println(myQueue.poll());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(() -> {
try {
System.out.println(myQueue.poll());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
Thread.sleep(5000);
myQueue.add("a");
}
我們啟動了兩個消費者線程,同時從隊列里獲取數據,此時,隊列是空的,兩個線程都進行等待,5秒后,我們插入元素"a",看看結果如何,
a
null
結果兩個消費者都打印出了日志,一個獲取到null,一個獲取到”a“,這是什么原因呢?還記得我們怎么判斷空和滿的嗎?對了,使用的是if
,我們捋一下整體的過程,
- 兩個消費者線程同時從隊列獲取數據,隊列是空的,兩個消費者通過
if
判斷,進入等待; - 5秒后,向隊列中插入"a"元素,并喚起所有等待線程;
- 兩個消費者線程被依次喚起,一個取到值,一個沒有取到。沒有取到是因為取到的線程將front加了1導致的。這里為什么說依次喚起等待線程呢?因為
notifyAll()
不是同時喚起所有等待線程,是依次喚起,而且順序是不確定的。
我們希望得到的結果是,一個消費線程得到”a“元素,另一個消費線程繼續等待。這個怎么實現呢?對了,就是將判斷是用到的if
改為while
,如下:
/**
* 入隊操作
* @param e
* @return
*/
public synchronized boolean add(T e) throws InterruptedException {
while (isFull()) {
wait();
}
data[tail] = e;
tail = (tail + 1) % size;
if (tail == front) {
flag = 1;
}
notifyAll();
return true;
}
/**
* 出隊操作
* @return
*/
public synchronized T poll() throws InterruptedException {
while (isEmpty()) {
wait();
}
T rtnData = data[front];
front = (front + 1) % size;
if (front == tail) {
flag = 0;
}
notifyAll();
return rtnData;
}
在判斷空還是滿的時候,我們使用while
去判斷,當兩個消費線程被依次喚起時,還會再進行空和滿的判斷,這時,第一個消費線程判斷隊列中有元素,會進行獲取,第二個消費線程被喚起時,判斷隊列沒有元素,會再次進入等待。我們寫段代碼測試一下,
public static void main(String[] args) throws InterruptedException {
MyQueue<String> myQueue = new MyQueue<>(5);
new Thread(() -> {
try {
System.out.println(myQueue.poll());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
new Thread(() -> {
try {
System.out.println(myQueue.poll());
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}).start();
Thread.sleep(5000);
myQueue.add("a");
Thread.sleep(5000);
myQueue.add("b");
}
同樣,有兩個消費線程去隊列獲取數據,此時隊列為空,然后,我們每隔5秒,插入一個元素,看看結果如何,
a
b
10秒過后,插入的兩個元素正常打印,說明我們的隊列沒有問題。入隊的測試,大家自己進行吧。
總結
好了,我們手擼的消息隊列完成了,看看都有哪些重點吧,
- 循環數組;
- 數組下標的計算,用取模法;
- 隊列空與滿的判斷,注意flag;
- 并發;
- 喚起線程注意使用
while
;