當你依次訪問完一串頁面 a-b-c 以後,點擊瀏覽器的後退按鈕,就能夠查看以前瀏覽過的頁面 b 和 a。當你後退到頁面 a,點擊前進按鈕,就能夠從新查看頁面 b 和 c。可是,若是你後退到頁面 b 後,點擊了新的頁面 d,那就沒法再經過前進、後退功能查看頁面 c 了。python
假設你是 Chrome 瀏覽器的開發工程師,你會如何實現這個功能呢?算法
這就要用到咱們今天要講的「棧」這種數據結構。帶着這個問題,咱們來學習今天的內容。數組
如何理解「棧」?瀏覽器
關於「棧」,我有一個很是貼切的例子,就是一摞疊在一塊兒的盤子。咱們平時放盤子的時候,都是從下往上一個一個放;取的時候,咱們也是從上往下一個一個地依次取,不能從中間任意抽出。後進者先出,先進者後出,這就是典型的「棧」結構。數據結構
從棧的操做特性上來看,棧是一種「操做受限」的線性表,只容許在一端插入和刪除數據。函數
我第一次接觸這種數據結構的時候,就對它存在的意義產生了很大的疑惑。由於我以爲,相比數組和鏈表,棧帶給個人只有限制,並無任何優點。那我直接使用數組或者鏈表不就行了嗎?爲何還要用這個「操做受限」的「棧」呢?學習
事實上,從功能上來講,數組或鏈表確實能夠替代棧,但你要知道,特定的數據結構是對特定場景的抽象,並且,數組或鏈表暴露了太多的操做接口,操做上的確靈活自由,但使用時就比較不可控,天然也就更容易出錯。this
當某個數據集合只涉及在一端插入和刪除數據,而且知足後進先出、先進後出的特性,咱們就應該首選「棧」這種數據結構。操作系統
從剛纔棧的定義裏,咱們能夠看出,棧主要包含兩個操做,入棧和出棧,也就是在棧頂插入一個數據和從棧頂刪除一個數據。理解了棧的定義以後,咱們來看一看如何用代碼實現一個棧。線程
實際上,棧既能夠用數組來實現,也能夠用鏈表來實現。用數組實現的棧,咱們叫做順序棧,用鏈表實現的棧,咱們叫做鏈式棧。
我這裏實現一個基於數組的順序棧。基於鏈表實現的鏈式棧的代碼,你能夠本身試着寫一下。我會將我寫好的代碼放到 Github 上,你能夠去看一下本身寫的是否正確。
我這段代碼是用 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; } }
瞭解了定義和基本操做,那它的操做的時間、空間複雜度是多少呢?
不論是順序棧仍是鏈式棧,咱們存儲數據只須要一個大小爲 n 的數組就夠了。在入棧和出棧過程當中,只須要一兩個臨時變量存儲空間,因此空間複雜度是 O(1)。
注意,這裏存儲數據須要一個大小爲 n 的數組,並非說空間複雜度就是 O(n)。由於,這 n 個空間是必須的,沒法省掉。因此咱們說空間複雜度的時候,是指除了本來的數據存儲空間外,算法運行還須要額外的存儲空間。
空間複雜度分析是否是很簡單?時間複雜度也不難。不論是順序棧仍是鏈式棧,入棧、出棧只涉及棧頂個別數據的操做,因此時間複雜度都是 O(1)。
支持動態擴容的順序棧
剛纔那個基於數組實現的棧,是一個固定大小的棧,也就是說,在初始化棧時須要事先指定棧的大小。當棧滿以後,就沒法再往棧裏添加數據了。儘管鏈式棧的大小不受限,但要存儲 next 指針,內存消耗相對較多。那咱們如何基於數組實現一個能夠支持動態擴容的棧呢?
你還記得,咱們在數組那一節,是如何來實現一個支持動態擴容的數組的嗎?當數組空間不夠時,咱們就從新申請一塊更大的內存,將原來數組中數據通通拷貝過去。這樣就實現了一個支持動態擴容的數組。
因此,若是要實現一個支持動態擴容的棧,咱們只須要底層依賴一個支持動態擴容的數組就能夠了。當棧滿了以後,咱們就申請一個更大的數組,將原來的數據搬移到新數組中。我畫了一張圖,你能夠對照着理解一下。
實際上,支持動態擴容的順序棧,咱們平時開發中並不經常使用到。我講這一塊的目的,主要仍是但願帶你練習一下前面講的複雜度分析方法。因此這一小節的重點是複雜度分析。
你不用死記硬背入棧、出棧的時間複雜度,你須要掌握的是分析方法。可以本身分析纔算是真正掌握了。如今我就帶你分析一下支持動態擴容的順序棧的入棧、出棧操做的時間複雜度。
對於出棧操做來講,咱們不會涉及內存的從新申請和數據的搬移,因此出棧的時間複雜度仍然是 O(1)。可是,對於入棧操做來講,狀況就不同了。當棧中有空閒空間時,入棧操做的時間複雜度爲 O(1)。但當空間不夠時,就須要從新申請內存和數據搬移,因此時間複雜度就變成了 O(n)。
也就是說,對於入棧操做來講,最好狀況時間複雜度是 O(1),最壞狀況時間複雜度是 O(n)。那平均狀況下的時間複雜度又是多少呢?還記得咱們在複雜度分析那一節中講的攤還分析法嗎?這個入棧操做的平均狀況下的時間複雜度能夠用攤還分析法來分析。咱們也正好藉此來實戰一下攤還分析法。
爲了分析的方便,咱們須要事先作一些假設和定義:
棧空間不夠時,咱們從新申請一個是原來大小兩倍的數組;
爲了簡化分析,假設只有入棧操做沒有出棧操做;
定義不涉及內存搬移的入棧操做爲 simple-push 操做,時間複雜度爲 O(1)。
若是當前棧大小爲 K,而且已滿,當再有新的數據要入棧時,就須要從新申請 2 倍大小的內存,而且作 K 個數據的搬移操做,而後再入棧。可是,接下來的 K-1 次入棧操做,咱們都不須要再從新申請內存和搬移數據,因此這 K-1 次入棧操做都只須要一個 simple-push 操做就能夠完成。爲了讓你更加直觀地理解這個過程,我畫了一張圖。
你應該能夠看出來,這 K 次入棧操做,總共涉及了 K 個數據的搬移,以及 K 次 simple-push 操做。將 K 個數據搬移均攤到 K 次入棧操做,那每一個入棧操做只須要一個數據搬移和一個 simple-push 操做。以此類推,入棧操做的均攤時間複雜度就爲 O(1)。
經過這個例子的實戰分析,也印證了前面講到的,均攤時間複雜度通常都等於最好狀況時間複雜度。由於在大部分狀況下,入棧操做的時間複雜度 O 都是 O(1),只有在個別時刻纔會退化爲 O(n),因此把耗時多的入棧操做的時間均攤到其餘入棧操做上,平均狀況下的耗時就接近 O(1)。
前面我講的都比較偏理論,咱們如今來看下,棧在軟件工程中的實際應用。棧做爲一個比較基礎的數據結構,應用場景仍是蠻多的。其中,比較經典的一個應用場景就是函數調用棧。
咱們知道,操做系統給每一個線程分配了一塊獨立的內存空間,這塊內存被組織成「棧」這種結構%2C+用來存儲函數調用時的臨時變量。每進入一個函數,就會將臨時變量做爲一個棧幀入棧,當被調用函數執行完成,返回以後,將這個函數對應的棧幀出棧。爲了讓你更好地理解,咱們一塊來看下這段代碼的執行過程。
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; }
從代碼中咱們能夠看出,main() 函數調用了 add() 函數,獲取計算結果,而且與臨時變量 a 相加,最後打印 res 的值。爲了讓你清晰地看到這個過程對應的函數棧裏出棧、入棧的操做,我畫了一張圖。圖中顯示的是,在執行到 add() 函數時,函數調用棧的狀況。
咱們再來看棧的另外一個常見的應用場景,編譯器如何利用棧來實現表達式求值。
爲了方便解釋,我將算術表達式簡化爲只包含加減乘除四則運算,好比:34+13*9+44-12/3。對於這個四則運算,咱們人腦能夠很快求解出答案,可是對於計算機來講,理解這個表達式自己就是個挺難的事兒。若是換做你,讓你來實現這樣一個表達式求值的功能,你會怎麼作呢?
實際上,編譯器就是經過兩個棧來實現的。其中一個保存操做數的棧,另外一個是保存運算符的棧。咱們從左向右遍歷表達式,當遇到數字,咱們就直接壓入操做數棧;當遇到運算符,就與運算符棧的棧頂元素進行比較。
若是比運算符棧頂元素的優先級高,就將當前運算符壓入棧;若是比運算符棧頂元素的優先級低或者相同,從運算符棧中取棧頂運算符,從操做數棧的棧頂取 2 個操做數,而後進行計算,再把計算完的結果壓入操做數棧,繼續比較。
我將 3+5*8-6 這個表達式的計算過程畫成了一張圖,你能夠結合圖來理解我剛講的計算過程。
這樣用兩個棧來解決的思路是否是很是巧妙?你有沒有想到呢?
除了用棧來實現表達式求值,咱們還能夠藉助棧來檢查表達式中的括號是否匹配。
咱們一樣簡化一下背景。咱們假設表達式中只包含三種括號,圓括號 ()、方括號 [] 和花括號 {},而且它們能夠任意嵌套。好比,{ [ {} ] }或[ { [] } { [] } ] 等都爲合法格式,而 { [ } ( ) ]或 [ ({ ) ]爲不合法的格式。那我如今給你一個包含三種括號的表達式字符串,如何檢查它是否合法呢?
這裏也能夠用棧來解決。咱們用棧來保存未匹配的左括號,從左到右依次掃描字符串。當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號。若是可以匹配,好比「(」跟「)」匹配,「[」跟「]」匹配,「(」跟「)」匹配,則繼續掃描剩下的字符串。若是掃描的過程當中,遇到不能配對的右括號,或者棧中沒有數據,則說明爲非法格式。
當全部的括號都掃描完成以後,若是棧爲空,則說明字符串爲合法格式;不然,說明有未匹配的左括號,爲非法格式。
好了,我想如今你已經徹底理解了棧的概念。咱們再回來看看開篇的思考題,如何實現瀏覽器的前進、後退功能?其實,用兩個棧就能夠很是完美地解決這個問題。
咱們使用兩個棧,X 和 Y,咱們把首次瀏覽的頁面依次壓入棧 X,當點擊後退按鈕時,再依次從棧+X+中出棧,並將出棧的數據依次放入棧 Y。當咱們點擊前進按鈕時,咱們依次從棧 Y 中取出數據,放入棧 X 中。當棧 X 中沒有數據時,那就說明沒有頁面能夠繼續後退瀏覽了。當棧 Y 中沒有數據,那就說明沒有頁面能夠點擊前進按鈕瀏覽了。
好比你順序查看了 a,b,c 三個頁面,咱們就依次把 a,b,c 壓入棧,這個時候,兩個棧的數據就是這個樣子:
當你經過瀏覽器的後退按鈕,從頁面 c 後退到頁面 a 以後,咱們就依次把 c 和 b 從棧 X 中彈出,而且依次放入到棧 Y。這個時候,兩個棧的數據就是這個樣子:
這個時候,你經過頁面 b 又跳轉到新的頁面 d 了,頁面 c 就沒法再經過前進、後退按鈕重複查看了,因此須要清空棧 Y。此時兩個棧的數據這個樣子:
咱們來回顧一下今天講的內容。棧是一種操做受限的數據結構,只支持入棧和出棧操做。後進先出是它最大的特色。棧既能夠經過數組實現,也能夠經過鏈表來實現。無論基於數組仍是鏈表,入棧、出棧的時間複雜度都爲 O(1)。除此以外,咱們還講了一種支持動態擴容的順序棧,你須要重點掌握它的均攤時間複雜度分析方法。
咱們在講棧的應用時,講到用函數調用棧來保存臨時變量,爲何函數調用要用「棧」來保存臨時變量呢?用其餘數據結構不行嗎?
咱們都知道,JVM 內存管理中有個「堆棧」的概念。棧內存用來存儲局部變量和方法調用,堆內存用來存儲 Java 中的對象。那 JVM 裏面的「棧」跟咱們這裏說的「棧」是否是一回事呢?若是不是,那它爲何又叫做「棧」呢?