8 |棧:如何實現瀏覽器的前進和后退功能?

棧:如何實現瀏覽器的前進和后退功能?

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

當你依次訪問完一串頁面 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)。除此之外,我們還講了一種支持動態擴容的順序棧,你需要重點掌握它的均攤時間復雜度分析方法。

課后思考

我們在講棧的應用時,講到用函數調用棧來保存臨時變量,為什么函數調用要用“棧”來保存臨時變量呢?用其他數據結構不行嗎?

我們都知道,JVM 內存管理中有個“堆棧”的概念。棧內存用來存儲局部變量和方法調用,堆內存用來存儲 Java 中的對象。那 JVM 里面的“棧”跟我們這里說的“棧”是不是一回事呢?如果不是,那它為什么又叫作“棧”呢?

經典評論

  1. 內存中的堆棧和數據結構堆棧不是一個概念,可以說內存中的堆棧是真實存在的物理區,數據結構中的堆棧是抽象的數據存儲結構。
    內存空間在邏輯上分為三部分:代碼區、靜態數據區和動態數據區,動態數據區又分為棧區和堆區。
    代碼區:存儲方法體的二進制代碼。高級調度(作業調度)、中級調度(內存調度)、低級調度(進程調度)控制代碼區執行代碼的切換。
    靜態數據區:存儲全局變量、靜態變量、常量,常量包括final修飾的常量和String常量。系統自動分配和回收。
    棧區:存儲運行方法的形參、局部變量、返回值。由系統自動分配和回收。
    堆區:new一個對象的引用或地址存儲在棧區,指向該對象存儲在堆區中的真實數據。

  2. 一、什么是棧?
    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)級別。我們說空間復雜度的時候,是指除了原本的數據存儲空間外,算法運行還需要額外的存儲空間。
    實現代碼:(見另一條留言)
    3.鏈表實現
    時間復雜度分析:壓棧和彈棧的時間復雜度均為O(1)級別,因為只需更改單個節點的索引即可。
    空間復雜度分析:在入棧和出棧的過程中,只需要一兩個臨時變量存儲空間,所以O(1)級別。我們說空間復雜度的時候,是指除了原本的數據存儲空間外,算法運行還需要額外的存儲空間。
    實現代碼:(見另一條留言)
    四、棧的應用
    1.棧在函數調用中的應用
    操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構,用來存儲函數調用時的臨時變量。每進入一個函數,就會將其中的臨時變量作為棧幀入棧,當被調用函數執行完成,返回之后,將這個函數對應的棧幀出棧。
    2.棧在表達式求值中的應用(比如:34+13*9+44-12/3)
    利用兩個棧,其中一個用來保存操作數,另一個用來保存運算符。我們從左向右遍歷表達式,當遇到數字,我們就直接壓入操作數棧;當遇到運算符,就與運算符棧的棧頂元素進行比較,若比運算符棧頂元素優先級高,就將當前運算符壓入棧,若比運算符棧頂元素的優先級低或者相同,從運算符棧中取出棧頂運算符,從操作數棧頂取出2個操作數,然后進行計算,把計算完的結果壓入操作數棧,繼續比較。
    3.棧在括號匹配中的應用(比如:{}{()})
    用棧保存為匹配的左括號,從左到右一次掃描字符串,當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號,如果能匹配上,則繼續掃描剩下的字符串。如果掃描過程中,遇到不能配對的右括號,或者棧中沒有數據,則說明為非法格式。
    當所有的括號都掃描完成之后,如果棧為空,則說明字符串為合法格式;否則,說明未匹配的左括號為非法格式。
    4.如何實現瀏覽器的前進后退功能?
    我們使用兩個棧X和Y,我們把首次瀏覽的頁面依次壓如棧X,當點擊后退按鈕時,再依次從棧X中出棧,并將出棧的數據一次放入Y棧。當點擊前進按鈕時,我們依次從棧Y中取出數據,放入棧X中。當棧X中沒有數據時,說明沒有頁面可以繼續后退瀏覽了。當Y棧沒有數據,那就說明沒有頁面可以點擊前進瀏覽了。
    五、思考
    1.我們在講棧的應用時,講到用函數調用棧來保存臨時變量,為什么函數調用要用“棧”來保存臨時變量呢?用其他數據結構不行嗎?
    答:因為函數調用的執行順序符合后進者先出,先進者后出的特點。比如函數中的局部變量的生命周期的長短是先定義的生命周期長,后定義的生命周期短;還有函數中調用函數也是這樣,先開始執行的函數只有等到內部調用的其他函數執行完畢,該函數才能執行結束。
    正是由于函數調用的這些特點,根據數據結構是特定應用場景的抽象的原則,我們優先考慮棧結構。
    2.我們都知道,JVM 內存管理中有個“堆棧”的概念。棧內存用來存儲局部變量和方法調用,堆內存用來存儲 Java 中的對象。那 JVM 里面的“棧”跟我們這里說的“棧”是不是一回事呢?如果不是,那它為什么又叫作“棧”呢?
    答:JVM里面的棧和我們這里說的是一回事,被稱為方法棧。和前面函數調用的作用是一致的,用來存儲方法中的局部變量。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,488評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,034評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,327評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,554評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,337評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,883評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,975評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,114評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,625評論 1 332
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,555評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,737評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,244評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,973評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,615評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,343評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,699評論 2 370

推薦閱讀更多精彩內容

  • 1.ios高性能編程 (1).內層 最小的內層平均值和峰值(2).耗電量 高效的算法和數據結構(3).初始化時...
    歐辰_OSR閱讀 29,463評論 8 265
  • Swift1> Swift和OC的區別1.1> Swift沒有地址/指針的概念1.2> 泛型1.3> 類型嚴謹 對...
    cosWriter閱讀 11,120評論 1 32
  • 那天一個同學排練時很不認真,總是笑場。老師喝令停止,然后組織我們站隊,問了我們一個非常嚴肅的問題——你熱愛...
    steven_young閱讀 182評論 1 1
  • 過去,聽母親"講古"。講古就是講她小時候和年輕時所經過和見過的事。 講到我家蓋房時,她能清楚地說出都是誰來幫過忙。...
    春曉滌生閱讀 301評論 0 0
  • 問天, 為何如此多變? 問地, 為何如此多幻? 問世, 為何如此繁無? 問情, 為何如此無常?
    玉蘭的心閱讀 212評論 2 2