1. 为什么括号匹配是栈最经典的入门场景
括号匹配这道题,几乎每个刷过题、面过试的程序员都不陌生——栈、括号匹配、左括号入栈、右括号弹栈比对,满打满算十来行代码。但说句实话,我在面试别人的时候,真能一遍写对的人其实不多,大部分人纠结的不是思路,而是各种边角情况:第一个字符就是右括号怎么办?字符串遍历完了栈里还有东西怎么办?左右括号类型对不上怎么办?这些细节恰恰是这道题的精髓。
先说清楚题目本身,避免后面云里雾里。给一个字符串,里面只包含( ) [ ] { }这六种字符,判断括号的嵌套顺序是否合法。合法意味着每一个右括号都能恰好配上一个最近的、尚未匹配的左括号,并且类型一致。比如([{}])合法,([)]不合法,(([]))合法,)(不合法。直观感受一下:([)]里,下标 0 的(和下标 3 的)能配对,但中间的[却先和)相遇了,这种交叉嵌套在括号体系里是被禁止的。
这个场景离我们太近了。编译器解析源代码时要检查语法括号是否配平,IDE 编辑器要实时高亮未闭合的括号,计算器计算带括号的表达式时要先处理内层括号,JSON/XML 解析器要处理嵌套结构。这些工具底层都在做本质相同的事:维护一个“最近未闭合”的待处理列表。而这个“最近”两个字,直接指向了栈这个数据结构——后进先出,正好用来记录谁是当前最内层的未闭合左括号。
我教学员的时候常打一个比方:栈就像一摞盘子,你洗好一个放上去一个,用的时候只能从最上面拿。括号的配对规则也是这样,字符串从左往右扫,遇到左括号就往“盘子堆”上压一个,遇到右括号时,能跟它配对的只有当前堆顶的那一个。盘子堆顶永远代表“最新出现的那个还没配对的左括号”,这是栈后进先出特性和嵌套结构之间的天然对应,也是这道题非栈不可的根本原因。
2. 算法核心思路与边界条件拆解
2.1 三步主流程
整个算法就三步,我建议任何人在动手写代码之前,先把这个流程在心里过一遍,能避免一半以上的失误。
- 从左到右遍历字符串中的每一个字符
- 遇到左括号(
(、[、{)时,无条件压入栈 - 遇到右括号(
)``]``})时,先看一眼栈是否为空,为空说明这个右括号没有对应的左括号,直接判定非法;不为空则取栈顶元素,检查两个括号类型是否匹配,匹配就弹栈,不匹配直接判定非法
遍历完整个字符串之后,还要再检查一步:栈是否为空。如果栈里还残留着左括号,说明有左括号一直没有被闭合,同样非法。这第三步太容易漏了,字符串为((()的时候,每一步宽度检查都是正常的,没有任何右括号触发失败分支,但遍历结束后栈里有三个(,这当然是非法输入。
2.2 四类边界条件的判定策略
我梳理了这道题最常见的四种边界场景,面试里考的就是你有没有提前想到它们:
| 场景 | 示例 | 判定结果 | 判定时机 |
|---|---|---|---|
| 空字符串 | "" | 合法 | 遍历结束,栈为空 |
| 首字符是右括号 | ")(","]" | 非法 | 第一次遇到右括号时栈为空 |
| 类型不匹配 | "(]","[)" | 非法 | 栈顶元素与右括号类型不符 |
| 左括号多余 | "((()","{}][" | 非法 | 遍历结束后栈非空 |
空字符串是一个容易被忽略的测试输入,很多初学者看到空串就懵了。其实空串意味着没有括号需要配对,天然合法,算法跑完栈为空,正确返回true就行,不需要特判。关键在于类型不匹配和空栈弹栈这两条,是代码实现里最容易出逻辑漏洞的地方。
2.3 匹配关系怎么组织更优雅
左右括号的对应关系,实现上有两种常见写法:一种是写一个isMatch(left, right)函数,里面用 if-else 或者 switch 判断三组配对;另一种是建立一个右括号到左括号的映射表,比如{')': '(', ']': '[', '}': '{'},遇到右括号直接查表比对栈顶。后者在代码整洁度和可读性上明显胜出,我推荐优先使用映射表,因为 if-else 链一旦括号类型增多,代码会迅速变得难以维护。如果有天产品经理要求扩展成< >尖括号或者 HTML 标签,映射表只需要多加一个键值对,而 if-else 得再改一堆分支。
3. 代码实现与细节解读
3.1 C 语言版本:手写数组栈
C 语言没有现成的栈容器,需要自己实现,这也正好把栈的原理彻底暴露出来。我用动态数组实现了一个简单栈,容量不足时自动翻倍扩容,这是最接近工程实践的做法。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdbool.h> typedef struct { char *data; int top; int capacity; } Stack; void initStack(Stack *s, int cap) { s->data = (char *)malloc(sizeof(char) * cap); s->top = -1; s->capacity = cap; } bool isEmpty(Stack *s) { return s->top == -1; } void push(Stack *s, char c) { if (s->top + 1 == s->capacity) { s->capacity *= 2; s->data = (char *)realloc(s->data, sizeof(char) * s->capacity); } s->data[++s->top] = c; } char pop(Stack *s) { if (isEmpty(s)) { return '\0'; } return s->data[s->top--]; } char peek(Stack *s) { if (isEmpty(s)) { return '\0'; } return s->data[s->top]; } bool isLeft(char c) { return c == '(' || c == '[' || c == '{'; } bool isMatch(char left, char right) { return (left == '(' && right == ')') || (left == '[' && right == ']') || (left == '{' && right == '}'); } bool isValid(const char *s) { Stack st; initStack(&st, 16); int n = strlen(s); for (int i = 0; i < n; i++) { char c = s[i]; if (isLeft(c)) { push(&st, c); } else { if (isEmpty(&st)) { free(st.data); return false; } char topChar = peek(&st); if (!isMatch(topChar, c)) { free(st.data); return false; } pop(&st); } } bool result = isEmpty(&st); free(st.data); return result; }这里有几个容易踩的坑,我说一下:
- 栈的
top初始化为 -1,代表空栈。这样入栈时先自增再赋值,出栈时直接访问再自减,不用单独维护栈内元素数量。 - 每次函数 return 之前必须
free(st.data),否则每次调用都会泄漏内存。我在两个false分支里都释放了,最后的结果分支也释放了。写 C 的人对内存管理要有条件反射,只要 malloc 了就一定要能找到对应的 free 路径。 pop和peek对空栈的防御性返回'\0'是必要的,因为 C 语言里对空栈做data[--top]会产生未定义行为,越界访问数组是灾难性的。虽然我在isValid里已经先判断了空栈,但底层函数的防御性检查仍然值得保留,防止未来有人改了调用方。
3.2realloc扩容的代价与时机
我在这里用了realloc实现动态扩容,初始容量 16,满了直接翻倍。为什么是翻倍而不是加固定大小?因为翻倍扩容的均摊时间复杂度是 O(1),加固定大小扩容的均摊时间虽然也是 O(1),但常数更大,而且总扩容次数更多。对于括号匹配这种一次性的短字符串处理,其实初始 16 个字符通常根本不会触发扩容,但工程习惯要养成,以后你的栈要服务几百万次 push 时,扩容策略就决定了性能上限。
realloc还有一个隐患:如果分配失败会返回 NULL,直接赋值给s->data会让原来的指针丢失,造成泄漏。严格的生产代码应该用一个临时指针接收realloc的返回值,先判断是否为 NULL 再赋值。篇幅关系我没有在这个示例里写全,但你自己写的时候最好补上这一层防御。
3.3 Python 版本:十行以内解决问题
Python 的list天生就是栈:append是入栈,pop是出栈,取最后一个元素用stack[-1]。用上映射表之后,核心逻辑极其简洁。
def is_valid(s: str) -> bool: stack = [] pairs = {')': '(', ']': '[', '}': '{'} for ch in s: if ch in "([{": stack.append(ch) elif ch in pairs: if not stack or stack[-1] != pairs[ch]: return False stack.pop() return not stack注意if not stack这个判断把空栈的情况和类型不匹配的情况合并处理了:空栈时stack[-1]会抛 IndexError,所以必须先判断。Python 的and短路求值在这里保证了安全,not stack为真时根本不会执行后面的stack[-1]。这段代码的正确性依赖短路机制,新手容易把两个条件写反,导致空栈时崩溃。
这段代码和 C 版本逻辑完全一致,差别只是容器由底层替你实现了。建议初学者两个版本都写一遍,能彻底明白“栈是一种抽象逻辑,数组只是它的实现方式之一”这句话。同样的逻辑,用链表也一样可以实现栈,只是数组在连续内存访问上更高效,缓存命中率更高。
4. 复杂度分析与性能优化空间
4.1 时间和空间复杂度
这个算法的时间复杂度是 O(n),其中 n 是输入字符串长度。每个字符最多被处理两次:一次入栈,一次出栈。扫描本身的遍历是 n 次操作,加上栈操作的均摊 O(1),总体是线性时间。空间复杂度在最坏情况下是 O(n),也就是当字符串全是一串未闭合的左括号时,栈里会积累 n 个字符。
有人会问:能不能做到 O(1) 额外空间?对纯括号匹配来说,如果是只有一种括号,确实可以,用一个计数器记录未闭合左括号数量就行。但题目一旦规定多种括号类型且必须合法交叉嵌套,计数器就无法区分[和(了。你可以试想用三个计数器分别记录三种左括号,遇到([)]这个输入时,三个计数器都能对上,但实际上它是非法的。原因就是括号的交叉嵌套要求我们记住“顺序”,而不只是“数量”。栈本质上是在用额外的空间保存顺序信息,这是“多类型括号匹配”这一复杂度下不可避免的代价。
4.2 少存字符,空间减半
很多人的第一版实现会把左右括号都压栈,这是多余的。我们只需要压左括号,因为右括号的作用仅是“拿来比较”,它自己永远不需要被后续的什么字符匹配。遇到右括号时从栈里弹出左括号比对,用完即弃。这个小小的优化能省下近一半的栈空间,虽然复杂度量级不变,但工程上占用越少越友好。
我还见过一种写法:用一个整型栈存左括号的 ASCII 码,比较时直接算差值。比如(的 ASCII 是 40,)是 41,[是 91,]是 93,{是 123,}是 125。三组括号的左右 ASCII 差值恰好都是 1(或 2),所以有人用right - left == 1 || right - left == 2来判断匹配。这个技巧能通过一些题目的测试,但可读性太差,而且依赖 ASCII 编码的巧合,一旦字符集变化就是隐患。我不推荐在正式代码里用这种“聪明写法”,面试官也不会因此加分。
4.3 提前剪枝:长度必须为偶数
还有一个零成本的优化:遍历前先判断strlen(s) % 2 != 0,奇数长度的字符串必然非法,因为括号必须成对。这个判断消耗 O(1) 时间,却能帮我们跳过大量不可能合法的输入。虽然strlen本身就是 O(n),但反正后面也要完整扫描一遍,提前检查一次不会增加复杂度,纯粹是白捡的剪枝。同理,如果允许只包含括号字符,还可以先确认字符串里没有其他非法字符,不过这属于额外约束,看题目要求。
5. 常见问题排查与调试实录
5.1 我见过的高频 Bug 排行榜
这些年帮同事和学员 review 过无数版括号匹配代码,我总结了几个出现频率最高的错误,按坑的等级排个序:
| 坑位 | 错误写法 | 问题后果 |
|---|---|---|
| 空栈直接弹栈 | char top = pop(&st);且没有 isEmpty 判断 | 未定义行为,轻则乱取值,重则程序崩溃 |
| 忘记最后检查栈空 | 遍历结束直接return true | (((被误判为合法 |
| 匹配方向写反 | if (stack[-1] != ch)推了右括号再比较 | 逻辑彻底混乱,全错 |
| 栈顶取成了未弹出的字符 | 用peek但记成了弹出 | 出栈元素丢失,后续匹配错乱 |
| 三种括号共用一组判断 | 用一个isMatch但不区分左右 | 无法发现交叉嵌套的非法情况 |
其中空栈直接弹栈是最隐蔽的。C 语言里空栈时data[--top]会访问data[-1],也就是栈数组首地址的前一个字节,那个位置的数据完全不可预期。这在本地测试时可能碰巧不炸,但一旦被恶意输入(比如输入第一个字符就是))触发,结果不可控。所以我在讲解时有个硬性要求:任何涉及pop、peek的地方,先问自己一句“这里有没有可能栈是空的”。
5.2 定位 bug 的实用调试手段
如果你实现完发现测试不过,我推荐按下面的顺序排查,比盲改快得多。
第一,打印栈的内容。在入栈和出栈的位置各加一行打印,输出当前字符和栈内所有元素。括号匹配的栈内容很简单,一眼就能看出问题。比如输入([)],你会看到扫到)时栈是[ ( , [ ],栈顶是[,它和)不匹配,立刻定位到算法判断正确,是你的isMatch写错了,还是查表写错了。
第二,用最小测试集逐个过。我调试时习惯先用四个最小用例:""、"()"、"(){}[]"、"([)]"。这四个用例分别验证空串处理、基本配对、多类型并存、交叉嵌套非法。如果这四个都过了,再上长用例和随机用例。很多人的代码在"()"上是对的,到"([)]"就翻车,这通常是匹配逻辑没有正确区分三种括号类型造成的。
第三,检查你的弹栈时机。常见错误是在匹配成功后没有pop,导致栈反复拿同一个元素比较。这种错误的表现是"()"能过但"(())"会错——内层的)匹配了外层的(,然后外层的)又来匹配同一个(,结果自然是错的。
5.3 面试中的加分细节
这道题在面试里已经不是“能不能做出来”的问题,而是“能不能做得漂亮、聊得清楚”。我自己面试候选人时会特别关注三个点:
- 候选人有没有主动问“输入里只有括号吗?还是可能有空格和其他字符?”——这暴露了需求分析意识
- 候选人有没有主动提边界条件再写代码——这暴露了测试思维
- 候选人能否解释清楚“为什么栈是唯一合理的数据结构”——这暴露了对数据结构的理解深度
所以我的建议是:先和面试官确认输入范围,然后口述流程和边界条件,最后再动笔。写完之后主动说“我来补几个测试用例验证一下”,这一句话的加分效果比代码本身更大。这道题本身不难,区分度全在沟通和严谨性上。
6. 从括号匹配到更广阔的应用场景
6.1 表达式求值与编译原理
括号匹配只是栈应用的冰山一角。编译原理里,词法分析之后的语法分析阶段,表达式求值用的还是栈:中缀表达式转后缀表达式、后缀表达式计算、运算符优先级处理,全都要依赖栈。你在计算器里输入一个(a + b) * c,底层就是把中缀转成后缀a b + c *,再用一个栈完成求值。括号在这里的作用是临时改变运算优先级,而匹配规则保证的正是这个“临时改变”是合法的。
很多全栈开发者写后端接口时也会遇到类似场景,比如解析用户提交的查询语法、模板引擎的标签嵌套。我自己写过一个简单的模板引擎,处理{{if}}...{{endif}}的嵌套时,用的就是同一套思路:遇到起始标签入栈,遇到结束标签出栈比对,最后的归属关系天然形成一棵树。理解了括号匹配,你就理解了嵌套型文本解析的一半。
6.2 函数调用栈与栈帧
把视野拉开一点,程序运行时的函数调用本质也是在用栈。每次函数调用压入一个栈帧,里面装着局部变量、返回地址、参数等;函数返回时弹出栈帧,回到调用者的现场。这就是热搜词里“栈帧形成过程”、“调用栈回溯”的底层逻辑。调试器打印的调用栈,本质就是当前时刻所有未返回函数栈帧的列表,最上方永远是最新进入的函数。
理解了这一点,你再看递归和回溯算法会更通透:递归函数的每一层调用就是在压栈,返回就是在弹栈。括号匹配里的栈,和递归难度里那个装状态参数的栈,本质上用的是同一个抽象模型。以后你刷二叉树遍历、深度优先搜索、迷宫寻路这类回溯问题,会发现全都长着类似的模样。
6.3 同类变体题和扩展方向
刷题讲究触类旁通,括号匹配这个点能衍生出一串相关的题目:
- 最小添加使字符串有效:给定字符串,算至少加多少个括号能让它合法。解法依然是栈,统计匹配失败的次数即可
- 括号的得分:给一个合法的括号串,按特定规则算分。需要在栈里存分数而不是字符
- 最长有效括号:找字符串中最长的合法括号子串。经典解法是栈存下标
- HTML/XML 标签匹配:把
[](){}换成<div>和</div>,匹配规则从字符相等变成字符串相等,本质思路完全一致
这些题我建议都刷一遍,因为每一道都会强化你对“栈里存什么”的思考。括号匹配存的是字符,最长有效括号存的是下标,括号得分存的是数值,同样是栈,存取的对象不同,解题思路就完全打开了。
实际操作中的体会是,栈这个数据结构理解透了,受益的不只是刷题,更是日常写代码时对“上下文嵌套”这类问题的敏感度。我最后分享一个小技巧:写任何嵌套结构的处理逻辑之前,先画一个简单的压栈弹栈示意图,哪怕在纸上画几笔都行,比直接写代码快得多,也稳得多。栈的代码本身没有难度,难的是在动手之前想清楚栈里到底要放什么。