棧理論就是這麼簡單
一、背景
之前寫了五篇文章來講解了《 陣列就是這麼簡單 》系列,昨天寫了一篇文章來講解《 佇列就是這麼簡單 》,今天用一篇文章來介紹《棧就是這麼簡單》吧。
二、棧的定義
佇列是一種 LIFO
的資料結構。
LIFO
全稱是 Last In First Out
,即後進先出的意思。
內建的操作有:入棧(push)、出棧(pop)、是否為空(empty)、棧頂(top),大小(size)等。
使用陣列實現的話,程式碼大概如下:
class MyStack { private: vector<int> data; public: void push(int x) { data.push_back(x); } void pop() { data.pop_back(); } bool empty() { return data.empty(); } int top() { return data.back(); } int size(){ return data.size(); } };
下面我們來看看棧的一些初級應用吧。
三、最小棧
題意:實現一個最小棧,包含push、pop、top、getMin 四個功能。
思路:如果沒有 getMin
這個功能,上一小節的程式碼就可以滿足題意。
但是有一個查詢最小值,這裡就需要考慮怎麼實現了。
如果暴力實現,則是每次查詢最小值時,遍歷整個陣列。複雜度 O(n)
。
如果我們提前使用某種資料結構儲存起來的話,就可以做到快速查詢。
這裡我們利用 map
的 key
是有序的特點來在 O(log(n))
的時間內找到最小值。
當然,代價是入棧和出棧時也要使用 O(log(n))
的複雜度來更新這個資料結構。
四、有效的括號
題意:給一個括號組成的字串,判斷括號是否匹配。
這是學習棧的經典例子。
思路:掃描到位置 k
的時候,我們假設前面匹配的括號我們已經刪除了,尚未匹配的字元我們儲存在某種資料結構中了(肯定都是左括號)。
第 k
個字元如果是左括號,我們需要將這個左括號放進資料結構中。
而第 k
個字元如果是右括號,則資料結構裡面,最後放進去的那個字元肯定和第 k
個括號匹配。否則這個字串就是不匹配的。
匹配時,還需要從資料結構裡刪除最後進去的那個字元。
當掃描結束時,如果字串是匹配的,我們的資料結構應該恰好也為空。
程式碼如下:
五、每日溫度
題意:告訴你每天的溫度,需要計算每天需要等幾天才會高於當天的溫度。
思路:這道題使用數學術語表示,就是在數組裡,求每個數字之後第一個大於當前數字的距離。
第一個方法當然是暴力方案,即每個數字都迴圈一遍去計算答案。複雜度 O(n^2)
。
第二個方法就需要觀察這個問題的特徵了。
假設我們掃描到位置 k
時,前面的資料分兩部分:一部分是已經知道答案的,一部分是不知道答案的。
先看不知道答案的資料有什麼特徵。
對於 k-1
位置的數字,我們肯定不知道答案,因為是最後一天。
倒數第二個不知道答案的位置假設是 p
,則 p
的值肯定大於 k-1
的值,而且 p+1 ~ k-1
之間的數字都不大於 k-1
。
倒數第三個不知道答案的位置與 p
的關係已經如此。
這樣總結一下,就可以得出尚未不知道答案的數字是一個遞減序列,且序列之間的數字都小於後面那個數字。
如下圖:
對於當前位置 k
,肯定是要入隊的,但是入隊前我們需要更新資料結構,使其滿足遞減的特徵。
如果 k
的值大於資料結構裡面的最後一個元素,則那個元素就找到答案,即答案是位置 k
。
這樣不斷操作只到不滿足或者資料節後為空。
如果 k
的值小於等於資料結構裡最後一個元素,則進入資料結構也滿足遞減的特徵,正常進入即可。
由於我們每次都是操作資料結構最後一個元素,所以這種資料結構可以使用棧來代替。
六、逆波蘭表示式求值
題意:給一個逆波蘭表示式,求對應的答案。
比如有一個操作符 op1
和兩個變數 E1
和 E2
,使用正常的中綴表達法就是 E1 op1 E2
。
但是當 E1
或 E2
是另外一個表示式時,我們就需要加括號來區分計算的優先順序了。
例如 E1 = E3 op2 E4
時,完整的表示式將是 (E3 op2 E4) op E2
。
而逆波蘭表示式是字尾表達法,這種定義可以做到不使用括號且明確各個公式的優先順序。
比如還是上面的式子,第一個可以表達為 E1 E2 op1
,第二個可以表達為 E3 E4 op2 E2 op1
。
而當 E4 = E5 E6 op3
時,式子又可以展開為 E3 E5 E6 op3 op2 E2 op1
。
現在就是給了這樣一個字尾表示式,讓我們計算出表示式的值。
思路:我們可以確定遇到一個操作符時,前面肯定可以計算出兩個數字供這個操作符來運算。
但是那兩個數字怎麼來我們就不容易看出來了。
但是我們從前往後看,可以看到一個連續的數字,然後是一個操作符。
此時這個操作符的運算是可以確定的,參與運算後,連續數字的最後連個數字會被使用刪除,同時運算結果這個數字也會加進來。
這樣前面的就又是一片連續的數字了,直到遇到下個運算子,然後再次參與到上面的計算邏輯中。
這裡我們需要一種資料結構,來儲存前面的連續數字。
考慮到每次只會操作連續數字的最後兩個數字,我們可以使用棧來儲存。
每次遇到數字,就入棧。
當遇到操作符,就出棧兩個數字,參與計算後,結果再入棧。
如果表示式正確,計算完畢後,棧裡面就只剩一個數字,即答案。
具體程式碼如下:
七、最後
這篇文章主要介紹了棧的基礎理論,以及基本的應用。
比如自己實現棧、括號匹配、逆波蘭表示式等。
簡單的觀察就可以發現,這些問題之所以可以使用棧,是因為這些問題提取特徵後,變成了只關注最後面的資料。
即只關注最後進入資料結構的資料,這樣就滿足了後進先出的特徵,從而可以使用棧來做。
這裡面有一個因果關係需要解釋一下。
我們選擇棧的原因是問題的特徵和棧的特徵吻合,從而被選擇。
而不是我們選擇棧,然後發現問題的特徵。
面對任何問題其實都是這樣的。
先觀察問題的特徵,然後找現有的資料結構去匹配,如果沒有就自己使用多種資料結構組合使用,來滿足問題特徵的操作要求。
此時,我們實際上就是構造出了一個新的資料結構,不過我們沒有使用類封裝起來罷了。
這裡使用棧來講這個道理大家可能認為太簡單了。
其實對於其他資料結構或演算法,比如動態規劃(DP)、二分優化也是這樣的道理。
我們通過一系列的特徵推理出了一個演算法和資料結構,而不是啟發式的想到一個演算法,然後做出這道題。
千萬不要把因果關係弄反了。
畢竟啟發式演算法並不是可以隨時能夠想到,但是根據特徵推理出的演算法任何有邏輯與理性的人還是可以構造出來的。
PS:這篇文章簡單的介紹了棧的應用,下篇文章繼續來介紹棧的高階用法。
-EOF-