資料結構基礎之佇列
轉自:http://www.cnblogs.com/edisonchou/p/4620379.html
佇列
在日常生活中,佇列的例子比比皆是,例如在車展排隊買票,排在隊頭的處理完離開,後來的必須在隊尾排隊等候。在程式設計中,佇列也有著廣泛的應用,例如計算機的任務排程系統、為了削減高峰時期訂單請求的訊息佇列等等。與棧類似,佇列也是屬於操作受限的線性表,不過佇列是隻允許在一端進行插入,在另一端進行刪除。在其他資料結構如樹的一些基本操作中(比如樹的廣度優先遍歷)也需要藉助佇列來實現,因此這裡我們來看看佇列。
一、佇列的概念及操作
1.1 佇列的基本特徵
佇列
(queue)是隻允許在一端進行插入操作,而在另一端進行刪除操作的線性表。它是一種先進先出(First In First Out)的線性表,簡稱FIFO。允許插入的一端稱為隊尾,允許刪除的一端稱為隊頭。
1.2 佇列的基本操作
(1)入隊(Enqueue):將一個數據元素插入隊尾;
(2)出隊(Dequeue):讀取隊頭節點資料並刪除該節點;
二、佇列的基本實現
既然佇列也屬於特殊的線性表,那麼其實現也會有兩種形式:順序儲存結構和鏈式儲存結構。首先,對於Queue,我們希望能夠提供以下幾個方法供呼叫:
Queue<T>() |
建立一個空的佇列 |
void Enqueue(T s) |
往佇列中新增一個新的元素 |
T Dequeue() |
移除佇列中最早新增的元素 |
bool IsEmpty() |
佇列是否為空 |
int Size() |
佇列中元素的個數 |
2.1 佇列的順序儲存實現
與Stack不同,在佇列中我們需要定義一個head隊頭“指標”和tail隊尾“指標”,當新元素入隊時tail+1,當老元素出隊時head+1。下面重點來看看Enqueue和Dequeue兩個方法的程式碼實現。
(1)入隊:Enqueue
public void EnQueue(T item) { if (Size == items.Length) { // 擴大陣列容量 ResizeCapacity(items.Length * 2); } items[tail] = item; tail++; size++; }
新元素入隊後,tail隊尾指標向前移動指向下一個新元素要插入的位置;這裡仍然模仿.NET中的實現,在陣列容量不足時及時進行擴容以容納新元素入隊。
(2)出隊:Dequeue
public T DeQueue() { if (Size == 0) { return default(T); } T item = items[head]; items[head] = default(T); head++; if (head > 0 && Size == items.Length / 4) { // 縮小陣列容量 ResizeCapacity(items.Length / 2); } size--; return item; }
在對老元素進行出隊操作時,首先取得head指標所指向的老元素,然後將head指標向前移動一位指向下一個將出隊的老元素。這裡將要出隊的元素所在陣列中的位置重置為預設值。最後判斷容量是否過小,如果是則進行陣列容量的縮小。
下面是完整的佇列模擬實現程式碼,僅供參考,這裡就不再做基本功能測試了,有興趣的讀者可以自行測試:
/// <summary> /// 基於陣列的佇列實現 /// </summary> /// <typeparam name="T">型別</typeparam> public class MyArrayQueue<T> { private T[] items; private int size; private int head; private int tail; public MyArrayQueue(int capacity) { this.items = new T[capacity]; this.size = 0; this.head = this.tail = 0; } /// <summary> /// 入隊 /// </summary> /// <param name="item">入隊元素</param> public void EnQueue(T item) { if (Size == items.Length) { // 擴大陣列容量 ResizeCapacity(items.Length * 2); } items[tail] = item; tail++; size++; } /// <summary>v /// 出隊 /// </summary> /// <returns>出隊元素</returns> public T DeQueue() { if (Size == 0) { return default(T); } T item = items[head]; items[head] = default(T); head++; if (head > 0 && Size == items.Length / 4) { // 縮小陣列容量 ResizeCapacity(items.Length / 2); } size--; return item; } /// <summary> /// 重置陣列大小 /// </summary> /// <param name="newCapacity">新的容量</param> private void ResizeCapacity(int newCapacity) { T[] newItems = new T[newCapacity]; int index = 0; if (newCapacity > items.Length) { for (int i = 0; i < items.Length; i++) { newItems[index++] = items[i]; } } else { for (int i = 0; i < items.Length; i++) { if (!items[i].Equals(default(T))) { newItems[index++] = items[i]; } } head = tail = 0; } items = newItems; } /// <summary> /// 棧是否為空 /// </summary> /// <returns>true/false</returns> public bool IsEmpty() { return this.size == 0; } /// <summary> /// 棧中節點個數 /// </summary> public int Size { get { return this.size; } } }
2.2 佇列的鏈式儲存實現
跟Stack鏈式儲存結構不同,在Queue鏈式儲存結構中需要設定兩個節點:一個head隊頭節點,一個tail隊尾節點。現在我們來看看在鏈式儲存結構中,如何實現Enqueue與Dequeue兩個方法。
(1)入隊:Enqueue
public void EnQueue(T item) { Node<T> oldLastNode = tail; tail = new Node<T>(); tail.Item = item; if(IsEmpty()) { head = tail; } else { oldLastNode.Next = tail; } size++; }
入隊操作就是在連結串列的末尾插入一個新節點,將原來的尾節點的Next指標指向新節點。
(2)出隊:Dequeue
public T DeQueue() { T result = head.Item; head = head.Next; size--; if(IsEmpty()) { tail = null; } return result; }
出隊操作本質就是返回連結串列中的第一個元素即頭結點,這裡可以考慮到如果佇列為空,將tail和head設為null以加快垃圾回收。
模擬的佇列鏈式儲存結構的完整程式碼如下,這裡就不再做基本功能測試了,有興趣的讀者可以自行測試:
View Code
2.3 迴圈佇列
首先,我們來看看下面的情景,在陣列容量固定的情況下,隊頭指標之前有空閒的位置,而隊尾指標卻已經指向了末尾,這時再插入一個元素時,隊尾指標會指向哪裡?
圖1
從圖中可以看出,目前如果接著入隊的話,因陣列末尾元素已經佔用,再向後加,就會產生陣列越界的錯誤,可實際上,我們的佇列在下標為0和1的地方還是空閒的。我們把這種現象叫做“假溢位”。現實當中,你上了公交車,發現前排有兩個空座位,而後排所有座位都已經坐滿,你會怎麼做?立馬下車,並對自己說,後面沒座了,我等下一輛?沒有這麼笨的人,前面有座位,當然也是可以坐的,除非坐滿了,才會考慮下一輛。
所以解決假溢位的辦法就是後面滿了,就再從頭開始,也就是頭尾相接的迴圈。我們把佇列的這種頭尾相接的順序儲存結構稱為迴圈佇列。在迴圈佇列中需要注意的幾個問題是:
(1)入隊與出隊的索引位置如何確定?
這裡我們可以藉助%運算對head和tail兩個指標進行位置確定,實現方式如下所示:
// 移動隊尾指標 tail = (tail + 1) % items.Length; // 移動隊頭指標 head = (head + 1) % items.Length;
(2)在佇列容量固定時如何判斷佇列空還是佇列滿?
①設定一個標誌變數flag,當head==tail,且flag=0時為佇列空,當head==tail,且flag=1時為佇列滿。
②當佇列空時,條件就是head=tail,當佇列滿時,我們修改其條件,保留一個元素空間。也就是說,佇列滿時,陣列中還有一個空閒單元。如下圖所示:
圖2
從上圖可以看出,由於tail可能比head大,也可能比head小,所以儘管它們只相差一個位置時就是滿的情況,但也可能是相差整整一圈。所以若佇列的最大尺寸為QueueSize,那麼佇列滿的條件是 (tail+1)%QueueSize==head(取模“%”的目的就是為了整合tail與head大小為一個問題)。比如上面這個例子,QueueSize=5,圖中的左邊front=0,而rear=4,(4+1)%5=0,所以此時佇列滿。再比如圖中的右邊,front=2而rear=1。(1+1)%5=2,所以此時佇列也是滿的。
(3)由於tail可能比head大,也可能比head小,那麼佇列的長度如何計算?
當tail>head時,此時佇列的長度為tail-head。但當tail<head時,佇列長度分為兩段,一段是QueueSize-head,另一段是0+tail,加在一起,佇列長度為tail-head+QueueSize。因此通用的計算佇列長度公式為:(tail-head+QueueSize)%QueueSize。
三、佇列的應用場景
佇列在實際開發中應用得非常廣泛,這裡來看看在網際網路系統中常見的一個應用場景:訊息佇列。“訊息”是在兩臺計算機間傳送的資料單位。訊息可以非常簡單,例如只包含文字字串;也可以更復雜,可能包含嵌入物件。訊息被髮送到佇列中,“訊息佇列”是在訊息的傳輸過程中儲存訊息的容器。
在目前廣泛的Web應用中,都會出現一種場景:在某一個時刻,網站會迎來一個使用者請求的高峰期(比如:淘寶的雙十一購物狂歡節,12306的春運搶票節等),一般的設計中,使用者的請求都會被直接寫入資料庫或檔案中,在高併發的情形下會對資料庫伺服器或檔案伺服器造成巨大的壓力,同時呢,也使響應延遲加劇。這也說明了,為什麼我們當時那麼地抱怨和吐槽這些網站的響應速度了。當時2011年的京東圖書促銷,曾一直出現在購物車中點選“購買”按鈕後一直是“Service is too busy”,其實就是因為當時的併發訪問量過大,超過了系統的最大負載能力。當然,後邊,劉強東臨時購買了不少伺服器進行擴充套件以求增強處理併發請求的能力,還請了資訊部的人員“喝茶”,現在京東已經是超大型的網上商城了,我也有同學在京東成都研究院工作了。
從京東當年的“Service is too busy”不難看出,高併發的使用者請求是網站成長過程中必不可少的過程,也是一個必須要解決的難題。在眾多的實踐當中,除了增加伺服器數量配置伺服器叢集實現伸縮性架構設計之外,非同步操作也被廣泛採用。而非同步操作中最核心的就是使用訊息佇列,通過訊息佇列,將短時間高併發產生的事務訊息儲存在訊息佇列中,從而削平高峰期的併發事務,改善網站系統的效能。在京東之類的電子商務網站促銷活動中,合理地使用訊息佇列,可以有效地抵禦促銷活動剛開始就開始大量湧入的訂單對系統造成的衝擊。
四、.NET中的Queue<T>
雖然佇列有順序儲存和鏈式儲存兩種儲存方式,但在.NET中使用的是順序儲存,它所對應的集合類是System.Collections.Queue與System.Collections.Generic.Queue<T>,兩者結構相同,不同之處僅在於前者是非泛型版本,後者是泛型版本的佇列。它們都屬於迴圈佇列,這裡我們通過Reflector來重點看看泛型版本的實現。
我們來看看在.NET中的Queue<T>是如何實現入隊和出隊操作的。首先來看看入隊Enqueue方法:
public void Enqueue(T item) { if (this._size == this._array.Length) { int capacity = (this._array.Length * 200) / 100; if (capacity < (this._array.Length + 4)) { capacity = this._array.Length + 4; } this.SetCapacity(capacity); } this._array[this._tail] = item; this._tail = (this._tail + 1) % this._array.Length; this._size++; this._version++; }
可以看出,與我們之前所實現的Enqueue方法類似,首先判斷了佇列是否滿了,如果滿了則進行擴容,不同之處在我們是直接*2倍,這裡是在原有容量基礎上+4。由於是迴圈佇列,對tail指標使用了%運算來確定下一個入隊位置。
我們再來看看Dequeue方法時怎麼實現的:
public T Dequeue() { if (this._size == 0) { ThrowHelper.ThrowInvalidOperationException(ExceptionResource.InvalidOperation_EmptyQueue); } T local = this._array[this._head]; this._array[this._head] = default(T); this._head = (this._head + 1) % this._array.Length; this._size--; this._version++; return local; }
同樣,與之前類似,不同之處在於判斷隊空時這裡直接拋了異常,其次由於是迴圈佇列,head指標也使用了%運算來確定下一個出隊元素的位置。
參考資料
(1)程傑,《大話資料結構》
(2)陳廣,《資料結構(C#語言描述)》
(3)段恩澤,《資料結構(C#語言版)》
(4)yangecnu,《淺談演算法與資料結構:—棧和佇列》
(5)李智慧,《大型網站技術架構:核心原理與案例分析》
(6)Edison Chou,《Redis初探:訊息佇列》
作者:周旭龍
出處:http://edisonchou.cnblogs.com
本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連結。