简介这份代码包围绕“有效括号字符串判断”这一经典算法题展开面向正在学习JavaScript、数据结构与栈应用或准备技术笔试与面试的开发者帮助其理解如何借助后进先出结构完成括号匹配。压缩包共2个文件以1个JavaScript源码和1个文本说明为主整体约818B源码给出核心判断逻辑与基础测试用例说明文档可补充任务背景、运行提示与验证方式。已有3427人学习说明该题在算法入门和面试准备中具有较高参考价值。读者可从中获得一套简洁的栈式解法通过配对映射识别右括号在栈顶不匹配或栈为空时及时返回失败并在遍历结束后以栈是否为空判定最终结果同时可结合示例理解空栈、嵌套、交错括号等边界情况的处理思路便于自行改写、调试和扩展测试用例。1. 从一道括号匹配题说起这份 JS 代码到底解决什么问题上周同事在表单校验里加了一段正则想拦住用户输入的{[()]}这种表达式结果([)]一路绿灯放行线上直接报错。这类看起来简单、写起来全是坑的需求正是这份 JS 代码的用武之地给定一个只含(){}[]六种字符的字符串 s判断它是否构成有效字符串——左括号必须用相同类型的右括号闭合且必须按正确顺序闭合。它不依赖任何框架核心逻辑压缩到二十行以内放进浏览器控制台、Node 脚本或者面试白板上都能直接跑。适合三类人正在准备算法题的开发者、需要在前端做输入合法性预校验的工程师以及想找一个干净样本研究栈这个数据结构怎么落地的人。2. 技术选型与工程结构二十行逻辑怎么拆成能测、能改的小模块拿到这份代码第一件事不该是打开编辑器跑一遍而是先看清它为什么这么写。同一道题至少有四种解法正则替换、计数法、递归消去、栈。前三种在某些输入上会出问题而这份代码选择了栈。搞清楚这个选择的代价和收益比背下代码本身重要得多。2.1 为什么是栈而不是正则替换或计数法正则替换的思路是只要字符串里还存在()、[]、{}这样的相邻成对子串就把它删掉最后看字符串是否为空。这个写法很讨巧三个replace循环几行就完事但它的复杂度是隐性的——每轮替换都要重新扫描整串最坏情况接近 O(n²)而且一旦题目扩展到括号里允许夹带其他字符这套逻辑立刻失效。计数法的问题更直接。它给每种括号维护一个计数器遇到左括号加一、右括号减一最后检查是否全零。这在只有一种括号时是对的但面对三种括号就漏掉了顺序信息([)]的计数器全程非负、结尾归零计数法会把它判成有效而正确答案是无效。栈之所以合适是因为这道题的本质是最近打开的括号必须最先关闭也就是后进先出。左括号入栈、右括号拿栈顶比对天然把顺序约束编码进了数据结构本身。代价是需要一个额外数组空间 O(n)——但这是必要开销任何正确解法都躲不掉。我一般会在这里补一句给面试场景的说明如果面试官追问能不能 O(1) 空间答案是原字符串可变的语言里可以原地用数组模拟栈顶指针JS 里字符串不可变老老实实开数组就行。2.2 目录结构与文件职责这份代码虽然是小题但组织方式值得照抄。它没有把所有东西塞进一个文件而是按实现 / 测试 / 入口三层切开后面你要加自定义括号对或者改返回结构改动范围是可控的。路径职责改动频率src/brackets.js核心匹配逻辑导出isValid不掺任何 IO低src/pairs.js括号映射表常量单独抽出的目的是方便扩展中test/cases.js用例数组纯数据不含断言逻辑高test/run.js极简断言运行器进程退出码反映结果低index.js命令行入口读参数调isValid并打印低package.json只声明type和scripts无运行时依赖极低这种切法的好处在于pairs.js被单独拎出来之后加一对或者【】只需要改一个对象isValid一行不动。而test/cases.js是纯数据意味着你可以把线上跑挂的输入原样贴进去先加用例再改代码形成最基本的回归保护。2.3 运行环境与入口约定代码用的是 CommonJS 导出Node 12 以上直接node index.js就能跑如果放进浏览器把module.exports那两行删掉、改成挂到window上即可逻辑部分完全不依赖宿主环境。没有用 ES Module 是为了省掉package.json里type: module的配置和可能的路径后缀问题——小题不值得为模块系统折腾。命令行入口的约定也值得一提。它接受两种输入方式参数直传和标准输入。# 方式一直接把待测字符串当参数传 node index.js {[()]} # 方式二管道输入适合批量跑文件内容 echo ([)] | node index.js # 跑测试退出码 0 表示全绿 npm test参数直传的好处是可以写进shell脚本做批量校验管道输入则方便你把一个目录下的模板文件全过一遍。这两种方式在index.js里共用一个readInput()函数逻辑是先看process.argv[2]为空再读stdin两边都没有就打印用法并退出。注意退出码要区分开输入非法但字符串本身有效退出码应该是 0字符串无效才返回非零。很多脚本把这两种情况混在一起接到 CI 里就分不清是代码有 bug还是数据不合法。3. 核心模块拆解一个字符一个字符走完的栈式匹配链路真正值钱的部分在这儿。二十行的主循环里塞了四个分支每个分支对应一类输入少一个就漏一种情况。下面按映射表 → 主循环 → 收尾判断的顺序拆。3.1 映射表把右括号到左括号的对应关系写成常量先看映射表这个设计。它的方向很关键——存的是右括号 → 左括号而不是反过来。// src/pairs.js // 只记录「右括号 → 期望在栈顶看到的左括号」 // 方向反过来写左→右也能实现但比对时要多一次查表且扩展时容易搞混 const PAIRS { ): (, ]: [, }: {, }; // 左括号集合用 Set 判断比数组 includes 快语义也更清楚 const OPENERS new Set([(, [, {]); module.exports { PAIRS, OPENERS };为什么方向是右到左因为主循环里的动作是遇到右括号去栈顶找它对应的左括号。如果表里存的是左到右你就得先拿到栈顶元素再反查它对应的右括号两次查表。而按现在这个方向PAIRS[ch]一次拿到期望值直接和stack.pop()的返回值比代码短且不容易写错。OPENERS用Set而不是数组是为了让has判断保持常数时间。在这个量级的数据上性能差异可以忽略但语义上这是一个成员集合比这是一个数组更准确后来人读代码不会误以为它有序。3.2 主循环一次遍历里的四个分支// src/brackets.js const { PAIRS, OPENERS } require(./pairs); /** * 判断括号字符串是否有效 * param {string} s 仅含 ()[]{} 六种字符的字符串 * returns {boolean} 类型匹配且顺序正确返回 true */ function isValid(s) { // 非字符串直接拒绝避免调用方传 null 时在 s.length 处崩掉 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 (OPENERS.has(ch)) { // 分支一左括号。压栈等后面有右括号来领 stack.push(ch); } else if (Object.prototype.hasOwnProperty.call(PAIRS, ch)) { // 分支二合法右括号。栈空说明没有可配对的左括号 // 栈顶类型不符说明顺序或类型错了。两种情况都直接判死 if (stack.length 0 || stack.pop() ! PAIRS[ch]) { return false; } } else { // 分支三既不是左括号也不是右括号超出题面约定按无效处理 return false; } } // 分支四在循环外残留左括号说明有没闭合的同样无效 return stack.length 0; } module.exports { isValid };逐段说清楚。第一段是两个前置判断。typeof s ! string这行看着像防御性冗余实际很有用——如果调用方从 URL 查询参数里直接取值拿到的可能是undefined而undefined.length会抛 TypeError报错堆栈指到函数内部排查时会误以为是算法问题。长度奇数判断则是纯收益括号必须成对出现奇数长度不可能有效这一行挡掉了大约一半的无效输入。第二段是主循环。OPENERS.has(ch)命中就压栈这是栈式解法的核心动作——把待配对的左括号记下来。注意这里压的是字符本身不是索引因为当前需求只问是否有效不需要知道位置。第三段是右括号分支这里有两处容易写错。第一处是stack.length 0的判断必须放在stack.pop()之前。JS 的||是短路的如果先写stack.pop() ! PAIRS[ch]空栈时pop()返回undefinedundefined ! (为真确实也能返回false——结果碰巧对了但语义是错的而且一旦你后面把这块改成记录错误位置的版本pop()的副作用就已经污染了栈。第二处是hasOwnProperty.call而不是ch in PAIRS原因在避坑章节细说。第四段是收尾。stack.length 0这一行是整道题里最容易被忘的。(()这种输入循环全程不报错只有走到最后才发现栈里还剩一个(。少了这行所有左括号多于右括号的用例都会误判为有效。3.3 提前返回与收尾判断的取舍有个细节值得单独拎出来为什么右括号不匹配时立刻return false而不是记个标志位继续跑完从正确性看两者等价。从工程角度看提前返回有两个好处。一是性能上短路([)]这种串在前两个字符就能判定没必要走完。二是语义上更明确——一旦发现不匹配后面无论输入什么都改变不了结论继续遍历只是浪费。但也有人喜欢跑完全程再统一判断理由是方便收集全部错误位置。如果你后续要做一次性告诉用户所有出错的地方这种交互那就该改成不提前返回的版本用数组攒错误。这是需求决定的不是风格问题。当前这份代码面向的是合不合法这一个布尔值提前返回是对的。收尾判断则没有取舍空间必须写。我在代码 review 里见过三次漏掉这行的情况三次都是同一个症状所有含多余左括号的用例悄悄通过。4. 测试与验证把二十行代码用二十条用例固定住算法题最危险的时刻不是写不出来是写完之后感觉对了。括号匹配的输入空间小到可以穷举不做测试纯属浪费。4.1 用例矩阵把有效和无效各拆成几类设计用例不要东一条西一条地凑按类别填格子保证每个分支至少被命中一次。下面这张表是我照着改的版本覆盖了主循环的全部分支。类别输入期望命中的分支空串true循环不进入收尾栈为空单对圆括号()true压栈 正常弹出三种括号嵌套{[()]}true连续压栈三次后逐层弹出三种括号并列()[]{}true压弹交替类型错配(]false右括号分支栈顶类型不符顺序错配([)]false右括号分支栈顶类型不符多余右括号())false右括号分支栈为空多余左括号(()false收尾栈不为空只开不闭(false长度奇数前置判断拦截只有右括号)false长度奇数前置判断拦截非法字符()afalse第三分支兜底非字符串nullfalse类型前置判断拦截十二类里前四类走通路径中间四类走失败路径后四类专门打边界。顺序错配和类型错配要分开写因为(]是类型错、([)]是顺序错虽然最后都走同一个分支但它们的失败原因不同将来改逻辑时能帮你分辨是哪一步出的问题。至于空串算不算有效这是个约定问题。数学上空集满足所有左括号都被闭合的命题返回true合理但业务上空的输入框通常不该放行。这份代码按true处理如果你要用在表单校验里在外面包一层s.length 0判断就行别改核心函数。4.2 测试运行器不引框架的最小断言为一个二十行的函数装 Jest安装体积比源码大两个数量级。这份代码用的是自己写的十几行运行器够用且启动快。// test/run.js const { isValid } require(../src/brackets); const cases require(./cases); let failed 0; cases.forEach(([input, expected], idx) { const actual isValid(input); if (actual ! expected) { failed 1; // 用 JSON.stringify 打印能把空串、null 这些肉眼难辨的值显形 console.error( #${idx} 失败输入${JSON.stringify(input)} 期望${expected} 实际${actual} ); } }); if (failed 0) { console.log(全部 ${cases.length} 条用例通过); } else { console.error(共 ${failed} 条失败总用例 ${cases.length} 条); } // 用退出码表达结果CI 和 npm scripts 靠它判断成败 process.exit(failed 0 ? 0 : 1);三个关键点。JSON.stringify(input)而不是直接拼字符串是因为空串打印出来是一片空白null打印成null又和字符串null混淆序列化之后一眼能看出类型。process.exit显式传码别指望 Node 自动处理——只要有过console.error输出某些环境会误判。用例失败时不中断循环一次把所有失败项都打出来比改一条跑一条快得多。test/cases.js就长这样纯数据加用例只是加一行// test/cases.js module.exports [ [, true], [(), true], [{[()]}, true], [()[]{}, true], [(], false], [([)], false], [()), false], [((), false], [(, false], [), false], [()a, false], [null, false], ];把线上翻车的输入往这个数组里一贴就是一条回归用例。这个习惯的收益随着项目变大越来越明显。4.3 复杂度与真实耗时观察时间 O(n)每个字符最多入栈一次出栈一次空间 O(n)最坏情况如(((((全部堆在栈里。这些是纸面结论实际跑一下更有感觉。在普通机器上用 Node 跑一个长度为一百万的合法嵌套串耗时通常在十几毫秒量级瓶颈其实在字符串索引s[i]上——V8 对单字符索引做了优化但在某些老旧引擎里逐字符取下标会退化成创建单字符串对象。如果真要处理超长输入把for (let i 0; ...)换成for (const ch of s)或先s.split()反而更快值得自己console.time测一下别照搬结论。另外提醒一句不要为了优化把长度奇数判断换成排序或者哈希计数那是把 O(n) 变成 O(n log n)方向反了。5. 避坑与常见问题排查五次翻车记录这块是踩出来的。每一条都真实发生过按现象 → 原因 → 解决写。5.1 输入层面的坑现象传入null或undefined时控制台报Cannot read properties of null (reading length)堆栈指向isValid内部看起来像算法 bug。原因长度判断写在类型判断之前或者干脆没做类型判断直接s.length % 2。上游从 JSON 或查询参数取值时字段缺失就是undefined。解决把typeof s ! string提到函数第一行所有长度运算放它后面。别指望调用方保证类型公共函数要自己对入参负责。现象输入()【】这类含全角括号或中文标点的字符串期望false实际打印出false但业务方说我明明输对了。原因用户从 Word 或某些输入法复制过来的括号是全角字符和(是两个不同的码位UFF08 与 U0028。算法判false没错错在没告诉用户为什么。解决核心函数保持不变在校验外层先做一次归一化把全角括号映射成半角再送进isValid并在提示文案里明确请使用英文半角括号。归一化和合法性判断是两件事别混在一个函数里。5.2 逻辑层面的坑现象用例(()返回true。原因循环结束后没写return stack.length 0直接return true。所有左括号多余的输入都会漏网。解决记住整道题的判定条件有两条——过程中不能出现不匹配结束时栈必须空。两条缺一不可。现象把if (stack.length 0 || stack.pop() ! PAIRS[ch])的顺序调换或者只写stack.pop() ! PAIRS[ch]大部分用例仍然通过但空栈时行为变得不可预期。原因空数组pop()返回undefinedundefined ! (恒为真恰好也能返回false所以测试全绿。但这一pop()已经把栈的状态改了一旦后续改成收集全部错误位置的版本栈就被污染错误位置全乱。解决永远先判空再pop()。这个顺序不只是风格问题是避免副作用的关键。现象用ch in PAIRS判断右括号某次传入()constructor时出现诡异行为。原因in运算符会沿着原型链查找constructor in {}是true。虽然当前题目限制了只含六种字符但一旦你把这个函数复用到宽松场景PAIRS[constructor]拿到的是个函数比对必然失败但不报错排查起来很费劲。解决改用Object.prototype.hasOwnProperty.call(PAIRS, ch)或者建表时用Object.create(null)切断原型链。前者更稳后者更省事我一般选前者。5.3 工程层面的坑现象npm test在 CI 上永远显示成功即使有用例失败。原因package.json里的test脚本写成了node test/run.js || true或者运行器里忘了process.exitNode 默认退出码是 0。解决去掉|| true运行器末尾显式process.exit(failed 0 ? 0 : 1)。本地跑通一次再拿一个故意写错的用例验证它确实会失败——测试框架本身也要被测。现象把函数改成返回{ valid, position }之后原来所有if (isValid(s))的判断全部恒为真。原因对象是 truthyif判断不看里面的valid字段。解决改返回结构属于破坏性变更要么换函数名如validate要么在调用处统一改成解构const { valid } validate(s)。别在原函数上偷偷改类型。6. 二次开发的改造思路从判断结果到定位第一个出错位置实际项目里合不合法这个布尔值往往不够用。用户输入了三十个字符你告诉他错了他会问错哪儿了。所以最值得做的一版改造是把isValid升级成validate返回第一个出错的下标和错误类型。思路很简单栈里存的从字符变成{ ch, index }右括号失配时把当前下标和对面的下标一起记下来。// src/validate.js const PAIRS { ): (, ]: [, }: { }; const OPENERS new Set([(, [, {]); /** * 校验并定位 * param {string} s * returns {{valid: boolean, index: number, reason: string}} * index 为第一个出错字符的位置valid 为 true 时是 -1 */ function validate(s) { if (typeof s ! string) { return { valid: false, index: -1, reason: input_not_string }; } const stack []; for (let i 0; i s.length; i) { const ch s[i]; if (OPENERS.has(ch)) { // 多存一个 index为后面报错定位留证据 stack.push({ ch, index: i }); } else if (Object.prototype.hasOwnProperty.call(PAIRS, ch)) { if (stack.length 0) { return { valid: false, index: i, reason: unexpected_closer }; } const top stack.pop(); if (top.ch ! PAIRS[ch]) { // 类型或顺序错报当前右括号的位置 return { valid: false, index: i, reason: mismatched_pair }; } } else { return { valid: false, index: i, reason: illegal_char }; } } if (stack.length 0) { // 残留左括号报第一个没闭合的位置通常是栈底那个 return { valid: false, index: stack[0].index, reason: unclosed_opener }; } return { valid: true, index: -1, reason: ok }; } module.exports { validate };改造点有三个。压栈时多存了index代价是每个元素从字符串变成对象空间常数变大但在业务输入的长度量级上完全无感。失配时不立刻返回布尔值而是带出reason前端可以据此选择不同的提示文案——unexpected_closer提示多余的右括号unclosed_opener提示括号没有闭合illegal_char提示含非法字符体验差别很大。残留左括号时报stack[0].index而不是stack[stack.length - 1].index因为用户习惯从左往右读先修最左边那个更符合直觉这是我改了两次才定下来的。再进一步可以做括号高亮把validate的返回扩展成pairs: [[openIdx, closeIdx], ...]匹配成功时把每一对的起止下标都记下来前端拿去做彩色下划线。实现上只需在top.ch PAIRS[ch]的分支里多推一条记录不影响主流程。扩展自定义括号对时记住PAIRS和OPENERS必须同步改。我一般把新增的括号对写成一个数组[[, ]]然后在模块初始化时一次性生成两个结构避免手改两处漏一处。从那以后我每次改这类看起来已经写完的小函数都强制先跑一遍用例矩阵再动手哪怕是加一个index字段这种改动——血泪经验是有一回只把返回值从字符串换成了对象忘了同步改调用处的if判断所有输入都变成了有效测试却因为没覆盖无效用例走调用层而全绿。希望帮到你。本文还有配套的精品资源点击获取