精衛填海系列——隊列

隊列的定義

隊列(queue)是一種線性表數據結構,具有先進先出、後進後出的特點。

通俗的講,隊列有點像排隊買票,先來的先買,後來的人只能站末尾,而且不允許插隊。

隊列的使用

隊列和棧類似,都是“操作受限”的線性表,而且只有兩個基本操作:入隊enqueue(),放一個數據到隊列的尾部;出隊dequeue(),從隊列頭部取一個元素。

隊列的概念很好理解,基本操作也很容易掌握。作爲一種非常基礎的數據結構,隊列的應用也非常廣泛,特別是一些具有某些額外特性的隊列,比如循環隊列、阻塞隊列、併發隊列。它們在很多偏底層系統、框架、中間件的開發中,起到了關鍵性的作用。比如高性能隊Disruptor、Linux環形緩存,都用到了循環併發隊列;Java concurrent併發包利用ArrayBlockingQueue來實現公平鎖等。

實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過“隊列”這種數據結構來實現請求排隊。

隊列的實現

跟棧一樣,隊列可以用數組實現,也可以用鏈表來實現。用數組實現的棧叫作順序棧,用鏈表實現的棧叫作鏈式棧。同樣,用數組實現的隊列叫作順序隊列,用鏈表實現的隊列叫作鏈式隊列。

下面是基於Java數組實現的順序隊列。

//用數組實現的隊列
public class ArrayQueue(){
  //數組:items,數組大小:n
  private String[] items;
  private int n=0;
  //head表示隊頭下標,tail表示隊尾下標
  private int head=0;
  private int tail=0;
  
  //申請一個大小爲capacity的數組
  public ArrayQueue(int capacity){
    items= new String[capacity];
    n=capacity;
  }
  
  //入隊
  public boolean enqueue(String item){
    //tail==n 表示隊列已經滿了
    if(tail==n){
      return false;
    }
    items[tail]=item;
    ++tail;
    return true;
  }
  
  //出隊
  public String dequeue(){
    //head==tail 表示隊列已經爲空
    if(head==tail){
      return false;
    }
    String item=items[head];
    ++head;
    return items;
  }
}

比起棧的數組實現,隊列的數組實現稍微有點複雜,但是沒關係。我稍微解釋一下實現思路,你就容易就能明白了。

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

你可以結合下面的圖來理解。當a、b、c、d依次入隊之後,隊列中的head指針指向下標爲0的位置,tail指針指向下標爲4的位置。

當我們調用兩次出隊操作之後,隊列中head指針指向下標爲2的位置,tail指針仍然指向下標爲4的位置。

你肯定已經發現了,隨着不停地進行入隊、出隊操作,head和tail都會持續往後移動。當tail移動到最右邊,即數組中還有空閒空間,也無法繼續往隊列中添加數據了。這個問題該如何解決呢?

入隊的優化

在數組中也會遇到類似的問題,比如數組的刪除操作會導致數組中的數據不連續。這個時候我們會選擇用數據遷移的方法來解決問題!但是,在隊列中每次進行出隊操作都相當於刪除數組下標爲0的數據,要搬移整個隊列中的數據,這樣出隊操作的時間複雜度就會從原來的O(1)變成O(n)。能不能優化一下呢?

實際上,我們在出隊時可以不用搬移數據。如果沒有空閒空間了,我們只需要在入隊時,再集中觸發一次數據的搬移操作。藉助這個思想,出隊函數dequeue()保持不變,我們稍加改造一下入隊函數enqueue()的實現,就可以輕鬆解決剛纔的問題了。下面是具體的代碼:

//入隊操作,將item放入隊尾
public boolean enqueue(String item){
  //tail==n表示隊列末尾沒有空間了
  if(tail==n){
    //tail==n&&head==0,表示整個隊列都佔滿了
    if(head==0){
      retun false;
    }
    //數據遷移
    for(int i=head;i<tail;++i){
      items[i-head]=items[i];
    }
    //搬移完之後重新更新head和tail
    tail-=head;
    head=0;
  }
  items[tail]=item;
  ++tail;
  return true;
}

從代碼中我們看到,當對列的tail指針移動到數組的最右邊後,如果有新的數據入隊,我們可以將head到tail之間的數據,整體搬移到數組中0到tail-head的位置。

循環隊列

我們剛纔用數組來實現對列的時候,在tail==n時,會有數據搬移操作,這樣入隊操作性能就會受到影響。那有沒有辦法能過避免數據搬移呢?我們來看看循環隊列的解決思路。

循環隊列,顧名思義,它長得像一個環。原本數組是有頭有尾的,是一條直線。現在我們把首位相連,扳成了一個環。如圖。

 

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

通過這樣的方法,我們成功避免了數據搬移操作。看起來不難理解,但是循環隊列的代碼實現難度要比非循環隊列難多了。一定要確定好隊空和隊滿的判斷條件。

在數組實現的非循環隊列中,隊滿的判斷條件是tail==n,隊空的判斷條件是head==tail。那針對循環隊列,如何判斷隊空和隊滿呢?

隊列爲空的判斷條件仍然是head==tail。但隊列滿的判斷條件就有點稍微複雜了。你可以看一下下面的圖,試着總結一下規律。

 

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

你有沒有發現,當隊列滿時,圖中的tail指向的位置實際上是沒有存儲數據的。所以,循環隊列會浪費一個數組的存儲空間。

public class CircularQueue{
  //數組:items,數組大小:n
  private String[] items;
  private int n=0;
  //head表示隊頭下標,tail表示隊尾下標
  private int head=0;
  private int tail=0;
  //申請一個大小爲capacity的數組
  public CircularQueue(int capcacity){
    items=new String[capacity];
    n=capacity;
  }
  //入隊
  public boolean enqueue(String item){
    //隊列滿了
    if((tail+1)%n==head){
      return false;
    }
    items[tail]=item;
    tail=(tail+1)%n;
    return true;
  }
  //出隊
  public boolean dequeue(){
    //如果head==tail表示隊列爲空
    if(head==tail){
      return null;
    }
    String ret=items[head];
    head=(head+1)%n
    return ret;
  }
}

阻塞隊列和併發隊列

前面講的內容理論比較多,看起來很難跟實際的項目開發扯上關係。確實,隊列這種數據結構結構很基礎,平時的業務開發不大可能從零實現一個隊列,甚至都不會直接用到。而一些具有特殊性質的隊列應用卻比較廣泛,比如阻塞隊列和併發隊列。

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

你應該已經發現了,上述的定義就是一個“生產者-消費者模型”!是的,我們可以使用阻塞隊列,輕鬆實現一個“生產者-消費者模型”!

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

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

前面我們講了阻塞隊列,在多線程情況下,會有多個線程同時操作隊列,這個時候就會存在線程安全問題,那如何實現一個線程安全的隊列呢?

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

學習了王爭老師的《數據結構與算法之美》,根據課程內容整理的筆記

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章