简介:这是一份围绕括号匹配(平衡括号)判定编写的 JavaScript 代码示例包,面向正在学习字符串处理与栈结构的初学者,以及准备算法面试的开发者,用于解决“左括号须由同类型右括号按正确顺序闭合”的有效性判断问题。压缩包共 2 个文件,以 1 个 js 脚本和 1 个 txt 说明为主:js 文件给出基于数组模拟栈的 isValid 实现,并用映射表处理三类括号的配对,附带 true/false 测试调用;txt 文件用于说明运行方式与使用提示,整体仅 818B,轻量易读。已有 3428 人学习下载。读者可从中掌握入栈、出栈与栈顶比对的完整思路,理解空栈、括号类型不匹配、遍历结束后栈非空等边界情形的处理方式,也可直接用示例用例验证结果,作为面试手写题与日常练习的参考底稿。
1. 括号匹配的本质:数量相等为什么不等于结构有效
先看一个反例:([)]。左圆括号、左方括号各一个,右圆括号、右方括号各一个,长度 4,每种括号的开闭数量都相等。按“数一数对不对”的思路它应该通过,但它无效。原因很直接:第 3 个字符)出现时,最近一个尚未闭合的左括号是[,而不是(。括号的合法性从来不由计数决定,而由嵌套顺序决定。
这就是“判断字符串 s 是否有效”这道题真正的考点。它表面是字符串处理,本质是判断一个串是否属于某个括号文法,也就是括号匹配问题。而解决它所需的全部机制,只有一个栈。搞懂为什么是栈、栈顶到底代表了什么信息、以及这份信息为什么无法被更小的状态替代,比背下一段 isValid 代码有用得多——因为同一条推理链会复现在表达式求值、JSON 解析、模板引擎的标签配对、甚至编辑器语法高亮的状态维护里。
2. 栈与括号结构的同构:LIFO 为什么正好对上嵌套
2.1 括号语言为什么不是正则语言
先排除一个常见误判。很多人第一反应是写正则,比如/^\(\)$/这类固定层数的模式,或者试图用/\([^()]*\)/反复替换直到串为空。后者在小规模输入上确实能跑,但它不是“判断”,而是“归约”,每一轮都要重新扫一遍整个串,复杂度直接退化到 O(n²);而且它靠替换模拟栈,本质是把栈藏进了字符串里。
从语言理论看,括号串不是正则语言。有限状态机只有有限个状态,它能记住的“历史”被状态数封顶;但括号的嵌套深度没有上界,((((...))))无论多深都合法。要判定它,机器必须能记住“我打开了多少个、按什么顺序”,这是一份长度无界的信息,需要无界存储。下推自动机(带一个栈的自动机)恰好就是为这类语言设计的模型,而栈式判定算法就是它的直接实现。
提示:判断“能不能用有限状态解决”的一个实操判据是——如果把输入前缀的信息压缩成常数个变量还够用,那就是正则的;如果需要的变量个数随输入长度增长,那就得上栈。
2.2 栈顶承载的不变量:最近一个未闭合的左括号
把左括号看作“开始”,右括号看作“结束”,括号串合法等价于:每次遇到“结束”,它必须对上最近一次还没被结束的“开始”。这个“最近未闭合”正是栈顶。
于是整个算法只需要维护一条不变量:
- 任意时刻,栈中从底到顶,是当前所有尚未闭合的左括号,且按出现顺序排列;
- 栈顶就是下一个右括号唯一合法的配对对象;
- 扫描结束时,栈必须为空,否则说明有左括号开了没关。
这条不变量能一次说清所有合法与非法:
| 步骤 | 读入 | 栈(自底向上) | 判定依据 |
|---|---|---|---|
| 1 | { | { | 左括号,入栈 |
| 2 | [ | { [ | 左括号,入栈 |
| 3 | ( | { [ ( | 左括号,入栈 |
| 4 | ) | { [ | 栈顶(与)同类,弹出 |
| 5 | ] | { | 栈顶[与]同类,弹出 |
| 6 | } | 空 | 栈顶{与}同类,弹出 |
| 7 | 结束 | 空 | 栈空,判有效 |
对照非法输入([)]:
| 步骤 | 读入 | 栈(自底向上) | 判定依据 |
|---|---|---|---|
| 1 | ( | ( | 入栈 |
| 2 | [ | ( [ | 入栈 |
| 3 | ) | ( [ | 栈顶是[,与)不同类,判无效 |
注意第 3 步:此时“数量”层面完全平衡,失败纯粹来自类型与顺序的联合约束。
2.3 顺序校验与类型校验必须同时发生
把两条约束拆开看会更清楚:
- 顺序约束由 LIFO 结构自动满足。只要坚持“右括号只跟栈顶比”,
(]这种顺序错位就无法通过,因为栈顶永远是最新的那个。 - 类型约束必须依赖一张映射表。栈本身只记录“有个左括号还没关”,不记录它是圆的还是方的。
两者缺一不可。只做类型不做顺序,)(这种前缀非法的情况会被漏掉;只做顺序不做类型,([)]会被放过。这也解释了为什么单一的计数器在多种括号下必然失效——计数器能表达“还差几个”,无法表达“还差哪几种、按什么顺序”,而后者所需的信息量随嵌套深度增长。
3. 从规则到代码:映射表、遍历与三处短路
3.1 映射表的三种写法与取舍
右括号到左括号的映射,JS 里有三种常见写法,差别主要在查表成本和可读性上。
第一种是对象字面量,const pairs = { ')': '(', ']': '[', '}': '{' }。属性名是单字符字符串,V8 对这类小对象会走内联缓存(inline cache),同一条查表语句反复执行时会命中缓存,实际开销接近一次属性偏移访问。写法最短,可读性最好。
第二种是Map。键值查找是哈希,理论上稳定 O(1),但每次get都是一次方法调用,且失去了内联缓存的优化空间。对于只有三个键的表,这属于用大炮打蚊子。
第三种是switch。在小规模、离散分支上,引擎有机会把它编译成跳转表,性能不差,但代码会膨胀成七八行。
一个容易被忽略的细节:如果用ch in pairs判断“是不是右括号”,in会沿原型链查找。对')'、']'、'}'这三个键来说没问题,因为Object.prototype上没有同名属性;但如果输入字符集合可能变化(比如把这段代码抽成通用工具函数),'toString' in pairs会返回true,然后pairs['toString']拿到一个函数,比较必然失败,行为就不可控了。稳妥写法是Object.prototype.hasOwnProperty.call(pairs, ch),或者干脆判断pairs[ch] !== undefined。
3.2 一次遍历的完整骨架
function isValid(s) { // 长度为奇数时不可能配对成功,直接短路,省掉整趟遍历 if (s.length % 2 === 1) return false; // 只记录"右括号 -> 左括号"这一个方向,键必须是右括号 const pairs = { ')': '(', ']': '[', '}': '{' }; const stack = []; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (ch === '(' || ch === '[' || ch === '{') { stack.push(ch); // 左括号:登记一个"待闭合" } else if (Object.prototype.hasOwnProperty.call(pairs, ch)) { // 右括号:栈顶必须与它同类。 // 这里直接 pop 再比较,空栈时 pop 返回 undefined, // undefined !== '(' 恒为真,天然覆盖了"右括号多余"的情况 if (stack.pop() !== pairs[ch]) return false; } else { return false; // 出现六种括号之外的字符 } } // 扫描结束仍有残留,说明有左括号没闭合 return stack.length === 0; }逐段说明关键决策:
if (s.length % 2 === 1) return false;放在函数最前面。任何合法串里左右括号成对出现,长度必为偶数。这一行把一整个 O(n) 循环降到 O(1),对超长非法输入(比如几 MB 的日志里截出来的一段)收益明显。
stack.pop() !== pairs[ch]这一行是整段代码里最值得推敲的。常见写法是先const top = stack[stack.length - 1]再判空、再比较、再pop(),四行。而pop直接拿返回值比较,把“取栈顶”和“弹出”合成一次操作,同时利用了 JavaScript 的一个语言特性:空数组pop()返回undefined,而undefined与任何字符串都不相等,所以空栈遇到右括号会自动判为无效,不需要单独写if (stack.length === 0) return false。这是把边界判断折叠进类型系统的一次取巧,代价是可读性略降——读代码的人必须知道pop在空数组上的返回值。
return stack.length === 0;而不是return true;。这是最高频的错误点,第 5 章会单独拆。
3.3 遍历方式的选择:索引循环、for...of 与展开
s[i]逐字符索引,在 V8 中对单字节字符串(Latin1 编码)非常快,返回的是单字符字符串。for (const ch of s)走的是字符串迭代器协议,每次迭代要创建一个迭代器结果对象,在现代引擎上虽有优化,但仍比索引慢一截。[...s]则更糟——它先把整个字符串摊成一个数组,空间从 O(n) 栈变成 O(n) 栈加 O(n) 数组,纯粹是浪费。
如果只关心 ASCII 范围内的六个字符,还有一条更快的路径:用charCodeAt拿码点做整数比较,40 / 41 / 91 / 93 / 123 / 125。整数比较比字符串比较少了装箱和内容比对,在几百万字符的输入上差距能到两三倍。是否值得这么写,取决于这段代码是跑在一次性脚本里还是跑在热路径上。
3.4 三处短路的先后次序
短路点的位置决定了失败有多快返回:
- 奇数长度,在最外层,代价 O(1);
- 循环内遇到不匹配或非法字符,平均能在扫描到第一个错误位置时返回;
- 循环结束后判栈空,这是唯一一个必须扫完全串才能确定的失败类型(比如
((((),因为“还差几个”只有到末尾才知道。
把这三者按这个顺序排,最坏情况仍是一次 O(n) 遍历,最好情况是 O(1) 返回。这也是为什么这道题被当作栈的入门题:它的优化空间几乎全在“提前退出”上,而核心逻辑一行都省不掉。
4. 复杂度与边界:O(n) 背后藏着什么
4.1 push/pop 的均摊 O(1) 与扩容毛刺
复杂度分析里写“每个字符入栈出栈各一次,故 O(n)”是对的,但省略了一个前提:Array.prototype.push并非严格 O(1)。
V8 的数组底层是一块连续内存加一个 length。当 push 时容量不足,引擎会申请一块更大的内存(大约是原来的 1.5 倍),把旧元素逐个拷过去,再继续写。单次扩容是 O(k),k 是当前长度。但因为每次扩容后容量至少翻 1.5 倍,n 次 push 触发的总拷贝量构成一个等比数列,求和后仍是 O(n)。这就是“均摊 O(1)”的来源:把偶发的昂贵操作摊到每一次普通操作上。
实际影响是:处理一个超长输入时,如果你在打点监控,可能会看到某个时间片突然变长——那不是算法退化,而是扩容拷贝。真正要命的是当输入规模已知时忘了预分配,比如事先new Array(Math.ceil(s.length / 2))配合手写栈指针top,可以完全避开扩容。这类优化在刷题场景下属于过度设计,在解析器、词法分析器这类被反复调用的模块里则很常见。
顺带说一句空间。栈的最大深度是“未闭合左括号的个数”,最坏情况是输入全为左括号,深度 n(若长度必为偶数且前半全左,则 n/2 就够触发失败判定,但栈确实会被填到 n/2)。V8 里存的是指向字符串的指针,单字符字符串有专门的缓存池,所以实际占用约为 n/2 × 8 字节加数组头部开销。这也解释了为什么对超长输入,内存往往先于时间成为瓶颈。
4.2 边界输入的实际路径
把常见边界过一遍,能确认每条分支都被真正覆盖到,而不是“看起来覆盖了”。
| 输入 | 期望结果 | 实际走的分支 |
|---|---|---|
"" | true | 长度 0 是偶数,不进循环,栈空,返回 true |
"(" | false | 长度 1 为奇数,O(1) 短路 |
")" | false | 偶数长度,循环内pop()得undefined,与'('不等,返回 false |
"(]" | false | 偶数长度,栈顶(与]所需的[不等,返回 false |
"([)]" | false | 偶数长度,第 3 位类型不匹配,返回 false |
"{{}}" | true | 全流程走完,栈空 |
"()((" | false | 长度 4 为偶数,走完全程,栈中残留两个(,末尾判空失败 |
注意""的处理。空串在多数题面定义里算作有效——它没有任何未闭合的括号。如果你的实现把它判成 false(比如加了if (!s) return false),会在单元测试里被逮住。这个分歧不是代码 bug,而是需求定义问题,最好在函数注释里写死。
4.3 非法字符该判有效还是无效
题面明确保证“只包括(、)、{、}、[、]六种字符,所以第三分支else { return false; }在题面下永远不会被执行。但如果这段代码被拿去当工具函数用(校验一段 JSON 片段、校验用户输入的模板表达式),输入里很可能混进空格、换行、全角括号()、甚至 UTF-8 BOM\uFEFF。
此时行为分两种派别:严格模式直接判无效,宽松模式跳过空白继续校验。选哪一种取决于调用方语义——校验“这段括号结构是否配平”时应该跳过空白;校验“这段字符串是否只由合法括号构成”时必须严格。把两种语义混在一个函数里,是后面所有诡异 bug 的源头。
5. 常见问题排查:五类常见错误与它们的根因
5.1 用数量相等代替结构有效
现象:实现了两个计数器(左括号数、右括号数),相等就返回 true。单元测试跑([)]、(]、}{全部误判为有效。
原因:把括号匹配当成了集合层面的配平问题。计数器只能回答“差几个”,无法回答“差哪一种、在哪一层”。([)]的计数完全平衡,但它要求[在(闭合之前闭合,违反了后开先闭。
解决:删掉计数器,换成栈。判断标准从“数量是否相等”改成“每次右括号是否命中当前栈顶”,并在末尾追加栈空判定。三个条件同时满足才叫有效。
5.2 循环结束忘了检查栈空
现象:"(("、"{{"、"[()"全部返回 true,而正确答案是 false。
原因:循环体内的逻辑只处理“右括号找左括号”,对“左括号没人要”这个方向没有约束。扫描结束时栈里还压着东西,说明有左括号开了没关,但函数已经执行到末尾,直接return true。
解决:末尾必须是return stack.length === 0。这一行没有替代品,也不能靠前面的短路逻辑覆盖——因为前缀合法的输入天然会让循环完整跑完。
5.3 映射表方向写反,或in穿透原型链
现象 A:所有含右括号的输入一律返回 false,纯左括号输入返回 false(栈非空),只有空串返回 true。
原因:写成了{ '(': ')', '[': ']', '{': '}' },然后用pairs[ch]去查右括号。右括号在表中不存在,得到undefined,与栈顶比较必然不等。映射表的键必须是“当前读到的字符”即右括号,值才是“栈顶应该是什么”。
现象 B:改成ch in pairs判断后,某些非括号字符(比如某个属性名恰好叫constructor的字符串)被当成右括号处理,行为诡异。
原因:in运算符查的是整条原型链。'constructor' in {}为 true。
解决:方向固定为右括号作键;存在性判断用Object.prototype.hasOwnProperty.call(pairs, ch),或者直接pairs[ch] !== undefined(前提是表里不会有值为 undefined 的键)。
5.4 用递归下降处理超长输入导致调用栈溢出
现象:几千字符能跑,几万字符直接抛RangeError: Maximum call stack size exceeded。
原因:把括号匹配写成了递归形式——读到一个左括号就递归进去找它的配对,靠函数调用栈当数据结构。这在理论上完全正确,递归下降解析器和下推自动机等价。但 JavaScript 引擎的调用栈深度有上限(V8 默认约一万多层,还会被当前已有的帧占用一部分),输入嵌套深度一旦超过这个数,直接崩。
解决:改成显式栈的迭代版本。显式栈用的是堆内存,上限由可用内存决定,和调用栈深度无关。如果确实需要递归形式的可读性(比如要顺带构建语法树),至少加一个深度计数器提前抛出自定义的、可捕获的错误,别让引擎的 RangeError 冒到最外层。
5.5 输入被预处理污染,导致静默误判
现象:本地跑测试全绿,接到线上接口后,明明看起来配平的字符串被判无效,日志里又看不出差别。
原因:输入来自 HTTP 请求体或文件读取,末尾带了\r\n、开头带了 BOM、或者中间混了全角括号()(U+FF08/U+FF09)。这些字符不在六种之内,走到else分支被判无效;如果代码里漏写了else,则会被静默跳过,产生“少判一段”的假有效。
解决:在校验入口处先做一次规范化——trim()去首尾空白,显式剥掉\uFEFF,然后按严格模式校验。别在核心逻辑里做隐式容错,容错逻辑要显式写在调用方,这样出问题时能一眼定位是数据脏还是算法错。这类问题没有后悔药,只能靠输入断言提前暴露。
6. 设计取舍:O(1) 空间的诱惑与递归的代价
6.1 单一括号类型可以压到 O(1),但推广不了
如果题目退化成只有一种括号——只有(和)——复杂度可以立刻降一个维度:用一个整数计数器,遇到左括号加一,遇到右括号减一,任何时刻计数器为负就返回 false,结束时判断是否为零。空间 O(1),代码三行。
这个优化为什么不能推广到多种括号?关键在于“计数器压缩了什么信息”。单一类型时,栈里所有元素都长得一样,一串(((可以被无损压缩成数字 3,因为元素没有类型差异。一旦有三种括号,栈里存的是“未闭合左括号的类型序列”,这个序列的任何一位都可能影响后续判定,而它的长度无上界,因此无法用常数个变量表达。换句话说,O(1) 空间的可行性来自信息可压缩性,不是来自算法技巧。
这也给出一个通用的判断方法:当你面对一个需要栈的问题时,先问“栈里元素是否同质”。同质就能压成计数,异质就得老实开栈。括号匹配是这条判据最干净的样本。
6.2 递归下降、显式栈与正则归约的横向对比
| 方案 | 时间 | 空间 | 嵌套深度上限 | 可读性 | 适用场景 |
|---|---|---|---|---|---|
| 显式栈 + 映射表 | O(n) | O(n) | 无(受堆内存限制) | 中 | 通用,首选 |
| 递归下降 | O(n) | O(d),d 为嵌套深度 | 约一万层 | 高 | 需要顺带构建 AST |
| 计数器 | O(n) | O(1) | 无 | 高 | 仅单一括号类型 |
| 正则反复归约 | O(n²) | O(n) | 无 | 低 | 不推荐 |
表格里最容易被低估的是第二行和第四行的差别。递归下降的 O(d) 空间用的是调用栈,属于“看不见的”内存——它不出现在任何变量声明里,却会在深层嵌套时突然炸掉;而显式栈的空间是可见的,你能在调试器里看到它有多长。对生产代码来说,“可见的内存”通常比“省几行代码”更重要。
至于正则归约方案,它的问题不只是 O(n²)。它把栈的状态编码进了字符串本身,用替换操作模拟弹栈,这让整个过程的中间态无法被观测——出问题时你只能看到一个不断变短的字符串,没有任何可断点的地方。这是典型的“能跑但不可维护”。
6.3 我自己的检查习惯
这道题我前后写过不下十遍,每次都以为自己记住了,每次都在某个边界上翻车。最惨的一次是把return true写在了循环末尾而不是return stack.length === 0,一个处理嵌套配置的模块上线后,所有深层未闭合的配置都被当成合法放行,排查了半天才定位到这一行。
从那以后我每次写这类“状态机 + 容器”的校验函数,都强制走一遍固定流程:先在纸上手推三个反例(一个数量假平衡如([)]、一个前缀多余如)(、一个后缀多余如((),确认这三条路径分别被哪一行代码拦下;然后单独测空串和单字符;最后把else分支的语义写进注释。三步走完,基本不会漏。
如果这段代码要进生产环境,我会再加一条:把递归版本和迭代版本都写出来,用随机生成的字符串对拍一千组。两个独立实现的共识,比任何单侧的单元测试都可靠。希望这套流程帮到你——尤其是那句“末尾必须判栈空”,它值得被写进肌肉记忆里。
本文还有配套的精品资源,点击获取