资讯动态

最长有效括号的三种解法:栈、动态规划与双向计数

发布时间:2026/9/28 8:30:07 来源:尧图企业网站定制
1. 题目与核心思路拆解1.1 理解问题什么是最长有效括号先把题目说清楚给定一个字符串里面只有左括号(和右括号)求最长的、合法的、连续的括号子串的长度。注意这里的关键词是“连续”也就是说必须是原始字符串中一个紧挨着一个的片段而不能像做括号匹配的某些变体那样跳着选。比如)()(())它的最长有效括号子串是()(())长度是 6但如果你把第一个)去掉从第 2 个字符开始到结尾这一整段刚好合法所以答案是 6而不是 4。很多人一开始容易和“最长合法括号子序列”搞混。子序列可以删掉中间的一些字符再拼起来而子串不行。这道题考的就是子串要求连续所以难度比子序列版本高不少。子序列版本可以用简单的贪心计数解决但子串版本不行因为连续意味着你必须在遇到问题的时候立刻“清零”或“重置”否则会把不合法的片段也算进去。我刷这道题的时候第一反应是这题的难点其实不在“匹配”本身而在“连续”。括号匹配谁都会左括号入栈右括号出栈最后栈空就是合法。但要求“最长连续合法片段”你就得在匹配的基础上额外记录“从哪里开始算有效”。这个“从哪里开始”是所有解法最核心的思考点。1.2 解法的大方向与选型思考这道题在 LeetCode 上标注了困难但实际上解法非常多而且思路跨度很大。我总结下来主要有三大流派栈最直观、最容易想到也最容易写出 bug。动态规划状态转移比较绕但想通了之后代码量很少很优雅。双向计数不需要额外空间纯数学思维适合追求极致空间复杂度的人。三者都能做到 O(n) 时间复杂度区别主要在空间复杂度和理解门槛。栈需要 O(n) 的栈空间动态规划需要一个长度 n 的 dp 数组也是 O(n)双向计数法可以把空间压到 O(1)。从面试角度看这三种解法都会被问到尤其是栈和动态规划面试官经常要求切换着讲。我的建议是不要只背一种解法因为每种解法背后其实对应着不同的“如何定义有效”的视角。栈视角是“用下标差算长度”动态规划视角是“以每个字符结尾能形成多长的合法串”双向计数视角是“通过 balance 和重置规则来捕捉合法片段”。抓住这三种视角以后遇到变种题比如括号匹配的生成、删除括号使合法等会轻松得多。2. 解法一栈——最直观的 O(n) 思路2.1 标准栈方法为什么栈里存下标而不是字符初看这道题几乎所有算法书都会提到“用栈”。但具体怎么用最土的办法是用栈存括号本身比如见到(入栈见到)出栈然后怎么算长度如果存字符只能判断“当前字符串是否合法”根本算不了长度。所以核心技巧是栈里存下标用下标之差来计算长度。具体流程是这样的先往栈里放一个初始下标-1当作“哨兵”。遍历字符串下标 i 从 0 开始如果当前字符是(就把它的下标 i 压进栈。如果当前字符是)先弹出栈顶元素。弹出之后如果栈不为空那么从“当前栈顶元素的下一个位置”到 i 这一段就是当前找到的合法子串长度为i - 栈顶值更新答案。如果弹出之后栈为空说明这个)没有对应的(或者说它把之前积累的合法片段给“截断”了那么就把当前下标 i 压进栈作为新的哨兵。这里最妙的就是“哨兵”。你可以把哨兵理解为“最后一个没被匹配的多余右括号”的位置或者理解为“当前合法片段的起点前一个位置”。因为一旦遇到一个无法匹配的右括号它就把前面的所有片段隔断了之后只有从这个右括号之后重新开始匹配所有计算都必须以它为基准。我用一个例子走一遍字符串(()。初始栈[-1]。i0(入栈栈变成[-1,0]。i1(入栈栈变成[-1,0,1]。i2)弹出栈顶1栈为[-1,0]此时栈非空长度 2 - 0 2更新答案。最终答案 2完全正确。如果没有哨兵初始栈空那 i1 遇到右括号弹出后栈空长度就没法计算了。或者说你可能会误以为长度是 2 - 1 1那就错了。2.2 边界处理与一个关键陷阱很多人写栈方法的时候第一次都栽在“栈为空时遇到右括号”这个情况。比如字符串())走到 i2 时栈里已经空了此时这个)无法匹配任何左括号。如果你不处理直接弹出就栈空异常了。正确的做法就是上面说的弹出后如果栈空就把 i 压栈。你可能会问为什么弹出之后栈空要把 i 压进去因为当前这个右括号是“多余”的它让前面的合法片段彻底中断。以后如果再遇到左括号新的合法片段必须从这个右括号之后开始所以这个右括号的下标 i 正好充当新片段的“哨兵”。还有一个很隐蔽的坑如果弹出后栈非空计算长度用的是“当前栈顶元素的下标”而不是之前弹出的那个下标。很多人误以为应该用 i - 弹出的下标那就错了。比如()()初始栈[-1]i0 入栈0i1 弹出 0栈为[-1]长度1-(-1)2更新。i2 入栈2i3 弹出 2栈为[-1]长度3-(-1)4更新。如果你用 i 减弹出的下标第一次是 1-01第二次是 3-21结果就完全错了。为什么能用i - 栈顶因为栈顶下面存的是“上一段合法片段结束的位置”换句话说从栈顶下标的下一个字符开始一直到当前 i这一整段都是最近一次匹配成功后形成的连续合法串。栈顶下标的含义在每次弹出后都会变化要仔细体会。下面是 Python 实现def longestValidParentheses(s: str) - int: stack [-1] # 哨兵 max_len 0 for i, ch in enumerate(s): if ch (: stack.append(i) else: stack.pop() # 弹出匹配的左括号或哨兵 if not stack: stack.append(i) # 没有匹配当前右括号成为新的哨兵 else: max_len max(max_len, i - stack[-1]) return max_len2.3 复杂度分析与个人心得复杂度没什么好说遍历一次字符串每个字符最多入栈出栈各一次时间 O(n)。栈最大会到 n空间 O(n)。在所有解法里栈属于“思路直观边界难缠”的类型。我个人刷题时体会是背代码不难但要能在五分钟后自己默写出来、且不踩哨兵这个坑需要真正理解栈内每个元素的含义。我给你一个记忆锚点栈内永远保留一个“尚未被匹配成功的多余右括号的下标”作为分隔符。每当遇到右括号时它就去“消化”一个左括号如果消化完了栈里没有东西了说明刚才那个右括号本身成了新的分隔符。栈底那个 -1 其实就是最开始的“虚拟分隔符”用来处理从字符串第一个字符开始就匹配的情况。另外这类栈解法可以迁移到很多题目比如“接雨水”LeetCode 42里的单调栈思路也是用下标差算距离。所以栈里存下标这个思想值得记牢。3. 解法二动态规划——让每一步都有迹可循3.1 状态定义与转移方程推导动态规划永远是算法题的“万金油”但也是最容易让人绕晕的。这道题的状态定义很经典dp[i]表示以第 i 个字符结尾的最长有效括号子串的长度。注意“以第 i 个字符结尾”这个限定非常重要。因为只有右括号)才可能成为合法子串的结尾左括号(不可能结尾所以左括号位置的 dp 直接是 0。我们只考虑s[i] )的情况。分两种可能如果s[i-1] (那么s[i-1]和s[i]本身组成一对()。此时以 i 结尾的最长合法串至少是 2还要看这对括号前面有没有连续合法的串所以dp[i] (i 2 ? dp[i-2] : 0) 2。如果s[i-1] )那么说明位置 i-1 自己已经形成了一个合法的片段长度为dp[i-1]。这个片段的起始位置是i - dp[i-1]。如果这个起点前面一个字符恰好是(那么它正好和当前的)配对从而把原来那个合法片段包裹起来形成一个更大的合法串。这个更大的串长度 dp[i-1] 2但还要看更前面是否还有连续的合法串于是再加上dp[i - dp[i-1] - 2]这一段。所以转移方程是dp[i] dp[i-1] 2 (i - dp[i-1] - 2 0 ? dp[i - dp[i-1] - 2] : 0)这个式子看起来复杂实际上就是先加上中间已经匹配的那一段再加外面这对括号的长度 2最后把这对括号之前的连续合法串也接上。举个例子字符串()(())下标从 0 开始。计算 dpi0 是(dp[0]0。i1 是)s[0](dp[1]dp[-1]不存在取02 2。i2 是(dp[2]0。i3 是(dp[3]0。i4 是)s[3](dp[4]dp[2]2 2。i5 是)s[4])此时 dp[4]2起点是 5-23s[3]( 正好匹配所以 dp[5]dp[4]24再加上更前面i - dp[4] - 2 5-2-21s[1]) 的那段 dp[1]2所以 dp[5] 4 2 6。答案就是 max(dp) 6。3.2 核心转移方程的边界防护细节写动态规划最头疼的是数组越界。这个题目里访问s[i-1]要求 i1访问dp[i-2]要求 i2访问s[i-dp[i-1]-1]和dp[i-dp[i-1]-2]都要小心负数下标。一个安全写法是把 dp 数组长度设为 n1但下标偏移一下或者写一堆i 2之类的判断。我个人推荐直接用 Python 的短路特性像下面这样def longestValidParentheses(s: str) - int: n len(s) dp [0] * n res 0 for i in range(1, n): if s[i] ): if s[i-1] (: dp[i] (dp[i-2] if i 2 else 0) 2 else: # s[i-1] 是 ) if i - dp[i-1] - 1 0 and s[i - dp[i-1] - 1] (: dp[i] dp[i-1] 2 # 如果更前面还有合法片段接上来 if i - dp[i-1] - 2 0: dp[i] dp[i - dp[i-1] - 2] res max(res, dp[i]) return res这个写法把最复杂的情况拆开了不容易出错。还有一种更简洁的方式是把 dp 数组整体右移一位dp[i1]对应s[i]然后用i代表当前字符下标写起来判断更少。不过我觉得对于正在学习的人拆开写更利于理解每一步在干什么。另外一个容易忽略的点是动态规划的答案不是dp[n-1]而是 dp 数组中的最大值。因为合法子串可以出现在字符串中间的任意位置比如)(()的答案在 i3 处dp[3]2而 dp[n-1] 不一定最大。3.3 动态规划的空间优化可能理论上 dp 数组只需要关心前一个值以及更前一个值可以滚动数组优化到 O(1) 空间但那样代码会变得异常难读。面试的时候如果被追问“能不能优化空间”可以提一句“可以用滚动数组但需要保存多个值可读性下降实际意义不大”。这道题已经有栈和计数法的 O(1) 空间方案了动态规划老老实实开数组就好。我觉得动态规划法最大的价值在于它逼你精确理解“以 i 结尾”的合法串内部结构。如果你能把这个转移方程自己推出来说明你已经理解了括号匹配的“递归结构”一个合法串由更小的合法串加一对括号组成。这种递归视角可以迁移到很多字符串 DP 题。4. 解法三双向计数法——零额外空间的魔法4.1 用 left 和 right 两个计数器模拟匹配如果你不想用栈也不想开数组还有一个很巧的方法用两个变量 left 和 right 分别记录当前遇到的左括号和右括号数量从左到右遍历手动模拟匹配过程。规则如下遇到(left。遇到)right。如果left right说明从这段起始位置到当前位置是一个合法片段更新答案max_len max(max_len, 2 * right)。如果right left说明右括号太多了当前这段已经不可能合法把 left 和 right 都清零重新开始。这个逻辑和括号匹配的“消耗”很相似。右括号多了意味着前面所有左括号都被消耗完了再多一个右括号整个片段就断开了必须从下一个位置重新开始计数。但这个办法有一个致命问题如果从头到尾左括号一直比右括号多比如(((()遍历完 left4right1永远不相等答案就是 0。但实际上最长合法子串长度是 2。为什么因为从左到右计数时左括号过多时我们不知道那些多余的左括号会不会在未来被匹配。假如字符串结束了还没匹配上前面那段就全部作废但中间其实有部分匹配成功的片段被漏算了。4.2 为什么要反向遍历一次解决“左括号过多”的办法很简单从右往左再遍历一次只是把角色对调。反向遍历时我们把)当成“左括号”把(当成“右括号”规则完全对称遇到)right或者叫 left2随便命名。遇到(left同理。如果left right更新答案。如果left right说明左括号太多了清零重新开始。为什么这样就能补救因为任何合法的括号串从左往右看时任意前缀中左括号数量 右括号数量从右往左看时任意前缀中右括号数量 左括号数量。如果一个合法串在正向遍历时因为“左括号盈余”而没有被捕捉到那么反向遍历时它因为“右括号盈余”的对应特性而被捕捉。两次遍历合起来就覆盖了所有情况。举个例子(()。正向遍历i0 左1i1 左2i2 右1此时 left ! right不更新最后答案 0。反向遍历从右往左i2 是)记 right1i1 是(记 left1leftright更新 2*12。i0 是(记 left2此时 leftright 清零注意规则是如果 left right 就清零此时 left2,right1确实 leftright清零。最终答案 2。正确。风格简洁def longestValidParentheses(s: str) - int: res 0 # 左到右 left right 0 for ch in s: if ch (: left 1 else: right 1 if left right: res max(res, 2 * right) elif right left: left right 0 # 右到左 left right 0 for ch in reversed(s): if ch (: left 1 else: right 1 if left right: res max(res, 2 * left) elif left right: left right 0 return res4.3 这个方法的工程意义与实际体验这个算法的时间复杂度是 O(n)空间复杂度 O(1)代码量也很少在所有解法里是最轻量的。刷题网站评论区有人说它像“脑筋急转弯”但仔细想想两次遍历的思想在字符串处理中非常常见比如“最长交替子串”“被截断的连续段”都可能用到双向扫描来规避单向扫描的盲区。不过我得泼一盆冷水这个解法虽然很优雅但在面试中如果你直接甩出这个方法面试官很可能会追问“为什么两次遍历就能保证答案正确”如果解释不清反而减分。所以我建议把双向计数法作为“进阶解法”展示先讲栈或 DP然后说“还能再优化空间”再引出这个。这样既显示了知识广度又显得有逻辑。我自己的体会是双向计数法非常适合用来做“极限优化”比如嵌入式环境或者面试官要求“能不能不用额外空间”的时候。但平时写代码我更倾向于用栈因为栈的语义最清晰不容易被边界条件迷惑。另外还要注意反向遍历时清零条件要和正向对称。正向是right left清零右括号多了反向是left right清零左括号多了。搞反了就全错了。5. 三种解法对比与实战选择建议5.1 时间、空间、可读性全面对照解法时间复杂度空间复杂度代码量理解难度面试推荐度栈O(n)O(n)短中哨兵易错高动态规划O(n)O(n)较短高转移方程难推中高双向计数O(n)O(1)极短中两次遍历思想中从刷题效率来看栈是最容易记住的适合考前突击。动态规划适合用来理解“字符串类 DP 的通用套路”因为很多题目比如“最长回文子串”“编辑距离”都有类似的状态定义思路。双向计数法适合用来展示优化意识但平时不推荐作为首选因为它的正确性不够直观代码里一旦写错很难调试。我在实际刷题过程中发现一个规律看起来越聪明的解法面试时要解释清楚越难。所以我的策略是第一轮讲栈写出干净代码第二轮主动说“我还能用 O(1) 空间做”再顺手给出双向计数如果面试官对 DP 感兴趣再把 DP 拿出来。这样既展示了基本功力又展示了优化能力。5.2 不同场景下的选型优先级如果是笔试/线上评测追求速度和正确率选栈。因为栈的思路最机械不容易漏边界。如果面试官明确要求“不用额外空间”必须上双向计数法。如果面试官问“这题还能怎么做”可以补充 DP体现你懂状态转移。如果实际问题中需要处理超长字符串又对内存敏感双向计数法优势明显。如果你想要一份“能解释清楚所有细节”的解法栈依然是首选。说白了没有绝对的最优解只有最适合当前场景的解。5.3 相关变体题与扩展思路这道题的变体非常多刷题时值得一并掌握LeetCode 20 有效的括号判断整个字符串是否合法用栈存字符即可是这道题的简化版。LeetCode 678 有效的括号字符串多了一个*万能字符需要双向计数或者贪心维护区间。LeetCode 22 括号生成要求生成所有合法括号组合是回溯/DFS 的经典题和本题的匹配逻辑相通。最长有效括号子序列如果换成子序列简单的贪心计数就能做因为不需要连续只要统计能配成对的数量。去掉若干括号使字符串合法这类题目往往要你输出删掉哪些字符和栈的“哨兵”思想关联紧密。从这些变体可以看出掌握“括号匹配的内部结构”比死记某道题更重要。无论题目怎么变核心都在于理解什么情况下一个片段会断开断开之后从哪里重新开始抓住这个问题整个知识体系就串起来了。6. 常见问题与易错点排查实录6.1 栈方法中栈底 -1 真的是必须的吗可以明确地说需要。如果你不事先在栈里放 -1那么处理()这种从第一个字符就开始匹配的情况时当 i1 弹出左括号下标后栈就空了此时你不敢用i - 栈顶计算长度因为栈空。你可能想改成“如果栈空就把长度记为 i1”但这只在当前字符是右括号时成立后续遇到新的左括号又得区分逻辑会变得支离破碎。所以干脆统一栈底永远放一个哨兵。这个哨兵的值表示“合法片段起点前一个位置”。初始时字符串起点前一个位置是 -1所以哨兵初始化为 -1。以后每次遇到不匹配的右括号就把当前下标压进去当作新的哨兵。只要栈非空i - stack[-1]就永远是对的。6.2 动态规划中为什么既要加 2 又要加前面的 dp这是很多人卡住的地方。看一个例子()(())计算 dp[5] 时我们已经知道中间的(())长度是 4也就是说 dp[4] 或 dp[3] 记录了 2但最后那个)要和最前面的(配对时不能只dp[4]2。因为配对成功后整个串变成()(())它包含两部分外层的( ... )和内部的(())内部那一段的起始位置在 i - dp[i-1] 到 i-1但当外层括号把整段夹起来时内部所有字符都被包含在外层括号里而外层括号的“前一个位置”是在内部片段之前的那个(之前也就是i - dp[i-1] - 2位置。如果那个位置本身属于另一个合法片段那还得接上去。所以 dp[i - dp[i-1] - 2]的本质是“拼接外层匹配之前的剩余合法前缀”。初学者最容易忘掉最后这个拼接。我建议你在纸上画一条下标线把()(())的每个字符下标标出来然后实际代入 i5 算一遍绝对比死记公式有效。6.3 边界情况测试单写算法题边界测试是必须的。我整理了一份针对这道题的测试清单你可以直接拿来用输入期望输出说明0空串(0单个左括号)0单个右括号()2最小合法串((0只有左括号))0只有右括号)()(2合法片段被夹在中间(()2左括号盈余需反向扫描())2右括号截断()(())6嵌套加并列)(()())6开头多余右括号不影响拿这些用例去验证你的解法每个都要过。我喜欢把这些用例写成一个小的测试脚本每次写完新解法就全跑一遍比肉眼检查靠谱得多。6.4 调试时的小技巧与个人经验最后分享一个调试技巧当你觉得自己的栈解法或者 DP 解法在某些用例上不对时不要只靠 print 看变量而是把栈的内容或者 dp 数组的值完整打印出来手动模拟一遍。比如打印stack在每次迭代后的内容你会发现哨兵的变化一目了然。这个习惯帮我省了大量时间。再一个小技巧是写 DP 时先不追求代码精简先把所有分支用if写得清清楚楚跑通测试后再压缩。我见过太多人一上来就写那种一行三元表达式结果出 bug 根本没法查。代码可读性永远比代码短重要。我自己的经历是第一次刷这道题时用栈方法写错了哨兵的处理导致)())这种用例输出 2 而不是 2反正不对后来老老实实打印栈才意识到每次遇到多余右括号时要把新下标入栈而不是继续留空。从那以后我对所有“哨兵”类型的题都格外敏感比如子数组和的最大值、滑动窗口等都有类似思想。这道题的价值并不仅仅在于应付面试它锻炼的是对“连续有效片段”的建模能力。实际开发中解析配置文件里的括号嵌套、检查 SQL 语句结构、甚至处理正则表达式的大括号时都会用到类似的匹配和截断逻辑。把这道题吃透你的字符串处理功底会扎实不少。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑