1. 程式人生 > >算法系列- 棧:如何實現瀏覽器的前進和後退功能?

算法系列- 棧:如何實現瀏覽器的前進和後退功能?

整理自極客時間-資料結構與演算法之美。原文內容更完整具體,且有音訊。購買地址:

1.如何理解棧

關於“棧”,有一個非常貼切的例子,就是一摞疊在一起的盤子。我們平時放盤子的時候,都是從下往上一個一個放;取的時候,我們也是從上往下一個一個地依次取,不能從中間任意抽出。後進先出,先進後出,這就是典型的“棧”結構

從棧的操作特性上來看,棧是一種“操作受限”的線性表,只允許在一端插入和刪除資料。

相比陣列和連結串列,棧帶給我的只有限制,並沒有任何優勢。那我直接使用陣列或者連結串列不就好了嗎?為什麼還要用這個“操作受限”的“棧”呢?

事實上,從功能上來說,陣列或連結串列確實可以替代棧,但你要知道,特定的資料結構是對特定場景的抽象,而且,陣列或連結串列暴露了太多的操作介面,操作上的確靈活自由,但使用時就比較不可控,自然也就更容易出錯

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

2如何實現一個棧

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

棧既可以用陣列來實現,也可以用連結串列來實現。用陣列實現的棧,我們叫作順序棧,用連結串列實現的棧,我們叫作鏈式棧

我這裡實現一個基於陣列的順序棧。

// 基於陣列實現的順序棧
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 個空間是必須的,無法省掉。所以我們說空間複雜度的時候,是指除了原本的資料儲存空間外,演算法執行還需要額外的儲存空間

3.支援動態擴容的順序棧

剛才那個基於陣列實現的棧,是一個固定大小的棧,也就是說,在初始化棧時需要事先指定棧的大小。當棧滿之後,就無法再往棧裡新增資料了。儘管鏈式棧的大小不受限,但要儲存 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)。

4.棧在函式呼叫中的應用

前面我講的都比較偏理論,我們現在來看下,棧在軟體工程中的實際應用。棧作為一個比較基礎的資料結構,應用場景還是蠻多的。其中,比較經典的一個應用場景就是函式呼叫棧

我們知道,作業系統給每個執行緒分配了一塊獨立的記憶體空間,這塊記憶體被組織成“棧”這種結構, 用來儲存函式呼叫時的臨時變數。每進入一個函式,就會將臨時變數作為一個棧幀入棧,當被呼叫函式執行完成,返回之後,將這個函式對應的棧幀出棧。為了更好地理解,我們一塊來看下這段程式碼的執行過程。

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() 函式時,函式呼叫棧的情況。

5.棧在表示式求值中的應用

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

為了方便解釋,我將算術表示式簡化為只包含加減乘除四則運算,比如:34+13*9+44-12/3。對於這個四則運算,我們人腦可以很快求解出答案,但是對於計算機來說,理解這個表示式本身就是個挺難的事兒。

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

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

我將 3+5*8-6 這個表示式的計算過程畫成了一張圖,你可以結合圖來理解我剛講的計算過程。

6.棧在括號匹配中的應用

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

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

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

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

7.解答開篇:實現瀏覽器前進後退

我們來看看開篇的思考題,如何實現瀏覽器的前進、後退功能?其實,用兩個棧就可以非常完美地解決這個問題

我們使用兩個棧,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。此時兩個棧的資料這個樣子:

課後思考

  1. 我們在講棧的應用時,講到用函式呼叫棧來儲存臨時變數,為什麼函式呼叫要用“棧”來儲存臨時變數呢?用其他資料結構不行嗎?

  2. 我們都知道,JVM 記憶體管理中有個“堆疊”的概念。棧記憶體用來儲存區域性變數和方法呼叫,堆記憶體用來儲存 Java 中的物件。那 JVM 裡面的“棧”跟我們這裡說的“棧”是不是一回事呢?如果不是,那它為什麼又叫作“棧”呢?