手把手帶你利用棧來實現一個簡易版本的計算器

雙子孤狼發表於2022-01-10

什麼是棧

我們來看一下百度百科中對棧的定義:棧(stack)又名堆疊,它是一種運算受限的線性表。限定僅在表尾進行插入和刪除操作的線性表。這一端被稱為棧頂,相對地,把另一端稱為棧底。

向一個棧插入新元素又稱作進棧、入棧或壓棧,它是把新元素放到棧頂元素的上面,使之成為新的棧頂元素;從一個棧刪除元素又稱作出棧或退棧,它是把棧頂元素刪除掉,使其相鄰的元素成為新的棧頂元素。

棧的實現

棧和陣列,連結串列一樣,也是一種線性的資料結構。在有些程式語言中並沒有棧這種資料結構,但是要實現一個棧卻也簡單,通過陣列或者連結串列都可以來實現一個棧。

通過陣列實現

Java 中,棧(Stack 類)就是通過陣列來實現的,下面我們就自己利用陣列來實現一個簡單的棧:

package com.lonely.wolf.note.stack;

import java.util.Arrays;

/**
 * 基於陣列來實現自定義棧
 */
public class MyStackByArray<E> {
    public static void main(String[] args) {
        MyStackByArray stack = new MyStackByArray();
        stack.push(1);
        System.out.println("stack有效元素個數:" + stack.size);//輸出 1
        System.out.println("檢視棧頂元素:" + stack.peek());//輸出 1
        System.out.println("棧是否為空:" + stack.isEmpty());//輸出 false
        System.out.println("彈出棧頂元素:" + stack.pop());// 輸出 1
        System.out.println("棧是否為空:" + stack.isEmpty());//輸出 true
        stack.push(2);
        stack.push(3);
        stack.push(4);
        System.out.println("stack有效元素個數:" + stack.size);//輸出 3
        System.out.println("彈出棧頂元素:" + stack.pop()); //輸出 4
    }

    private Object[] element;//儲存元素的陣列
    private int size;//棧內有效元素
    private int DEFAULT_SIZE = 2;//預設陣列大小

    public MyStackByArray() {
        element = new Object[DEFAULT_SIZE];
    }

    /**
     * 判斷是否為空,注意不能直接用陣列的長度
     * @return
     */
    public boolean isEmpty(){
        return size == 0;
    }

    /**
     * 檢視棧頂元素
     * @return
     */
    public synchronized E peek() {
        if (size == 0){
            return null;
        }
        return (E)element[size-1];
    }


    /**
     * 檢視並彈出棧頂元素
     * @return
     */
    public E pop() {
        if (size == 0){
            return null;
        }
        E obj = peek();
        size--;//利用 size 屬性省略元素的移除
        return obj;
    }

    /**
     * 壓棧
     * @param item
     * @return
     */
    public E push(E item) {
        ensureCapacityAndGrow();
        element[size++] = item;
        return item;
    }

    /**
     * 擴容
     */
    private void ensureCapacityAndGrow() {
        int len = element.length;
        if (size + 1 > len){//擴容
            element = Arrays.copyOf(element,len * 2);
        }
    }
}

通過佇列實現

除了通過陣列,其實通過連結串列等其他資料結構也能實現,實現棧最關鍵就是要注意棧的後進先出特性。

leetcode 中的第 225 是利用兩個佇列來實現一個棧,具體要求是這樣的:

請你僅使⽤兩個佇列實現⼀個後⼊先出(LIFO)的棧,並⽀持普通棧的全部四種操作(push、top、pop 和 empty),你只能使用佇列的基本操作(也就是 push to backpeek/pop from frontsizeis empty 這些操作)。

在解決這個問題之前,我們先要明白佇列的特性,佇列最主要的一個特性就是先進先出,所以我們不管先把元素放到哪個佇列,最後出來的元素依然是先進先出,似乎實現不了棧的後進先出特性。

實現思路

為了滿足棧的特性,我們就必須要讓先入隊的元素最後出隊,所以我們可以這麼做:

使用一個佇列作為主佇列,每次出棧都從主佇列(mainQqueue)獲取元素,另外一個佇列作為輔助佇列(secondQueue),僅僅用來儲存元素,每次儲存元素的時候先存入 secondQueue,然後將 mainQueue 內的元素依次放入 secondQueue,最後再將兩個佇列互換,這樣每次出隊的時候只需要從 mainQueue 依次獲取元素即可。

下面我們一起來畫一個流程圖幫助理解這個過程:

  1. 放入元素 1

先將元素放入 secondQqueue,這時候 mainQueue 為空,所以不需要移動元素,直接交換兩個佇列即可,所以最終得到的依然是 mainQueue 內有一個元素 1,而 secondQueue 中沒有元素:

  1. 繼續放入元素 2

這時候元素 2 依然先放入 secondQqueue,然後此時發現 mainQueue 裡面有元素,一次取出來,入隊 secondQueue,然後繼續將兩個佇列交換:

  1. 繼續放入元素 3

繼續放入元素 3 也是一樣的道理,依然先放入 secondQqueue,然後將 mainQueue 中的兩個元素一次放回到 secondQueue,最後再將兩個佇列進行交換:

大部分演算法都是這樣,關鍵是理清思路,思路理清了剩下的就是程式碼翻譯的過程,這個過程只要做好好邊界控制及其他注意事項,相對來說就比較容易實現了:

package com.lonely.wolf.note.stack;

import java.util.LinkedList;
import java.util.Queue;

public class MyStackByTwoQueue {
    public static void main(String[] args) {
        MyStackByTwoQueue queue = new MyStackByTwoQueue();
        queue.push(1);
        queue.push(2);
        System.out.println(queue.pop());
    }

    private Queue<Integer> mainQueue = new LinkedList<>();
    private Queue<Integer> secondQueue = new LinkedList<>();


    public void push(int e){
        secondQueue.add(e);
        if (!mainQueue.isEmpty()){
            secondQueue.add(mainQueue.poll());
        }
        //交換連個 queue,此時新加入的元素 e 即為 mainQueue 的頭部元素
        Queue temp = mainQueue;
        mainQueue = secondQueue;
        secondQueue = temp;
    }


    public int top(){
        return mainQueue.peek();
    }

    public int pop(){
        return mainQueue.poll();
    }

    public boolean empty() {
        return mainQueue.isEmpty();
    }
}

棧的經典應用場景

因為棧是一種操作受限的資料結構,所以其使用場景也比較有限,下面我們列舉幾個比較經典的應用場景。

瀏覽器前進後退

瀏覽器的前進後退就是典型的先進後出,因為有前進後退,所以我們需要定義兩個棧:forwardStackbackStack。當我們從頁面 1 訪問到頁面 4,那麼我們就把訪問過的頁面依次壓入 backStack

後退的時候直接從 backStack 出棧就可以了,當 backStack 為空就說明不能繼續後退了;而且當從 backStack 出棧的同時又將頁面壓入 forwardStack,這樣前進的時候就可以從 forwardStack 依次出棧:

括號配對

利用棧來驗證一個字串中的括號是否完全配對會非常簡單,因為右括號一定是和最靠近自己的一個左括號配對的,這就滿足了後進先出的特性。所以我們可以直接遍歷字串,遇到左括號就入棧,遇到右括號就看看是否和當前棧頂的括號匹配,如果不匹配則不合法,如果匹配則將棧頂元素出棧,並繼續迴圈,直到迴圈完整個字串之後,如果棧為空就說明括號恰好全部配對,當前字串是有效的。

leetcode 20 題

leetcode 中的第 20 題就是一道括號配對的題,題目是這樣的:

給定一個只包括 '(',')','{','}','[',']' 的字串 s ,判斷字串是否有效。有效字串需滿足:左括號必須用相同型別的右括號閉合;左括號必須以正確的順序閉合。

知道了上面的解題思路,程式碼實現起來還是比較簡單的:

public static boolean isValid(String s){
       if (null == s || s.length() == 0){
           return false;
       }

       Stack<Character> stack = new Stack<>();
       Map<Character,Character> map = new HashMap<>();
       map.put(')','(');
       map.put(']','[');
       map.put('}','{');
       for (int i=0;i<s.length();i++){
           char c = s.charAt(i);
           if (c == '(' || c == '[' || c == '{'){
               stack.push(c);//左括號入棧
           }else{
               if (stack.isEmpty() || map.get(c) != stack.peek()){
                   return false;
               }
               stack.pop();//配對成功則出棧
           }
       }
       return stack.isEmpty();
   }

表示式求值

演算法表示式也是棧的一個經典應用場景,為了方便講解,我們假設表示式中只有 +、-、*、/ 四種符號,然後我們要對錶示式 18-12/3+5*4 進行求解應該如何做呢?

其實這道題也可以利用兩個棧來實現,其中一個用來儲存運算元,稱之為運算元棧,另一個棧用來儲存運算子,稱之為運算子棧。具體思路如下:

  1. 遍歷表示式,當遇到運算元,將其壓入運算元棧。
  2. 遇到運算子時,如果運算子棧為空,則直接將其壓入運算子棧。
  3. 如果運算子棧不為空,那就與運算子棧頂元素進行比較:如果當前運算子優先順序比棧頂運算子高,則繼續將其壓入運算子棧,如果當前運算子優先順序比棧頂運算子低或者相等,則從運算元符棧頂取兩個元素,從棧頂取出運算子進行運算,並將運算結果壓入運算元棧。
  4. 繼續將當前運算子與運算子棧頂元素比較。
  5. 繼續按照以上步驟進行遍歷,當遍歷結束之後,則將當前兩個棧內元素取出來進行運算即可得到最終結果。

leetcode 227 題

題目:給你一個有效的字串表示式 s,請你實現一個基本計算器來計算並返回它的值,整數除法僅保留整數部分,s 由整數和算符 ('+', '-', '*', '/') 組成,中間由一些空格隔開。

這道題目可以利用我們上面講述的思路進行解決,不過除此之外,在審題時我們應該還需要考慮兩個點:

  1. 表示式中有空格,我們需要將空格處理掉
  2. 運算元可能有多位,也就是說我們需要將運算元先計算出來。

使用兩個棧求解

這道題如果我們按照上面的思路,使用兩個棧來做的話,雖然程式碼有點繁瑣,但是思路還是清晰的,具體程式碼如下:

public static int calculateByTwoStack(String s){
       if (null == s || s.length() == 0){
           return 0;
       }
       Stack<Integer> numStack = new Stack<>();//運算元棧
       Stack<Character> operatorStack = new Stack<>();//運算子棧

       int num = 0;
       for (int i = 0;i<s.length();i++){
           char c = s.charAt(i);
           if (Character.isDigit(c)){//數字
               num = num * 10 + (c - '0');
               if (i == s.length() - 1 || s.charAt(i+1) == ' '){//如果是最後一位或者下一位是空格,需要將數字入棧
                   numStack.push(num);
                   num = 0;
               }
               continue;
           }
           if (c == '+' || c == '-' || c == '*' || c == '/'){
               if (s.charAt(i-1) != ' '){//如果前一位不是空格,那需要將整數入棧
                   numStack.push(num);
                   num = 0;
               }
               if (c == '*' || c == '/'){//如果是乘除法,那麼需要將當前運演算法棧內的乘除法先計算出來
                   while (!operatorStack.isEmpty() && (operatorStack.peek() == '*' || operatorStack.peek() == '/')){
                       numStack.push(sum(numStack,operatorStack.pop()));//將計算出的結果再次入棧
                   }
               } else {//如果是加減法,優先順序已經是最低,那麼當前運算子棧內所有資料都需要被計算掉
                   while (!operatorStack.isEmpty()){
                       numStack.push(sum(numStack,operatorStack.pop()));
                   }
               }
               operatorStack.push(c);
           }
       }
       //最後開始遍歷:兩個運算元,一個運算子進行計算
       while (!numStack.isEmpty() && !operatorStack.isEmpty()){
           numStack.push(sum(numStack,operatorStack.pop()));//計算結果再次入棧
       }
       return numStack.pop();//最後一定剩餘一個結果入棧了
   }

   private static int sum(Stack<Integer> numStack,char operator){
       int num1 = numStack.pop();
       int num2 = numStack.pop();
       int result = 0;
       switch (operator){
           case '+':
               result = num2 + num1;
               break;
           case '-':
               result = num2 - num1;
               break;
           case '*':
               result = num2 * num1;
               break;
           case '/':
               result = num2 / num1;
               break;
           default:
       }
       return result;
   }

上面題目中我們也可以先使用正則把表示式中所有空格去除,這樣的話也可以省去空格的判斷

使用一個棧求解

其實這道題因為只有加減乘除法,所以我們其實可以取巧,只利用一個棧也可以實現。

因為乘除法一定優先於加減法,所以可以先把乘除法計算出來後將得到的結果放回表示式中,最後得到的整個表示式就是加減法運算,具體做法為:

遍歷字串 s,並用變數 preOperator 記錄每個數字之前的運算子,對於表示式中的第一個數字,我們可以預設其前一個運算子為加號。每次遍歷到數字末尾時(即:讀到一個運算子,或者讀到一個空格,或者遍歷到字串末尾),根據 preOperator 來決定計算方式:

  • 加號:將數字直接壓入棧內。
  • 減號:將對應的負數壓入棧內。
  • 乘/除號:計算數字與棧頂元素,並將棧頂元素替換為計算結果。

這樣最終只需要將棧內的所有資料相加就可以得到結果,具體程式碼示例如下:

public static int calculateOneStack(String s){
    if (null == s || s.length() == 0){
        return 0;
    }
    Stack<Integer> stack = new Stack<>();
    char preOperator = '+';//預設前一個操作符是加號
    int num = 0;
    for (int i = 0;i<s.length();i++){
        char c = s.charAt(i);
        if (Character.isDigit(c)){
            num = num * 10 + (c - '0');
        }
        if ((!Character.isDigit(c) && c != ' ') || i == s.length()-1){//判斷數字處理是否已經結束,如果結束需要將數字入棧或者計算結果入棧
            switch (preOperator){
                case '+':
                    stack.push(num);//加法則直接將數字入棧
                    break;
                case '-':
                    stack.push(-num);//減法則將負數入棧
                    break;
                case '*':
                    stack.push(stack.pop() * num);//乘法則需要計算結果入棧
                    break;
                case '/':
                    stack.push(stack.pop() / num);//除法則需要計算結果入棧
                    break;
                default:
            }
            preOperator = c;
            num = 0;
        }
    }
    int result = 0;
    while (!stack.isEmpty()){//最後將棧內所有資料相加即可得到結果
        result+=stack.pop();
    }
    return result;
}

函式呼叫

除了上面的三個經典場景,其實我們平常的方法呼叫也是用的棧來實現的,我們每次呼叫一個新的方法就會定義一個臨時變數,並將其作為一個棧幀進行入棧,當方法執行完畢之後,就會將當前方法對應的棧幀進行出棧。

總結

本文主要講述了棧這種操作受限的資料結構,並通過陣列實現了一個簡易版的棧,同時講述瞭如何通過兩個佇列實現一個棧。最後我們列舉了棧的四大經典應用場景:括號配對,表示式求值,瀏覽器前進後退,函式呼叫等,而其中括號配對和表示式求值這兩種場景又會衍生出不同的演算法題。

相關文章