LeetCode 20|有效的括号:从暴力枚举到栈的五种解法 | 星雨笔录
以 LeetCode 20「有效的括号」为例,从最直观的暴力消除法出发,逐步推导标准栈、提前压入右括号、映射表与双端队列、数组模拟栈五种解法。结合黑盒与白盒测试、详细执行步骤、复杂度分析,以及 C、C++、Java、Python、JavaScript 五语言实现,理解括号匹配的核心原理、算法优化过程与不同方案的适用场景。
一、题目描述
题目: LeetCode 20. 有效的括号 难度: 简单 知识点: 字符串、模拟、栈(Stack)
给定一个字符串 s,其中只包含 (、)、[、]、{、} 六种字符,判断这个字符串是否有效。
有效字符串必须同时满足:
- 类型正确:
(只能由)闭合,[只能由]闭合,{只能由}闭合。 - 顺序正确: 内层括号必须先闭合,外层括号才能闭合,不能交叉配对。
- 完全匹配: 不能有多余的右括号,也不能留下未闭合的左括号。
输入: 字符串 s,1 ≤ s.length ≤ 10⁴,并且只包含以上六种括号。
输出: 布尔值;有效返回 true,否则返回 false。
例如,"([])" 合法,因为 [] 先闭合,外层的 () 再闭合;"([)]" 不合法,虽然三种括号中 () 与 [] 的数量都相等,但 ) 出现时,最近一个尚未闭合的是 [,顺序被破坏了。
本题最关键的限制是:不能只检查数量,还要检查配对类型和嵌套顺序。
二、测试用例
编写算法前,先明确怎样判断程序是否正确。测试分为黑盒测试和白盒测试:前者只看输入与输出,后者还要检查代码中的关键判断和执行路径是否被覆盖。
2.1 黑盒测试:根据题目规则设计输入与输出
黑盒测试不关心程序使用栈、数组还是字符串替换,只根据需求划分有效等价类、无效等价类以及边界值。下表的用例适用于后面的全部五种解法。
| 编号 | 测试目的 | 输入s |
预期输出 | 原因 |
|---|---|---|---|---|
| B01 | 最短合法圆括号 | "()" |
true |
正确配对 |
| B02 | 最短合法方括号 | "[]" |
true |
正确配对 |
| B03 | 最短合法花括号 | "{}" |
true |
正确配对 |
| B04 | 并列不同类型 | "()[]{}" |
true |
三对分别闭合 |
| B05 | 多层嵌套 | "{[()]}" |
true |
由内向外闭合 |
| B06 | 复杂合法嵌套 | "([{}])" |
true |
多类型嵌套顺序正确 |
| B07 | 同类嵌套 | "((()))" |
true |
多层圆括号逐级闭合 |
| B08 | 相邻与嵌套混合 | "([]){}" |
true |
两种结构混合 |
| B09 | 配对类型错误 | "(]" |
false |
左右类型不相同 |
| B10 | 交叉嵌套 | "([)]" |
false |
数量正确但闭合顺序错误 |
| B11 | 第一个字符是右括号 | ")(" |
false |
无左括号可供配对 |
| B12 | 中途出现多余右括号 | "())(" |
false |
匹配完成后又出现) |
| B13 | 末尾仍有未闭合括号 | "((" |
false |
两个左括号都没有闭合 |
| B14 | 只有右括号 | "))" |
false |
没有左括号 |
| B15 | 最短奇数输入 | "(" |
false |
长度为 1,不可能成对 |
| B16 | 奇数且类型混合 | "[]]" |
false |
多余一个右括号 |
| B17 | 相同数量但顺序颠倒 | "][" |
false |
右括号先于左括号 |
| B18 | 无法消除的交错结构 | "[(])" |
false |
括号交叉 |
| B19 | 最大长度、深度嵌套 | "("重复 5000 次,再")"重复 5000 次 |
true |
长度 10000、嵌套深度 5000 |
| B20 | 最大长度、左括号未闭合 | "("重复 10000 次 |
false |
所有括号都没有闭合 |
| B21 | 最大长度、立即冲突 | ")"重复 10000 次 |
false |
第一个字符就不合法 |
| B22 | 最大长度、类型不符 | "("重复 5000 次,再"]"重复 5000 次 |
false |
首次闭合就类型冲突 |
输入范围说明: 题目规定字符串长度至少为 1,所以空字符串不属于官方测试范围。可以额外检验
s = "":下文五种实现都会返回true,表示空串没有不匹配的括号;这只是扩展约定,不能把它当作题目强制要求。
2.2 白盒测试:检查算法里的关键分支
黑盒测试能验证结果,却未必能证明每个分支都执行过。白盒测试要提前指出:哪个条件需要触发、用什么输入触发、应该走哪条路径。
| 关注的执行路径 | 代表用例 | 要观察的现象 |
|---|---|---|
| 奇数长度的提前返回 | B15:"(" |
不必进入主循环,直接返回false |
| 普通左括号入栈 | B05:"{[()]}" |
先依次保存尚未闭合的括号 |
| 三种括号类型的成功配对 | B04:"()[]{}" |
()、[]、{}三个判断都能通过 |
| 栈为空时遇到右括号 | B17:"][" |
不能执行pop,直接返回false |
| 栈非空但类型不匹配 | B09:"(]" |
取出的括号类型与当前字符冲突 |
| 已经匹配成功,后面又出现右括号 | B12:"())(" |
不能因为前面出现合法括号就提前返回true |
| 扫描结束但栈不为空 | B13:"((" |
不能只看循环中是否出错 |
| 多层嵌套导致反复消除 | B05:"{[()]}" |
删除内层后必须重新查找外层配对 |
| 一轮消除后字符串完全没变化 | B10:"([)]" |
退出消除循环,残余非空则失败 |
数组栈top加减与边界 |
B05、B17、B13 | 验证入栈、出栈、空栈及最终剩余状态 |
| 映射规则查找成功与失败 | B04、B05 | 分别走到右括号查表和左括号入栈分支 |
这里列出的是应覆盖的测试路径,不是声称已经通过工具测得 100% 分支覆盖率。由于五种代码的条件分支存在差异,测试时应对照各自的实现逐项核查。
2.3 大规模与组合测试
只测试表中的二十几个输入还不够。还可以采用两种补充办法:
- 穷举短串: 对长度 1 到 6 的六种括号进行全排列,生成全部
6¹ + 6² + … + 6⁶ = 55986个字符串,让五种方法的结果相互比较;如果结果不一致,输出导致差异的输入。(相互比较有助于发现问题,但不能单独证明所有结果正确。) - 构造合法串再变形: 如果
A、B有效,那么AB、(A)、[A]、{A}都有效;也可以故意删除、交换或替换某个字符,检查输出是否符合改变后的实际配对规则。注意修改一个字符不一定让字符串无效,因此每个变形后的预期结果仍需核对。
接下来按“先做出正确结果,再逐步减少重复工作、改进代码表达和工程适应性”的思路研究五种写法。
三、解法一:暴力枚举与消除——先用最直观的办法做出来
3.1 核心思想
拿到 "{[()]}",先不考虑任何数据结构。人眼最容易发现的是中间的 ():这两个字符挨在一起,肯定构成有效的一对。把它们删掉,原字符串变成 "{[]}";再删掉 [],得到 "{}";最后删掉 {},字符串变为空。
{[()]} → {[]} → {} → ""
反过来,如果是 "([)]",无论怎样检查,都找不到相邻的 ()、[] 或 {},也就无法继续消除,最后剩下非空字符串,因此无效。
于是可以先得到一个完全直接的判断办法:反复查找并删除紧挨着的合法括号对,直到没有括号可以删除;若剩余字符串为空,则原字符串有效。
为什么这成立?有效的非空括号字符串一定存在一对“最内层”的相邻括号;删除这对不会改变其他括号的配对关系。一直删除下去,合法串可以化为空串;如果删不动时还有字符,说明存在无法匹配的结构。
3.2 执行步骤
- 读取字符串长度。如果长度是奇数,直接返回
false,因为每对括号都需要两个字符。 - 保存一份当前待处理的字符串。后续删除操作只修改这份字符串;在 C 等允许原地修改的语言中,最好先复制输入,避免改变调用者的数据。
- 从左到右枚举相邻的两个字符,寻找三种合法组合:
()、[]、{}。只有这三种组合能够直接删除,例如(]和[)都不能删除。 - 如果发现合法组合,删除它,使右侧字符向左靠拢。可以一次只删除一对,也可以在一次处理中删除多对;关键是只删除真正相邻且类型匹配的组合。
- 删除后再次查找,因为原来隔开的字符可能刚刚变成一对。例如
"([])"先删掉[]才会出现新的()。 - 当一轮检查中没有删除任何字符时停止。如果此时字符串长度为
0,返回true;否则返回false。
完整推演: "([]){}" → "(){}" → "{}" → "",结果是 true。而 "(]" 没有任何可删除的相邻组合,第一轮便停止,结果是 false。
3.3 代码实现
下面五份代码都是同一种消除规则,但一次删除的数量可能不同:C 实现每轮删一对,其他实现允许连续删除若干对。两者均会重复检查直到无法继续删除。
::: code-group C|C++|Java|Python|JavaScript
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
static bool isPair(char left, char right) {
return (left == '(' && right == ')') ||
(left == '[' && right == ']') ||
(left == '{' && right == '}');
}
bool isValid(char *s) {
size_t n = strlen(s);
// 先过滤奇数长度,减少不必要的字符串处理。
if (n % 2 != 0) return false;
// 复制输入到可修改的缓冲区,避免修改传入的原字符串。
char *buf = malloc(n + 1);
if (buf == NULL) return false;
memcpy(buf, s, n + 1);
// 不断重复消除,直到这一轮再也找不到合法相邻括号对。
while (true) {
bool removed = false;
// 查找能直接配对的相邻字符,如 ()、[]、{}。
for (size_t i = 0; i + 1 < n; ++i) {
// 找到第一对相邻合法括号,马上删除。
if (isPair(buf[i], buf[i + 1])) {
// 包括末尾 '\0' 一并左移。
// 把后面的字符整体左移两格,同时保留结尾字符串终止符。
memmove(buf + i, buf + i + 2, n - i - 1);
n -= 2;
removed = true;
break;
}
}
// 这一轮没有任何变化时停止;若还有残余字符就是无效。
if (!removed) break;
}
// 只有所有括号对都能消除,最终字符串才会变为空。
bool ok = (n == 0);
free(buf);
return ok;
}
#include <string>
using namespace std;
class Solution {
public:
bool isValid(string s) {
// 先过滤奇数长度,减少不必要的字符串处理。
if (s.size() % 2 != 0) return false;
// 不断重复消除,直到这一轮再也找不到合法相邻括号对。
while (true) {
bool changed = false;
// 查找能直接配对的相邻字符,如 ()、[]、{}。
for (const string pair : {"()", "[]", "{}"}) {
size_t pos;
while ((pos = s.find(pair)) != string::npos) {
// 从当前字符串里移除找到的两个字符。
s.erase(pos, 2);
changed = true;
}
}
// 这一轮没有任何变化时停止;若还有残余字符就是无效。
if (!changed) break;
}
// 只有所有括号对都能消除,最终字符串才会变为空。
return s.empty();
}
};
class Solution {
public boolean isValid(String s) {
// 先过滤奇数长度,减少不必要的字符串处理。
if (s.length() % 2 != 0) return false;
// 不断重复消除,直到这一轮再也找不到合法相邻括号对。
while (true) {
// 尝试删除三种括号对,得到变短后的新字符串。
String next = s.replace("()", "")
.replace("[]", "")
.replace("{}", "");
// 这一轮没有任何变化时停止;若还有残余字符就是无效。
if (next.equals(s)) return s.isEmpty();
s = next;
}
}
}
class Solution:
def isValid(self, s: str) -> bool:
# 先过滤奇数长度,减少不必要的字符串处理。
if len(s) % 2 != 0:
return False
# 不断重复消除,直到这一轮再也找不到合法相邻括号对。
while True:
# 尝试删除三种括号对,得到变短后的新字符串。
next_s = s.replace('()', '').replace('[]', '').replace('{}', '')
# 这一轮没有任何变化时停止;若还有残余字符就是无效。
if next_s == s:
# 只有所有括号对都能消除,最终字符串才会变为空。
return not s
s = next_s
var isValid = function(s) {
// 先过滤奇数长度,减少不必要的字符串处理。
if (s.length % 2 !== 0) return false;
// 不断重复消除,直到这一轮再也找不到合法相邻括号对。
while (true) {
// 尝试删除三种括号对,得到变短后的新字符串。
const next = s.replaceAll('()', '')
.replaceAll('[]', '')
.replaceAll('{}', '');
// 这一轮没有任何变化时停止;若还有残余字符就是无效。
if (next === s) return s.length === 0;
s = next;
}
};
:::
3.4 复杂度分析
设字符串长度为 n。
- 时间复杂度:最坏
O(n²)。 每次扫描、查找或删除可能处理O(n)个字符;最坏情况下最多会进行n/2次有效消除。例如高度嵌套的字符串,外层括号往往要等内层先删除后才能配对。 - 辅助空间复杂度:
O(n)上界。 C 版先复制输入;Java、Python、JavaScript 的字符串替换会构造新字符串;C++ 在删除过程中也可能产生临时字符串。具体实现的峰值内存因语言而异,但都可以用线性上界描述。
问题出在哪里? 我们反复扫描字符串,是为了不断找到“刚刚形成的相邻括号”。如果能在第一次读取字符时就记住哪些左括号还没闭合,也许根本不用反复扫描。这个发现自然引出栈。
四、解法二:标准栈——把平方级扫描优化成一次遍历
4.1 核心思想
仔细看 "{[()]}" 的匹配顺序:最后出现的左括号 ( 最先被 ) 闭合;其次才是 [、{。也就是说,后出现、尚未闭合的左括号,必须先匹配。
这种顺序叫后进先出(LIFO,Last In, First Out),恰好就是栈(Stack)的规则。栈只允许从栈顶放入元素(push),也从栈顶取走元素(pop)。
因此,不再反复删除字符串,而是从左往右只扫描一次,用栈保存尚未匹配的左括号。遇到右括号,就检查它能否闭合栈顶最近的左括号。
4.2 执行步骤
- 判断字符串长度是否为奇数;若是,返回
false。 - 创建一个空栈。规定:栈里每一个元素都是已经读到、但尚未匹配的左括号。
- 从左到右读取一个字符
c。如果c是(、[或{,直接入栈;因为现在只有左括号,暂时无法判断它何时闭合。 - 如果
c是右括号,先检查栈是否为空。若栈为空,说明右括号出现在任何可匹配的左括号之前,立即返回false,绝不能先执行pop。 - 栈非空时取出栈顶左括号,分别检查三种合法组合:
()、[]、{}。若类型不同,例如栈顶是[却读到),立即返回false。 - 如果匹配成功,就继续读取下一个字符。不要因为成功匹配一对括号就返回
true,后面还可能出现额外的右括号。 - 全部字符读完后检查栈:栈为空,说明每个左括号都被正确闭合,返回
true;栈不为空,说明还有未闭合的左括号,返回false。
用 "([)]" 推演一次,栈从左到右表示“栈底 → 栈顶”:
| 当前字符 | 动作 | 剩余栈 | 结果 |
|---|---|---|---|
( |
左括号入栈 | ( |
继续 |
[ |
左括号入栈 | ( [ |
继续 |
) |
弹出[,发现与)类型不同 |
( |
false,停止 |
关键不在于 () 与 [] 有没有各出现一次,而在于当前右括号是否对应最近的那个未完成左括号。这就是栈比数量统计更合适的原因。
4.3 代码实现
注意这里保存的是原始左括号。Java 示例沿用现代 Java 推荐的 ArrayDeque,而不是历史遗留的 Stack 类;Deque 的 push / pop 操作同样可以实现栈。
::: code-group C|C++|Java|Python|JavaScript
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
bool isValid(char *s) {
size_t n = strlen(s);
// 括号必须成对出现;奇数长度不可能完全匹配。
if (n % 2 != 0) return false;
// 建立空栈,存储仍需要匹配的字符。
char *stack = malloc(n + 1);
if (stack == NULL) return false;
size_t top = 0;
// 从左到右扫描,每次只处理一个括号。
for (size_t i = 0; i < n; ++i) {
char c = s[i];
// 左括号直接入栈,等待未来对应的右括号。
if (c == '(' || c == '[' || c == '{') {
stack[top++] = c;
} else {
// 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if (top == 0) {
free(stack);
return false;
}
// 取出最近一个未闭合的左括号,准备核对类型。
char left = stack[--top];
// 只有 ()、[]、{} 三种组合合法,其他组合一律失败。
if (!((left == '(' && c == ')') ||
(left == '[' && c == ']') ||
(left == '{' && c == '}'))) {
free(stack);
return false;
}
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
bool ok = (top == 0);
free(stack);
return ok;
}
#include <stack>
#include <string>
using namespace std;
class Solution {
public:
bool isValid(string s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.size() % 2 != 0) return false;
// 建立空栈,存储仍需要匹配的字符。
stack<char> st;
// 从左到右扫描,每次只处理一个括号。
for (char c : s) {
// 左括号直接入栈,等待未来对应的右括号。
if (c == '(' || c == '[' || c == '{') {
st.push(c);
} else {
// 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if (st.empty()) return false;
// 取出最近一个未闭合的左括号,准备核对类型。
char left = st.top();
st.pop();
// 只有 ()、[]、{} 三种组合合法,其他组合一律失败。
if (!((left == '(' && c == ')') ||
(left == '[' && c == ']') ||
(left == '{' && c == '}'))) return false;
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return st.empty();
}
};
import java.util.Stack;
class Solution {
public boolean isValid(String s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.length() % 2 != 0) return false;
// 建立空栈,存储仍需要匹配的字符。
Stack<Character> stack = new Stack<>();
// 从左到右扫描,每次只处理一个括号。
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// 左括号直接入栈,等待未来对应的右括号。
if (c == '(' || c == '[' || c == '{') {
stack.push(c);
} else {
// 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if (stack.isEmpty()) return false;
// 取出最近一个未闭合的左括号,准备核对类型。
char left = stack.pop();
// 只有 ()、[]、{} 三种组合合法,其他组合一律失败。
if (!((left == '(' && c == ')') ||
(left == '[' && c == ']') ||
(left == '{' && c == '}'))) return false;
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return stack.isEmpty();
}
}
class Solution:
def isValid(self, s: str) -> bool:
# 括号必须成对出现;奇数长度不可能完全匹配。
if len(s) % 2 != 0:
return False
# 建立空栈,存储仍需要匹配的字符。
stack = []
# 从左到右扫描,每次只处理一个括号。
for c in s:
# 左括号直接入栈,等待未来对应的右括号。
if c in "([{":
stack.append(c)
else:
# 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if not stack:
return False
# 取出最近一个未闭合的左括号,准备核对类型。
left = stack.pop()
# 只有 ()、[]、{} 三种组合合法,其他组合一律失败。
if not ((left == '(' and c == ')') or
(left == '[' and c == ']') or
(left == '{' and c == '}')):
return False
# 处理完所有字符后,只有没有遗留待匹配字符才有效。
return not stack
var isValid = function(s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.length % 2 !== 0) return false;
// 建立空栈,存储仍需要匹配的字符。
const stack = [];
// 从左到右扫描,每次只处理一个括号。
for (const c of s) {
// 左括号直接入栈,等待未来对应的右括号。
if (c === '(' || c === '[' || c === '{') {
stack.push(c);
} else {
// 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if (stack.length === 0) return false;
// 取出最近一个未闭合的左括号,准备核对类型。
const left = stack.pop();
// 只有 ()、[]、{} 三种组合合法,其他组合一律失败。
if (!((left === '(' && c === ')') ||
(left === '[' && c === ']') ||
(left === '{' && c === '}'))) return false;
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return stack.length === 0;
};
:::
4.4 复杂度分析
- 时间复杂度:
O(n)。 每个字符读取一次。左括号最多入栈一次,成功匹配时最多出栈一次,单次栈操作为常数时间。 - 辅助空间复杂度:最坏
O(n)。 当字符串前半部分全是左括号时,需要暂存大量待匹配元素。若同时最多有d个左括号未闭合,动态栈实际所需元素数量为O(d),最坏d = O(n)。
优化发生在哪里? 暴力法删除内层括号后可能再次扫描剩余字符串;栈法把未闭合状态直接存起来,省去了反复寻找和移动字符的工作,最坏时间从 O(n²) 降到 O(n)。
五、解法三:提前保存右括号——把匹配判断简化为一次比较
5.1 核心思想
标准栈法在读到 ) 时,会先从栈顶拿出一个左括号,再判断它是不是 (。其实,我们可以在左括号入栈时就把它改成“未来应该看到什么”:
| 当前读到的左括号 | 存入栈中的字符 | 意思 |
|---|---|---|
( |
) |
等待一个) |
[ |
] |
等待一个] |
{ |
} |
等待一个} |
栈仍然遵守后进先出,只是里面存的不再是“已经出现的左括号”,而是“尚未出现、但下一步应该匹配的右括号”。这样一来,读到真正的右括号时,只需比较它和栈顶字符是否相同。
5.2 执行步骤
- 如果字符串长度为奇数,返回
false。 - 创建空栈,规定栈中存放的是期待出现的右括号。
- 从左到右读取字符
c。若c为(,则把)入栈;若为[,把]入栈;若为{,把}入栈。 - 若
c已经是右括号,先检查栈是否为空。空栈意味着没有正在等待它的左括号,返回false。 - 若栈不为空,弹出栈顶期待字符
expected,检查它是否等于c。不相同就返回false;相同才继续。 - 扫描全部结束后,只有栈为空才能返回
true。只要还有期待字符没有出现,就说明至少有一对没有闭合。
例子: 读取 "([])":
| 当前字符 | 处理动作 | 栈内期待的右括号(底 → 顶) |
|---|---|---|
( |
保存将来需要的) |
) |
[ |
保存将来需要的] |
) ] |
] |
与栈顶]相同,弹出 |
) |
) |
与栈顶)相同,弹出 |
空 |
| 结束 | 栈为空 | 返回true |
相较标准栈,改变的只是保存的信息,不是匹配顺序。不要误认为这种写法的渐进复杂度更低。
5.3 代码实现
留意 stack.pop() != c 这样的写法:在比较之前,必须保证栈非空,否则会发生无效出栈;代码中使用 || 的短路特性保障这一点。
::: code-group C|C++|Java|Python|JavaScript
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
bool isValid(char *s) {
size_t n = strlen(s);
// 括号必须成对出现;奇数长度不可能完全匹配。
if (n % 2 != 0) return false;
// 建立空栈,只保存「期望将来出现的右括号」。
char *stack = malloc(n + 1);
if (stack == NULL) return false;
size_t top = 0;
// 从左到右扫描,每次只处理一个括号。
for (size_t i = 0; i < n; ++i) {
char c = s[i];
// 左括号不入栈本身,而是存入它期待的右括号。
if (c == '(') stack[top++] = ')';
else if (c == '[') stack[top++] = ']';
else if (c == '{') stack[top++] = '}';
// 空栈直接失败;否则取出栈顶期待字符,和当前字符比较。
else if (top == 0 || stack[--top] != c) {
free(stack);
return false;
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
bool ok = (top == 0);
free(stack);
return ok;
}
#include <stack>
#include <string>
using namespace std;
class Solution {
public:
bool isValid(string s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.size() % 2 != 0) return false;
// 建立空栈,只保存「期望将来出现的右括号」。
stack<char> st;
// 从左到右扫描,每次只处理一个括号。
for (char c : s) {
// 左括号不入栈本身,而是存入它期待的右括号。
if (c == '(') st.push(')');
else if (c == '[') st.push(']');
else if (c == '{') st.push('}');
else {
// 先判断是否为空,再取出栈顶进行严格比较。
if (st.empty() || st.top() != c) return false;
st.pop();
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return st.empty();
}
};
import java.util.ArrayDeque;
import java.util.Deque;
class Solution {
public boolean isValid(String s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.length() % 2 != 0) return false;
// 建立空栈,只保存「期望将来出现的右括号」。
Deque<Character> stack = new ArrayDeque<>();
// 从左到右扫描,每次只处理一个括号。
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// 左括号不入栈本身,而是存入它期待的右括号。
if (c == '(') stack.push(')');
else if (c == '[') stack.push(']');
else if (c == '{') stack.push('}');
// 空栈直接失败;否则取出栈顶期待字符,和当前字符比较。
else if (stack.isEmpty() || stack.pop() != c) return false;
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return stack.isEmpty();
}
}
class Solution:
def isValid(self, s: str) -> bool:
# 括号必须成对出现;奇数长度不可能完全匹配。
if len(s) % 2 != 0:
return False
# 建立空栈,只保存「期望将来出现的右括号」。
stack = []
# 从左到右扫描,每次只处理一个括号。
for c in s:
# 左括号不入栈本身,而是存入它期待的右括号。
if c == '(':
stack.append(')')
elif c == '[':
stack.append(']')
elif c == '{':
stack.append('}')
# 空栈直接失败;否则取出栈顶期待字符,和当前字符比较。
elif not stack or stack.pop() != c:
return False
# 处理完所有字符后,只有没有遗留待匹配字符才有效。
return not stack
var isValid = function(s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.length % 2 !== 0) return false;
// 建立空栈,只保存「期望将来出现的右括号」。
const stack = [];
// 从左到右扫描,每次只处理一个括号。
for (const c of s) {
// 左括号不入栈本身,而是存入它期待的右括号。
if (c === '(') stack.push(')');
else if (c === '[') stack.push(']');
else if (c === '{') stack.push('}');
// 空栈直接失败;否则取出栈顶期待字符,和当前字符比较。
else if (stack.length === 0 || stack.pop() !== c) return false;
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return stack.length === 0;
};
:::
5.4 复杂度分析
- 时间复杂度:
O(n)。 每个字符最多触发一次入栈或者出栈操作。 - 辅助空间复杂度:最坏
O(n)。 最多保存O(n)个等待闭合的右括号。
这一轮的优化是什么? 不再每次从栈顶取到左括号后检查三种组合,而是提前记录其对应右括号,将配对判断收敛为一次相等性比较。这是代码逻辑与常数因子的简化,不是复杂度数量级的改变。
六、解法四:映射表 + 双端队列——把配对规则独立出来
6.1 核心思想
前面通过 if / else 判断三种括号配对。对于题目限定的三种括号完全够用,但如果在编辑器、解释器或配置解析器中,需要把不同定界符的匹配规则集中管理,一张**映射表(Map)**会更清晰。
右括号 → 对应左括号
) → (
] → [
} → {
这里将两项职责分开:栈负责匹配顺序,映射表负责匹配类型。栈里仍保存尚未闭合的左括号。
Java 中,Deque 是双端队列接口,ArrayDeque 是常见实现,可以用 push / pop 当作栈。Map<Character, Character> 保存 ')' → '(' 等关系。由于题目只有三条规则,映射表属于常数规模。
6.2 执行步骤
- 判断字符串长度;奇数时直接返回
false。 - 建立映射表:右括号
)对应(,]对应[,}对应{。 - 创建空栈,用于保存所有已经读到但还没匹配的左括号。
- 从左到右读取当前字符
c,先判断它是否存在于映射表的键中。 - 如果
c不在映射表的键中,按照本题输入只包含六种括号的规定,它一定是左括号,于是将它入栈。(真实工程中若可能有其他字符,应单独验证或定义忽略规则。) - 如果
c是右括号,先确认栈非空;若为空,立即返回false。 - 栈非空时,通过映射表查出
c应该对应哪个左括号,再与栈顶最近的左括号比较;若不同,返回false;否则取走这个左括号并继续。 - 遍历完毕时,栈为空才返回
true,否则返回false。
例如读取 "{[]}":先将 {、[ 依次放入栈;读到 ],查表得到期望左括号是 [,与栈顶一致;再读到 },查表得到 {,也与栈顶一致;最后栈为空。
6.3 代码实现
C 标准库没有直接内置 HashMap 和 Deque;在只有三种规则时,用 switch 查询对应关系、用字符数组保存待匹配括号即可。C++、Java、Python、JavaScript 则采用各自适合的映射容器。数据结构 API 可以不同,匹配规则与执行过程保持一致。
::: code-group C|C++|Java|Python|JavaScript
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
/* C 标准库没有内置哈希映射;本题只有三种配对,用 switch 即可。 */
static char expectedLeft(char right) {
switch (right) {
case ')': return '(';
case ']': return '[';
case '}': return '{';
default: return '\0';
}
}
bool isValid(char *s) {
size_t n = strlen(s);
// 括号必须成对出现;奇数长度不可能完全匹配。
if (n % 2 != 0) return false;
// 建立空栈,存储仍需要匹配的字符。
char *stack = malloc(n + 1);
if (stack == NULL) return false;
size_t top = 0;
// 从左到右扫描,每次只处理一个括号。
for (size_t i = 0; i < n; ++i) {
char c = s[i];
// C 用 switch 查询这一个字符应匹配的左括号。
char left = expectedLeft(c);
// 非右括号即左括号,直接入栈。
if (left == '\0') {
stack[top++] = c;
} else {
// 先防空栈,再取出最近左括号检查是否符合映射关系。
if (top == 0 || stack[--top] != left) {
free(stack);
return false;
}
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
bool ok = (top == 0);
free(stack);
return ok;
}
#include <deque>
#include <string>
#include <unordered_map>
using namespace std;
class Solution {
public:
bool isValid(string s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.size() % 2 != 0) return false;
// 建立配对关系;查到右括号时可获得它要求的左括号。
const unordered_map<char, char> matching = {
{')', '('}, {']', '['}, {'}', '{'}
};
// 建立空栈,存储仍需要匹配的字符。
deque<char> stack;
// 从左到右扫描,每次只处理一个括号。
for (char c : s) {
// 判断当前字符是不是右括号;右括号需要检查规则表。
auto it = matching.find(c);
// 不是右括号,则按题目限定可认定为左括号,压入栈。
if (it == matching.end()) {
stack.push_back(c);
} else {
// 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if (stack.empty() || stack.back() != it->second)
return false;
stack.pop_back();
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return stack.empty();
}
};
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Map;
class Solution {
public boolean isValid(String s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.length() % 2 != 0) return false;
// 建立配对关系;查到右括号时可获得它要求的左括号。
Map<Character, Character> matching = Map.of(
')', '(', ']', '[', '}', '{'
);
// 建立空栈,存储仍需要匹配的字符。
Deque<Character> stack = new ArrayDeque<>();
// 从左到右扫描,每次只处理一个括号。
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
// 判断当前字符是不是右括号;右括号需要检查规则表。
if (matching.containsKey(c)) {
// 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if (stack.isEmpty() || stack.pop() != matching.get(c))
return false;
} else {
stack.push(c);
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return stack.isEmpty();
}
}
from collections import deque
class Solution:
def isValid(self, s: str) -> bool:
# 括号必须成对出现;奇数长度不可能完全匹配。
if len(s) % 2 != 0:
return False
# 建立配对关系;查到右括号时可获得它要求的左括号。
matching = {')': '(', ']': '[', '}': '{'}
# 建立空栈,存储仍需要匹配的字符。
stack = deque()
# 从左到右扫描,每次只处理一个括号。
for c in s:
# 判断当前字符是不是右括号;右括号需要检查规则表。
if c in matching:
# 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if not stack or stack.pop() != matching[c]:
return False
else:
stack.append(c)
# 处理完所有字符后,只有没有遗留待匹配字符才有效。
return not stack
var isValid = function(s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.length % 2 !== 0) return false;
// 建立配对关系;查到右括号时可获得它要求的左括号。
const matching = new Map([
[')', '('], [']', '['], ['}', '{']
]);
// JS 数组的 push/pop 即可实现末端栈操作。
// 建立空栈,存储仍需要匹配的字符。
const stack = [];
// 从左到右扫描,每次只处理一个括号。
for (const c of s) {
// 判断当前字符是不是右括号;右括号需要检查规则表。
if (matching.has(c)) {
// 先排除空栈,否则右括号没有匹配对象(也避免非法出栈)。
if (stack.length === 0 || stack.pop() !== matching.get(c))
return false;
} else {
stack.push(c);
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return stack.length === 0;
};
:::
6.4 复杂度分析
- 时间复杂度:本题为
O(n)。 仅有三组固定规则,查表为常数级工作,遍历一次字符串即可完成判断。对于常规哈希表的通用分析,哈希查找通常按期望O(1)讨论。 - 辅助空间复杂度:最坏
O(n)。 栈可能暂存O(n)个左括号,映射表只保存三条规则,占O(1)。
这一步不是“必然更快”。 构造映射表有额外成本,小规模固定规则时可能比简单的三个条件判断更慢。它的主要价值是规则集中、职责清楚、便于扩展。如果将来支持额外定界符,要同时检查这些定界符的词法含义以及嵌套规则,而不只是机械新增一个 Map 条目。
七、解法五:数组模拟栈——进一步控制内存与常数开销
7.1 核心思想
栈不是必须由某个 Stack 类实现。栈真正需要的操作其实只有两个:从末尾放入一个元素、从末尾取走最近放入的元素。普通数组也能完成这两件事。
先申请一个长度为 n 的字符数组 stack,再设置整数 top = 0。本文统一规定:
stack[0]到stack[top - 1]是当前有效的栈元素。top表示栈里有多少元素,也等于下一次入栈要写入的位置。top == 0表示栈为空;top > 0才可以出栈。
借用上一种“提前保存右括号”的思路,可以做到仅靠数组下标与字符比较就完成判断。
7.2 执行步骤
- 读取字符串长度
n。如果n是奇数,直接返回false。 - 建立容量为
n的字符数组stack,初始化top = 0。最坏情况下每个字符都是左括号,因此n个槽位一定足够。 - 从左向右处理当前字符
c。如果c是(、[或{,先找出它对应的右括号expected。 - 把
expected写入stack[top],随后执行top = top + 1。先写再加,因为top原本指向下一处空位置。 - 如果读到右括号,先判断
top == 0是否成立。若成立,说明没有任何等待匹配的括号,直接返回false,绝不能读取stack[-1]。 - 如果栈非空,先让
top = top - 1,然后读取stack[top],与当前右括号比较。若不同,返回false;若相同,继续处理后面的字符。 - 全部处理完后检查
top:等于0,说明所有括号均已闭合,返回true;否则返回false。
特别容易混淆的细节: 如果已经保存两个字符,分别在数组下标 0 和 1,那么此时 top = 2。最近压入的字符在 stack[1],而不是 stack[2]。因此出栈必须先减 1,才能读取正确的位置。
| 当前输入 | 动作 | 当前有效栈(底 → 顶) | top |
|---|---|---|---|
| 开始 | 创建数组 | 空 | 0 |
( |
写入)后top++ |
) |
1 |
[ |
写入]后top++ |
) ] |
2 |
] |
先--top,再读到] |
) |
1 |
) |
先--top,再读到) |
空 | 0 |
| 结束 | 判断top == 0 |
空 | 返回true |
数组里的旧字符即使没有被擦除,只要已经不在有效区间 [0, top) 中,就不再属于栈,也不影响最终结果。
7.3 代码实现
Java 的 char[] 可以直接存原始字符,避免 Deque<Character> 的泛型容器与可能的装箱开销;其他语言也可以用数组或类似的线性存储结构。C 语言没有标准库内建的栈,所以它前面几种写法实际上也已经借助数组实现栈;本节的优化重点尤其体现在 Java 等具有通用对象容器的语言中。
::: code-group C|C++|Java|Python|JavaScript
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
bool isValid(char *s) {
size_t n = strlen(s);
// 括号必须成对出现;奇数长度不可能完全匹配。
if (n % 2 != 0) return false;
// 一次准备 n 个槽位;top 表示有效元素个数(下一写入下标)。
char *stack = malloc(n + 1);
if (stack == NULL) return false;
size_t top = 0;
// 从左到右扫描,每次只处理一个括号。
for (size_t i = 0; i < n; ++i) {
char c = s[i];
// 将期待字符写到 stack[top],随后 top 加一。
if (c == '(') stack[top++] = ')';
else if (c == '[') stack[top++] = ']';
else if (c == '{') stack[top++] = '}';
// 空栈直接失败;否则取出栈顶期待字符,和当前字符比较。
else if (top == 0 || stack[--top] != c) {
free(stack);
return false;
}
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
bool ok = (top == 0);
free(stack);
return ok;
}
#include <string>
#include <vector>
using namespace std;
class Solution {
public:
bool isValid(string s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.size() % 2 != 0) return false;
// 一次准备 n 个槽位;top 表示有效元素个数(下一写入下标)。
vector<char> stack(s.size());
size_t top = 0;
// 从左到右扫描,每次只处理一个括号。
for (char c : s) {
// 将期待字符写到 stack[top],随后 top 加一。
if (c == '(') stack[top++] = ')';
else if (c == '[') stack[top++] = ']';
else if (c == '{') stack[top++] = '}';
// 空栈直接失败;否则取出栈顶期待字符,和当前字符比较。
else if (top == 0 || stack[--top] != c) return false;
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return top == 0;
}
};
class Solution {
public boolean isValid(String s) {
int n = s.length();
// 括号必须成对出现;奇数长度不可能完全匹配。
if (n % 2 != 0) return false;
// 一次准备 n 个槽位;top 表示有效元素个数(下一写入下标)。
char[] stack = new char[n];
int top = 0;
// 从左到右扫描,每次只处理一个括号。
for (int i = 0; i < n; i++) {
char c = s.charAt(i);
// 将期待字符写到 stack[top],随后 top 加一。
if (c == '(') stack[top++] = ')';
else if (c == '[') stack[top++] = ']';
else if (c == '{') stack[top++] = '}';
// 空栈直接失败;否则取出栈顶期待字符,和当前字符比较。
else if (top == 0 || stack[--top] != c) return false;
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return top == 0;
}
}
class Solution:
def isValid(self, s: str) -> bool:
# 括号必须成对出现;奇数长度不可能完全匹配。
if len(s) % 2 != 0:
return False
# 一次准备 n 个槽位;top 表示有效元素个数(下一写入下标)。
stack = [None] * len(s)
matching = {'(': ')', '[': ']', '{': '}'}
top = 0
# 从左到右扫描,每次只处理一个括号。
for c in s:
# 将期待字符写到 stack[top],随后 top 加一。
if c in matching:
stack[top] = matching[c]
# 入栈:已写入一个新元素,有效栈长度加一。
top += 1
else:
# 先判断是否为空,再取出栈顶进行严格比较。
if top == 0:
return False
# 出栈:先缩小有效区间,再读取最后一个元素。
top -= 1
if stack[top] != c:
return False
# 处理完所有字符后,只有没有遗留待匹配字符才有效。
return top == 0
var isValid = function(s) {
// 括号必须成对出现;奇数长度不可能完全匹配。
if (s.length % 2 !== 0) return false;
// 一次准备 n 个槽位;top 表示有效元素个数(下一写入下标)。
const stack = new Array(s.length);
let top = 0;
// 从左到右扫描,每次只处理一个括号。
for (const c of s) {
// 将期待字符写到 stack[top],随后 top 加一。
if (c === '(') stack[top++] = ')';
else if (c === '[') stack[top++] = ']';
else if (c === '{') stack[top++] = '}';
else if (top === 0 || stack[--top] !== c) return false;
}
// 处理完所有字符后,只有没有遗留待匹配字符才有效。
return top === 0;
};
:::
7.4 复杂度分析
- 时间复杂度:
O(n)。 每个字符只处理一次,写入或读取指定数组下标是常数级操作。 - 辅助空间复杂度:
O(n)。 实现一开始就申请了长度为n的字符数组;即使实际嵌套深度很浅,已经预分配的数组也不会因此缩小。
数组模拟栈并没有把 O(n) 优化为 O(1)。 它改善的是某些语言环境下的常数开销:省去部分集合操作、减少元素装箱与额外对象管理;但是否明显更快,需要在具体运行环境做基准测试,不能根据 LeetCode 的单次击败率下结论。
八、总结:不同情况下该选哪种方法?
五种方法的改进方向并不完全相同:暴力法 → 栈法是算法复杂度的提升;标准栈 → 提前存右括号是状态表示的简化;映射表 + 双端队列注重结构和扩展性;数组模拟栈主要减少特定语言实现中的常数开销。
| 解法 | 时间复杂度 | 辅助空间复杂度 | 主要特点 | 适用情况 |
|---|---|---|---|---|
| ① 暴力枚举消除 | 最坏O(n²) |
O(n)上界 |
反复删除合法相邻括号对 | 初次理解问题、短串演示、对照验证 |
| ② 标准栈 | O(n) |
最坏O(n) |
保存未匹配的左括号,直观体现后进先出 | 学习栈、面试解释正确性 |
| ③ 提前存右括号 | O(n) |
最坏O(n) |
保存期待字符,出栈只做相等比较 | LeetCode 常规提交、手写简洁实现 |
| ④ 映射表 + 双端队列 | O(n) |
最坏O(n) |
顺序与配对规则分离,便于维护 | Java 工程逻辑、可配置匹配规则 |
| ⑤ 数组模拟栈 | O(n) |
O(n)(预分配) |
自己管理数组与栈顶下标,控制常数开销 | 算法竞赛、性能分析、理解栈底层实现 |
怎样判断该用哪一种?
遇到相似题目时,可以先问自己三个问题:
- 能不能直接化简? 例如不断删掉相邻的
(),这能迅速帮助你验证想法,但可能存在反复扫描的问题。 - 要不要记住“最近一个尚未完成的结构”? 如果下一次匹配必须优先处理最近的未完成对象,就应首先考虑栈。
- 真正想优化的是什么? 如果是减少重复扫描,换算法;如果是让判断更清晰,改变栈中保存的状态;如果是便于扩展,抽出映射规则;如果是降低集合开销,再考虑数组模拟栈。
最终记住一句话:括号匹配之所以想到栈,不是因为题目里有括号,而是因为“最后出现、尚未完成的左括号必须最先闭合”。
这套判断方式也适用于编辑器的括号高亮、简单表达式检查、嵌套结构解析等场景;如果输入涉及字符串字面量、转义字符或注释,还需要额外的词法规则,不能直接把本题代码原封不动搬过去。