數據結構與算法之美 - 08 | 棧:如何實現瀏覽器的前進和後退功能?

這系列相關博客,參考 數據結構與算法之美

瀏覽器的前進、後退功能,我想你肯定很熟悉吧?

當你依次訪問完一串頁面a-b-c之後,點擊瀏覽器的後退按鈕,就可以查看之前瀏覽過的頁面b和a。當你後退到頁面a,點擊前進按鈕,就可以重新查看頁面b和c。但是,如果你後退到頁面b後,點擊了新的頁面d ,那就無法再通過前進、後退功能查看頁面c了。

假設你是Chrome瀏覽器的開發工程師,你會如何實現這個功能呢? 這就要用到我們今天要講的”棧”這種數據結構。帶着這個問題,我們來學習今天的內容。

如何理解”棧” ?

關於”棧”,我有一個非常貼切的例子,就是一摞疊在一起的盤子。我們平時放盤子的時候,都是從下往上一個—個放;取的時候,我們也是從上往下一個一個地依次取,不能從中間任意抽出。後進者先出,先進者後出,這就是典型的”棧”結構。
在這裏插入圖片描述
從棧的操作特性上來看,棧是一種”操作受限”的線性表,只允許在一端插入和刪除數據。

我第一次接觸這種數據結構的時候,就對它存在的意義產生了很大的疑惑。因爲我覺得,相比數組和鏈表,棧帶給我的只有限制,並沒有任何優勢。那我直接使用數組或者鏈表不就好了嗎?爲什麼還要用這個”操作受限”的”棧”呢?

事實上,從功能上來說,數組或鏈表確實可以替代棧,但你要知道,特定的數據結構是對特定場景的抽象,而且,數組或鏈表暴露了太多的操作接口,操作上的確靈活自由,但使用時就比較不可控,自然也就更容易出錯。

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

如何實現一個’棧’?

從剛纔棧的定義裏,我們可以看出,棧主要包含兩個操作,入棧和出棧,也就是在棧頂插入一個數據和從棧頂刪除一個數據。理解了棧的定義之後,我們來看一看如何用代碼實現一個棧。

實際上,棧既可以用數組來實現,也可以用鏈表來實現。用數組實現的棧,我們叫作順序棧,用鏈表實現的棧,我們叫作鏈式棧。

我這裏實現一個基於數組的順序棧。基於鏈表實現的鏈式棧的代碼,你可以自己試着寫一下。我會將我寫好的代碼放到Github上 ,你可以去看一下自己寫的是否正確。

我這段代碼是用Java來實現的,但是不涉及任何高級語法,並且我還用中文做了詳細的註釋,所以你應該是可以看懂的。

// 基於數組實現的順序棧public class ArrayStack { private String]] items; // 數組 private int count;

瞭解了定義和基本操作,那它的操作的時間、空間複雜度是多少呢?

不管是順序棧還是鏈式棧,我們存儲數據只需要一個大小爲n的數組就夠了。在入棧和出棧過程中,只需要一兩個臨時變量存儲空間,所以空間複雜度是O(1)。

注意,這裏存儲數據需要一個大小爲n的數組,並不是說空間複雜度就是0(n)°因爲,這n個空間是必須的,無法 省掉。所以我們說空間複雜度的時候,是指除了原本的數據存儲空間外,算法運行還需要額外的存儲空間。

空間複雜度分析是不是很簡單?時間複雜度也不難。不管是順序棧還是鏈式棧,入棧、出棧只涉及棧頂個別數據的操作,所以時間複雜度都是O(1)。

支持動態擴容的順序棧

剛纔那個基於數組實現的棧,是一個固定大小的棧,也就是說,在初始化棧時需要事先指定棧的大小。當棧滿之後,就無法再往棧裏添加數據了。儘管鏈式棧的大小不受限,但要存儲next指針,內存消耗相對較多。那我們如何基於數組實現一個可以支持動態擴容的棧呢?

你還記得,我們在數組那節,是如何來實現一個支持動態擴容的數組的嗎?當數組空間不夠時,我們就重新申請一塊更大的內存,將原來數組中數據統統拷貝過去。這樣就實現了一個支持動態擴容的數組。

所以,如果要實現一個支持動態擴容的棧,我們只需要底層依賴一個支持動態擴容的數組就可以了。當棧滿了之後,我們就申請一個更大的數組,將原來的數據搬移到新數組中。我畫了一張圖,你可以對照着理解一下。
在這裏插入圖片描述
實際上,支持動態擴容的順序棧,我們平時開發中並不常用到。我講這一塊的目的,主要還是希望帶你練習一下講的複雜度分析方法。所以這一小節的重點是複雜度分析。

你不用死記硬背入棧、出棧的時間複雜度,你需要掌握的是分析方法。能夠自己分析纔算是真正掌握了。現在我就帶你分析一下支持動態擴容的順序棧的入棧、出棧操作的時間複雜度。

對於出棧操作來說,我們不會涉及內存的重新申請和數據的搬移,所以出棧的時間複雜度仍然是O(1)。但是,對於入棧操作來說,情況就不一樣了。當棧中有空閒空間時,入棧操作的時間複雜度爲O(1)。但當空間不夠時,就需要重新申請內存和數據搬移,所以時間複雜度就變成了O(n)。

也就是說,對於入棧操作來說,最好情況時間複雜度是O⑴,最壞情況時間複雜度是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⑴,只有在個別時刻纔會退化爲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 i, int j){
	return i + j;
}

從代碼中我們可以看出,main()函數調用了add()函數,獲取計算結果,並且與臨時變量a相加,最後打印res的值。爲了讓你清晰地看到這個過程對應的函數棧裏出棧、入棧的操作,我畫了一張圖。圖中顯示的是,在執行到 add()函數時,函數調用棧的情況。
在這裏插入圖片描述

棧在表達式求值中的應用

我們再來看棧的另一個常見的應用場景,編譯器如何利用棧來實現表達式求值。

爲了方便解釋,我將算術表達式簡化爲只包含加減乘除四則運算,比如:34+13*9+44-12/3。對於這個四則運算,我們人腦可以很快求解出答案,但是對於計算機來說,理解這個表達式本身就是個挺難的事兒。如果換作你,讓你來實現這樣一個表達式求值的功能,你會怎麼做呢?

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

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

我將 3 + 5 * 8 - 6 這個表達式的計算過程畫成了一張圖,你可以結合圖來理解我剛講的計算過程。
在這裏插入圖片描述
這樣用兩個棧來解決的思路是不是非常巧妙?你有沒有想到呢?

棧在括號匹配中的應用

除了用棧來實現表達式求值,我們還可以藉助棧來檢查表達式中的括號是否匹配。

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

這裏也可以用棧來解決。我們用棧來保存未匹配的左括號,從左到右依次掃描字符串。當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號。如果能夠匹配,比如”(“跟”)”匹 配,"[’跟”]“匹配,”{“跟”}"匹配,則繼續掃描剩下的字符串。如果掃描的過程中,遇到不能配對的右括號,或者棧中沒有數據,則說明爲非法格式。

當所有的括號都掃描完成之後,如果棧爲空,則說明字符串爲合法格式;否則,說明有未匹配的左括號,爲非法格式。

解答開篇

好了,我想現在你已經完全理解了棧的概念。我們再回來看看開篇的思考題,如何實現瀏覽器的前進、後退功能?其實,用兩個棧就可以非常完美地解決這個問題。

我們使用兩個棧,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。此時兩個棧的數據這個樣子:
在這裏插入圖片描述

內容小結

我們來回顧一下今天講的內容。棧是一種操作受限的數據結構,只支持入棧和出棧操作。後進先出是它最大的特 點。棧既可以通過數組實現,也可以通過鏈表來實現。不管基於數組還是鏈表,入棧、出棧的時間複雜度都爲O(1)。除此之外,我們還講了一種支持動態擴容的順序棧,你需要重點掌握它的均攤時間複雜度分析方法。

課後思考

  • 我們在講棧的應用時,講到用函數調用棧來保存臨時變量,爲什麼函數調用要用”棧”來保存臨時變量呢?用其他數據結構不行嗎?
  • 我們都知道,JVM內存管理中有個"堆棧”的概念。棧內存用來存儲局部變量和方法調用,堆內存用來存儲Java中的對象。那JVM裏面的"棧”跟我們這裏說的"棧”是不是一回事呢?如果不是,那它爲什麼又叫作"棧"呢?
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章