1. 程式人生 > >資料結構與演算法之佇列篇

資料結構與演算法之佇列篇

 

1、如何理解"佇列"?

類比棧,棧只支援兩個基本操作:入棧push()和出棧pop()。對於佇列的基本操作也只有兩個:入隊enqueue(),放一個數據到佇列尾部;出隊dequeue(),從佇列頭部去一個元素。(如下圖)

2、順序佇列和迴圈佇列

用陣列實現的佇列叫做順序佇列,用連結串列實現的佇列叫做鏈式佇列。佇列具有先進先出的特性,支援在隊尾插入元素,在隊頭刪除元素。

(1)、 順序佇列

那究竟如何實現一個佇列?我們先來看下基於陣列的實現方法(Java語言)

那麼佇列是如何實現的?請繼續往下看

對於棧來說,我孟只需要一個棧頂指標就可以。但是佇列需要兩個指標:一個是head指標,指向隊頭;一個是tail指標,指向隊尾。

當a,b,c,d依次入佇列之後,佇列中head指標指向下標為0的位置,tail指標指向下標為4的位置。(如下圖)

當我們呼叫兩次出對操作之後,佇列中的head指標指向下標為2的位置,tail指標仍然指向下標為4的位置。

隨著不停地進行入隊、出隊操作,head和tail都會持續往後移動。當tail移動到最右邊,即使陣列中還有空閒空間,也無法繼續往佇列中新增資料了。那麼怎麼解決呢?

類比陣列的刪除操作會導致陣列中的資料不連續,我們將資料搬移。但是,每次進行出隊操作都相當於刪除陣列下標為0的資料,要搬移整個佇列中的資料,這樣出隊操作的時間複雜度就會從原來的O(1)變為O(n).

優化一下,實際上我們在出隊時可以不用搬移資料。如果沒有空閒空間了,我們只需要在入隊時,再集中觸發一次資料的搬移操作。藉助這個思想,出對函式dequeue()保持不變,我們稍加改造一下入隊函式enqueue()的實現,就可以解決。(如下圖)

從程式碼中我們看到,當佇列的tail指標移動到陣列的最右邊後,如果有新的資料入隊,我們可以將head到tail之間的資料,整體搬移到陣列中0到tail-head的位置。

這種實現思路中,出隊操作的時間複雜度仍然是O(1)。

基於連結串列的佇列實現方法。

基於連結串列的實現我們同樣需要兩個指標:head指標和tail指標。他們分別指向連結串列的第一個結點和最後一個結點。(如圖所示),入隊時,tail->next=new_node,tail->next;出隊時,head=head->next。

(2)、迴圈佇列

迴圈佇列,顧名思義,它長得像環。原本陣列是有頭有尾,是一條直線。現在我們把首尾相連,掰成了一個環。(如下圖)

我們可以看到,圖中這個佇列得大小為8,當前head=4,tail=7。但有一個新的元素入隊時,我們放入下標為7的位置。但這個時候,我們並不把tail更新為8,而是將其在環中後移一位,到了下標為0的位置。再當有一個元素b入隊時,我們將b放入下標為0的位置,然後tail加1更新為1。所以,在a,b 依次入隊後,迴圈佇列中的元素就變成了下面的樣子:

通過這種方法,我們成功避免了資料搬移操作。看起來不難理解,但是迴圈佇列的程式碼實現難度要比前面講的非迴圈佇列難得多。要想寫出沒有bug的迴圈佇列的實現程式碼,我個人覺得,最關鍵的是,確定好隊空和隊滿的判定條件。

對於佇列的隊空判斷條件仍然是head==tail。但佇列滿的判斷條件就稍微有點複雜。下圖為一張佇列滿的圖:

如圖中畫的佇列滿的情況,tail=3,head=4,n=8,所以總結一下規律就是:(3+1)%8=4。多畫幾張隊滿的圖,你就會發現,但隊滿時,(tail+1)%n=head。

如圖所示,但佇列滿時,圖中的tail指向的位置實際上是沒有儲存資料的。所以迴圈佇列會浪費一個數組的儲存空間。

下面是實現的程式碼(如下圖)

3、組塞佇列和併發佇列

阻塞佇列其實就是在佇列基礎上增加了阻塞操作。簡單來說,就是在佇列為空的時候,從頭取資料會被阻塞。因為此時還沒有資料可取,直到佇列中有了資料才能返回;如果佇列滿了,那麼插入資料的資料操作就會被阻塞,直到佇列中有空閒位置後再插入資料,然後再返回。

上述的定義就是一個“生產者-消費者模型”!是的,我們可以使用阻塞佇列,輕鬆實現一個“生產者-消費者模型”!

這種基於阻塞佇列實現的“生產者-消費者模型”,可以有效地協調生產和消費的速度。當“生產者”生產資料的速度多快,“消費者”來不及消費時,儲存資料的佇列就會很滿了。這個時候生產者就阻塞等待,直到“消費者”消費了資料,“生產者”才會被喚醒繼續“生產“。

而且不僅如此,基於阻塞佇列,我們還可以通過協調“生產者”和“消費者”的個數來提高資料的處理效率。比如前面的例子,我們可以配置幾個”消費者“,來對應一個”生產者“。

在多執行緒情況下,會有多個執行緒同時操作佇列,這個時候就會存線上程安全問題,那如何實現一個執行緒安全的佇列?

執行緒安全的佇列我們叫做併發佇列。最簡單直接的方式是直接在enqueue ()、dequeue()方法上加鎖,但是鎖粒度大併發度會比較低,同一時刻僅允許一個存或者取操作。實際上,基於陣列的迴圈佇列,利用CAS原子操作,可以實現非常高的併發佇列。這也是迴圈佇列比鏈式佇列應用更加廣泛的原因。

4、問題:

當我們向固定大小的執行緒池中請求一個執行緒時,如果執行緒中沒有空閒的資源了,這個時候執行緒池如何處理這個請求?是拒絕請求還是排隊,各種處理策略又是怎麼實現的呢? 

我們一般有兩種處理策略。第一種是非阻賽的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排佇列,等到有空閒執行緒時,取出排隊的請求繼續處理。

 那如何儲存排隊的請求?

我們希望公平地處理每個排隊的請求,先進者先服務,所以佇列這種資料結構很適合來儲存排隊請求。我們前面說過,佇列有基於連結串列和陣列這兩種實現方式,那麼這兩種實現方式對於排隊請求又有什麼區別呢?

基於連結串列的實現方式,可以實現一個支援無限排隊的無界佇列,但是可能會導致過多的請求排隊等候,請求響應的時間過長。所以針對響應時間比較敏感的系統,基於連結串列實現的無限迴圈排隊的執行緒池是不合適的。

而基於陣列實現的有界佇列,佇列的大小有限,所以執行緒池中排隊的請求超過佇列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設定一個合理的佇列大小,也是非常有講究的佇列太大導致等待的請求太多,佇列太小會導致無法充分利用資源、發揮最大效能。

除了前面講到佇列應用線上程池請求排場的場景之外,佇列可以應用在任何有限資源池中,用於排隊請求,比如資料庫連線池等。實際上,對於大部分資源有限的場景,但沒有空閒資源時,基本上都可以通過“佇列”這種資料結構來實現請求排隊。

 


 

歡迎大家掃碼關注微信公眾號,其中含有有大量免費的人工智慧、影象處理、IT資料:

                                                                           Change,There is no better way !