一、如何理解棧
棧就是一個先進後出,後進先出的數據結構。
從操作特性上看,棧是一種操作受限的線性表,只允許在一端插入和刪除數據。
雖然使用數組和鏈表能夠替代棧這種數據結構,但是數組與鏈表向外暴露了太多的api接口,操作上面雖然自由,但是使用的時候就比較不可控,自然也就更容易出錯。
當某個數據集合只涉及在一端插入和刪除數據,並且滿足先進後出,後進先出的特性,我們就應該首選“棧”這種數據結構。
二、如何實現一個棧?
棧總共就兩個數據結構,一個是出棧,一個是入棧。我們只需要實現這兩個即可。棧可以使用數組或者鏈表來實現。
- 使用數組實現的棧叫做順序棧
- 使用鏈表實現的棧叫做鏈式棧
順序棧的實現如下圖所示:
// 基於數組實現的順序棧 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; } }
鏈式棧的實現如下圖所示:
public class LinkedStack { private Item dummy = new Item("dummy"); //作爲棧中最後一個節點 private Item head; LinkedStack() { head = dummy; } boolean pop() { if (dummy.next == null) { return false; } else { head = head.before; head.next = null; return true; } } boolean push(String val) { Item item = new Item(val); item.next = null; item.before = head; head.next = item; head = head.next; return true; } } class Item { String val; //使用雙向鏈表來處理pop Item next; Item before; Item(String val) { this.val = val; } }
不管是順序棧還是鏈式棧,入棧、出棧只涉及棧頂個別數據的操作,所以時間和空間複雜度都是 O(1)。
三、如何實現支持動態擴容的順序棧
1、實現方式
我們使棧動態擴容的方法很簡單,因爲順序棧的底層是數組,當超過數組的範圍時,新建一個兩倍原來大小的數組,然後將數據複製過去就可以了。
動態擴容的順序棧代碼如下:
package stack; public class StackByArrayAutoCapacity { String[] arrays; int autoCapacity; int count; int index; public StackByArrayAutoCapacity(int n) { arrays = new String[n]; autoCapacity = n; count = n; index = 0; } public boolean push(String item) { if (index == count) { String[] temp = arrays; arrays = new String[count + autoCapacity]; for (int i =0;i<temp.length;i++) { arrays[i] = temp[i]; } count = count + autoCapacity; } arrays[index] = item; index++; return true; } public String pop() { if (index == 0) { return null; } String temp = arrays[index - 1]; index--; return temp; } public String peek() { if (index == 0) { return null; } String temp = arrays[index - 1]; return temp; } public boolean empty() { return index == 0; } }
2、出入棧複雜度分析
對於出棧操作來說,我們不會涉及內存的重新申請和數據的搬移,所以出棧的時間複雜度仍然是O(1)。
- 但是,對於入棧操作來說,情況就不一樣了。
- 當棧中有空閒空間時,入棧操作的時間複雜度爲O(1)。
- 但當空間不夠時,就需要重新申請內存和數據搬移,所以時間複雜度就變成了O(n)。
3、攤還分析法
對於出棧操作來說,最好情況時間複雜度是O(1),最壞情況時間複雜度是O(n)。那平均情況下的時間複雜度又是多少了?
爲了分析的放便,我們需要事先做一些假設和定義:
-
- 棧空間不夠時,我們重新申請一個是原來大小兩倍的數組;
- 爲了簡化分析,假設只有入棧操作沒有出棧操作;
- 定義不涉及內存搬移的⼊棧操作爲simple-push操作,時間複雜度爲O(1)。
如果當前棧大小爲K,並且已滿,當再有新的數據要入棧時:
1、就需要重新申請2倍大小的內存,並且做K個數據的搬移操作,然後再入棧。
2、但是,接下來的K-1次入棧操作,我們都不需要再重新申請內存和搬移數據,所以這K-1次入棧操作都只需要一個simple-push操作就可以完成。
3、爲了讓你更加直觀地理解這個過程,我畫了一張圖。
你應該可以看出來
1、這K次入棧操作,總共涉及了K個數據的搬移,以及K次simple-push操作。將K個數據搬移均攤到K次入棧操作,
2、那每個入棧操作只需要一個數據搬移和一個simple-push操作。以此類推,入棧操作的均攤時間複雜度就爲O(1)。
通過這個例子的實戰分析
1、也印證了前面講到的,均攤時間複雜度一般都等於最好情況時間複雜度。
2、因爲在大部分情況下,入棧操作的時間複雜度O都是O(1),只有在個別時刻纔會退化爲O(n),
3、所以把耗時多的入棧操作的時間均攤到其他入棧操作上,平均情況下的耗時就接近O(1)。
四、棧在函數調用中的應用
我們知道,操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構,用來存儲函數調用時的臨時變量。在Java虛擬機中,就是其虛擬機棧。
比較經典的一個應用場景就是函數調用棧,每進入一個函數,就會將臨時變量作爲一個棧幀入棧,當被調用函數執行完成,返回之後,將這個函數對應的棧幀出棧。
爲了讓你更好地理解,我們一塊來看下這段代碼的執行過程:
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; }
從代碼中我們可以看出:
1、main()函數調用了add()函數,獲取計算結果,並且與臨時變量a相加,最後打印res的值。
2、爲了讓你清晰地看到這個過程對應的函數棧裏出棧、入棧的操作,我畫了一張圖。圖中顯示的是,在執行到add()函數時,函數調用棧的情況。
五、棧在表達式求值中的應用
實際上,編譯器就是通過兩個棧來實現的。其中一個保存操作數的棧,另一個是保存運算符的棧。
我們從左向右遍歷表達式,當遇到數字,我們就直接壓入操作數棧;當遇到運算符,就與運算符棧的棧頂元素進行比較。
1、如果比運算符棧頂元素的優先級高,就將當前運算符壓入棧;
2、如果比運算符棧頂元素的優先級低或者相同,從運算符棧中取棧頂運算符,
3、從操作數棧的棧頂取2個操作數,然後進行計算,再把計算完的結果壓入操作數棧,繼續比較。
我將3+5*8-6這個表達式的計算過程畫成了一張圖,你可以結合圖來理解我剛講的計算過程。
六、棧在括號匹配中的應用
除了⽤棧來實現表達式求值,我們還可以藉助棧來檢查表達式中的括號是否匹配。
我們同樣簡化一下背景:
1、我們假設表達式中只包含三種括號,圓括號()、中括號[]和花括號{},並且它們可以任意嵌套。
2、比如,{[{}]}或[{()}([])]等都爲合法格式,
3、{[}()]或[({)]爲不合法的格式。
那我現在給你⼀個包含三種括號的表達式字符串,如何檢查它是否合法呢?
這裏也可以用棧來解決:
1、我們用棧來保存未匹配的左括號,從左到右依次掃描字符串。當掃描到左括號時,則將其壓入棧中;
2、當掃描到右括號時,從棧頂取出一個左括號。如果能夠匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,則繼續掃描剩下的字符串。
3、如果掃描的過程中,遇到不能配對的右括號,或者棧中沒有數據,則說明爲非法格式。
當所有的括號都掃描完成之後,如果棧爲空,則說明字符串爲合法格式;否則,說明有未匹配的左括號,爲非法格式
七、解答開篇
好了,我想現在你已經完全理解了棧的概念。我們再回來看看開篇的思考題,如何實現瀏覽器的前進、後退功能?
其實,⽤兩個棧就可以非常完美地解決這個問題。
1、我們使用兩個棧,X和Y,我們把首次瀏覽的頁面依次壓入棧X,
2、當點擊後退按鈕時,再依次從棧X中出棧,並將出棧的數據依次放入棧Y。
3、當我們點擊前進按鈕時,我們依次從棧Y中取出數據,放入棧X中。當棧X中沒有數據時,那就說明沒有頁面可以繼續後退瀏覽了。
4、當棧Y中沒有數據,那就說明沒有面可以點擊前進按鈕瀏覽了。
比如你順序查看了a,b,c三個頁面,我們就依次把a,b,c壓入棧,這個時候,兩個棧的數據就是這個樣子
當你通過瀏覽器的後退按鈕,從頁面c後退到頁面a之後,我們就依次把c和b從棧X中彈出,並且依次放入到棧Y。這個時候,兩個棧的數據就是這個樣子:
這個時候你又想看頁面b,於是你有點擊前進按鈕回到b頁面,我們就把b再從棧Y中出棧,放入棧X中。此時兩個棧的數據是這個樣子:
這個時候,你通過頁面b又跳轉到新的頁面d了,頁面c就無法再通過前進、後退按鈕重複查看了,所以需要清空棧Y。此時兩個棧的數據這個樣子
八、問題思考
1. 我們在講棧的應用時,講到了函數調用棧來保存臨時變量,爲什麼函數調用要用“棧”來保存臨時變量呢?用其他數據結構不行嗎?
答:因爲函數調用的執行順序符合後進者先出,先進者後出的特點。
比如函數中的局部變量的生命週期的長短是先定義的生命週期長,後定義的生命週期短;
還有函數中調用函數也是這樣,先開始執行的函數只有等到內部調用的其他函數執行完畢,該函數才能執行結束。
正是由於函數調用的這些特點,根據數據結構是特定應用場景的抽象的原則,我們優先考慮棧結構。
2. 我們都知道,JVM內存管理中有個“堆棧”的概念。棧內存用來存儲局部變量和方法調用,堆內存用來存儲Java中的對象。
那JVM裏面的“棧”跟我們這裏說的“棧”是不是一回事呢?如果不是,那它爲什麼又叫作“棧”呢
答:JVM裏面的棧和我們這裏說的是一回事,被稱爲方法棧。和前面函數調用的作用是一致的,用來存儲方法中的局部變量。
參考文獻:https://www.cnblogs.com/luoahong/p/11812840.html