08-棧:如何實現瀏覽器的前進後退功能
瀏覽器的前進、後退功能,我想你肯定很熟悉吧?
當你依次訪問完一串頁面 a-b-c 之後,點選瀏覽器的後退按鈕,就可以檢視之前瀏覽過的頁面 b 和 a。當你後退到頁面 a,點選前進按鈕,就可以重新檢視頁面 b 和 c。但是,如果你後退到頁面 b 後,點選了新的頁面 d,那就無法再通過前進、後退功能檢視頁面 c 了。
假設你是 Chrome 瀏覽器的開發工程師,你會如何實現這個功能呢?
這就要用到我們今天要講的“棧”這種資料結構。帶著這個問題,我們來學習今天的內容。
如何理解“棧”? 關於“棧”,我有一個非常貼切的例子,就是一摞疊在一起的盤子。我們平時放盤子的時候,都是從下往上一個一個放;取的時候,我們也是從上往下一個一個地依次取,不能從中間任意抽出。後進者先出,先進者後出,這就是典型的“棧”結構。
從棧的操作特性上來看,棧是一種“操作受限”的線性表,只允許在一端插入和刪除資料。
我第一次接觸這種資料結構的時候,就對它存在的意義產生了很大的疑惑。因為我覺得,相比陣列和連結串列,棧帶給我的只有限制,並沒有任何優勢。那我直接使用陣列或者連結串列不就好了嗎?為什麼還要用這個“操作受限”的“棧”呢?
事實上,從功能上來說,陣列或連結串列確實可以替代棧,但你要知道,特定的資料結構是對特定場景的抽象,而且,陣列或連結串列暴露了太多的操作介面,操作上的確靈活自由,但使用時就比較不可控,自然也就更容易出錯。
當某個資料集合只涉及在一端插入和刪除資料,並且滿足後進先出、先進後出的特性,我們就應該首選“棧”這種資料結構。
如何實現一個“棧”? 從剛才棧的定義裡,我們可以看出,棧主要包含兩個操作,入棧和出棧,也就是在棧頂插入一個數據和從棧頂刪除一個數據。理解了棧的定義之後,我們來看一看如何用程式碼實現一個棧。
實際上,棧既可以用陣列來實現,也可以用連結串列來實現。用陣列實現的棧,我們叫作順序棧,用連結串列實現的棧,我們叫作鏈式棧。
我這裡實現一個基於陣列的順序棧。基於連結串列實現的鏈式棧的程式碼,你可以自己試著寫一下。我會將我寫好的程式碼放到 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)。
棧在函式呼叫中的應用 前面我講的都比較偏理論,我們現在來看下,棧在軟體工程中的實際應用。棧作為一個比較基礎的資料結構,應用場景還是蠻多的。其中,比較經典的一個應用場景就是函式呼叫棧。
我們知道,作業系統給每個執行緒分配了一塊獨立的記憶體空間,這塊記憶體被組織成“棧”這種結構, 用來儲存函式呼叫時的臨時變數。每進入一個函式,就會將臨時變數作為一個棧幀入棧,當被呼叫函式執行完成,返回之後,將這個函式對應的棧幀出棧。為了讓你更好地理解,我們一塊來看下這段程式碼的執行過程。
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,於是你又點選前進按鈕回到 b 頁面,我們就把 b 再從棧 Y 中出棧,放入棧 X 中。此時兩個棧的資料是這個樣子:
這個時候,你通過頁面 b 又跳轉到新的頁面 d 了,頁面 c 就無法再通過前進、後退按鈕重複查看了,所以需要清空棧 Y。此時兩個棧的資料這個樣子:
內容小結 我們來回顧一下今天講的內容。棧是一種操作受限的資料結構,只支援入棧和出棧操作。後進先出是它最大的特點。棧既可以通過陣列實現,也可以通過連結串列來實現。不管基於陣列還是連結串列,入棧、出棧的時間複雜度都為 O(1)。除此之外,我們還講了一種支援動態擴容的順序棧,你需要重點掌握它的均攤時間複雜度分析方法。
課後思考?
1. 我們在講棧的應用時,講到用函式呼叫棧來儲存臨時變數,為什麼函式呼叫要用“棧”來儲存臨時變數呢?用其他資料結構不行嗎?
2. 我們都知道,JVM 記憶體管理中有個“堆疊”的概念。棧記憶體用來儲存區域性變數和方法呼叫,堆記憶體用來儲存 Java 中的物件。那 JVM 裡面的“棧”跟我們這裡說的“棧”是不是一回事呢?如果不是,那它為什麼又叫作“棧”呢?
一、什麼是棧?
1.後進者先出,先進者後出,這就是典型的“棧”結構。 2.從棧的操作特性來看,是一種“操作受限”的線性表,只允許在端插入和刪除資料。
二、為什麼需要棧?
1.棧是一種操作受限的資料結構,其操作特性用陣列和連結串列均可實現。 2.但,任何資料結構都是對特定應用場景的抽象,陣列和連結串列雖然使用起來更加靈活,但卻暴露了幾乎所有的操作,難免會引發錯誤操作的風險。 3.所以,當某個資料集合只涉及在某端插入和刪除資料,且滿足後進者先出,先進者後出的操作特性時,我們應該首選棧這種資料結構。
三、如何實現棧?
1.棧的API
public class Stack<Item> {
//壓棧
public void push(Item item){}
//彈棧
public Item pop(){}
//是否為空
public boolean isEmpty(){}
//棧中資料的數量
public int size(){}
//返回棧中最近新增的元素而不刪除它
public Item peek(){}
}
2.陣列實現(自動擴容) 時間複雜度分析:根據均攤複雜度的定義,可以得陣列實現(自動擴容)符合大多數情況是O(1)級別複雜度,個別情況是O(n)級別複雜度,比如自動擴容時,會進行完整資料的拷貝。 空間複雜度分析:在入棧和出棧的過程中,只需要一兩個臨時變數儲存空間,所以O(1)級別。我們說空間複雜度的時候,是指除了原本的資料儲存空間外,演算法執行還需要額外的儲存空間。 實現程式碼:
public class StackOfArray<Item> implements Iterable<Item>{
//儲存資料的陣列
Item[] a = (Item[])new Object[1];
//記錄元素個數N
int N = 0;
//構造器
public StackOfArray(){}
//新增元素
public void push(Item item){
//自動擴容
if (N == a.length ) resize(2*a.length );
a[N++] = item;
}
//刪除元素
public Item pop(){
Item item = a[--N];
a[N] = null;
if (N > 0 && N == a.length / 4) resize(a.length / 2);
return item;
}
//是否為空
public boolean isEmpty(){
return N == 0;
}
//元素個數
public int size(){
return N;
}
//改變陣列容量
private void resize(int length) {
Item[] temp = (Item[])new Object[length];
for (int i = 0; i < N; i++) {
temp[i] = a[i];
}
a = temp;
}
//返回棧中最近新增的元素而不刪除它
public Item peek(){
return a[N-1];
}
@Override
public Iterator<Item> iterator() {
return new ArrayIterator();
}
//內部類
class ArrayIterator implements Iterator{
//控制迭代數量
int i = N;
@Override
public boolean hasNext() {
return i > 0;
}
@Override
public Item next() {
return a[--i];
}
}
}
3.連結串列實現 時間複雜度分析:壓棧和彈棧的時間複雜度均為O(1)級別,因為只需更改單個節點的索引即可。 空間複雜度分析:在入棧和出棧的過程中,只需要一兩個臨時變數儲存空間,所以O(1)級別。我們說空間複雜度的時候,是指除了原本的資料儲存空間外,演算法執行還需要額外的儲存空間。 實現程式碼:
public class StackOfLinked<Item> implements Iterable<Item> {
//定義一個內部類,就可以直接使用型別引數
private class Node{
Item item;
Node next;
}
private Node first;
private int N;
//構造器
public StackOfLinked(){}
//新增
public void push(Item item){
Node oldfirst = first;
first = new Node();
first.item = item;
first.next = oldfirst;
N++;
}
//刪除
public Item pop(){
Item item = first.item;
first = first.next;
N--;
return item;
}
//是否為空
public boolean isEmpty(){
return N == 0;
}
//元素數量
public int size(){
return N;
}
//返回棧中最近新增的元素而不刪除它
public Item peek(){
return first.item;
}
@Override
public Iterator<Item> iterator() {
return new LinkedIterator();
}
//內部類:迭代器
class LinkedIterator implements Iterator{
int i = N;
Node t = first;
@Override
public boolean hasNext() {
return i > 0;
}
@Override
public Item next() {
Item item = (Item) t.item;
t = t.next;
i--;
return item;
}
}
}
四、棧的應用
1.棧在函式呼叫中的應用 作業系統給每個執行緒分配了一塊獨立的記憶體空間,這塊記憶體被組織成“棧”這種結構,用來儲存函式呼叫時的臨時變數。每進入一個函式,就會將其中的臨時變數作為棧幀入棧,當被呼叫函式執行完成,返回之後,將這個函式對應的棧幀出棧。 2.棧在表示式求值中的應用(比如:34+13*9+44-12/3) 利用兩個棧,其中一個用來儲存運算元,另一個用來儲存運算子。我們從左向右遍歷表示式,當遇到數字,我們就直接壓入運算元棧;當遇到運算子,就與運算子棧的棧頂元素進行比較,若比運算子棧頂元素優先順序高,就將當前運算子壓入棧,若比運算子棧頂元素的優先順序低或者相同,從運算子棧中取出棧頂運算子,從運算元棧頂取出2個運算元,然後進行計算,把計算完的結果壓入運算元棧,繼續比較。 3.棧在括號匹配中的應用(比如:{}{()}) 用棧儲存為匹配的左括號,從左到右一次掃描字串,當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號,如果能匹配上,則繼續掃描剩下的字串。如果掃描過程中,遇到不能配對的右括號,或者棧中沒有資料,則說明為非法格式。 當所有的括號都掃描完成之後,如果棧為空,則說明字串為合法格式;否則,說明未匹配的左括號為非法格式。 4.如何實現瀏覽器的前進後退功能? 我們使用兩個棧X和Y,我們把首次瀏覽的頁面依次壓如棧X,當點選後退按鈕時,再依次從棧X中出棧,並將出棧的資料一次放入Y棧。當點選前進按鈕時,我們依次從棧Y中取出資料,放入棧X中。當棧X中沒有資料時,說明沒有頁面可以繼續後退瀏覽了。當Y棧沒有資料,那就說明沒有頁面可以點選前進瀏覽了。
五、思考
- 我們在講棧的應用時,講到用函式呼叫棧來儲存臨時變數,為什麼函式呼叫要用“棧”來儲存臨時變數呢?用其他資料結構不行嗎? 答:因為函式呼叫的執行順序符合後進者先出,先進者後出的特點。比如函式中的區域性變數的生命週期的長短是先定義的生命週期長,後定義的生命週期短;還有函式中呼叫函式也是這樣,先開始執行的函式只有等到內部呼叫的其他函式執行完畢,該函式才能執行結束。 正是由於函式呼叫的這些特點,根據資料結構是特定應用場景的抽象的原則,我們優先考慮棧結構。 2.我們都知道,JVM 記憶體管理中有個“堆疊”的概念。棧記憶體用來儲存區域性變數和方法呼叫,堆記憶體用來儲存 Java 中的物件。那 JVM 裡面的“棧”跟我們這裡說的“棧”是不是一回事呢?如果不是,那它為什麼又叫作“棧”呢? 答:JVM裡面的棧和我們這裡說的是一回事,被稱為方法棧。和前面函式呼叫的作用是一致的,用來儲存方法中的區域性變數。 3.記憶體中的堆疊和資料結構堆疊不是一個概念,可以說記憶體中的堆疊是真實存在的物理區,資料結構中的堆疊是抽象的資料儲存結構。 記憶體空間在邏輯上分為三部分: 程式碼區、靜態資料區和動態資料區,動態資料區又分為棧區和堆區。 程式碼區:儲存方法體的二進位制程式碼。高階排程(作業排程)、中級排程(記憶體排程)、低階排程(程序排程)控制程式碼區執行程式碼的切換。 靜態資料區:儲存全域性變數、靜態變數、常量,常量包括final修飾的常量和String常量。系統自動分配和回收。 棧區:儲存執行方法的形參、區域性變數、返回值。由系統自動分配和回收。 堆區:new一個物件的引用或地址儲存在棧區,指向該物件儲存在堆區中的真實資料。