棧和括號匹配,一文搞懂

什麼是棧

棧在我們日常編碼中遇到的非常多,很多人對棧的接觸可能僅僅侷限在 遞歸使用的棧 和 StackOverflowException,棧是一種後進先出的數據結構(可以想象生化金字塔的牢房和生化角鬥場的狗洞)。

image-20231102234445811

棧(stack)是一種運算受限的線性數據結構,它具有以下特點:

1. 運算受限: 棧限定僅在表尾進行插入和刪除操作,這一端被稱爲棧頂,而另一端稱爲棧底。這限制了對棧的操作,只能按照後進先出(LIFO,Last-In-First-Out)的原則進行插入和刪除操作。插入操作又稱爲進棧、入棧或壓棧,它將新元素放到棧頂,使之成爲新的棧頂元素;刪除操作又稱爲出棧或退棧,它將棧頂元素刪除,使其相鄰的元素成爲新的棧頂元素。

2. 線性表: 棧也是一種線性表,它表示數據元素之間的邏輯關係是線性的。雖然具體實現可以使用數組或鏈表等不同的物理存儲結構,但邏輯上各個元素之間是相鄰的,操作也是按照順序進行的。

3. 棧頂和棧底: 棧的邏輯結構中有棧頂和棧底的概念。棧頂表示可以進行插入和刪除操作的一端,通常與數組的末尾或鏈表的頭部有關。棧底則是相對的另一端,用於限制操作的另一端。

4. 棧的應用: 棧在計算機科學和編程中有廣泛的應用,例如程序執行調用堆棧、四則運算表達式求值、非遞歸算法實現、括號匹配問題、瀏覽器歷史、內存分配、任務管理等的解決。掌握棧是非常重要的,它是必須瞭解的數據結構之一。

棧可以使用數組或鏈表來實現,選擇合適的實現方式取決於具體的應用場景和性能需求。數組實現的棧通常更適合於需要固定大小的棧(當然也可以進行擴容),而鏈表實現的棧可以動態擴展,適用於不確定大小的棧。在棧的操作中,棧頂元素是非常關鍵的,因爲它在插入和刪除操作中起着重要作用。

總之,棧是一個非常有用的數據結構,它在計算機科學中扮演着重要的角色,瞭解它的特性和應用對於編程和算法設計至關重要。

對於一個棧的接口,我們簡易定義如下:

public interface Stack<T> {
    void push(T item);      // 壓棧
    T pop();               // 彈棧
    T peek();              // 獲取棧頂元素
    boolean isEmpty();     // 判斷棧是否爲空
    int size();            // 返回棧的大小
}

數組實現

數組實現的棧用的比較多,我們經常刷題也會用數組去實現一個簡單的棧去解決簡單的問題。

結構設計

對於數組來說,我們模擬棧的過程很簡單,因爲棧是後進先出,我們很容易在數組的末尾進行插入和刪除。所以我們選定末尾爲棧頂。所以對於一個棧所需要的基礎元素是 一個array[]數組和一個size表示大小,還需要一個負載因子表示數組的大小。

push入棧操作

  • 如果數組滿了,需要擴容
  • size位置賦值, array[size++] = data;

image-20231105213018107

pop彈出棧並返回首位

  • 如果棧不爲空,可以彈出。return array[--size];

如下圖,當棧中還剩1,2,3,4執行pop操作,棧頂變爲3的位置並且返回4

image-20231105213649409

peek返回棧頂

  • peek操作時返回棧頂不彈出,所以棧不爲空時候return data[size-1]即可。

數組實現:

import java.util.EmptyStackException;

public class SeqStack<T> implements Stack<T> {

    private T array[];
    private int size;
    private static final int DEFAULT_CAPACITY = 10;

    public SeqStack() {
        this.size = 0;
        array = (T[]) new Object[DEFAULT_CAPACITY];
    }

    @Override
    public void push(T data) {
        if (size == array.length) {
            // 如果數組已滿,擴展數組
            resizeArray();
        }
        array[size++] = data;
    }

    @Override
    public T pop() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        // 下面可以寫成 return array[--size];
        T data = array[size - 1];
        size--;
        return data;
    }

    @Override
    public T peek() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        return array[size - 1];
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public int size() {
        return size;
    }

    private void resizeArray() {
        int newCapacity = (int) (array.length * 2);
        T[] newArray = (T[]) new Object[newCapacity];
        for (int i = 0; i < size; i++) {
            newArray[i] = array[i];
        }
        array = newArray;
    }
}

鏈表實現

棧可以使用數組或鏈表來實現,兩種思路如下:

  1. 鏈表尾部作爲棧頂: 在數組實現中,棧的操作是在尾部進行插入和刪除。鏈表中即使使用尾指針可以提高尾部插入效率,但刪除操作仍然需要查找前驅節點。要實現高效的刪除操作,需要使用雙向鏈表,這增加了整個結構的複雜性。
  2. 鏈表頭部作爲棧頂: 在這種實現中,棧的設計不帶頭節點的單鏈表(不需要啞結點),所有操作都在鏈表的頭部進行。頭部插入刪除都很方便效率比較高,編寫代碼也很簡單。

基礎結構

public class LinkedStack<T> implements Stack<T> {
    private Node<T> top;
    private int size;

    public LinkedStack() {
        top = null;
        size = 0;
    }

    private static class Node<T> {
        T data;
        Node<T> next;
        public Node(T data) {
            this.data = data;
            this.next = null;
        }
    }
  //其他方法
}

push入棧

與不帶頭結點單鏈表頭插入一致

  • 創建新節點
  • 新節點的next指向棧頂節點top
  • 棧頂節點top指向新節點,表示這個節點爲新的棧頂節點
  • size++

部分操作流程如下圖

image-20231105221137828

pop彈出

與不帶頭結點單鏈表頭插入一致

  • 判斷是否爲空
  • 記錄頭結點top的值data
  • 頭結點top指向top.next
  • size--,返回前面記錄的值data

部分操作流程如下圖

image-20231105222129654

peek返回棧頂

不爲空的時候返回 top.data即可

鏈表實現:

import java.util.EmptyStackException;

public class LinkedStack<T> implements Stack<T> {
    private Node<T> top;
    private int size;

    public LinkedStack() {
        top = null;
        size = 0;
    }

    private static class Node<T> {
        T data;
        Node<T> next;

        public Node(T data) {
            this.data = data;
            this.next = null;
        }
    }

    @Override
    public void push(T item) {
        Node<T> newNode = new Node<>(item);
        newNode.next = top;
        top = newNode;
        size++;
    }

    @Override
    public T pop() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        T data = top.data;
        top = top.next;
        size--;
        return data;
    }

    @Override
    public T peek() {
        if (isEmpty()) {
            throw new EmptyStackException();
        }
        return top.data;
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }

    @Override
    public int size() {
        return size;
    }
}

棧能這麼玩

既然上面詳細講解設計棧,這裏來兩道棧非常經典非常經典的例題(非常高頻,很容易忘,又很重要,普通問題就不放的)

力扣20有效的括號:

題意:給定一個只包括 '(',')','{','}','[',']' 的字符串,判斷字符串是否有效。

有效字符串需滿足:

左括號必須用相同類型的右括號閉合。
左括號必須以正確的順序閉合。
注意空字符串可被認爲是有效字符串。

示例 :

輸入: "()[]{}"
輸出: true

示例 :

輸入: "([)]"
輸出: false

分析:
括號類的問題是經典棧類問題,肯定要想到用棧處理。判斷一個字符串滿不滿足一個有效的字符串,就要看它是不是都能組成對。

從單個括號對來說,((,))都是不滿足的,只有()纔可滿足,即一左一右。

從多個括號對來說 {[(字符串還可接受任意無限([,{的括號。但是如果向左的括號只能先接收)括號(變成{[)。

從上面可以看作一種相消除的思想。例如(({[()()]}))字符串遍歷時候可以這樣處理:

  • (({[(下一個)消掉成(({[
  • (({[(下一個)消掉成(({[
  • (({[下一個]消掉成(({
  • (({下一個}消掉成((
  • ((下一個)消掉成(
  • (下一個)消掉成 這樣就滿足題意

每次操作的時候都判斷剩餘有效括號最頂部那個括號是否能夠和遍歷的相消除,這個過程利用棧判斷當前是加入棧還是消除頂部,到最後如果棧爲空說明滿足,否則不滿足,當然具體括號要對應,具體實現代碼爲:

public boolean isValid(String s) {
    Stack<Character> stack = new LinkedStack<Character>();
    for (int i = 0; i < s.length(); i++) {
        char te = s.charAt(i);
        if (te == ']') {
            if (!stack.isEmpty() && stack.pop() == '[')
                continue;
            else {
                return false;
            }
        } else if (te == '}') {
            if (!stack.isEmpty() && stack.pop() == '{')
                continue;
            else {
                return false;
            }
        } else if (te == ')') {
            if (!stack.isEmpty() && stack.pop() == '(') {
                continue;
            } else {
                return false;
            }
        } else {
            stack.push(te);
        }
    }
    return stack.isEmpty();
}

當然,JDK自帶的棧用起來不快,可以用數組優化:

public boolean isValid(String s) {
    char a[] = new char[s.length()];
    int index = -1;
    for (int i = 0; i < s.length(); i++) {
        char te = s.charAt(i);
        if (te == ']') {
            if (index >= 0 && a[index] == '[')
                index--;
            else {
                return false;
            }
        } else if (te == '}') {
            if (index >= 0 && a[index] == '{')
                index--;
            else {
                return false;
            }
        } else if (te == ')') {
            if (index >= 0 && a[index] == '(')
                index--;
            else {
                return false;
            }
        } else {
            a[++index] = te;
        }
    }
    return index == -1;
}

力扣32最長有效括號(困難)

題目描述:給定一個只包含 '(' 和 ')' 的字符串,找出最長的包含有效括號的子串的長度。

示例 :

輸入: "(()"
輸出: 2
解釋: 最長有效括號子串爲 "()"

示例 :

輸入: ")()())"
輸出: 4
解釋: 最長有效括號子串爲 "()()"

方案一暴力

這種題核心思想就是使用棧模擬。本題的話更簡單一點因爲只有()兩種括號,使用暴力的時候就可以循環每次找到最長的有效括號。而括號匹配的時候可以直接終止的情況是)右括號多出無法匹配。

例如())(到第三個不可能和前面相連。如果來(只需要期待後面能夠來),一個)可以和一個(組成一對,消除棧中的一個(

當然,在具體的實現上,我們用數組模擬棧,實現代碼爲:

public int longestValidParentheses(String s) {
    char str[] = s.toCharArray();//字符數組
    int max = 0;
    for (int i = 0; i < str.length - 1; i++) {
        int index = -1;
        if (max >= str.length - i)
            break;
        for (int j = i; j < str.length; j++) {
            if (str[j] == '(') {
                index++;
            } else {
                if (index < 0) {
                    i = j;
                    break;
                } else {
                    index--;
                }
            }
            if (index == -1 && (j - i + 1 > max)) {
                max = j - i + 1;
            }
        }
    }
    return max;
}

這個複雜度太高,我們看看如何用棧優化。

方案二棧優化

如何將這道題從一個O(n^2)的時間複雜度優化到O(n)?這其實非常簡單,只需要注意處理的過程。讓我們首先考慮一些可能的最大情況。

  • ( ) ) ( ) ( ( ) ( ) ) 最大爲後面部分(空格分開)
  • ( ) ( ) ( ( ( ) 最大爲前面部分
  • ( ( ( ( ( ( ) ( ) ( ) ( ) 最大爲後面部分

在處理這道題時,我們會注意到不同類型的括號可能會有一些區別:
(:左括號一旦出現那麼他就期待一個)進行匹配,但它的後面可能有)並且在這中間有很多其他括號對。
):右擴號有兩種情況:

  • 一種是當前已經超過左括號前面已經不可能連續了。例如( ) ) ( )第三個括號出現已經使得整個串串不可能連續,最大要麼在其左面要麼再其右面。 你可以理解其爲一種清零初始機制。
  • 另一種情況)就是目標棧中存在(可與其進行匹配。匹配之後要疊加到消除後平級的數量上,並且判斷是否是最大值。(下面會解釋)

具體實現的思路上,就是使用一個int數組標記當前層級(棧深)有正確的括號數量。 模擬一次棧行爲從左向右,遇到)太多(當前棧中不存在(進行匹配)就將數據清零重新開始。這樣一直到最後。你可以把它看成臺接,遇到(就上一個臺階並清零該新臺階,遇到)就下一個臺階並且把數量加到下降後的臺階上。具體可以看下面圖片模擬的過程:
( ) ( ( ) ( ) ( ( ) ) )

image-20231105224429516

具體實現代碼爲:

public static int longestValidParentheses(String s) {
    int max = 0;
    int value[] = new int[s.length() + 1];
    int index = 0;
    for (int i = 0; i < s.length(); i++) {
        if (s.charAt(i) == '(') {
            index++;
            value[index] = 0;
        } else {//")"
            if (index == 0) {
                value[0] = 0;
            } else {
                value[index - 1] += value[index--] + 2;//疊加
                if (value[index] > max)//更新
                    max = value[index];
            }
        }
    }
    return max;
}

用棧也可以實現,但是效率比數組略低:

public int longestValidParentheses(String s) {
  int maxans = 0;
  Stack<Integer> stack = new Stack<>();
  stack.push(-1);
  for (int i = 0; i < s.length(); i++) {
    if (s.charAt(i) == '(') {//(將當前的 
      stack.push(i);
    } else {
      stack.pop();
      if (stack.empty()) {
        stack.push(i);
      } else {//i-stack.peek就是i是出現的總個數 peek是還沒匹配的個數
        maxans = Math.max(maxans, i - stack.peek());
      }
    }
  }
  return maxans;
}

總結

到這裏,本文對棧的介紹就結束了,相信你可以手寫個棧並且可以小試牛刀解決括號匹配問題!當然棧能解決的問題還有很多比如接雨水問題、二叉樹非遞歸遍歷等等,有些重要的還會再總結。

github地址https://github.com/javasmall/bigsai-algorithm
持續更新系列,歡迎star!

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