最长有效括号这道题,我前后刷了三遍,每遍都有新东西。第一遍用栈,AC完心里发虚,感觉是背下来的答案;第二遍啃动态规划,看完状态转移方程直接头皮发麻;第三遍把两种解法真正内化之后,又补上了O(1)空间的双向扫描,才算把这题吃透。而这三次反复没有一次是白费的,因为它同时串起了栈、线性DP、空间优化三个高频考点,面试里出现频率又高,值得单独写一篇掰开揉碎讲清楚。这篇文章的目标很简单:让你一次看明白最长有效括号到底在考什么、三种主流解法是怎么思考出来的、面试现场怎么选怎么讲才最加分。
如果你正在刷力扣热题100,或者算法面试进入到字符串和栈这个专题,这期内容可以帮你把这道Hard题完整装进脑子里。
1. 先看清题目在问什么:连续子串与三路解法
1.1 题目到底在考什么
题目描述很简短:给定一个只包含(和)的字符串,找出最长有效括号子串的长度。
关键在于"有效"两个字。什么算有效?满足两点:括号能正确配对,而且顺序合法。比如()、()()、(())都有效,而)(、(()都不算完整有效。题目要的是最长有效括号子串,它要求这一段在原始字符串里是连续出现的,不是跳着选几个字符拼在一起。
我在自己带的刷题小群里统计过,第一次碰到这题的人,有相当比例第一反应是"直接用栈匹配,然后数个数"。这个思路方向是对的,但直接数配对数量会踩大坑。比如)()(这种,配对数是2,可它内部根本不连续,正确答案应该是2(中间那段()),而不是4。这就是为什么"子串"这个限定条件才是真正的考点。
1.2 为什么这道题值得反复刷
这道题在力扣上是Hard难度,但它的代码量其实非常小,三种解法都能在三十行以内搞定。难的是思路,不是写法。
它更独特的地方在于,一道题能同时踩中两个高频考点:栈和动态规划。面试官想考栈,它有经典的"栈存下标"思路;想考DP,它有教科书级的dp[i]状态定义。而且它还藏着一个O(1)空间的贪心解法,专门用来考察你是否真的理解了解法的本质而不是只背了模板。
我在微软、字节、阿里的模拟面试题单里都见过这题的影子。它不是那种偏门怪题,而是典型的"题型多样、思路密集、实现简洁"的面试友好题。说人话就是:你刷一次这道题,等于同时复习了栈、DP、贪心三个专题。
1.3 三种解法,一条思考主线
这道题的主流做法可以分成三条路:
- 栈解法:用下标记录待匹配的左括号和可能成为新起点的位置。
- 动态规划:用
dp[i]记录以第i个字符结尾的最长有效括号长度。 - 双向扫描:利用括号配对的对称性,从左到右、从右到左各扫一遍,空间压到O(1)。
先说结论:面试时我推荐你先讲栈解法,因为它最好理解、最不容易写崩;如果面试官追问优化,再上双向扫描;DP解法作为展示深度的加分项,但自己私下必须练熟,因为很多面试官会指定用DP做。
接下来我们一个一个拆。
2. 栈解法:为什么要用下标而不是括号本身
2.1 核心游戏规则:栈里存的是下标
栈解法的第一原则是:不要在栈里存括号字符本身,要存下标。存字符只能判断配对是否成功,但算不出长度。存下标之后,每次匹配成功,都能用当前下标减去栈顶下标得到这一段连续有效子串的长度。
为什么是"减去栈顶下标"而不是"减去被弹出那个下标"?因为有效括号子串可能是连续的,比如()()这种。当第二个()匹配成功时,如果减去第一个()的左括号下标,得到的是4,没问题;但如果中间插了无效内容,比如() ) (),第二个()匹配时,栈里真正该作为起点的应该是第二个()自己前面的位置。所以栈顶保留的是"当前连续有效片段开始位置的前一个位置",有点绕,看下面的过程就清楚了。
我用字符串()()完整推演一遍:
| 索引 | 字符 | 操作 | 栈内容 | 计算长度 |
|---|---|---|---|---|
| 初始 | - | 压入哨兵 | [-1] | - |
| 0 | ( | 压入0 | [-1,0] | - |
| 1 | ) | 弹出0 | [-1] | 1-(-1)=2 |
| 2 | ( | 压入2 | [-1,2] | - |
| 3 | ) | 弹出2 | [-1] | 3-(-1)=4 |
所以答案就是4。这个推演里能看到,第一个()匹配完后,栈顶重新变回了哨兵-1,于是第二个()的长度照样能用当前下标减去-1算出来。这正好体现了"连续拼接"的本质。
2.2 哨兵-1到底是什么:一个新起点问题
现在说说栈初始化时候那个-1。很多人第一次看到push(-1)会疑惑:字符串下标从0开始,压个-1进去是要干嘛?
回到刚才的推演:当i=1匹配成功后,如果栈里没有这个-1,栈就空了,此时i - 栈顶这一步根本没法进行。所以-1其实是人为设置的"起点哨兵":它代表有效子串开始位置的前一位置。配合公式i - stack[-1],当整个字符串从头开始就匹配,比如(),长度就是1-(-1)=2。如果你不压-1而试图用i+1来代替,嵌套匹配的时候又会出问题。
还有一个更隐蔽的场景能体现哨兵的作用:字符串以)开头,比如)()。
| 索引 | 字符 | 操作 | 栈内容 | 计算长度 |
|---|---|---|---|---|
| 初始 | - | 压入哨兵 | [-1] | - |
| 0 | ) | 弹出-1,栈空 | [] | - |
| - | - | 压入0作为新哨兵 | [0] | - |
| 1 | ( | 压入1 | [0,1] | - |
| 2 | ) | 弹出1 | [0] | 2-0=2 |
注意索引0的)弹出的不是索引,而是哨兵-1。弹出后栈空,说明这个右括号没有可以匹配的左括号,那它就成为一个新的"断点"。此时要把它的下标0压入栈中,作为后半部分的新哨兵。后面()匹配时,2-0=2,答案正确。
所以处理逻辑其实只有两条分支:遇到(就压入下标;遇到)就弹出,弹出后如果栈不为空就尝试更新答案,如果栈为空就把当前下标压入作为新的哨兵。简洁到不行。
2.3 栈解法的完整实现
def longestValidParentheses(s: str) -> int: stack = [-1] ans = 0 for i, ch in enumerate(s): if ch == '(': stack.append(i) else: stack.pop() if not stack: stack.append(i) else: ans = max(ans, i - stack[-1]) return ans这段代码是三种解法里我唯一保证自己在任何面试场景下都能一遍写对的。时间复杂度O(n),空间复杂度O(n)。所有边界情况都靠哨兵和"栈空重置"这两条规则兜住了。
注意:
(not stack)的判断一定不能省。如果你弹出后栈为空却直接用i - stack[-1],会直接越界报错,而且逻辑上那个多余的右括号会把连续有效片段切断,必须重新定义起点。
3. 动态规划解法:状态定义的第一步定生死
3.1 为什么 dp[i] 是"以第i位结尾"
栈解法直观,但面试官如果想加大难度,会让你别用栈,换动态规划试试。DP解法的核心就是状态定义,而这个定义几乎是这种括号题的标准答案:
dp[i]表示以s[i]结尾的最长有效括号子串的长度。
这里"以s[i]结尾"非常关键。它意味着你在考虑第i位时,答案必须包含它。为什么这样定义?因为有效括号子串要么接在中间某处结束,要么一直延伸到末尾。用"以第i位结尾"这个状态,天然兼容了"字符串连续"这个约束。
处理思路:当s[i]=='('时,以它结尾不可能构成有效括号,所以dp[i]=0。当s[i]==')'时,才有两类转移可能。接下来把两类情况拆开看。
3.2 转移方程的两类情况
情况1:s[i-1]=='(',正好凑成一对
此时s[i-1]和s[i]组成最简单的()。那么以i结尾的有效长度,至少是2,还要加上这对括号前面已经形成的有效长度,也就是dp[i-2]。所以:
dp[i] = dp[i-2] + 2注意当i<2时dp[i-2]不存在,此时结果就是2。
情况2:s[i-1]==')',需要向前找匹配
这种时候,s[i]这个右括号要匹配的是一个位于更早位置的左括号。这个左括号应该在dp[i-1]覆盖的那段有效子串的更前面一位,也就是下标i - dp[i-1] - 1。
举个例子最清晰。字符串()(()),看最后的),也就是 i=5:
s[4]=')',s[5]=')',走情况2dp[4]=2,因为s[3]=()这个括号对长度是2- 所以可能匹配的位置是
i - dp[4] - 1 = 2,s[2]='(',成功匹配 - 此时有效长度为:内部
dp[4]的2,加上新匹配的这一对2,再加上这个左括号前面还能接上的有效长度dp[i - dp[i-1] - 2] = dp[1]=2
最终dp[5] = dp[4] + 2 + dp[1] = 2 + 2 + 2 = 6,整个字符串都是有效的。
转移公式:
dp[i] = dp[i-1] + 2 + dp[i - dp[i-1] - 2]前提条件:i - dp[i-1] - 1 >= 0且s[i - dp[i-1] - 1] == '('。
这里最绕的就是下标计算。多写几组还容易把自己绕晕?没关系,记一条口诀:想找匹配左括号的位置,就用当前下标减去内部有效长度,再往前挪一位。这个挪字就是整个方程的灵魂。
3.3 完整实现与下标边界处理
def longestValidParentheses(s: str) -> int: n = len(s) dp = [0] * n ans = 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 elif i - dp[i-1] > 0 and s[i - dp[i-1] - 1] == '(': dp[i] = dp[i-1] + 2 + (dp[i - dp[i-1] - 2] if i - dp[i-1] >= 2 else 0) ans = max(ans, dp[i]) return ans代码里所有三元表达式都是为了处理下标越界。你可以把dp开成n+1长度并把定义改成"前i个字符"来规避这些判断,但是那样转移起来要更小心,我建议面试时还是用上面这个版本,把边界判断写清楚,反而显得思路严谨。
DP解法的时间复杂度同样是O(n),空间复杂度O(n)。它的思考价值在于:让你真正理解"连续有效括号串"是如何由内部子串向外延伸的。我刷了几遍之后,把这道题的DP思想和最长回文子串做了一次类比,两者的状态转移都依赖于"内部子串是否有效"这个前提,理解了这一点,以后做字符串类DP题会顺手很多。
4. 巅峰对决:栈和DP的正面硬碰硬
4.1 面试官视角下的两种解法
这场对决里,栈和DP到底谁更强?我的结论是:栈解法胜在简洁,DP解法胜在深度。
面试官问这道题,其实想考察两件事:第一,你知不知道用栈来处理括号匹配;第二,你理解不理解"状态"这个概念。如果你一上来甩一个DP解法,虽然正确,但面试官很难快速验证你内心是否真的清楚。栈解法正好相反,它可以在白板上一步一步画出来,面试官很容易跟上你的思路。
所以我推荐的策略是:先讲栈解法,并完整走一个例子。在讲的过程中主动提到"栈里存下标是为了算长度"这个设计点,这比AC本身更能体现你的思考。等面试官问能不能优化或者换种思路时,再提出DP和双向扫描。
4.2 两种解法硬碰硬对比
| 维度 | 栈解法 | 动态规划 |
|---|---|---|
| 时间复杂度 | O(n) | O(n) |
| 空间复杂度 | O(n) | O(n) |
| 代码量 | 极短,约12行 | 约18行,边界判断多 |
| 理解门槛 | 低,配合画图即可 | 高,状态转移要绕一下 |
| 面试观感 | 思路清晰,易验证 | 状态定义熟练,加分 |
| 翻车风险 | 较低 | 较高,下标边界容易错 |
空间上双方平手,时间上也是平手。真正的差别在于你有没有第二手准备。只掌握栈解法,面试追问"还有别的办法吗"会卡住;只掌握DP,面试官让你一步步推演(()的转移过程,也容易露怯。所以我才反复强调两种解法都要练,而不是依赖某一个。
4.3 隐藏的第三招:O(1)空间的双向扫描
如果面试官继续追问"空间能不能到O(1)",大部分人都会卡住。栈都用了O(n)空间,两个数组都没有,怎么省?答案是双向扫描。
思路极其简单:从左往右扫一遍,分别记录左括号数量left和右括号数量right。当left == right时,说明当前这一段是平衡的,长度至少是left+right,更新答案;当right > left时,说明出现了多余的右括号,这一段已经被打断了,重置left=right=0。
但只做这一遍够吗?不够。考虑(()这种情况,从左往右扫到结束,left始终比right大,根本没出现过left == right,答案是0,可正确答案明明是2。怎么办?再从右往左扫一遍,对称地处理:右括号计数当左括号用,遇到多余的左括号时重置。
def longestValidParentheses(s: str) -> int: ans = 0 left = right = 0 for ch in s: if ch == '(': left += 1 else: right += 1 if left == right: ans = max(ans, 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: ans = max(ans, 2 * left) elif left > right: left = right = 0 return ans这段代码的时间复杂度仍然是O(n),但空间复杂度O(1)。我一开始看这个解法总觉得像是歪门邪道,直到自己手动跑了十几个用例才发现,它本质上就是在模拟括号配对的"对称性":有效括号串无论从左看还是从右看,都一定满足某个方向的平衡条件。
这个解法的启发是:很多看似需要额外数据结构的题目,最后都能用"两次扫描"这个技巧优化到常数空间。类似的题目还有力扣上的接雨水、最长连续序列,都可以从不同方向试一遍。面试时能把这一招抛出来,基本等于告诉面试官你刷题不只是背答案,而是理解了解法背后的结构。
5. 实战中的坑与排查心得
5.1 我踩过的三个典型坑
坑一:栈解法忘了特判空栈。我第一次写的时候,一个)()用例直接数组越界。原因很简单,没有在pop之后判断not stack,而是直接取栈顶算长度。后来我才理解not stack这个分支的真正语义:它不只是防御性编程,更是"断点重置"这个逻辑的一部分。
坑二:DP的下标计算多了个1。在找(())内部匹配的左括号位置时,我用i - dp[i-1]取到了内部的右括号,导致判断永远失败。这个位置是i - dp[i-1] - 1,不是i - dp[i-1]。为什么?因为dp[i-1]是从i-1往前延伸的长度,它能覆盖到的起点是i - dp[i-1],但我们要找的是这个起点再往前一个位置。多画几组例子,你会发现这个"-1"是所有下标计算里最容易出错的地方。
坑三:只做一次双向扫描拿不到全部答案。我从左往右扫完(()直接返回0,以为双向扫描法就是骗人的。后来才明白,两个方向必须都做,因为左括号多于右括号的情况需要在反向扫描里补充。这个坑让我深深记住了"两次扫描"技巧的应用条件。
5.2 面试现场的关键话术和节奏
如果你在面试中遇到这题,我的建议是先在白板上画出()()的栈变化过程,然后再写代码。讲的时候可以分三步:
第一步,说明思路:用栈存下标,计算长度靠当前下标减栈顶下标。第二步,走一遍示例:找个简单输入,一步一步模拟栈的变化,顺便引出哨兵-1的作用。第三步,写代码:代码很短,边写边说明每一个分支的作用。整个过程控制在十分钟以内最好。
如果面试官问"能否用DP",不要直接写代码,先讲状态定义:dp[i]表示以i结尾的最长有效长度。然后把两种转移的情况用()(())或()(()推一遍,最后再落代码。这样写出来的代码即使有小瑕疵,面试官也看得懂你的意图,比你闷头写半天再解释强得多。
5.3 刷题策略:这道题怎么举一反三
刷完这道题之后,我建议你顺手做三件事:
第一,把栈解法里的"压哨兵"技巧迁移到其他括号题上。力扣的有效的括号、删除无效的括号都是同一个家族。你会发现,一旦"用下标而不是字符"这个观念建立起来,括号类题目基本都通了。
第二,把DP解法里"以i结尾"这个状态定义记牢,去做几道同款线型DP。比如最长递增子序列、乘积最大子数组,状态定义的思想完全一样,只是转移方程复杂一些而已。你练多了会发现,DP题最难的从来不是转移公式本身,而是你有没有想到那个恰到好处的状态。
第三,主动练习"能否优化空间"的思考方式。每刷完一道题,习惯性问自己一句:这个O(n)空间能省吗?怎么省?如果省不掉,原因是什么?这个习惯帮我解决了不少面试追问环节,也是从"会做题"到"懂题目"之间最短的路。
最后说一点个人体会:最长有效括号这个题,三种解法都不是高不可攀的鬼畜技巧,它们的共同点是都建立在最朴素的思路之上。栈解法就是"匹配成功就量长度",DP解法就是"以i结尾的子问题"这种最基础的线性DP框架,双向扫描则是"对称扫描"这个通用思维模型的产物。如果你能把这一道题真正吃透,收获的远远不只是AC一个Hard那么简单。后面刷题如果遇到"栈+字符串"或者"dp[i]递推"的题目,你会明显感到思路顺畅很多。这题我建议你刷完这周再过一遍,尤其是DP解法的推导过程,隔几天手写复现一次,直到能闭着眼写出三种解法为止。