【資料結構與演算法】之佇列的基本介紹及其陣列、連結串列實現---第五篇
一、佇列的基本概念
1、定義
佇列是一種先進先出的線性表。它只允許在表的前端進行刪除操作,而在表的後端進行插入操作,具有先進先出、後進後出的特點。進行插入操作的一端成為隊尾(tail),進行刪除操作的一端稱為隊頭(head)。當佇列中沒有元素時,則稱之為空佇列。
在佇列中插入一個元素稱為入隊,從佇列中刪除一個元素稱為出隊。因為佇列只允許在隊尾插入元素,在隊頭刪除元素,所以佇列又稱為先進先出(FIFO-first in first out)線性表。其實佇列這種資料結構的特性,讓我們很容易就想到了平時生活中我們排隊場景,真是無處不在啊。。。圖書館、食堂、餐廳、公交車。。。
而在程式框架方面也有很多應用:最常見的就是各種“池“,比如:執行緒池、資料庫連線池,分散式中的訊息佇列等待。都體現出一種公平的思想,即先到的先得。
2、佇列和棧
其實佇列和棧有很多相似的地方,棧的兩個基本操作:壓棧(push)和彈棧(pop),而佇列的最基本的兩個操作是:入隊(enqueue())和出隊(dequeue())。因此佇列和棧一樣,也是一種操作受限的資料結構。
3、佇列的入隊和出隊操作(以陣列為例)
入隊操作enqueue:每次從隊尾插入元素,時間複雜度:O(1)
出隊操作dequeue:每次從隊頭刪除元素,時間複雜度:O(n)
但是可以發現,陣列實現的佇列有很大的不足,每次從陣列頭部刪除元素後,需要將頭部的所有元素往隊首移動一個位置,這是一個時間複雜度為O(n)的操作。
你可能會想到一種辦法:每進行一次出隊操作,就將隊首的的標誌往隊尾移動一個記憶體空間,這樣就不用進行資料搬移了,但是很明顯這樣又會產生一個很大的弊端,當隊尾標誌tail移動到最右邊時,即使陣列中還有空閒的記憶體空間,也無法往佇列中新增資料了,不能很好的利用記憶體空間。
你可能還會想到一種辦法:和JVM垃圾回收類似的思想,在出隊操作的時候,我們不用先搬移資料,當沒有空間空間,無法插入新資料的時候,在進行一次整體的資料搬移操作,這樣可以將出隊操作的時間複雜度降低為O(1),但是入隊操作時,需要先判斷佇列中是否有空間的記憶體空間,如果有,直接入隊,但是如果沒有,則需要將佇列中的資料進行一次整體的搬移,這樣時間複雜度就為O(n)了,顯然也不理想。其實現程式碼見文末:動態佇列的陣列實現
迴圈佇列很好的解決了這個問題。見下文......
4、迴圈佇列
從上面的陣列實現的佇列來看,其刪除操作的時間複雜度為O(n),而我們希望得到時間複雜度都為O(1)的插入和刪除操作,所以迴圈佇列就很好的符合了我們的標準。
所謂的迴圈佇列,就是長的像一個環,原本的佇列是由頭有尾的,是一條直線,我們現在將首位相連,就形成了一個環。如下圖所示:
我們現在來進行這樣的一組操作,如圖1所示,這是個大小為8的佇列,當前的隊首head=4,隊尾tail=7,此時有一個新元素A要隊時,我們將其放入下標為7的位置,然後將tail在環中順時針後移一個位置,即tail=0;當再有一個元素B要隊時,就將元素B放入0的位置,這時tail=1。在經過這兩次入隊操作後,迴圈佇列如圖2所示。
通過這樣,我們就成功的避免了資料搬移的操作(人類的智慧啊!!!)
二、佇列的實現
1、佇列的陣列實現
隊滿的判斷條件:tail == n
隊空的判斷條件:head == tail
public class ArrayQueue {
private Object[] items; // 儲存資料的陣列
private int n; // 佇列的容量
private int head = 0; // 隊頭索引
private int tail = 0; // 隊尾索引
// 申請一個指定容量為n的佇列
public ArrayQueue(int capacity){
items = new Object[capacity];
n = capacity;
}
// 入隊
public boolean enqueue(Object item){
// 首先判斷佇列是否滿了 tail == n
if(tail == n){
return false;
}
items[tail] = item; // 將資料插入到隊尾
tail++;
return true;
}
// 出隊
public Object dequeue(){
// 首先判斷佇列是否為空 head == tail
if(head == tail){
return null;
}
Object item = items[head]; // 將隊頭資料刪除
head++;
return item;
}
public int size(){
return tail;
}
// 顯示佇列中的資料
public void display(){
for(int i = 0; i < (tail - head); i++){
System.out.print(items[i] + ",");
}
}
}
測試程式碼:
public class ArrayQueueTest {
public static void main(String[] args) {
ArrayQueue queue = new ArrayQueue(10);
int size = queue.size();
System.out.println(size); // 0
// 入隊
queue.enqueue("A");
queue.enqueue("B");
queue.enqueue("C");
queue.enqueue("D");
queue.enqueue("E");
// 顯示佇列中的資料
queue.display();
System.out.println();
// 出隊
queue.dequeue();
queue.dequeue();
queue.dequeue();
// 顯示佇列中的資料
queue.display();
}
}
2、佇列的連結串列實現
隊滿的判斷條件:連結串列實現,不需要
隊空的判斷條件:tail == null
public class LinkQueue {
// 結點內部類
private static class Node{
private Object data; // 資料域
private Node next; // 指標域
public Node(Object data){
this.data = data;
}
// 提供一個獲取Node裡面資料的方法
public Object getData(){
return data;
}
}
private Node head = null; // 隊首結點
private Node tail = null; // 隊尾結點
// 入隊
public void enqueue(Object data){
Node newNode = new Node(data);
// 先判斷tail是否為空,如果為空,說明佇列為空
if(tail == null){
// 此時插入的是佇列裡的第一個資料元素,其head和tail均指向該結點
head = newNode;
tail = newNode;
}else{
tail.next = newNode; // 將之前tail的指標域指向newNode
tail = newNode; // 將tail變數指向newNode
}
}
// 出隊
public Object dequeue(){
//先判斷佇列裡是否還有資料元素了,如果head為null的時候,說明佇列為空
if(head == null){
return null;
}
Object data = head.getData();
head = head.next; // 將head的下一個結點標記為head
if(head == null){
tail = null; // 如果head為空了,則說明佇列為空,則tail也肯定為空了
}
return data;
}
// 顯示佇列裡面的資料
public void display(){
// 只要head不為空,佇列裡面就有資料
Node node = head;
while(node != null){
System.out.print(node.data + ", ");
node = node.next;
}
}
}
測試程式碼:
public class LinkQueueTest {
public static void main(String[] args) {
LinkQueue queue = new LinkQueue();
// 入隊
queue.enqueue("A");
queue.enqueue("B");
queue.enqueue("C");
queue.enqueue("D");
queue.enqueue("E");
// 顯示佇列中的資料
queue.display();
System.out.println();
// 出隊
queue.dequeue();
queue.dequeue();
queue.dequeue();
// 顯示佇列中的資料
queue.display();
}
}
3、迴圈佇列的陣列實現
隊滿的判斷條件:size == n 或者 (tail + 1) % n == head
隊空的判斷條件:head == tail
public class CircularQueue {
private Object[] items; // 宣告一個數組,用於存放佇列中的資料
private int n = 0; // 陣列的大小,即佇列的容量
private int size; // 記錄佇列中元素的個數
private int head = 0; // 隊頭的標誌
private int tail = 0; // 隊尾的標誌
// 申請一個容量為capacity的陣列
public CircularQueue (int capacity){
items = new Object[capacity];
n = capacity;
}
// 入隊
public boolean enqueue(Object item){
// 判斷佇列是否已經滿了 size == n,如果沒有size屬性,就用:(tail + 1) % n == head進行判斷
if(size == n){
return false;
}
items[tail] = item;
tail = (tail + 1) % n;
size++;
return true;
}
// 出隊
public Object dequeue(){
// 判斷佇列是否為空 head == tail
if(head == tail){
return null;
}
Object item = items[head];
head = (head + 1) % n;
size--;
return item;
}
// 佇列中元素的個數
public int size(){
return size;
}
}
測試程式碼:
public class CircularQueueTest {
public static void main(String[] args) {
CircularQueue queue = new CircularQueue(8);
// 入隊
queue.enqueue("A");
queue.enqueue("B");
queue.enqueue("C");
queue.enqueue("D");
queue.enqueue("E");
int size1 = queue.size();
System.out.println(size1); // 5
// 出隊
queue.dequeue();
queue.dequeue();
queue.dequeue();
int size2 = queue.size();
System.out.println(size2); // 2
}
}
附:4、動態佇列的陣列實現
這個過程出隊dequeue的程式碼不變,主要是入隊enqueue時,需要判斷當tail=n的時候,說明此時佇列中不能再進行入隊操作了,所以需要進行一次資料搬移操作,具體程式碼如下:
public class DynamicArrayQueue {
private Object[] items; // 儲存資料的陣列
private int n; // 佇列的容量
private int head = 0; // 隊頭索引
private int tail = 0; // 隊尾索引
// 申請一個指定容量為n的佇列
public DynamicArrayQueue(int capacity){
items = new Object[capacity];
n = capacity;
}
// 入隊操作
public boolean enqueue(Object item){
// 判斷佇列中是否還有儲存空間
if(tail == n){
// tail == n && head == 0表示整個佇列已經滿了
if(head == 0){
return false;
}
// 如果佇列中還有空閒位置,則進行資料搬移
for(int i = head; i < tail; i++){
items[i - head] = items[i];
}
// 搬移完成後,重新更新head和tail
tail = tail - head;
head = 0;
}
items[tail] = item;
tail++;
return true;
}
// 出隊
public Object dequeue(){
// 首先判斷佇列是否為空 head == tail
if(head == tail){
return null;
}
Object item = items[head]; // 將隊頭資料刪除
head++;
return item;
}
// 顯示佇列中的資料
public void display(){
for(int i = 0; i < (tail - head); i++){
System.out.print(items[i] + ",");
}
}
}
三、阻塞佇列和併發佇列
2、阻塞佇列
阻塞佇列:就是在佇列的基礎上加入了阻塞操作。換句話說,就是在佇列為空的時候,從隊頭取資料會被阻塞。因為此時還沒有資料可取,直到佇列中有了資料才能返回;如果佇列已經滿了,那麼之後的入隊操作就會被阻塞,直到佇列中有空閒的位置後才能再進行插入。
可以看出來,這樣的特點和“生產者---消費者”模型一致,因此可以使用阻塞佇列輕鬆的實現一個“生產者---消費者”模型。這種實現可以有效的協調生產和消費的速度。當生產者生產速度過快,消費者來不及消費時,佇列滿的時候就會讓生產者阻塞等待,直到消費者消費了資料,佇列中有了空閒的位置,生產者才能恢復生產。
阻塞佇列中,我們還可以協調生產者和消費者的個數,以此來提高資料的處理效率。【生活中,也是一樣,實際生產過程中,一個工廠肯定不止對應一個消費者的,畢竟一個消費者也養不起一個工廠的啊~】所以,往往一個生產者會對應多個消費者。在這種情況下,就會出現同一時間有多個執行緒同時操作這個佇列,那麼就需要我們考慮執行緒安全的問題了。
併發佇列:執行緒安全的佇列一般被稱之為併發對列。最簡單直接的實現方式就是在enqueue(),dequeue()方法上加鎖,但是鎖粒度大,併發度會比較低,同一時刻僅僅允許一個入隊或者出隊操作。實際上,基於陣列的迴圈佇列,利用CAS原子操作,可以實現非常高效的併發佇列。這也是迴圈佇列比鏈式佇列應用更加廣泛的原因,
最後來看這麼一個場景:線上程池、資料庫連池等的應用中,當遇到執行緒池中沒有空閒執行緒,但是又有新的任務請求執行緒資源時,我們一般有兩種處理策略:
(1)第一種是非阻塞的處理方式:直接拒絕;
(2)阻塞的處理方式,將請求加入佇列中,讓其排隊,等到有空閒的執行緒時,取出排在隊頭的請求。
實現方式 | 特點 | 適用場景 |
---|---|---|
佇列的陣列實現 | 佇列的大小是有限制的,所以執行緒池中排隊的請求超過佇列的大小時,接下來的請求就會被拒絕 | 對響應時間比較敏感的系統,即:請求等待執行緒的時間不會太長 |
佇列的連結串列實現 | 佇列的大小是無限的,但是這樣就很可能導致過多的請求排隊等待,請求處理的時間過長 | 對響應時間不太敏感的系統 |
所以這個時候合理的設定佇列的大小就成為了關鍵的問題。佇列太大會導致等待的請求過多,但是佇列太小又會導致無法充分利用系統資源。實際上對於大部分的資源連線池應用場景,當沒有空閒資源時,基本上都可以通過佇列這種資料結構讓新的請求排隊等待。
【ps:說明:阻塞佇列和併發佇列這一個模組的內容出自於極客時間的《資料結構與演算法之美》專欄】
參考及推薦:
1、佇列
3、佇列(queue)原理(入棧和出棧操作的兩張圖片源於此篇博文)
學習不是單打獨鬥,如果你也是做Java開發,可以加我微信,一起分享經驗學習!
本人微訊號:pengcheng941206