☰
JavaScript括号匹配算法:栈实现、边界用例与工程避坑
2026/11/4 9:05:14 网站建设 项目流程

简介:这份面向JavaScript初学者与算法刷题者的代码包,聚焦有效括号序列判断这一经典题型,帮助读者理解如何借助栈结构完成左括号入栈、右括号与栈顶配对、遍历结束后栈空校验等关键环节,并体会空栈访问、类型不匹配和提前返回等边界处理。压缩包共两个文件,以一份脚本核心实现和一份文本说明为主,整体约八百一十八字节,体量轻便,适合直接阅读源码或在本机运行验证。已有三千四百二十八人学习,说明该题在面试准备与基础训练中关注度较高。核心实现给出判断函数及多组测试用例,通过配对映射简化圆括号、花括号、方括号三类符号的匹配判断,读者可据此掌握线性时间复杂度的解题思路,并迁移到表达式解析、模板语法校验等场景;文本说明用于补充运行方式与理解上下文,便于快速复现、调试和二次改写。

1. 三十行 JS 代码判括号,为什么很多人第一次提交还是红色的

一段判断字符串是否有效的 JS 代码,核心逻辑只有十几行:遇到左括号就入栈,遇到右括号就比对栈顶,扫完再看栈是否为空。可就是这段代码,在面试白板、在线判题和表单校验里被写错的比例高得离谱。思路并不是很难,真正容易出错的是空串、单边括号、交叉嵌套这三类输入。

这份资源提供的就是一份能直接跑起来的 JS 实现,围绕(、)、{、}、[、]六种字符,按「同类型闭合」和「正确顺序」两条规则判真假。它适合刚接触栈结构的新手跟一遍完整链路,也适合想把括号校验放进配置编辑器、表达式解析器、模板语法检查里的老手直接拿走改。

下面按「模型怎么立」「代码怎么跑」「参数怎么改」「坑在哪」四步推进,每一步都给出可复制的代码块和失败时该看的地方。

2. 栈模型的成立理由:正则表达式判不了嵌套括号

2.1 为什么正则表达式判不了括号嵌套

先看一个常见想法:括号匹配不就是查找成对的()、[]、{}吗?用正则写一个^(\(\)|\[\]|\{\})*$不就完事了。对()、[]{}、()[]{}这类平铺输入确实能判对,但只要出现嵌套,例如([{}]),这个正则就判不了。

原因在于正则表达式背后的模型是有限状态机,它没有「记忆」能力,记不住「当前已经开了几层、每层是什么类型」。括号匹配属于上下文无关语言,需要的是一个能压入和弹出的栈,不是一张状态表。这也是为什么面试官喜欢拿它当栈的入门题,它恰好卡在正则表达式的能力边界之外。

实战中还有人用另一种「看起来不算正则」的办法:反复替换最内层的成对括号,直到替换不动为止。写法如下:

// 反复消除最内层成对括号:能判对,但代价高 function isValidByReplace(s) { const re = /\(\)|\[\]|\{\}/g; // 只匹配最内层相邻成对 let prev; do { prev = s; s = s.replace(re, ''); // 一轮只能消掉一层 } while (s !== prev); // 没变化说明消不动了 return s === ''; }

这段代码的逻辑是:"(())"第一轮变成"()",第二轮变成"",所以判定为有效。参数上re必须是全局标志g,否则replace只换第一处,循环条件会误判。但它的代价是每轮都要重扫整串,深度为 d 的嵌套就要跑 d 轮,整体接近 O(n²)。字符串长度一万、全嵌套的用例下,它的耗时是栈解法的几十倍,在线判题里很容易超时。所以这个方法只适合拿来做思路对照,不要当成生产实现。

2.2 栈的不变式:扫描过程中的三条约束

栈解法之所以可靠,是因为它维护了一个可以随时验证的不变式:扫描到第 i 个字符后,栈自底向上保存的是所有尚未闭合的左括号,且顺序与它们在字符串中出现的顺序完全一致。

由这个不变式可以推出三条硬性约束,后面所有的变体都从这里派生:

  • 遇到右括号时,栈顶必须是与之配对的左括号,否则立刻判定非法,因为「后开的先闭」这条顺序已经被破坏;
  • 遇到右括号时如果栈为空,说明这个右括号没有对应的左括号,直接判定非法;
  • 扫描结束后栈必须为空,否则说明还有左括号悬着没闭。

反过来说,如果只有一种括号(比如纯圆括号),其实不需要栈,一个计数器就够了,因为不存在类型混淆,只存在数量与顺序问题。三种括号并存时,计数器无法区分「这个)该配(还是配别的东西」,才必须上栈。这个区别在后面第 4 章讲变体时会反复用到。

2.3 十二行核心实现:逐行说明与参数含义

把上面的不变式翻译成代码,主体的实现如下:

function isValid(s) { // 右括号到左括号的反查表,用对象比 if-else 链更好扩展 const pairs = { ')': '(', ']': '[', '}': '{' }; const stack = []; for (let i = 0; i < s.length; i++) { const ch = s[i]; // 1. 左括号一律入栈,不做任何判断 if (ch === '(' || ch === '[' || ch === '{') { stack.push(ch); continue; } // 2. 右括号:栈空即为「多余的右括号」,先短路避免 pop 出 undefined if (stack.length === 0) return false; // 3. 弹出栈顶比对类型,不匹配立即失败 if (stack.pop() !== pairs[ch]) return false; } // 4. 收尾:栈非空说明有左括号没闭合 return stack.length === 0; }

逻辑说明分四段。第一段把左括号无条件压栈,因为左括号在任何位置都是合法的「待闭合」状态。第二段先判stack.length === 0,这个顺序不能调换——如果先pop()再比较,空栈会返回undefined,而undefined !== '('恰好也返回false,看起来结果是对的,但真正的空栈错误被掩盖了,换成自定义映射表就会翻车,这是第 5 章要展开的第一个坑。

第三段用pairs[ch]做反查,比写三条else if更短,也更容易在变体里换成Map。第四段的返回值必须是布尔表达式而不是return true,否则"("这种输入会被错判为有效。

参数上,s的约束是「只包含六种括号字符」,这是题目前提。实际项目里如果输入可能带空格、注释、转义字符,需要在函数外先做清洗,或者按第 4 章的流式校验器处理。pairs表的键必须是右括号,值必须是左括号,写反了整段逻辑会全部反向。

2.4 复杂度与必须覆盖的边界输入

时间上每个字符最多被访问一次,入栈出栈各一次,是 O(n);空间最坏情况是整串都是左括号,栈深度等于 n,所以是 O(n);最好的情况是纯右括号,第一次就返回,空间 O(1)。

有一处免费优化:如果字符串长度是奇数,直接返回false,因为每对括号贡献两个字符。这一行在长输入下能省掉整趟遍历:

if (s.length % 2 === 1) return false; // 奇数长度不可能全部配对

边界输入必须逐个覆盖,漏一个就可能判错:

输入期望考察点
""true空串是否算有效(按题目定义算)
")"false空栈遇右括号
"("false收尾栈非空
"()"true最小有效单元
"([)]"false数量对但顺序错,计数器法必错
"(([]){})"true多层嵌套加并列
"(){}}{"false中途提前闭合后多余右括号

其中"([)]"是最有价值的一条:它左右括号数量都是 2,任何只做计数的实现都会判成有效,而它恰恰是栈结构的试金石。

3. 本地运行:零依赖环境,从单文件到用例集

3.1 环境准备与文件骨架

这份代码没有依赖任何第三方库,用原生 JS 就能跑。本地验证的常见做法是装个 Node(16 及以上都行,栈实现没用到新语法),然后建一个单文件:

node -v # 确认 Node 可用,16+ 均可 mkdir bracket-check && cd bracket-check touch valid.js # 单文件即可,不需要 package.json node valid.js # 直接运行内置自测

文件骨架要让同一个文件既能被别的模块引用,又能直接执行自测,关键是require.main === module这个判断:

// valid.js:导出 + 直接运行时执行自测 function isValid(s) { /* 第 2 章的实现原样放这里 */ } module.exports = { isValid }; if (require.main === module) { // 只有 node valid.js 时才走进来,被 require 时不执行 const cases = [['""', ''], ['()', '()'], ['([)]', '([)]']]; for (const [name, input] of cases) { console.log(name, '=>', isValid(input)); } }

require.main === module的含义是「当前文件是不是进程入口」。少了这个判断,别人require('./valid.js')的时候你的测试代码也会跟着跑一遍,在单元测试里会输出一堆噪音。module.exports导出的是对象而不是裸函数,是为了将来往里追加validateWithIndex、createValidator等变体时不破坏调用方。

3.2 用例集:用断言把七条边界固定下来

手敲console.log看输出只能验证一次,改用assert把期望值写死,改代码后重跑就能立刻发现退化:

// cases.js:边界用例 + 断言驱动 const assert = require('node:assert'); const { isValid } = require('./valid'); const table = [ ['', true], [')', false], ['(', false], ['()', true], ['([)]', false], ['(([]){})', true], ['(){}}{', false], ]; for (const [input, expected] of table) { assert.strictEqual(isValid(input), expected, `用例失败: ${JSON.stringify(input)}`); } // 深层嵌套:构造 5000 层,验证栈没有被递归或字符串拼接拖垮 const deep = '('.repeat(5000) + ')'.repeat(5000); assert.strictEqual(isValid(deep), true); // 长串交叉:模拟真实文本里大量短括号对 const wide = '()[]{}'.repeat(2000); assert.strictEqual(isValid(wide), true); console.log('全部用例通过');

assert.strictEqual用严格相等,避免0 == false这类隐式转换把错误吞掉。第三个参数是失败时的提示信息,把输入原样打出来非常关键——JSON.stringify能让你看见空串和空格的区别,直接拼字符串会看不出来。

深层嵌套那条用例是为了验证空间复杂度:5000 层意味着栈里会同时存在 5000 个元素,如果实现里用了递归,这里会直接抛RangeError: Maximum call stack size exceeded。长串交叉那条则是验证实现没有意外的 O(n²) 行为,跑一遍如果明显卡顿,说明某处做了字符串拼接或数组复制。注意跑之前把s.length % 2 === 1的优化打开,长串用例能快一截。

3.3 查表、switch、码点差值:三种写法怎么选

核心实现里的pairs[ch]是最通用的写法,但在高频调用场景下,还有人会用码点差值或switch。三者的差别值得说清楚,因为选错了会在某些输入上出错。

码点差值法的依据是:配对的两个括号在 ASCII 表里相邻或隔一位。(是 40、)是 41,差 1;[是 91、]是 93,差 2;{是 123、}是 125,差 2。所以只要判断当前字符减去栈顶是否属于{1, 2}即可:

function isValidFast(s) { const stack = []; for (let i = 0; i < s.length; i++) { const c = s.charCodeAt(i); // 取码点,比 s[i] 少一次字符串分配 if (c === 40 || c === 91 || c === 123) { stack.push(c); continue; } const top = stack.pop(); if (top === undefined) return false; // 空栈遇右括号 const diff = c - top; if (diff !== 1 && diff !== 2) return false; } return stack.length === 0; }

这段比对象查表快,原因是charCodeAt返回的是小整数,栈里存的也是整数,没有字符串比较和哈希查找。但它有两个前提必须写进注释:输入只含六种半角括号,以及不做类型映射。如果传进来一个字母a(码点 97),它会走到else分支去弹栈,把合法状态破坏掉,最后给出一个看似合理实则错误的答案。所以在真实项目里用它之前,要么保证输入已经过白名单过滤,要么在函数开头加一行/^[\(\)\[\]\{\}]*$/.test(s)兜底。

switch版本介于两者之间,可读性最好,但每个字符都要走一次分支判断,在百万级字符上比码点法慢。我的习惯是:判题和脚本用对象查表,追求可读;被高频调用的校验器(比如编辑器每次按键都跑一遍)才换码点法,并且把白名单校验固定在上面。

3.4 三种调用方式:OJ 提交、浏览器控制台、ESM 引入

同一份代码在不同环境里的用法差别不小,容易出错的是 OJ。

在线判题平台通常只让你填函数体,不让你写module.exports,也不让你读标准输入。把第 2 章那段isValid原样贴进去就行,多写一行导出语句有些平台会直接报语法错误。另一个常见问题是输入解析:如果题目给的是「第一行一个整数 n,接下来 n 行字符串」,自己用readline拼解析逻辑时很容易把行尾的\r带进字符串,导致最后一个字符判错。

浏览器控制台和 ESM 场景下,改成具名导出即可:

// valid.mjs:ESM 形式,浏览器 <script type="module"> 也能直接用 export function isValid(s) { const pairs = { ')': '(', ']': '[', '}': '{' }; const stack = []; for (const ch of s) { // for...of 对 ASCII 括号安全 if (ch in pairs) { if (stack.pop() !== pairs[ch]) return false; } else { stack.push(ch); } } return stack.length === 0; }

这里把判断顺序倒过来了:先看是不是右括号(ch in pairs),否则当左括号压栈。这样写更短,代价是「非括号字符」会被当成左括号压进去,最后判非空时返回false——在题目约束下没问题,但接到真实输入上必须先把非括号字符滤掉。for...of按码点迭代,处理代理对(比如某些表情符号)比s[i]安全,不过对括号这种单字节字符两者等价。

4. 参数与变体:把判定器改成项目里真正要用的样子

4.1 括号对做成配置表,支持自定义符号

固定写死六种括号,在很多场景下不够用:模板引擎里有<% %>,某些配置语言里有<< >>,还有的项目要求同时支持全角括号。把括号对抽成参数,只改一处就能扩展:

function makeValidator(openList, closeList) { if (openList.length !== closeList.length) { throw new Error('左括号表与右括号表长度必须一致'); } const openSet = new Set(openList); const closeToOpen = new Map(); for (let i = 0; i < openList.length; i++) { closeToOpen.set(closeList[i], openList[i]); } return function validate(s) { const stack = []; for (const ch of s) { if (openSet.has(ch)) { stack.push(ch); } else if (closeToOpen.has(ch)) { if (stack.pop() !== closeToOpen.get(ch)) return false; } // 其他字符直接忽略,便于校验带文本的模板片段 } return stack.length === 0; }; } // 用法:在标准括号基础上加一对全角括号 const validate = makeValidator(['(', '[', '{', '('], [')', ']', '}', ')']);

参数的含义要说清楚:openList和closeList必须按位置一一对应,第 i 个左括号配第 i 个右括号,长度不等直接抛错而不是静默容错——这种配置错误越早暴露越好。用Set和Map而不是普通对象,是为了避开原型链上的键(比如输入字符恰好是constructor),虽然括号字符不可能撞上,但换成自定义符号后就不一定了。

函数用闭包返回,配置只解析一次,反复调用时不会重复建表。这是把「配置」和「执行」分开的典型写法,也是这个资源最值得复用的结构。

4.2 返回值从布尔升级成首个错误位置

只返回true/false在校验场景里不够用——用户在编辑器里写错一个括号,你需要告诉他错在第几个字符、期望的是什么。把返回值改成对象,成本很低:

function validateWithIndex(s) { const map = { ')': '(', ']': '[', '}': '{' }; const rev = { '(': ')', '[': ']', '{': '}' }; // 反向表,用来提示「期望字符」 const stack = []; for (let i = 0; i < s.length; i++) { const ch = s[i]; if (rev[ch]) { stack.push({ ch, i }); // 记下位置,报错时要用 continue; } if (!map[ch]) continue; // 非括号字符跳过 const top = stack.pop(); if (!top) return { ok: false, reason: 'unexpected-close', index: i, char: ch }; if (top.ch !== map[ch]) { return { ok: false, reason: 'mismatch', index: i, char: ch, expect: rev[top.ch], openAt: top.i }; } } if (stack.length) { const first = stack[0]; // 报最外层未闭合的,因为它是「最早」的 return { ok: false, reason: 'unclosed', index: first.i, char: first.ch, expect: rev[first.ch] }; } return { ok: true }; }

关键设计有三点。reason用固定枚举值而不是中文描述,方便前端按类型做不同高亮。index一律指向「出问题的那个字符」:多余的右括号指向它自己,类型不匹配指向右括号,未闭合指向最早的左括号——这三种指向方式必须统一,否则前端划线会错位。expect和openAt是给提示文案用的,能拼出「第 12 个字符处期望],与之配对的[在第 3 个字符」。

stack[0]报最外层而不是栈顶,这一点容易被忽略:"((("这种输入,栈顶是最后一个(,但用户真正该补的是最外层那个,因为补完外层可能整段就对了。

4.3 单一括号类型:计数器写法与它的适用边界

如果确定只会出现圆括号(比如某些表达式语法里只用圆括号分组),可以不用栈,一个计数器就够,空间降到 O(1):

function isValidParenOnly(s) { let depth = 0; for (const ch of s) { if (ch === '(') depth++; else if (ch === ')') { depth--; if (depth < 0) return false; // 中途变负说明右括号多了 } } return depth === 0; }

这段的逻辑是「深度不能为负、结束必须归零」。depth < 0的检查放在递减之后,顺序不能反,否则第一次遇到)时会漏判。它的性能优势明显,但不适用于混合括号:"([)]"在它眼里深度变化是 1→2→1→0,返回true,而正确答案是false。这个例子在第 2 章出现过一次,在第 5 章还会以另一种形式再出现,因为它是这类实现最典型的失败点。

4.4 流式校验:分块输入下的状态保持

编辑器、日志管道这类场景拿不到完整字符串,只能一块一块喂进来。这时候栈必须活在闭包里,不能每次调用重建:

function createStreamValidator() { const pairs = { ')': '(', ']': '[', '}': '{' }; const stack = []; let failed = false; return { feed(chunk) { if (failed) return false; // 已失败的流不再继续消费 for (const ch of chunk) { if (ch === '(' || ch === '[' || ch === '{') stack.push(ch); else if (pairs[ch]) { if (stack.pop() !== pairs[ch]) { failed = true; return false; } } } return true; }, end() { return !failed && stack.length === 0; }, depth() { return stack.length; }, // 暴露深度,方便 UI 显示嵌套层级 }; }

failed标志位是必须的:一旦判定失败,栈已经被pop破坏,后续feed继续跑会给出无意义的结果,所以要把失败状态固定住,让整个流提前结束。end()单独成一个方法而不是在最后一个feed里判空,是因为调用方不一定知道哪一块是最后一块。depth()是顺手加的能力,做语法高亮时用来决定缩进层级,成本几乎为零。

5. 避坑与排查:五类实际出错场景

下面这五条都是从实际调试里总结出来的,每条按「现象 → 原因 → 解决」写。它们有一个共同点:代码不报错,答案却是错的,比直接抛异常难查得多。

5.1 空栈 pop 出来的 undefined 恰好不匹配

现象:把映射表从对象换成Map之后,")"这类输入返回的结果从false变成了true,代码一行没改,只改了数据结构。

原因:[].pop()返回undefined,undefined !== '('结果为true,于是if (stack.pop() !== pairs[ch]) return false能正确返回。但换成Map后写成了stack.pop() !== map.get(ch),而map.get(')')在表里没配好时也返回undefined,两个undefined相等,判断被绕过。本质是「空栈」和「不匹配」两种错误共用了一个返回值。

解决:把空栈判断独立出来,放在pop之前,让两种错误有各自的出口。改成if (stack.length === 0) return false;再pop比对,两条路径就彻底分开了。这条也适用于任何「用返回值表示多种错误」的场景。

5.2 forEach 里的 return 退不出循环

现象:想在遇到不匹配时立刻返回false,于是写了s.split('').forEach(ch => { if (...) return false; }),结果函数最后返回undefined,判题系统报「返回值类型错误」。

原因:forEach的回调里的return只是结束当次回调,等价于continue,不影响外层。而且forEach本身没有返回值,回调里return false传不出去。这不是括号题特有的问题,只是在需要「提前中断」的遍历里特别容易出错。

解决:换成for...of或经典for循环,这两者支持return、break、continue。如果非要用数组方法,用some/every并把返回值传出去:const bad = chars.some(ch => ...)。我一般的习惯是:只要函数体里可能出现提前返回,就不用forEach。

5.3 忘记收尾判空,半个括号被判成有效

现象:"("、"(([])"这类输入返回true。测试里如果只写了"()"和"([)]",完全发现不了。

原因:循环里只处理了「右括号多余」和「类型不匹配」两种情况,漏掉了「扫描结束栈里还有东西」这第三种。第三种是唯一一种在循环结束后才能发现的错误,很容易在写代码时被忘掉。

解决:函数最后一行必须是return stack.length === 0;,不能写return true。对应的用例一定要包含只由左括号组成的字符串,长度分别为 1、2、3。另外,把「奇数长度直接返回false」的优化加上后,"("会因为长度是奇数被提前挡掉,但这只是掩盖问题——"(("长度是偶数,照样能测出漏判,所以这条用例不能省。

5.4 全角括号与不可见字符:看起来一样,码点不一样

现象:从文档里复制了一段带括号的配置粘进校验框,明明括号都成对,校验却返回false。把字符串console.log出来肉眼看不出任何区别。

原因:中文输入法下打出来的是全角括号(),码点和半角的()完全不同;另外复制粘贴还容易带进零宽空格、\u00A0不换行空格、行尾\r。这些字符在等宽字体里几乎看不出来,但它们既不在左括号集合里,也不在右括号集合里,会被当成「其他字符」忽略或者被当成左括号压栈。

解决:入口处先做一次规范化,把全角括号映射成半角、把不可见字符替换掉,再进校验器。查问题时用[...s].map(c => c.codePointAt(0).toString(16))把整串码点打出来,比肉眼看快得多。有一个细节要记住:六种半角括号的码点是 40、41、91、93、123、125,只要输出里出现ff08、ff09这类值,就是全角。

5.5 递归写法在深层嵌套下直接栈溢出

现象:本地小用例全过,线上遇到一段深层嵌套的模板就抛RangeError: Maximum call stack size exceeded,日志里看不出来是哪一步。

原因:有人把括号匹配写成递归——遇到左括号就递归处理到对应的右括号为止。这种写法逻辑上没错,但递归深度等于嵌套层数,几千层就能打爆调用栈。JS 引擎的栈深度限制跟运行环境有关,本地开发机可能扛得住,容器里就未必。

解决:改成迭代加显式栈,把递归深度转成数组长度,数组可以到几百万。如果已经写成递归又不想大改,至少要做深度上限保护:进入递归前判depth > 10000就返回错误,让失败可控。压测时构造'('.repeat(5000) + ')'.repeat(5000)这种用例跑一遍,能提前暴露问题。

6. 接入真实校验流程:两道保护和一次回归

把这段代码放进项目里,我一般会加两道保护。第一道是入口规范化:全角转半角、去掉零宽字符和\r,用一个normalize(s)单独承担,这样校验器本身只处理干净输入,责任清晰。第二道是超长截断:超过设定长度(比如一万字符)直接拒绝并返回原因,避免一段异常输入把编辑器主线程卡住。

回归测试则用一张固定用例表固定下来,每次改动校验器都跑一遍:

层级用例目的
单元""、"("、")"、"([)]"固定基础分支
结构5000 层嵌套、"()[]{}"重复两千次防止复杂度退化
输入清洗全角括号串、带\r\n的串防止编码问题回流

这三层里最容易被跳过的是第三层,因为它看起来跟算法无关。我踩过一次:校验器本身没问题,但从富文本编辑器复制过来的内容里混进了零宽字符,用户反复粘贴都提示「括号不匹配」,排查了半天才发现是输入层的问题。

从那以后我每次接这类校验器,都强制走一遍「码点打印 + 固定用例回归」,先把输入洗干净再谈算法。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询