
如果只能選一道題來理解棧這種數(shù)據(jù)結(jié)構(gòu)我會選 LeetCode 第 20 題——有效的括號。這道題沒有復(fù)雜的數(shù)學(xué)推導(dǎo)也不需要精巧的二分優(yōu)化但它把棧的核心語義展示得淋漓盡致。作為一個刷過幾百道題、也在面試現(xiàn)場看過別人寫這道題的過來人我可以很明確地說這道題值得你反復(fù)做三遍。它不僅僅是一個入門級的熱身題更是一把打開棧這一數(shù)據(jù)結(jié)構(gòu)大門的鑰匙。無論你是剛接觸算法的新手還是準(zhǔn)備面試的求職者甚至是寫過多年業(yè)務(wù)代碼但想補一補基本功的老開發(fā)都能從這道題里收獲一些東西。括號匹配這個場景我們在寫代碼時其實經(jīng)常遇到——編譯器的語法檢查、編輯器的自動補全、表達(dá)式求值里的括號優(yōu)先級處理底層都有一套類似判定括號是否合法的機制。它能幫你快速理解什么叫最近匹配什么叫后進(jìn)先出以及為什么要用棧而不是用簡單的計數(shù)打天下。這篇文章我會從題目本身出發(fā)把思路拆解、代碼實現(xiàn)、邊界陷阱和常見錯誤一次講透。1. 一道經(jīng)典題背后的核心思想1.1 題目到底在考什么先還原一下題目原貌給定一個只包含(、)、[、]、{、}六種字符的字符串判斷字符串中的括號是否都是有效閉合的。有效閉合的定義包括兩點左括號必須用相同類型的右括號閉合并且閉合順序要正確。空字符串可視為有效。這個題在面試?yán)锍霈F(xiàn)的頻率高得嚇人。我統(tǒng)計過自己參與過的技術(shù)面試候選人第一輪手撕代碼碰到的題目里這道題至少占了兩成左右。它的定位很有意思說難不難但很能反映基本功。有些人上來就寫錯了思路有些人寫對了但邊界條件處理得稀爛還有些人根本不知道 Java 里應(yīng)該用ArrayDeque而不是Stack——這些細(xì)節(jié)往往比 AC 本身更能讓面試官看清一個人的水平。它到底在考什么說穿了就三個東西第一你認(rèn)不認(rèn)得棧這個數(shù)據(jù)結(jié)構(gòu)第二你能不能把現(xiàn)實問題抽象成棧的入棧、出棧操作第三你的代碼能不能處理干凈各種邊界條件。這三點對應(yīng)的是數(shù)據(jù)結(jié)構(gòu)基礎(chǔ)、抽象建模能力和代碼嚴(yán)謹(jǐn)性面試官想要的就是這三樣。順便說一句這道題也是很多刷題網(wǎng)站和課程安排里的棧專題第一題。它就像棧類題目里的Hello World你要是能把這道題吃透后面再去碰單調(diào)棧、表達(dá)式求值、函數(shù)調(diào)用棧相關(guān)的題都會順暢很多。1.2 括號匹配的本質(zhì)最近匹配原則為什么括號匹配能和棧扯上關(guān)系關(guān)鍵在于括號天然有一個性質(zhì)一個右括號要和它左側(cè)最近的那個左括號配對而不是隨便找一個左括號配對。舉個例子看字符串([])。外層左括號(最先出現(xiàn)但匹配它的右括號)反而最后才出現(xiàn)內(nèi)層[次出現(xiàn)對應(yīng)的]卻更早出現(xiàn)。這種越早出現(xiàn)的左括號越晚被匹配的規(guī)律恰恰就是后進(jìn)先出LIFO的語義棧頂永遠(yuǎn)是最后壓入的元素也就永遠(yuǎn)是最新、最近的待匹配項。生活里的類比也很好理解你往桌上一疊盤子最后放上去的那個盤子總是你最先要取下來的那個。括號匹配里的嵌套結(jié)構(gòu)本質(zhì)上就是這樣一疊待匹配的左括號。每當(dāng)遇到一個右括號你只能從這疊盤子的頂部取一個左括號來配對不能跳過去取底部的。一旦取了底部那個上面的順序就亂套了。這個最近匹配原則是整個題目的靈魂。理解了它你不僅能寫出正確答案還能跟面試官解釋清楚為什么這道題不能用簡單的數(shù)量統(tǒng)計來做。這個點后面我會單獨展開講。2. 為什么棧是這道題的答案2.1 計數(shù)法的致命缺陷我知道很多人第一眼看到這道題的反應(yīng)是統(tǒng)計一下左右括號的數(shù)量看它們相不相等不就行了這個思路在最簡單的用例下確實能蒙混過關(guān)。比如()左括號 1 個右括號 1 個相等通過。又比如[]{}三種括號各一對數(shù)量也平衡看起來也通過了。但稍微給一點復(fù)雜的結(jié)構(gòu)這個方案立刻露餡。經(jīng)典反例是([)]。這個字符串里左括號有兩個(和[右括號也有兩個)和]數(shù)量上完全相等。如果你只做數(shù)量統(tǒng)計會判定它是合法的。但它真的是合法的嗎不是。因為(應(yīng)該匹配)[應(yīng)該匹配]而這個字符串里兩個右括號把兩個左括號交叉了(的左括號在[和]的外面可它的右括號)卻落在]的里面。這種交叉嵌套不合任何語言的語法規(guī)則。所以數(shù)量相等只是必要條件遠(yuǎn)不是充分條件。判斷括號是否合法不僅要看左右數(shù)量對得上還要看配對順序?qū)Φ蒙?。計?shù)法把順序信息完全丟掉了這是它的致命傷。如果你在面試?yán)锾岢鲇嫈?shù)法面試官大概率會追問一句([)]你怎么判斷這一問就能讓你意識到問題所在。2.2 棧的數(shù)據(jù)結(jié)構(gòu)特性與匹配過程的映射現(xiàn)在來看棧是怎么把最近匹配翻譯成程序的。算法的核心思路只有四步遍歷字符串的每個字符如果是左括號(、[、{把它壓入棧如果是右括號)、]、}從棧頂取出一個左括號檢查兩者是否是同一類型的一對如果棧頂元素不是配對的左括號或者棧里根本沒有元素直接判定不合法遍歷結(jié)束后如果棧不為空說明有左括號沒找到配對也不合法。我拿({[]})這個合法嵌套的例子走一遍。遍歷到(入棧棧變成[(]遍歷到{入棧棧變成[(, {]遍歷到[入棧棧變成[(, {, []遍歷到]是右括號看棧頂[剛好配對彈棧棧變回[(, {]遍歷到}看棧頂{配對彈棧棧變成[(]遍歷到)看棧頂(配對彈棧棧變成[]。最后棧為空返回合法。整個過程就像按下一個按鈕逐層剝開嵌套結(jié)構(gòu)。這套邏輯把最近匹配直接轉(zhuǎn)化成了棧頂匹配。為什么棧頂就是最近因為棧頂永遠(yuǎn)是最新壓入的那個左括號也就是當(dāng)前所有未匹配左括號中最新出現(xiàn)的那個。右括號要找的恰好就是它。這個映射關(guān)系非常自然沒有任何生搬硬套。2.3 兩種常見實現(xiàn)風(fēng)格對比實現(xiàn)上有兩種主流風(fēng)格。第一種只壓左括號。遇到左括號入棧遇到右括號做匹配判斷。這種寫法最直白三種括號的配對關(guān)系可以用哈希表存起來也可以寫成switch。第二種所有括號都壓棧遇到右括號時再把棧頂彈出來比較如果棧頂也是右括號或者匹配不上就返回False。第二種寫法其實更繞不推薦因為它把左括號和右括號混在同一個棧里棧頂?shù)呐袛噙壿嫹炊儚?fù)雜了。在面試場景里我強烈推薦第一種寫法并且用哈希表存儲配對關(guān)系。理由有兩個其一代碼里每個分支的意圖非常清晰——if判斷是不是右括號是就匹配不是就入棧面試官掃一眼就能看明白其二哈希表比一長串if-else更容易維護(hù)后續(xù)要擴展新的括號類型也方便。省下來的時間可以用來跟面試官討論邊界情況這在面試?yán)锸羌臃猪棥?. 完整實操手寫一套高效判定3.1 以 Python 為例的完整實現(xiàn)先說 Python 版本這是我在 LeetCode 上反復(fù)使用的一版代碼短但五臟俱全。def isValid(s: str) - bool: pairs {): (, ]: [, }: {} stack [] for char in s: # 當(dāng)前字符是右括號 if char in pairs: # 棧為空或棧頂不是配對的左括號 if not stack or stack[-1] ! pairs[char]: return False stack.pop() else: # 當(dāng)前字符是左括號入棧 stack.append(char) # 棧空說明全部配對成功 return not stack逐行解釋一下。pairs這個字典定義的是配對關(guān)系注意鍵是右括號值是左括號方向別搞反了。遍歷時char in pairs這一句就是判斷當(dāng)前字符是不是右括號時間復(fù)雜度是 O(1)因為字典底層是哈希表。如果是右括號先看棧空不空空棧說明這個右括號是個孤兒前面沒有任何左括號等它直接返回False再看棧頂元素是不是它期待的那個左括號不是也直接返回False。這兩步都通過了才執(zhí)行pop。如果是左括號不管具體是哪種直接append進(jìn)棧。最后一行return not stack很經(jīng)典棧為空說明所有左括號都成功配對了返回True棧不為空說明至少有一個左括號被晾在棧里返回False。這個寫法用 Python 的布爾語義把判斷壓縮成一行簡潔又不容易漏。3.2 以 Java 為例的完整實現(xiàn)很多面試是用 Java 考的所以 Java 版本也必須拿得出手。這里有一個很多新手不知道的坑不要用java.util.Stack要用ArrayDeque。class Solution { public boolean isValid(String s) { DequeCharacter stack new ArrayDeque(); MapCharacter, Character pairs new HashMap() {{ put(), (); put(], [); put(}, {); }}; for (char c : s.toCharArray()) { if (pairs.containsKey(c)) { if (stack.isEmpty() || stack.pop() ! pairs.get(c)) { return false; } } else { stack.push(c); } } return stack.isEmpty(); } }先說為什么不用Stack。Stack是 Java 早期遺留的類繼承自Vector而Vector的幾乎所有方法都加了synchronized鎖。在單線程算法題場景里這個鎖只有開銷、沒有收益。ArrayDeque是雙端隊列當(dāng)棧用的時候性能更好官方文檔也明確建議優(yōu)先使用。面試時你能說出這個區(qū)別本身就是技術(shù)深度的體現(xiàn)。再看看這段代碼里的細(xì)節(jié)。初始化哈希表時我用了雙括號寫法這在面試題里無傷大雅但要知道它每次會生成一個匿名內(nèi)部類正式項目里不推薦。更嚴(yán)格的寫法是在構(gòu)造函數(shù)里初始化。核心判斷邏輯在stack.isEmpty() || stack.pop() ! pairs.get(c)這一行——利用||的短路求值如果棧為空就不會執(zhí)行后面的pop避免了空棧異常。這在邏輯上和 Python 版本的if not stack or stack[-1] ! pairs[char]完全等價。3.3 關(guān)鍵分支邏輯逐行解讀我自己在指導(dǎo)別人寫這道題時發(fā)現(xiàn)最容易出問題的就是那個匹配分支的判斷順序。每次遇到右括號其實是一次匹配請求。這個請求有兩個前置條件缺一不可棧里必須有元素。棧為空說明當(dāng)前右括號前面沒有等待配對的左括號比如字符串就是)這種情況棧頂元素必須正好是它的另一半。比如當(dāng)前是)棧頂必須是(而不是[或{。我見過不少只寫了一半判斷的代碼比如只判斷棧頂元素是否匹配卻不判斷??読f stack[-1] ! pairs[char]: # ??諘r會 IndexError return False這種寫法在遇到以右括號開頭的字符串時會直接拋異常。教訓(xùn)就一句話先判空再取值。這一點在 Python 里尤其重要因為??諘r訪問stack[-1]會直接報IndexError而不是返回一個空值讓你好比較。Java 版本由于||短路求值的存在把判空和取值寫在同一個表達(dá)式里天然安全但我還是建議你在心里明確這一步的邏輯而不是把它當(dāng)成一個理所當(dāng)然的寫法。3.4 復(fù)雜度分析復(fù)雜度是面試的必問環(huán)節(jié)。時間上每個字符最多入棧一次、出棧一次所有操作都是常數(shù)級別所以總時間復(fù)雜度是 O(n)??臻g上最壞情況是字符串全由左括號組成比如(((((這時候棧里要存 n 個元素空間復(fù)雜度是 O(n)。這個復(fù)雜度結(jié)論本身不復(fù)雜但我想多說一句這道題的線性復(fù)雜度并不稀罕在 LeetCode 32 題最長有效括號里同樣的輸入可以玩出 O(n) 的 DP 配合棧、O(1) 的雙指針計數(shù)等花樣。所以這道基礎(chǔ)題不單是為了 AC它建立的是你對棧解決子串匹配類問題的直覺后面所有變體都是在這個直覺上做加法。面試時把這段復(fù)雜度分析說得有條理也能展示你的分析框架最壞情況、平均情況、空間占用一個一個來。4. 邊界情況與進(jìn)階陷阱4.1 空串與單字符很多題目喜歡在邊界條件上埋坑這道題也不例外。空字符串是合法的。雖然有個別業(yè)務(wù)場景可能要求非空但 LeetCode 和絕大多數(shù)算法題對空串的默認(rèn)判定都是true。你可以理解成沒有任何括號需要配對自然也是有效閉合的。我在代碼里沒有對空串做特殊處理因為return not stack直接返回True天然正確。單個左括號(不合法。它走到最后一步時棧不為空被return not stack攔下。單個右括號)也不合法它第一輪就會進(jìn)入右括號分支發(fā)現(xiàn)棧為空直接返回False。這兩個用例是筆試?yán)镒钊菀壮鲥e的有人把遍歷邏輯寫得復(fù)雜無比卻忘了檢查遍歷結(jié)束后棧是否為空這步導(dǎo)致(被誤判為合法。我還建議你在寫完代碼后第一時間跑一遍這幾個用例(、)、()、(()、())。跑完這五個邊界問題基本能暴露七八成。4.2 交叉嵌套誤區(qū)再回到那個經(jīng)典的([)]。用我們的棧算法跑一遍(入棧[入棧遇到)棧頂是[和)不匹配直接返回False。整個過程甚至沒走完整個字符串。這正是棧方案的威力交叉匹配在第一次出現(xiàn)張冠李戴時就會被攔截根本不需要等到最后。而計數(shù)法在這個用例上會完全失明。所以我在面試考這道題時特別喜歡把([)]作為追問用例拋出去看候選人能不能頂住這一問。如果你能主動在代碼里展示對這個用例的處理并且說明為什么它是非法的面試官對你是會有好感的。順便提一個變體([])是合法的([)]是非法的這兩個字符串長得極為相似差的就是那一層嵌套關(guān)系。建議你把這兩個用例對照著在代碼上跑一跑直觀感受一下順序?qū)ㄌ柶ヅ涞降滓馕吨裁础?.3 棧溢出與性能考量還有一種寫法是用遞歸來處理括號匹配遞歸函數(shù)每次處理一個括號對遞歸深度等于嵌套深度。如果測試用例里給出一個幾千層嵌套的字符串比如(重復(fù) 5000 次再接 5000 個)遞歸方案很容易觸發(fā)棧溢出。在 Python 里尤其明顯默認(rèn)遞歸深度限制大約在 1000 層稍微大一點就直接RecursionError。顯式棧方案就不會有這個問題。因為棧是分配在堆上的動態(tài)結(jié)構(gòu)不受函數(shù)調(diào)用棧深度限制只要內(nèi)存夠幾萬層嵌套也能處理。這也解釋了為什么算法題里遇到棧相關(guān)問題時優(yōu)先寫顯式棧而不是遞歸穩(wěn)定、可控、不依賴語言運行時設(shè)置。雖然業(yè)務(wù)代碼里我們經(jīng)常追求遞歸的簡潔但在這種深度可能很大的場景里顯式循環(huán)是更穩(wěn)妥的選擇。5. 常見錯誤與調(diào)試實錄5.1 經(jīng)典錯誤速查表我把平時見到的各種錯誤集中整理成一張表刷題時可以直接對照自檢易錯點典型輸入錯誤后果正確做法棧為空時直接取棧頂)拋出索引越界或空棧異常先判空再取棧頂遍歷結(jié)束忘記檢查棧空(()誤把未閉合括號判為合法返回前檢查棧是否為空只統(tǒng)計括號數(shù)量不判斷順序([)]交叉括號被誤判為合法用棧維護(hù)順序信息棧頂匹配時只比較是左括號(]不同類型括號混配用哈希表做精確配對用Stack類實現(xiàn)任意不必要的性能開銷用ArrayDeque誤把pairs鍵值方向?qū)懛?(匹配邏輯反了鍵是右括號值是左括號第五行和第六行看似小問題但實際犯的人不少特別是從 C 轉(zhuǎn)到 Java 的人習(xí)慣了std::stack就順手寫了Stack。算法題雖然不卡那點性能但這些細(xì)節(jié)能體現(xiàn)你對語言生態(tài)了解多少。5.2 實用調(diào)試技巧調(diào)試這道題我推薦兩個特別實用的方法。第一把棧的內(nèi)容打印出來。在每次入棧和出棧之后print(stack)尤其在處理復(fù)雜嵌套用例時肉眼看一下棧頂?shù)淖兓R上能定位是匹配邏輯錯了還是彈出時機錯了。我曾經(jīng)在處理三四種括號混合嵌套的用例時靠打印??焖侔l(fā)現(xiàn)自己在遇到右括號時把pop放在了比較之前導(dǎo)致棧頂已經(jīng)被拿走自然比較什么都不對。這種 bug 光靠讀代碼很難抓打印一次立刻現(xiàn)形。第二準(zhǔn)備一組九宮格測試用例覆蓋所有情況。我自己固定跑這九組1. → 預(yù)期 true空串合法 2. () → 預(yù)期 true簡單配對 3. (} → 預(yù)期 false類型不匹配 4. ({}) → 預(yù)期 true嵌套合法 5. ([)] → 預(yù)期 false交叉非法 6. {[]} → 預(yù)期 true多種嵌套合法 7. ((())) → 預(yù)期 true多層嵌套合法 8. (() → 預(yù)期 false左括號剩余 9. )( → 預(yù)期 false右括號開頭任何實現(xiàn)如果過不了這九組都不算真正寫完。它們涵蓋了空串、簡單匹配、類型不匹配、嵌套合法、交叉非法、未閉合、順序錯誤等所有場景。我還會順手在本地寫一個小的驅(qū)動器把這組用例和我的isValid函數(shù)綁在一起跑省去每次手動輸入的麻煩。5.3 幾道延伸題目學(xué)完這道題有幾道題我強烈建議立刻去刷它們都是有效括號的直系后代。第一道是 LeetCode 22括號生成。它要求生成所有合法的括號組合核心是回溯加左右括號數(shù)量控制和棧的關(guān)系在于你要理解什么才算一個合法前綴。第二道是 LeetCode 32最長有效括號。難度明顯上一個臺階需要動規(guī)或棧很考驗綜合運用能力。第三道是 LeetCode 678有效的括號字符串。它加入了通配符*可以用貪心或者雙棧解決思路極其巧妙能把你的思維從確定性匹配拉到概率性匹配。我的建議是先把第 20 題做透再按 22 → 32 → 678 的順序挑戰(zhàn)。這幾道題串下來你對棧模型的理解會有一個質(zhì)的飛躍。很多剛開始刷題的人喜歡一個專題只做一道題就走其實最吃虧——因為同一個數(shù)據(jù)結(jié)構(gòu)在不同變體里展現(xiàn)出的特性才是真正需要花時間吸收的東西。我在實際面試和刷題過程中最深的一點體會是有效的括號這道題代碼量不到二十行但它是理解棧的一把鑰匙。無數(shù)后來讓我頭疼的題目——單調(diào)棧、表達(dá)式求值、函數(shù)調(diào)用棧模型、編譯原理里的括號語法分析——追根溯源都和這題背后的最近匹配思想相通。第一次寫這道題時我也犯過只數(shù)左右括號的錯被([)]狠狠教訓(xùn)過之后才真正理解了為什么括號匹配不只是數(shù)量問題。如果你剛開始刷題我建議你把這題做透多跑幾組邊界用例把棧的 push、pop、判空練成肌肉記憶。之后再遇到嵌套匹配類的問題你會感謝這一道題打下的底子。