精衛填海系列——棧

棧的定義

棧(stack)是一種線性表數據結構,具有後進者先出,先進者後出的特點。

通俗的講,棧有點像摞摞的盤子。我們放盤子的時候,自下向上一個一個放;取盤子的時候,自上向下一個一個拿,不能從中間抽取。

棧的使用

從上面的定義我們可以看出,棧是一種“操作受限”的線性表,只允許在一端插入和刪除數據。

我剛開始接觸的時候,就對它存在的意義產生了很大的疑惑。因爲我覺得,相比數組和鏈表,棧帶給我的只有限制,並沒有任何優勢。那我直接使用數組或者鏈表不就好了嗎?爲什麼還要用這個“操作受限”的“棧”呢?

事實上,從功能上來說,數組或者鏈表確實可以替代棧,但你要知道,特定的數據結構是對特定場景的抽象,而且,數組或鏈表暴露了太多的操作接口,操作上的確靈活自由,但使用時比較不可控,自然也就更容易出錯。

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

棧的實現

棧主要包含兩個操作,入棧和出棧,也就是在棧頂插入一個數據和從棧頂刪除一個數據。

實際上,棧既可以用數組來實現,也可以用鏈表來實現。用數組實現的棧,我們叫順序棧,用鏈表實現的棧,我們叫作鏈式棧。

下面是基於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指針,內存消耗相對較多。那我們如何基於數組實現一個可以支持動態擴容的棧呢?

你還記得,我們在數組那一節,是如何來實現一個支持動態擴容數組的嗎?當數組空間不夠時,我們就重新申請一塊更大的內存,將原來數組中數據統統拷貝過去。這樣就實現了一個支持動態擴容的數組。

所以,要實現一個支持動態擴容的棧,我們只需要底層依賴一個支持動態擴容的數組就可以了。當棧滿了之後,我們就申請一個更大的數組,將原來的數據搬移到新的數組中。如圖。

 

不過,實際上,支持動態擴容的順序棧,我們平時開發中並不常用到。

棧在函數調用中的應用

我們知道,操作系統給每個線程分配了一塊獨立的內存空間,這塊內存被組織成“棧”這種結果,用來存儲函數調用時的臨時變量。每進入一個函數,就會將臨時變量作爲一個棧幀入棧,當被調用函數執行完成,返回之後,將這個函數對應的棧幀出棧。爲了讓你更好地理解,我們一塊來看下這段代碼的執行過程。

int main(){
  int a=1;
  int ret=0;
  int res=0;
  ret=add(3,5);
  res=a+ret;
  printf("%d",res);
  return 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這個表達式的計算過程畫成了一張圖,你可以結合圖來理解我剛講的計算過程。

棧在括號匹配中的應用

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

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

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

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

學習了王爭老師的《數據結構與算法之美》,根據課程內容整理的筆記

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