资讯详情

栈实现括号匹配:从有效括号校验到可诊断错误定位

📅 2026/11/4 9:05:26 | 华诺云谱 👁 阅读
栈实现括号匹配:从有效括号校验到可诊断错误定位
简介面向 JavaScript 初学者与算法练习者的括号匹配题配套代码包聚焦“有效括号”判定左括号须由同类型右括号闭合且闭合顺序正确。内容围绕栈这一后进先出结构展开完整呈现遍历字符、左括号入栈、右括号比对栈顶并在失败时提前返回、最终检查栈空的解题思路并配有 true/false 测试示例便于理解边界条件与常见错误。压缩包仅 818B含 2 个文件其中 1 个 JavaScript 源码承载核心实现1 个文本文件用于补充题目或运行说明体量轻巧适合课堂示例、面试刷题与本地快速验证。目前已有 3428 人学习下载可作为掌握栈应用、梳理解题逻辑和排错复盘的参考材料。1. 一段二十行的判题代码为什么值得单独拆一遍上周有人往群里丢了一段 JS 代码说结果不对([)]被判成了true。我打开一看思路没错是栈但pop之后他拿长度去比对类型等于把“配对”换成了“计数”嵌套顺序一乱就全崩。这个问题本身不复杂——给定一个只含(、)、{、}、[、]六种字符的字符串s判断它是否有效左括号必须用相同类型的右括号闭合且必须以正确顺序闭合。但真把它写成一份能放进项目里、能被别人调用、能在边界输入上不翻车的代码中间有不少细节值得摊开讲。这份代码的定位很明确一个纯函数式的括号有效性校验器零依赖、单文件、可直接 Node 跑也可以挂到表单校验、表达式解析、模板语法的预检查上。适合两类人刚学栈这个数据结构、想找一个最小可运行例子的新手以及手上有解析器、配置语法、富文本标记需要做前置校验想直接抄一个稳的实现的熟手。后面几章按“选型 → 拆代码 → 跑起来 → 踩坑 → 改造”走一遍。2. 技术选型为什么这三类括号必须用栈计数法和正则各差在哪先说结论这类题在工程里几乎只有一种正解用栈。但很多人的第一反应不是栈而是“数一数左右括号数量不就完了”或者“拿正则把成对的替换掉替换空了就合法”。这两种写法在特定输入下都能过放到真实场景里就露馅。把三条路摆在一起对比才知道为什么最后落到栈上。2.1 三种写法的实测差异计数法、正则替换、栈计数法最省事一个变量加减到底// 计数法只统计数量不记录类型和顺序 function isValidByCount(s) { let balance 0; for (const ch of s) { if (ch () balance; else if (ch )) balance--; // 中途变负说明右括号多了可以直接返回 if (balance 0) return false; } return balance 0; // 收尾必须归零 }这段代码对()、(())、())(都能给出正确答案但一遇到([)]就返回true。原因很直白balance只记了多少不记谁跟谁。(和[都加一)和]都减一类型信息在计数的瞬间就丢了。更麻烦的是它连“三种括号混用”都识别不了([)]的字符数量完全合法唯一的问题是顺序。所以计数法只能在“输入里只有一种括号”的前提下成立超出这个前提就是错的。正则替换的思路是“消消乐”反复把最内层的成对括号删掉删到不能再删看剩下的是不是空串。// 正则消消乐每轮消掉最内层的一对 function isValidByRegex(s) { const pair /\(\)|\[\]|\{\}/g; let prev; do { prev s; s s.replace(pair, ); // 一轮扫描只能消掉一层 } while (s ! prev); return s.length 0; }([)]在这套逻辑下第一轮没有()、[]、{}这样的相邻对s没变循环直接退出返回false结果是对的。它能正确处理嵌套问题在复杂度每一轮replace都要完整扫一遍字符串嵌套深度为n/2的时候就要扫n/2轮整体退化成 O(n²)。输入几百个字符看不出差别输入十万个字符就是几十亿次字符访问实测会明显卡住。而且它天然带一层“反复创建新字符串”的垃圾回收压力在浏览器主线程上跑大文本是要挨骂的。栈的写法一次遍历就能出结果时间 O(n)、空间 O(n)空间只跟嵌套深度有关不跟字符串长度强绑定// 栈左括号压栈右括号跟栈顶比对 function isValidByStack(s) { const stack []; const pairs { ): (, ]: [, }: { }; for (const ch of s) { if (ch ( || ch [ || ch {) { stack.push(ch); // 左括号记录待配对 } else { // pop 出来的必须和当前右括号的类型对上 if (stack.pop() ! pairs[ch]) return false; } } return stack.length 0; }三者的差别可以压成一张表写法时间复杂度空间([)]嵌套深度大时计数法O(n)O(1)判错判错正则替换O(n²)O(n)判对明显变慢栈O(n)O(嵌套深度)判对稳定我一般不会在项目里留前两种哪怕只是图省事写个临时校验。原因不是“算法课要求用栈”而是错误判定的代价太高——一个语法预检查放过了不该放过的字符串问题会被推到后面的解析阶段报错位置离真正的错误点十万八千里排查成本翻好几倍。2.2 单文件脚本的骨架入口、核心函数、导出方式这份代码的物理结构很简单一个文件三段映射表、核心函数、导出与自测入口。结构简单但边界要划清楚否则别人拿到手不知道从哪调。// valid-parentheses.js const PAIRS Object.freeze({ ): (, ]: [, }: { }); const OPENERS new Set([(, [, {]); function isValid(s) { // 核心实现放在这一层见第 3 章 } // 双出口CommonJS 方便 Node 直接 requireESM 环境走默认导出 if (typeof module ! undefined module.exports) { module.exports { isValid, PAIRS }; } export default isValid; // 打包器会把上面这段当成具名 默认混合导出PAIRS用Object.freeze包一层不是炫耀是防止下游某处手贱改了映射表导致全项目判定漂移OPENERS用Set而不是数组是因为Set.has在字符集变多之后仍然是 O(1)而Array.includes是线性扫描虽然这里只有三个元素差别不大但扩充字符集的时候不用回头改。双出口的写法在小工具库里很常见Node 侧require拿到的是对象前端打包器走export default两边都能用代价是文件不能直接当script引得配构建。注意如果你的运行环境同时支持 CJS 和 ESM别在同一个文件里既写module.exports又写export还指望node直接跑得用.mjs或package.json里的type字段明确模块类型否则会撞上SyntaxError: Unexpected token export。3. 核心函数拆解一次循环里做完的入栈、比对与提前返回核心函数是整个项目唯一需要读透的地方二十来行但每一行都对应一类输入。拆成三层看进循环之前挡掉一批明显不合法的输入循环里处理左括号和右括号两种角色循环结束后还要补一刀判断栈是否为空。3.1 映射表与入参校验先把脏输入挡在门外进循环之前的校验决定了这个函数在真实调用里稳不稳。判题环境永远给你一个“只含六种括号的字符串”业务环境不会这么客气可能传null可能传数字可能是用户粘贴的半截 JSON 里混着字母。function isValid(s) { // 1) 类型校验非字符串一律判非法避免后续 s.length 报错 if (typeof s ! string) return false; // 2) 长度剪枝合法串长度必为偶数奇数长度不用进循环 if (s.length % 2 1) return false; const stack []; // ...主循环 }第一行typeof s ! string挡的是null、undefined、数字、对象。这里有个取舍要不要对null抛错而不是返回false纯函数做布尔判定时返回false更省事但如果这个函数被当成“前置守卫”用抛错能更早暴露调用方的 bug。我个人倾向返回false理由是括号校验本身是“输入是否合规”的判断传错类型也算不合规的一种语义上站得住。第二行是常被忽略的剪枝。合法串里左右括号必然成对出现字符总数一定是偶数奇数长度直接返回能省掉整整一次遍历。空串走的是s.length % 2 0这条分支进循环一次都不转最后stack.length 0为真返回true——空串算合法这一点在后面的排查章会单独说因为踩过的人不少。3.2 主循环左括号入栈、右括号比对pop 的返回值就是答案主循环是全文最值得细读的一段短但每一句都有说法function isValid(s) { if (typeof s ! string) return false; if (s.length % 2 1) return false; const stack []; for (let i 0; i s.length; i) { const ch s[i]; if (ch ( || ch [ || ch {) { stack.push(ch); // 左括号只记录不判断 continue; } if (ch ) || ch ] || ch }) { // pop 直接拿来比对栈空时 pop 返回 undefined天然不等于 ( / [ / { if (stack.pop() ! PAIRS[ch]) return false; continue; } // 出现六种括号之外的字符严格模式直接判非法 return false; } return stack.length 0; // 收尾栈必须清空 }第一个分支只做压栈不做任何判断。这是栈写法最核心的一点遇到左括号时你不知道它后面会跟什么唯一能做的就是记住它把判断推迟到遇见右括号。第二个分支是整套逻辑的精华stack.pop() ! PAIRS[ch]这一句同时覆盖了两种失败。栈空的时候pop()返回undefinedPAIRS[)]是(undefined ! (成立直接返回false——这就是“右括号多了”的场景不用额外写if (stack.length 0)。栈非空时pop()取到栈顶元素如果类型对不上比如栈顶是[而当前是)同样返回false——这就是“类型交叉”的场景([)]卡在这一句。一行代码干掉两个分支省了一行判断也少了一个出错点。第三个分支处理“六种括号之外的字符”。这里有两种设计取向严格模式直接返回false宽松模式忽略跳过。这个函数默认走严格模式理由是它被定位成“整个字符串是否是合法的括号序列”混进其他字符本身就不合法。如果你的场景是“从一段混合文本里挑出括号部分校验”宽松模式更合适那需要改这一行第 6 章会给出配置化的写法。循环用for (let i 0; i s.length; i)加下标而不是for...of。对纯 ASCII 括号字符两者结果一样但下标版本在你要返回错误位置的时候直接就有i可用for...of按码点迭代遇到代理对字符时拿到的“位置”和字符串索引对不上后面扩展字符集时会咬人一口。3.3 三条失败路径与一个必须走到末尾的判断把上面的代码按失败时机归类一共三条路径缺一条就会漏判失败路径触发输入示例命中位置右括号多余)、())循环内pop()返回undefined类型不匹配(]、([)]循环内pop()值不等于映射值左括号未闭合(、(()循环结束后栈非空前两条在循环里就被return false拦掉了第三条只能等循环全部跑完才知道。这解释了为什么最后一行必须是return stack.length 0而不是return true只要还有左括号留在栈里就说明它们始终没等到对应的右括号输入不合法。很多翻车现场就出在这一行——有人循环里判完就return true结果(被判成合法。再往深一层stack.length 0这行还承担了“空串”的判定。空串不进循环栈一直是空的返回true。这个语义要和调用方对齐空串算不算“有效的括号序列”数学上空集满足任意全称命题工程上空字符串通常视为“没有语法错误”所以返回true是合理的。但如果你的调用方把空串当作“用户什么都没填”要单独报错那这一层就得在外面拦别改这个函数。4. 跑通与验证Node 环境下的执行、断言表与耗时观察代码写完了得能跑。这份脚本没有任何依赖Node 装好就能执行但要真正验证它对不对光跑一两个用例没用得有一张覆盖所有失败路径的用例表。这一章给两种跑法、一套表驱动测试外加一段大输入下的耗时观察方法。4.1 两种跑法命令行直跑与模块引入最直接的方式是把文件存成valid-parentheses.js加一段入口代码命令行执行// 文件末尾追加直接 node 执行时才会跑这一段 if (require.main module) { const samples [(), ()[]{}, (], ([)], {[]}, (, ), ]; for (const s of samples) { console.log(JSON.stringify(s), , isValid(s)); } }node valid-parentheses.js # () true # ()[]{} true # (] false # ([)] false # {[]} true # ( false # ) false # truerequire.main module这个判断的作用是隔离文件被别的模块require进来时这段不执行只有node 文件名直接跑时才执行。没有它测试用的console.log会混进业务日志里属于典型的“自己能跑别人集成完一堆输出”的问题。第二种跑法是当模块引// 在业务代码里 const { isValid } require(./valid-parentheses); // 表单提交前的语法预检查 const expr form.get(expression); if (!isValid(expr)) { throw new Error(括号不匹配请检查输入); }引入方式简单但要注意路径和模块类型。如果项目是 ESMpackage.json里有type: modulerequire会直接报错得改成import isValid from ./valid-parentheses.js而且导入路径必须带扩展名。4.2 表驱动测试把 20 个用例压进一个数组手写一个个if断言太啰嗦标准做法是表驱动把输入和期望值放进数组跑一轮循环。const assert require(assert); const { isValid } require(./valid-parentheses); const cases [ // [输入, 期望值, 备注] [(), true, 最小合法], [()[]{}, true, 三种类型并列], [{[]}, true, 嵌套合法], [([{}]), true, 三层嵌套], [(], false, 类型交叉], [([)], false, 嵌套顺序错乱], [{(), false, 左括号未闭合], [()), false, 右括号多余], [), false, 单右括号], [(, false, 单左括号], [, true, 空串], [((((((, false, 全左括号], [)))))), false, 全右括号], [(()()), true, 并列嵌套], [((), false, 深度不闭合], [a(b)c, false, 混入非括号字符], [null, false, 非字符串输入], [123, false, 数字输入], ]; let passed 0; for (const [input, expected, note] of cases) { const actual isValid(input); try { assert.strictEqual(actual, expected); passed; } catch (e) { console.error(FAIL: ${note} 输入${JSON.stringify(input)} 期望${expected} 实际${actual}); } } console.log(${passed}/${cases.length} 通过);几点说明。assert.strictEqual用严格相等避免1 true这种宽松比较把true和1混过去。JSON.stringify(input)在打印时把null和字符串null区分开比裸console.log清楚。用例表里[null, false]和[123, false]这两条专门测 3.1 那行类型校验很多人写测试只测 happy path边界一漏就白测。表驱动的好处是加用例只需要加一行不需要动测试逻辑。项目要扩展字符集比如支持当括号的时候改完实现直接在表里加几行新用例跑一遍就知道有没有破坏原有行为。4.3 大输入下的耗时与内存什么时候该关心括号校验在某些场景下会被喂进大文本整份 JSON、整个模板文件、几万行的配置文件。栈写法是 O(n) 的理论上线性增长但“线性”不等于“可以无视”。// 生成一段 10 万字符的合法括号序列并计时 function bench() { const n 50000; // 5 万对括号共 10 万字符 const s (.repeat(n) ).repeat(n); const t0 process.hrtime.bigint(); const ok isValid(s); const t1 process.hrtime.bigint(); console.log(结果:, ok, 耗时:, Number(t1 - t0) / 1e6, ms); console.log(堆内存:, (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2), MB); } bench();5 万对括号意味着栈的深度是 5 万stack数组会存下 5 万个单字符字符串。这里有个容易被忽略的细节JS 里从字符串取单个字符s[i]得到的虽然是长度 1 的字符串但 V8 有内部化interning优化单字符通常共享实际内存占用远小于 5 万个独立对象。所以这段代码的内存压力主要来自数组本身不是来自字符串对象。真正要注意的是极端深嵌套配合递归的场景。如果这个校验函数不是独立调用而是被一个递归下降的解析器包着深层嵌套会导致调用栈爆掉报RangeError: Maximum call stack size exceeded。栈写法本身不递归压力在数据不在调用栈这是它相对递归写法的一个实实在在的优势。我在处理配置文件解析时更偏好这种一次遍历的非递归实现就是为了把“递归深度”这个变量从系统里去掉。5. 排查记录五类括号匹配翻车现场与定位方法这套逻辑不长但翻车点不少而且很多是“看起来对、边界错”。下面五条都是我或者周围人真实遇到过的按现象、原因、解决写清楚。5.1 输入侧的三次误判一、空字符串被判成 false。现象测试用例里[, true]一直失败其他全过。原因函数开头写了if (!s) return false;本意是挡住null结果也是 falsy被一并挡掉了。解决把宽松真值判断换成类型判断if (typeof s ! string) return false;空字符串是合法字符串走正常流程自然返回true。这类问题的根子是 JS 的真值表里、0、null、undefined、NaN全是 falsy写守卫条件的时候必须明确你要挡的是哪一种。二、全角括号导致整段判非法。现象用户从文档里复制了一段配置括号肉眼看没问题程序判定全错。原因中文输入法下打出的是全角、、码点分别是 UFF08、UFF3B、UFF5B和半角括号完全不是一个字符。函数跑到第一个全角字符落进“六种之外”的分支直接return false。解决两种思路一是在校验前做一次归一化把全角映射回半角二是在错误返回里带上非法字符的位置和码点让用户自己能看到问题在哪。我倾向后者因为静默替换字符是有风险的用户可能真的想输入全角。归一化的写法大致是// 只做常见全角括号到半角的映射别用 NFKC 全量归一化 const FULLWIDTH_MAP { : (, : ), : [, : ], : {, : } }; function normalize(s) { return s.replace(/[]/g, (ch) FULLWIDTH_MAP[ch] ?? ch); }注意别图省事用s.normalize(NFKC)那会把更多字符一起变形比如全角数字、全角字母、连字属于“为了解决一个小问题引入一堆新问题”。三、代理对字符被拆开。现象输入里混了 emoji函数报错位置和用户在编辑器里看到的位置对不上。原因s[i]按 UTF-16 码元索引一个 emoji 占两个码元下标i指向的是半个字符。如果代码里要把错误位置返回给编辑器做高亮位置就会偏。解决要么统一用for...of按码点迭代并且自己维护字符计数要么在返回值里说明索引是“UTF-16 码元位置”而非“字符位置”让前端自己换算。这个坑不致命但排查起来很费时间因为现象是“高亮位置差一格”看着像编辑器的问题。5.2 栈操作与性能的两个坑四、pop之后又判断长度出现差一错误。现象([)]被判成合法其他交叉类型的也漏判。原因写成stack.pop(); if (stack.length 0 || stack[stack.length - 1] ! PAIRS[ch]) return false;。pop已经把栈顶移除了再去取stack.length - 1取到的是下一个元素比对对象整个错位。解决直接用pop的返回值比对if (stack.pop() ! PAIRS[ch]) return false;一行搞定也不需要额外判空——栈空时pop返回undefined自然不等于任何左括号。这个写法还有个附加好处不管从哪个分支返回栈的状态都是“已经弹出”不会留下脏状态。五、用正则消消乐处理大文本主线程卡死。现象小数据没问题用户粘贴一份几千行的 JSON 后页面卡三秒。原因每轮replace只消一层深层嵌套时轮数是嵌套深度总成本 O(n²)加上每轮都创建新字符串GC 压力叠加。解决换成栈实现一次遍历到底。如果历史代码不好改至少加个长度上限超过阈值走异步或者直接拒绝别在主线程上硬扛。我在浏览器侧处理用户输入时有个习惯任何对输入长度的假设都要写进代码比如if (s.length 100000) return { ok: false, reason: input too long }比事后查性能问题省事得多。6. 二次开发把布尔值升级成带错误位置的可诊断返回纯true/false在判题场景够用在业务场景不够用。用户输入{[}你告诉他“括号不匹配”他还是不知道该改哪。二次开发最值得做的第一件事就是把返回值从布尔升级成带位置和期望值的结构第二件事是把字符集从硬编码改成配置让同一个校验器能覆盖尖括号、书名号甚至自定义配对。6.1 返回结构改造从 true/false 到 { ok, index, expect }改造的核心是不提前返回把错误信息收集起来再返回。栈的入栈出栈流程完全不变只是在每个return false的位置多记一笔function validate(s) { if (typeof s ! string) { return { ok: false, index: -1, reason: not-a-string, expect: null }; } const stack []; for (let i 0; i s.length; i) { const ch s[i]; if (ch ( || ch [ || ch {) { stack.push({ ch, index: i }); // 记住左括号的位置报错时要用 continue; } if (ch ) || ch ] || ch }) { const top stack.pop(); if (!top) { return { ok: false, index: i, reason: unexpected-close, expect: null }; } if (top.ch ! PAIRS[ch]) { // 关键报的是“栈顶那个左括号”的位置不是当前右括号的位置 return { ok: false, index: top.index, reason: mismatch, expect: PAIRS[top.ch] }; } continue; } return { ok: false, index: i, reason: illegal-char, expect: null }; } if (stack.length 0) { const top stack[stack.length - 1]; return { ok: false, index: top.index, reason: unclosed, expect: PAIRS[top.ch] }; } return { ok: true, index: -1, reason: null, expect: null }; }改动看着多其实就三处。栈里存的是{ ch, index }而不是裸字符这样任何时候都能知道这个左括号是从哪来的。mismatch分支报的是top.index——栈顶左括号的位置因为用户真正要改的是那个配错对的左括号指到右括号上他会一头雾水。unclosed分支取stack[stack.length - 1]报最后一个没闭合的左括号通常也就是最内层那个改起来最直接。reason用短横线命名的字符串而不是数字码是为了日志里可读、前端可以直接映射成提示文案而不用查表。expect只在需要“补一个什么字符”的时候有值前端可以拼成“第 5 个字符处缺少]”这样的提示。6.2 字符集配置化与流式分块校验的接法字符集配置化很简单把PAIRS和开括号集合变成参数// 工厂函数传入配对表返回一个校验器 function createValidator(pairs) { const openers new Set(Object.values(pairs)); return function validate(s) { const stack []; for (const ch of s) { if (openers.has(ch)) { stack.push(ch); continue; } if (ch in pairs) { if (stack.pop() ! pairs[ch]) return false; continue; } return false; } return stack.length 0; }; } // 支持尖括号的版本 const validateWithAngle createValidator({ ): (, ]: [, }: {, : });注意配对表必须是“右括号 → 左括号”方向反了整套逻辑全错。Object.values取出所有左括号建集合加一种新括号只需要在传参时多一个键值对实现代码一行不用动。流式分块校验是另一个常见需求大文件按块读进来每块单独校验会出错因为一个括号对可能跨块。正确的接法是让栈在块之间存活每次只喂一块进去、只判断“当前块内有没有出错”最后一块读完再看栈是否为空// 跨块复用同一个栈块边界不影响正确性 function createStreamValidator(pairs) { const openers new Set(Object.values(pairs)); const stack []; return { feed(chunk) { for (const ch of chunk) { if (openers.has(ch)) { stack.push(ch); continue; } if (ch in pairs) { if (stack.pop() ! pairs[ch]) return false; continue; } return false; } return true; // 只是“到目前为止没错”不代表整体合法 }, finish() { return stack.length 0; }, // 收尾还得问一次 }; }这里最容易翻车的是把feed的返回值当成最终结论。feed返回true只说明“这块没出错”整个序列合不合法必须等finish()。我最初写流式版本时就漏了这一层块边界刚好切在一对括号中间最后一块返回true调用方直接放行脏数据就这么进去了。从那以后我每次做增量校验都强制在接口上把「块内检查」和「整体收尾」拆成两个方法名字上就不给人混淆的机会。希望这些细节能帮你少走一遍我走过的路。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑