數據結構——隊列

隊列也是一種數據結構,今天我們主要學習幾種基本的隊列,然後學習下隊列在線程池中的應用。

1. 什麼是隊列?

隊列具有“先進先出,後進後出”的特點。支持操作的也有限,最基本的操作也只有兩種:

  • 入隊enqueue():放一個數據到隊列尾部

  • 出隊dequeue():從隊列頭部取出一個元素

從上圖可以看出,隊列和棧一樣,都是操作受限的線性表數據結構。

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

2. 順序隊列和鏈式隊列

隊列跟棧一樣,都可以使用數組和鏈表來實現。

  • 用數組實現的隊列叫作順序隊列

  • 用鏈表實現的隊列叫作鏈式隊列

順序隊列

// 用數組實現的隊列
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 null;
    // 爲了讓其他語言的同學看的更加明確,把 -- 操作放到單獨一行來寫了
    String ret = items[head];
    ++head;
    return ret;
  }
}

在棧中,我們只需要一個棧頂指針,但在數列中需要兩個指針:

  • head指針:指向隊頭

  • tail指針:指向隊尾

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

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

從圖中我們可以看出,隨着不停的進行入隊、出隊操作,head和tail都會不斷的往後移動。當tail移動到最右邊時,即使數組中還有空閒的空間,但還是無法繼續往隊列中添加數據了。爲了優化這種情況,我們只需要在出隊時,不搬移數據。在入隊是,如果沒有空閒空間,就集中觸發一次數據搬移操作。

所以優化方案就是:出隊函數dequeue()保持不變,修改下enqueue()的實現:

// 入隊操作,將 item 放入隊尾
  public boolean enqueue(String item) {
    // tail == n 表示隊列末尾沒有空間了
    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 -= head;
      head = 0;
    }
    
    items[tail] = item;
    ++tail;
    return true;
  }

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

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

鏈式隊列

基於鏈表的實現,同樣需要兩個指針:

  • head指針:指向鏈表的第一個結點

  • tail指針:指向鏈表的最後一個結點

代碼實現如下:

package queue;

public class QueueBasedOnLinkedList {

  // 隊列的隊首和隊尾
  private Node head = null;
  private Node tail = null;

  // 入隊
  public void enqueue(String value) {
    if (tail == null) {
      Node newNode = new Node(value, null);
      head = newNode;
      tail = newNode;
    } else {
      tail.next = new Node(value, null);
      tail = tail.next;
    }
  }

  // 出隊
  public String dequeue() {
    if (head == null) return null;

    String value = head.data;
    head = head.next;
    if (head == null) {
      tail = null;
    }
    return value;
  }

  public void printAll() {
    Node p = head;
    while (p != null) {
      System.out.print(p.data + " ");
      p = p.next;
    }
    System.out.println();
  }

  private static class Node {
    private String data;
    private Node next;

    public Node(String data, Node next) {
      this.data = data;
      this.next = next;
    }

    public String getData() {
      return data;
    }
  }
}

爲了方便理解,畫了如下圖:

從圖中可以看出:

入隊時:tail->next=new_node,tail=tail->next

出隊時:head=head->next

3. 循環隊列

在前面用數組實現隊列的時候,當tail=n時,就會進行數據的搬移操作,這樣在入隊的時候性能就會受影響。而循環隊列就可以有效的避免這種情況下的數據搬移。

循環隊列的首尾相連,形成一個環形,如下圖所示:

上面隊列的大小是8,當前head=4,tail=7。

當有一個新元素a入隊時,將其放到下標爲7的位置,但此時並不將tail更新爲8,而是將其在環中後移一位,到下標爲0的位置。

當再有一個新元素b入隊時,將其放到下標爲0的位置,然後tail加1更新爲1。所以,在a、b依次入隊後,循環隊列就變成了如下所示:

通過上面的方法,就可以避免數據搬移的操作。

在用數組實現的非循環隊列中,隊滿的判斷條件是tail == n,隊空的判斷條件是head==tail。

在循環隊列中,隊空的條件仍然是head==tail,但隊滿的條件有點複雜,爲了總結出規律,畫了一張隊滿的圖,如下所示:

在上圖中,head=4,tail=3,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 capacity) {
    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 String dequeue() {
    // 如果 head == tail 表示隊列爲空
    if (head == tail) return null;
    String ret = items[head];
    head = (head + 1) % n;
    return ret;
  }
}

4. 阻塞隊列和併發隊列

阻塞隊列

在隊列的基礎上加上阻塞操作。

在隊列爲空的時候,從隊頭取數據會被阻塞。因爲此時沒有數據可取,直到隊列中有了數據才能返回;在隊列滿了的時候,從隊尾插入數據就會被阻塞,直到隊列中有空閒位置時,再插入數據,然後再返回。

從上面的圖可以看出,阻塞隊列是一種“生產者——消費者模型”,這種模型可以有效地協調生產和消費的速度。當“生產者”的生產速度過快,“消費者”來不及消費時,存儲數據的隊列很快就會變滿,此時,生產者就會阻塞等待,直到“消費者”消費了數據,“生產者”纔會被喚醒繼續“生產”。

其實,基於阻塞隊列,我們可以通過協調“生產者”和“消費者”的個數來提高數據的處理效率,例如我們可以配置多個“消費者”,如下圖所示:

併發隊列

在多線程情況下,會有多個線程同時操作隊列,而線程安全的隊列我們叫作“併發隊列”。

併發隊列最簡單的實現方式就是直接在enqueue()、dequeue()的方法上加鎖,同一時刻只允許一個存或取的操作。但當鎖粒度較大時,併發度會比較低。

5. 隊列在線程池中的應用

當線程池中沒有空閒線程時,此時線程池一般有兩種處理策略:

  1. 非阻塞式的處理方式,直接拒絕任務請求

  2. 阻塞式的處理方式,將請求排隊,等到有空閒線程時,取出排隊的請求繼續處理。

爲了公平的處理每個排隊的請求,我們可以採用隊列這種數據結構去處理。從前面我們可以知道隊列有基於鏈表和數組的兩種實現方式,但這兩種實現方式對於排隊請求又有區別:

  • 基於鏈表的實現方式,可以實現一個支持無限排隊的無解隊列,但可能會導致過多的請求排隊等待,請求處理響應的時間過長,所以,針對響應時間比較敏感的系統,基於鏈表實現的無限排隊線程池是不合適的。

  • 基於數組實現的有界隊列,隊列的大小有限,在線程池中排隊請求超過隊列大小時,後續的請求就會直接被拒絕。這種方式對響應時間敏感的系統來說,就會更加合理。但是,設置一個合理的隊列大小,也是非常重要的。隊列太大會導致等待的請求太多,隊列太小會導致無法充分利用系統資源,不能發揮出最大性能。

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

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