1. 程式人生 > >【資料結構與演算法】之佇列的基本介紹及其陣列、連結串列實現---第五篇

【資料結構與演算法】之佇列的基本介紹及其陣列、連結串列實現---第五篇

一、佇列的基本概念

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