數據結構與算法(四):棧與應用

一、定義

棧是一種操作受限的線性表。之所以說它操作受限,是由於其只能在一端插入和刪除數據。這一端叫做“棧頂”,另外一端則爲“棧底”。插入的操作我們通常稱之爲“PUSH”(入棧),刪除的操作我們通常稱之爲“POP”(出棧)。所以它具有FILO(先進後出)的特性。如果棧滿了,我們是不允許再繼續入棧的,稱這種情況爲“棧上溢”,對應如果棧內爲空,也是不允許繼續出棧的,這種情況爲“棧下溢”。這就像餐館裏的餐盤,工作人員放餐盤的時候都是從下往上一個一個堆積;而取餐盤的時候,則是從上往下依次取。

站在數據結構的角度,棧其實是一種抽象的數據結構,它不像數組這種基礎數據結構,我們需要自己去實現它。數組和鏈表都可以實現棧的結構,用數組實現的棧,我們稱之爲“線性棧”;而用鏈表實現的棧,我們則稱之爲“順序棧”。

二、實現

這裏我們簡單實現一下順序棧。

public class MyStack<T> {
    private Object[] arrays;
    private int position = -1;

    public MyStack(int size) {
        arrays = new Object[size];
    }

    public T pop() {
        return (T) arrays[position--];
    }

    public void push(T item) {
        if (position + 1 >= arrays.length) {
            //擴容,長度翻倍
            Object[] newArrays = new Object[arrays.length << 1];
            System.arraycopy(arrays, 0, newArrays, 0, arrays.length);
            arrays = newArrays;
        }
        arrays[++position] = item;
    }

}

以上就是一個最簡單的順序棧實現,對外提供了pop(出棧)和push(入棧)操作,沒有考慮任何併發場景。我們來分析一下對應操作的時間複雜度和空間複雜度。

三、分析時間複雜度

首先是pop方法:我們看到,出棧直接依託於數組的隨機訪問,所以時間複雜度爲O(1),也就是常數階的;而我們也沒有消耗多餘的存儲空間,所以空間複雜度也爲O(1)。

再看push方法:push方法就不那麼“直接”了,因爲涉及到擴容操作。當arrays數組沒有滿時,入棧操作只需要把元素放入到對應的位置即可,時間複雜度和空間複雜度都是O(1)。但是當arrays數組存滿了的時候(postition+1==arrays.length),會觸發一次擴容操作,而擴容涉及到新內存空間的申請和數據的搬移,所以此時的時間複雜度和空間複雜度也就成了O(n)。所以我們得出了,push的最好時間複雜度是O(1),最壞時間複雜度爲O(n)。

那push方法的平均時間複雜度是多少呢?前面提到過,平均時間複雜度=每種情況的指令次數的總和/情況的數量。

我們知道push操作的時間複雜度是由當前position在棧中的位置決定的(沒有到棧頂爲O(1),到了棧頂爲O(n))。所以我們執行push操作的時候,遇到的情況就有n種(n-1就代表位置)。而前n-1種情況(棧未滿)的指令次數爲1,第n種情況(棧滿)的指令次數爲n。所以我們得出了每種情況的執行次數之和:

                                                                   n+\sum_{i=1}^{n-1}1 = 2n-1

再除以n,就得到我們的平均時間複雜度:O(1)

那麼加權平均時間複雜度呢?我們設position出現在每個位置的概率是相同的:\frac{1}{n}。那麼我們的加權平均時間複雜度的表達式爲:

                                                \frac{n}{n}+\sum_{i=1}^{n-1}{\frac{1}{n}} = \frac{n}{n}+\frac{n-1}{n}=\frac{2n-1}{n}

所以忽略常數階之後,其複雜度還是爲:O(1)。

最後我們使用攤還分析來分析一下均攤時間複雜度(爲了區別前面的情況數量n,下面我們以m來表示第幾次操作)。

在我們的push操作中,前m次操作時間複雜度都是O(1),第m+1次操作由於需要將全部m個元素搬移到新的數組中,所以需要O(n)的複雜度。由於我們擴容的措施是申請一個兩倍於原空間大小的空間,所以搬移之後,m當然也翻倍了,接下來的個m次入棧操作,又是O(1)的複雜度,到第二輪m+1次時又需要搬移元素,複雜度又成了O(n),就這樣有“規律”的進行下去。

結合之前提到的均攤的概念,我們可以把第m+1次觸發的搬移每個元素的操作(其實就是搬移m個元素)均攤到前m次的入棧操作上。這樣的話,我們每次入棧就相當於搬移一個元素和一次O(1)的入棧操作,所以這樣我們就得出了均攤時間複雜度爲O(1)。

這進一步驗證了我們之前提到的,均攤時間複雜度一般就是最好情況時間複雜度。因爲我們把個別情況的高複雜度操作均攤到了低複雜度的操作上,而高複雜度操作均攤之後的每一步操作一般都是常量階的,所以最終均攤的結果就是最好情況時間複雜度。

四、應用

1.方法調用棧

在JVM運行時內存區域劃分中,有一個區域叫做虛擬機棧(當然還有本地方法棧,不在此處討論範圍)。這塊兒內存就是棧的結構,它是線程私有的,每調用一個方法,就會有一個棧幀入棧,方法返回則將對應的棧幀出棧。如果存在方法嵌套調用的情況,則是按照方法調用的順序依次入棧,棧頂元素其實就對應當前正在執行的方法。我們可以通過-Xss配置默認大小,而StackOverflowError,就是由於棧中的棧幀太多,超過了限制,出現的棧上溢異常。我們只需要一個簡單的遞歸調用就可引出該Error。

2.進制轉換

以十進制轉二進制爲例,我們“書面”的轉換流程爲:先除以2,將餘數存下來,然後將商繼續除以2,將餘數存下來,這樣依次進行,直到商爲0,最後將保存的餘數倒敘排列,就成了我們的二進制數(這裏只是單純的討論二進制,不考慮補位等操作哈)。比如一下例子,爲十進制50轉二進制的流程(隨便在網上找的):

上述結果爲:110010。而這種場景就正好符合我們棧的特性。我們將每個餘數依次入棧,最後依次出棧,就能得到我們想要的結果。

3.符號匹配檢查

假設現在有一個由左右圓括號、左右方括號、左右大括號中的一個或多個符號組成的字符串。我們評判這個字符串有意義的標準是:每一個左(右)括號都有且僅有一個對應的右(左)括號與之匹配,不同的括號可以任意嵌套,但是不能產生交叉。比如這些字符串是有意義的:[](){}、{[({})[]]};而這些字符串是沒有意義的:)、[{]}、[{(})]、[{}()。

基於這種場景,我們用棧的思想也很好解決:將字符串中的每個字符從左至右依次入棧,當遇到一個右括號時,將棧頂元素出棧,判斷該元素是否和右括號匹配,如果不匹配,則字符串無意義;否則繼續迭代,當每個字符都經過上述操作之後,棧如果是空的,字符串纔有意義,否則就沒有意義。

關於棧的應用可不止這些,比如根據算符優先級進行表達式運算、通過窮舉法找迷宮出口之類的,這裏就不再舉例了。

五、總結

對於棧這種抽象的數據結構,我們主要是要理解其先進後出的思想,而不是糾結棧本身,明白它的適用場景。比如我們平時在操作數組的時候,可能會從末尾往前遍歷,這其實就可以當做是棧的一種思想而不用真正要實現一個棧的結構

for (int i = arrays.length - 1; i >= 0; i--) {
    System.out.println(arrays[i]);
}

注:本文是博主的個人理解,如果有錯誤的地方,希望大家不吝指出,謝謝

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