算法:4棧和對列

棧的特性:

後進者先出,先進者後出

棧就像摞在圓筒裏的一摞盤子。放盤子的時候,都是從下往上一個一個放;取的時候,我們也是從上往下一個一個地依次取,不能從中間任意抽出。後進者先出,先進者後出,這就是典型的“棧”結構。

棧是一種“操作受限”的線性表,只允許在一端插入和刪除數據。棧主要包含兩個操作,入棧和出棧,也就是在棧頂插入一個數據和從棧頂刪除一個數據。

特定的數據結構是對特定場景的抽象,當某個數據集合只涉及在一端插入和刪除數據,並且滿足後進先出、先進後出的特性,我們就應該首選“棧”這種數據結構

用數組實現的棧,叫作順序棧,用鏈表實現的棧,叫作鏈式棧

Java數組實現一個固定大小的棧:
// 基於數組實現的順序棧
public class ArrayStack {
  private String[] items;  // 數組
  private int count;       // 棧中元素個數
  private int n;           // 棧的大小
 
  // 初始化數組,申請一個大小爲 n 的數組空間
  public ArrayStack(int n) {
    this.items = new String[n];
    this.n = n;
    this.count = 0;
  }
 
  // 入棧操作
  public boolean push(String item) {
    // 數組空間不夠了,直接返回 false,入棧失敗。
    if (count == n) return false;
    // 將 item 放到下標爲 count 的位置,並且 count 加一
    items[count] = item;
    ++count;
    return true;
  }
  
  // 出棧操作
  public String pop() {
    // 棧爲空,則直接返回 null
    if (count == 0) return null;
    // 返回下標爲 count-1 的數組元素,並且棧中元素個數 count 減一
    String tmp = items[count-1];
    --count;
    return tmp;
  }
}

時間複雜度和空間複雜度都是 O(1)。注意:空間複雜度,存儲數據需要一個大小爲 n 的數組,並不是說空間複雜度就是 O(n)。因爲,這 n 個空間是必須的,無法省掉。空間複雜度 特指除了原本的數據存儲空間外,算法運行還需要額外的存儲空間。

支持動態擴容的順序棧

原理就是數組擴容。對於入棧操作來說,最好情況時間複雜度是 O(1),最壞情況時間複雜度是 O(n)。

大部分情況下,入棧操作的時間複雜度 O 都是 O(1),只有在個別時刻(擴容)纔會退化爲 O(n),所以把耗時多的入棧操作的時間均攤到其他入棧操作上,平均情況下的耗時就接近 O(1)。再次印證:均攤時間複雜度一般都等於最好情況時間複雜度。

棧的應用:

函數調用棧:

操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構, 用來存儲函數調用時的臨時變量。每進入一個函數,就會將臨時變量作爲一個棧幀入棧,當被調用函數執行完成,返回之後,將這個函數對應的棧幀出棧。

int main() {
   int a = 1; 
   int ret = 0;
   int res = 0;
   ret = add(3, 5);
   res = a + ret;
   printf("%d", res);
   reuturn 0;
}
 
int add(int x, int y) {
   int sum = 0;
   sum = x + y;
   return sum;
}

在這裏插入圖片描述

表達式求值中的棧

編譯器如何利用棧來實現表達式求值

編譯器就是通過兩個棧來實現的。其中一個保存操作數的棧,另一個是保存運算符的棧。從左向右遍歷表達式,當遇到數字,直接壓入操作數棧;當遇到運算符,就與運算符棧的棧頂元素進行比較。

如果比運算符棧頂元素的優先級高,就將當前運算符壓入棧;如果比運算符棧頂元素的優先級低或者相同,從運算符棧中取棧頂運算符,從操作數棧的棧頂取 2 個操作數,然後進行計算,再把計算完的結果壓入操作數棧,繼續比較。

例如3+5*8-6 這個表達式的計算過程。

在這裏插入圖片描述

棧在括號匹配中的應用

假設表達式中只包含三種括號,圓括號 ()、方括號 [] 和花括號{},並且它們可以任意嵌套。比如,{[{}]}或 [{()}([])] 等都爲合法格式,而{[}()] 或 [({)] 爲不合法的格式。給出一個包含三種括號的表達式字符串,如何檢查它是否合法呢?

public boolean isValid(String str) {
    if (TextUtils.isEmpty(str)) {
        return true;
    }
    Map<Character, Character> map = new HashMap<>(3);
    map.put(')', '(');
    map.put(']', '[');
    map.put('}', '{');
    Stack<Character> stack = new Stack<>();
    for (int i = 0, len = str.length(); i < len; i++) {
        char c = str.charAt(i);
        //若爲左括號,先入棧
        if (map.containsValue(c)) {
            stack.push(c);
        } else if (map.containsKey(c)) {//若爲右括號
            if(stack.isEmpty()){//棧內無長度,即有右無左,則不合法
                return false;
            }
            if(stack.peek()==map.get(c)){//棧頂匹配,就彈出
                stack.pop();
            }else {
                return false;
            }
        }
    }
    //遍歷結束-棧空-則合法。
    return stack.isEmpty();
}
瀏覽器前進後退的實現

使用兩個棧,X 和 Y,把首次瀏覽的頁面依次壓入棧 X,當點擊後退按鈕時,再依次從棧 X 中出棧,並將出棧的數據依次放入棧 Y。當點擊前進按鈕時,依次從棧 Y 中取出數據,放入棧 X 中。當棧 X 中沒有數據時,那就說明沒有頁面可以繼續後退瀏覽了。當棧 Y 中沒有數據,那就說明沒有頁面可以點擊前進按鈕瀏覽了。

次壓入棧 X,當點擊後退按鈕時,再依次從棧 X 中出棧,並將出棧的數據依次放入棧 Y。當點擊前進按鈕時,依次從棧 Y 中取出數據,放入棧 X 中。當棧 X 中沒有數據時,那就說明沒有頁面可以繼續後退瀏覽了。當棧 Y 中沒有數據,那就說明沒有頁面可以點擊前進按鈕瀏覽了。

a、b、c依次入棧X,從c後退到b,此時從b新開頁面d,則需要清空Y棧(c被清除)。從b按前進 只能是d,不再是c。

隊列

CPU 資源是有限的,任務的處理速度與線程個數並不是線性正相關。相反,過多的線程反而會導致 CPU 頻繁切換,處理性能下降。所以,線程池的大小一般都是綜合考慮要處理任務的特點和硬件環境,來事先設置的。

當我們向固定大小的線程池中請求一個線程時,如果線程池中沒有空閒資源了,這個時候線程池如何處理這個請求?是拒絕請求還是排隊請求?各種處理策略又是怎麼實現的呢?——隊列(queue)。先進者先出,這就是典型的“隊列”。隊列跟棧一樣,也是一種操作受限的線性表數據結構。比如循環隊列、阻塞隊列、併發隊列。它們在很多偏底層系統、框架、中間件的開發中,起着關鍵性的作用。比如高性能隊列 Disruptor、Linux 環形緩存,都用到了循環併發隊列;Java concurrent 併發包利用 ArrayBlockingQueue 來實現公平鎖等。

與棧類似,數組實現的叫做順序對列,鏈表實現的叫做鏈式隊列。

對列比棧麻煩一點,需要連個指針,head和tail,固定長度數組,隨着入隊出隊二者分別後移。

在這裏插入圖片描述
在這裏插入圖片描述
怎麼解決tail = 7 ,head>0 明明有空間,卻無法入隊的問題? 數據搬移。

    // 數組:items,數組大小:n
    private int n = 0;
    private String[] items = new String[n];
    // head 表示隊頭下標,tail 表示隊尾下標
    private int head = 0;
    private int tail = 0;
    
    // 入隊操作,將 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;
    }

    // 出隊
    public String dequeue() {
        // 如果 head == tail 表示隊列爲空
        if (head == tail) return null;
        String ret = items[head];
        ++head;
        return ret;
    }

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

在這裏插入圖片描述
儘管這麼做均攤複雜度O(1)。可否避免數據搬移呢?

循環對列

把上圖7和0連接起來形成閉環。tail = 7時,直接存到0,這樣就避免了數據搬移,代碼實現上,關鍵點:確定好隊空和隊滿的判定條件。對空條件依然是 head == tail,隊滿如何判斷?

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-dKvWgUx8-1586081793593)(C:\Users\lagou\Desktop\數據結構之美筆記\循環隊滿.jpg)]

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;
  }
}
阻塞對列

在隊列爲空的時候,從隊頭取數據會被阻塞。直到隊列中有了數據才能返回;如果隊列已經滿了,那麼插入數據的操作就會被阻塞,直到隊列中有空閒位置後再插入數據,然後再返回。

可以使用阻塞隊列,輕鬆實現一個“生產者 - 消費者模型”!

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

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

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

線程池該如何處理?

我們一般有兩種處理策略。第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閒線程時,取出排隊的請求繼續處理。那如何存儲排隊的請求呢?

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

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

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

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

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