棧簡介
後進者先出,先進者後出,這就是典型的“棧”結構。
從棧的操作特性上來看,棧是一種“操作受限”的線性表,只允許在一端插入和刪除數據。
看了上面的介紹,你會發現從功能上來說,數組或鏈表是可以替代棧的。但你要知道,特定的數據結構是對特定場景的抽象,而且,數組或鏈表暴露了太多的操作接口,操作上的確靈活自由,但使用時就比較不可控,自然也就更容易出錯。
當某個數據集合只涉及在一端插入和刪除數據,並且滿足後進先出、先進後出的特性,我們就應該首選“棧”這種數據結構。
手動實現棧結構
從剛纔棧的定義裏,我們可以看出,棧主要包含兩個操作,入棧和出棧,也就是在棧頂插入一個數據和從棧頂刪除一個數據。理解了棧的定義之後,我們來看一看如何用代碼實現一個棧。
實際上,棧既可以用數組來實現,也可以用鏈表來實現。用數組實現的棧,我們叫作順序棧,用鏈表實現的棧,我們叫作鏈式棧。
public class MyStack<E> {
private int size;
private Object[] array;
public MyStack() {
// 默認創建長度是10的數組
array = new Object[10];
size = 0;
}
public MyStack(int capacity) {
// 默認創建長度是10的數組
array = new Object[capacity];
size = 0;
}
public E pop() {
if (size == 0) {
return null;
} else {
E item = (E) array[size - 1];
size--;
return item;
}
}
public boolean push(E item) {
int len = array.length;
if (len == size) {
Object[] ta = new Object[(int) (len * 1.5)];
System.arraycopy(array, 0, ta, 0, size);
array = ta;
}
array[size] = item;
size++;
return true;
}
}
上面操作的入棧和出棧操作的時間複雜度都是O(1)。
JDK中的棧結構
在Java中,我們一般用LinkedList來實現棧的功能。
LinkedList實現了Deque接口,Deque接口定義了pop和push方法。
public class MyStack<E> {
private LinkedList<E> list;
public MyStack() {
this.list = new LinkedList<>();
}
public MyStack(E data){
Set<E> singleton = Collections.singleton(data);
this.list = new LinkedList<>(singleton);
}
public E pop() {
return list.pop();
}
public void push(E item) {
list.push(item);
}
}
還是需要提示的是:LinkedList並不是線程安全的,如果需要做線程安全的棧,需要自己進行手動同步。
使用場景
1. 瀏覽器前進後退
在瀏覽器的同一個“tab”中訪問了多個頁面,我們可以使用瀏覽器的前進和後退功能來進行前後翻頁。
這個功能也可以通過棧來實現。
我們使用兩個棧,X 和 Y,我們把首次瀏覽的頁面依次壓入棧 X,當點擊後退按鈕時,再依次從棧 X 中出棧,並將出棧的數據依次放入棧 Y。當我們點擊前進按鈕時,我們依次從棧 Y 中取出數據,放入棧 X 中。當棧 X 中沒有數據時,那就說明沒有頁面可以繼續後退瀏覽了。當棧 Y 中沒有數據,那就說明沒有頁面可以點擊前進按鈕瀏覽了。
比如你順序查看了 a,b,c 三個頁面,我們就依次把 a,b,c 壓入棧,這個時候,兩個棧的數據就是這個樣子:
當你通過瀏覽器的後退按鈕,從頁面 c 後退到頁面 a 之後,我們就依次把 c 和 b 從棧 X 中彈出,並且依次放入到棧 Y。這個時候,兩個棧的數據就是這個樣子:
這個時候你又想看頁面 b,於是你又點擊前進按鈕回到 b 頁面,我們就把 b 再從棧 Y 中出棧,放入棧 X 中。此時兩個棧的數據是這個樣子:
這個時候,你通過頁面 b 又跳轉到新的頁面 d 了,頁面 c 就無法再通過前進、後退按鈕重複查看了,所以需要清空棧 Y。此時兩個棧的數據這個樣子:
2. 函數調用棧
每個函數調用被封裝成一個“棧幀”,每個“棧幀”就被放入棧中。
3. 表達式求值
爲了方便解釋,我將算術表達式簡化爲只包含加減乘除四則運算,比如:34+13*9+44-12/3。對於這個四則運算,我們人腦可以很快求解出答案,但是對於計算機來說,理解這個表達式本身就是個挺難的事兒。
實際上,編譯器就是通過兩個棧來實現的。其中一個保存操作數的棧,另一個是保存運算符的棧。我們從左向右遍歷表達式,當遇到數字,我們就直接壓入操作數棧;當遇到運算符,就與運算符棧的棧頂元素進行比較。
如果比運算符棧頂元素的優先級高,就將當前運算符壓入棧;如果比運算符棧頂元素的優先級低或者相同,從運算符棧中取棧頂運算符,從操作數棧的棧頂取 2 個操作數,然後進行計算,再把計算完的結果壓入操作數棧,繼續比較。
我將 3+5*8-6 這個表達式的計算過程畫成了一張圖,你可以結合圖來理解我剛講的計算過程。
對於表達式求值,你也可以使用一些求值引擎來實現。比如在Java中,你就可以調用JS引擎來實現表達式的值。
ScriptEngine engine= new ScriptEngineManager().getEngineByName("Nashorn");
String js="var a=10;var b=20; var c=a+b;c;";
Double o= (Double) engine.eval(js);
System.out.println(o);
4. 括號匹配
假設表達式中只包含三種括號,圓括號 ()、方括號[]和花括號{},並且它們可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都爲合法格式,而{[}()]或[({)]爲不合法的格式。那我現在給你一個包含三種括號的表達式字符串,如何檢查它是否合法呢?
這裏也可以用棧來解決。我們用棧來保存未匹配的左括號,從左到右依次掃描字符串。當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號。如果能夠匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,則繼續掃描剩下的字符串。如果掃描的過程中,遇到不能配對的右括號,或者棧中沒有數據,則說明爲非法格式。
當所有的括號都掃描完成之後,如果棧爲空,則說明字符串爲合法格式;否則,說明有未匹配的左括號,爲非法格式。