棧:如何實現瀏覽器前進後退功能

一、如何理解棧

  棧就是一個先進後出,後進先出的數據結構。
  從操作特性上看,棧是一種操作受限的線性表,只允許在一端插入和刪除數據。
  雖然使用數組和鏈表能夠替代棧這種數據結構,但是數組與鏈表向外暴露了太多的api接口,操作上面雖然自由,但是使用的時候就比較不可控,自然也就更容易出錯。

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

二、如何實現一個棧?

  棧總共就兩個數據結構,一個是出棧,一個是入棧。我們只需要實現這兩個即可。棧可以使用數組或者鏈表來實現。

  • 使用數組實現的棧叫做順序棧
  • 使用鏈表實現的棧叫做鏈式棧
    順序棧的實現如下圖所示:
// 基於數組實現的順序棧
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;
  }
}

  鏈式棧的實現如下圖所示:

public class LinkedStack {
    private Item dummy = new Item("dummy");
    //作爲棧中最後一個節點
    private Item head;
    LinkedStack() {
        head = dummy;
    }
    boolean pop() {
        if (dummy.next == null) {
            return false;
        } else {
            head = head.before;
            head.next = null;
            return true;
        }
    }
    boolean push(String val) {
        Item item = new Item(val);
        item.next = null;
        item.before = head;
        head.next = item;
        head = head.next;
        return true;
    }
}
class Item {
    String val;
    //使用雙向鏈表來處理pop
    Item next;
    Item before;
    Item(String val) {
        this.val = val;
    }
}

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

三、如何實現支持動態擴容的順序棧

  1、實現方式

    上面所寫的棧是不支持動態擴容的,如果增加的item的個數超過了棧的大小,則會報錯。
    我們使棧動態擴容的方法很簡單,因爲順序棧的底層是數組,當超過數組的範圍時,新建一個兩倍原來大小的數組,然後將數據複製過去就可以了。
    

    動態擴容的順序棧代碼如下:

package stack;

public class StackByArrayAutoCapacity {
    String[] arrays;
    int autoCapacity;
    int count;
    int index;

    public StackByArrayAutoCapacity(int n) {
        arrays = new String[n];
        autoCapacity = n;
        count = n;
        index = 0;
    }

    public boolean push(String item) {
        if (index == count) {
            String[] temp = arrays;
            arrays = new String[count + autoCapacity];
            for (int i =0;i<temp.length;i++) {
                arrays[i] = temp[i];
            }
            count = count + autoCapacity;
        }
        arrays[index] = item;
        index++;
        return true;
    }

    public String pop() {
        if (index == 0) {
            return null;
        }
        String temp = arrays[index - 1];
        index--;
        return temp;
    }

    public String peek() {
        if (index == 0) {
            return null;
        }
        String temp = arrays[index - 1];
        return temp;
    }

    public boolean empty() {
        return index == 0;
    }

}

  

  2、出入棧複雜度分析

    對於出棧操作來說,我們不會涉及內存的重新申請和數據的搬移,所以出棧的時間複雜度仍然是O(1)。

    

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

  3、攤還分析法

    對於出棧操作來說,最好情況時間複雜度是O(1),最壞情況時間複雜度是O(n)。那平均情況下的時間複雜度又是多少了?

    爲了分析的放便,我們需要事先做一些假設和定義:

    •   棧空間不夠時,我們重新申請一個是原來大小兩倍的數組;
    •   爲了簡化分析,假設只有入棧操作沒有出棧操作;
    •   定義不涉及內存搬移的⼊棧操作爲simple-push操作,時間複雜度爲O(1)。

    如果當前棧大小爲K,並且已滿,當再有新的數據要入棧時:

      1、就需要重新申請2倍大小的內存,並且做K個數據的搬移操作,然後再入棧。
      2、但是,接下來的K-1次入棧操作,我們都不需要再重新申請內存和搬移數據,所以這K-1次入棧操作都只需要一個simple-push操作就可以完成。
      3、爲了讓你更加直觀地理解這個過程,我畫了一張圖。

      

    你應該可以看出來

      1、這K次入棧操作,總共涉及了K個數據的搬移,以及K次simple-push操作。將K個數據搬移均攤到K次入棧操作,
      2、那每個入棧操作只需要一個數據搬移和一個simple-push操作。以此類推,入棧操作的均攤時間複雜度就爲O(1)。

    通過這個例子的實戰分析

      

      1、也印證了前面講到的,均攤時間複雜度一般都等於最好情況時間複雜度。
      2、因爲在大部分情況下,入棧操作的時間複雜度O都是O(1),只有在個別時刻纔會退化爲O(n),
      3、所以把耗時多的入棧操作的時間均攤到其他入棧操作上,平均情況下的耗時就接近O(1)。

 

四、棧在函數調用中的應用

  我們知道,操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結構,用來存儲函數調用時的臨時變量。在Java虛擬機中,就是其虛擬機棧。
  比較經典的一個應用場景就是函數調用棧,每進入一個函數,就會將臨時變量作爲一個棧幀入棧,當被調用函數執行完成,返回之後,將這個函數對應的棧幀出棧。

  爲了讓你更好地理解,我們一塊來看下這段代碼的執行過程:

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;
}

  從代碼中我們可以看出:

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

     

 

五、棧在表達式求值中的應用

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

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

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

     

六、棧在括號匹配中的應用

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

  我們同樣簡化一下背景:

    1、我們假設表達式中只包含三種括號,圓括號()、中括號[]和花括號{},並且它們可以任意嵌套。
    2、比如,{[{}]}或[{()}([])]等都爲合法格式,
    3、{[}()]或[({)]爲不合法的格式。

  那我現在給你⼀個包含三種括號的表達式字符串,如何檢查它是否合法呢?

 這裏也可以用棧來解決:

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

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

 

七、解答開篇

  好了,我想現在你已經完全理解了棧的概念。我們再回來看看開篇的思考題,如何實現瀏覽器的前進、後退功能?

  其實,⽤兩個棧就可以非常完美地解決這個問題。

  1、我們使用兩個棧,X和Y,我們把首次瀏覽的頁面依次壓入棧X,
  2、當點擊後退按鈕時,再依次從棧X中出棧,並將出棧的數據依次放入棧Y。
  3、當我們點擊前進按鈕時,我們依次從棧Y中取出數據,放入棧X中。當棧X中沒有數據時,那就說明沒有頁面可以繼續後退瀏覽了。
  4、當棧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裏面的“棧”跟我們這裏說的“棧”是不是一回事呢?如果不是,那它爲什麼又叫作“棧”呢

   答:JVM裏面的棧和我們這裏說的是一回事,被稱爲方法棧。和前面函數調用的作用是一致的,用來存儲方法中的局部變量。 

 

參考文獻:https://www.cnblogs.com/luoahong/p/11812840.html

     https://www.jianshu.com/p/b913262896f5

     https://time.geekbang.org/column/article/41222

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章