本文概览:本文讲解最长有效括号的核心思路:方法一是栈加哨兵记录断点位置,方法二是双指针正反各扫一遍,补齐"左侧多余左括号"导致的漏算
一、题目

二、题目分析
1. 题目要求
给你一个只包含 '(' 和 ')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。
有效括号 :每个左括号都有对应的右括号将其闭合,如 "(()())"。
示例 1 :s = "(()" → 2(最长有效括号子串是 "()")
示例 2 :s = ")()())" → 4(最长有效括号子串是 "()()")
括号匹配是前面 20. 有效的括号 讲过的栈题目,这里直接复用它的一个结论:从左到右遍历,一旦出现右括号数比左括号数还多,这个右括号就无法被闭合,整串失效。不了解有效括号判定的可以先回看那篇。
2. 从"有效"到"连续有效"
"有效"好判断,但这题要的是"连续"的合法子串,得先搞清楚"连续"是怎么被破坏的。
还是从左到右遍历,边扫边数左右括号。看例子 ( ( ) ) ) ( ):
下标: 0 1 2 3 4 5 6
字符: ( ( ) ) ) ( )
左括号数累计: 1 2 2 2 2 3 3
右括号数累计: 0 0 1 2 3 3 4
走到下标 4 这个 ) 时,右括号数累计到 3,左括号只有 2,右括号比左括号多了,它是多余的。它一出现,前面那段合法部分 (())(长度 4)就到此为止,后面 ()(长度 2)要重新另起一段。这个多余右括号就是一个"断点",把整串切成了两段。
所以题目可以转述成:找出所有断点,相邻断点之间的合法部分取最长。
3. 需要解决哪几个问题?
最直接的想法还是用栈,仿照有效括号那样左括号进栈、匹配时出栈。但栈只有一项能力,就是告诉你"谁和谁配上了对",一对一对地配对。题目要的是"连续合法的一段有多长",长度是按"段"算的,不是按"对"算的,这两者对不上。
拿 ( ) ) ( ) 看。下标 2 的 ) 前面已经没有能配的左括号了,它是个多余的右括号,从这里往后只能重新再起一段。换句话说,整串是被这些多余的右括号切成一段一段的。要用栈算长度,就得让栈在配对的同时,还能守住每一段的起点,这要解决两件事:
问题一:一个右括号配上一对后,怎么知道它所在的这一段是从哪开始的?长度该用它的下标减谁?
问题二:遇到多余的右括号(比如下标 2 那个),它把这一段切开了,栈里这时该怎么处理,才能让它顺势变成下一段的新起点?
三、方法一:栈,O(n)
1. 思路概览
java
public int longestValidParentheses(String s) {
int n = s.length();
if (n == 0) {
return 0;
}
Deque<Integer> stack = new ArrayDeque<>();
stack.push(-1);
int max = 0;
for (int i = 0; i < n; i++) {
if (s.charAt(i) == '(') {
stack.push(i); // 左括号直接进栈
} else {
stack.pop(); // 右括号先尝试弹出配对
if (stack.isEmpty()) {
stack.push(i); // 弹空了,说明它是多余右括号,作新断点
} else {
max = Math.max(max, i - stack.peek()); // 配对成功,算长度
}
}
}
return max;
}
思路简要说明:
- 栈里存下标:不放括号字符本身,因为要用下标算长度
- 右括号先弹再判:弹出栈顶尝试配对,剩下的栈顶就是"断点基准"
- 弹空则进断点:弹空说明这个右括号多余,把它的下标压入,当作新的断点
- 初始压 -1:充当"第 0 位之前"的哨兵断点
- 时间复杂度 O(n),空间 O(n)
2. 思路详解
第一步:解决长度怎么算------为什么减的是"断点"而不是"左括号"?
栈里存下标,遇到右括号先弹一下,弹出来的那个正是和它配对的左括号下标。那长度是不是直接用 i - 弹出来的下标 + 1?
不行。看 ()(),答案应是 4,它是两对连续拼成的一段。如果每次只算"这一对"的长度,第一对 () 算 2、第二对 () 又算 2,两个 2 拼不出 4------中间那道缝被漏掉了。
正确的做法是:长度 = 当前右括号下标 - 上一个断点下标 。断点指"合法子串开始位置的前一位"。对 ()() 来说,第一个 ( 的前一位是 -1,所以第二对算 3 - (-1) = 4,两段自动连起来了。
好在栈在配对时天然维护着这个断点:弹出配对的左括号后,剩下的栈顶要么是 -1,要么是上一个多余右括号,都是断点。所以 i - stack.peek() 就是这段的正确长度,不需要知道弹出来的是哪个左括号。
第二步:解决断点怎么处理------弹空了就压自己进去当新起点
配对弹出后,如果栈空了,说明左括号已经用光,当前这个右括号是多余的,它切开了这一段。这时把它的下标压回栈里,让它从"多余的右括号"变成"下一段的起点基准",后面的合法部分照着它往前算。
拿题目分析里的 ( ) ) ( ) 走一遍,重点看下标 2 那个右括号:
初始 stack = [-1]
i=0 (():push 0 → stack = [-1, 0]
i=1 ()):弹出 0 → stack = [-1],不空,长度 = 1 - (-1) = 2
i=2 ()):弹出 -1 → 栈空 → push 2 → stack = [2] ← 多余右括号,成为下一段起点
i=3 (():push 3 → stack = [2, 3]
i=4 ()):弹出 3 → stack = [2],不空,长度 = 4 - 2 = 2 ✓
第一段 () 靠断点 -1 算出长度 2;下标 2 的 ) 弹空后压自己进去,成了新的起点;第二段 () 再靠它算出 4 - 2 = 2。两段互不干扰,各自长度的计算都归功于"栈里一直留着上一段的断点------配对后剩下的栈顶,要么是 -1,要么是上一个多余右括号"。
第三步:为什么初始要压 -1?
栈初始化压入 -1,有两个作用,缺一不可。
第一个作用,让第一个 ) 也有东西可弹。如果栈一开始是空的,字符串第一个字符就是 ) 时,stack.pop() 会直接抛空栈异常。压了 -1,多余右括号永远能先弹再判,逻辑才不乱。
第二个作用,充当"第 0 位之前的断点"。像 () 这种从下标 0 就开头的合法子串,配对完成后栈里只剩 -1,1 - (-1) = 2 才对;如果栈空着,第一个 ( 就没有"上一个断点"了。
第四步:完整执行过程
示例 1 "(()",答案是 2:
下标: 0 1 2
字符: ( ( )
初始 stack = [-1], max = 0
i=0 (():push 0 → stack = [-1, 0]
i=1 (():push 1 → stack = [-1, 0, 1]
i=2 ()):弹出 1 → stack = [-1, 0],不空,max = max(0, 2 - 0) = 2 ✓
示例 2 ")()())",答案是 4:
下标: 0 1 2 3 4 5
字符: ) ( ) ( ) )
初始 stack = [-1], max = 0
i=0 ()):弹出 -1 → 栈空 → push 0 → stack = [0]
i=1 (():push 1 → stack = [0, 1]
i=2 ()):弹出 1 → stack = [0],不空,max = max(0, 2 - 0) = 2
i=3 (():push 3 → stack = [0, 3]
i=4 ()):弹出 3 → stack = [0],不空,max = max(2, 4 - 0) = 4
i=5 ()):弹出 0 → 栈空 → push 5 → stack = [5]
返回 max = 4 ✓
下标 0 和下标 5 的两个多余 ) 都作为断点进了栈,中间 ()() 这段靠着断点 0 连起来算出了 4。
3. 复杂度分析
时间复杂度 O(n):每个字符最多进栈一次、弹栈一次。
空间复杂度 O(n):栈最坏存 O(n) 个下标(全是左括号时)。
四、栈能不能省掉?
方法一用了一个栈,空间 O(n)。栈里真正有用的信息是什么?只有"断点位置"------多余右括号的下标,外加一个 -1。而断点的本质,就是"某个位置右括号比左括号多了"。
那能不能不用栈,直接数一数左括号和右括号的数量,把"多了"这件事跟踪出来?
顺着这个想法,用两个计数器 left、right 从头上扫:
- 遇到
(,left 加 1 - 遇到
),right 加 1 - 当 left == right,说明从断点之后扫到这是一段平衡的合法串,长度 = 2 × right
- 当 right > left,右括号多了,前面作废,left、right 清零重来
但这么扫一遍,会不会漏? 会,而且漏在哪值得想清楚。
看 ((),从左边扫:
( → left = 1
( → left = 2
) → right = 1,left = 2 ≠ right = 1,到最后也没等到相等,max = 0
真实答案明明是 2(后两个字符 ())。漏在这段从没触发 right > left 的重置,也从没走到 left == right,从头到尾 left 一直比 right 大。那个多余的左括号(第一个 ()垫高了 left,把后面的合法部分 () 盖住了。
换句话说,从左到右扫描是"右括号"负责触发重置、负责和 left 对齐。一旦合法子串前面垫着一串多余左括号,left 就永远领先,right 怎么追都追不平,这一段就统计不到。
五、方法二:双指针正反各扫一遍,O(1)
1. 思路概览
java
public int longestValidParentheses(String s) {
int n = s.length();
if (n == 0) {
return 0;
}
int left = 0;
int right = 0;
int max = 0;
// 第一遍:从左到右,右括号触发重置
for (int i = 0; i < n; i++) {
if (s.charAt(i) == '(') {
left++;
} else {
if (right < left) {
right++; // 有左括号可配,右括号+1
} else {
left = 0; // 右括号不多于左括号,多加这个就超了,重置
right = 0;
}
if (left == right) {
max = Math.max(max, right * 2); // 扫到一段平衡,更新长度
}
}
}
left = 0;
right = 0;
// 第二遍:从右到左,左括号触发重置,角色对调
for (int i = n - 1; i >= 0; i--) {
if (s.charAt(i) == ')') {
right++;
} else {
if (left < right) {
left++; // 有右括号可配,左括号+1
} else {
right = 0; // 左括号不多于右括号,重置
left = 0;
}
if (left == right) {
max = Math.max(max, left * 2);
}
}
}
return max;
}
思路简要说明:
- 先想清答案长什么样:最长合法子串两侧的多余括号,最多只出现在一侧(左边多左括号与右边多右括号不能共存)
- 第一遍从左到右:右括号变多就清零,处理掉"右边多余的右括号"
- 第二遍从右到左:角色对调,左括号变多就清零,处理掉"左边多余的左括号"
- 时间复杂度 O(n),空间 O(1)
2. 思路详解
第一步:先想清楚答案长什么样
假定答案是某个最长合法子串,记作 S。它本身左右括号正好配对,是平衡的。往 S 的左右两边看,字符串里 S 之外的部分,只可能是三种情况之一:
- 左边多挂了一串左括号,没有右括号去配;
- 右边多挂了一串右括号,没有左括号去配;
- 两边都干净,没有多余。
会不会左边、右边同时都挂着多余括号?不会。如果左边有多余的左括号、右边也有多余的右括号,这俩正好能配成一对,把 S 整个括起来,形成一个比 S 更长的合法子串------这跟"S 最长"矛盾。
所以多余括号只能出现在一侧,甚至一侧都没有。这就把问题简化成两件事:左边的多余左括号怎么解决,右边的多余右括号怎么解决。
第二步:从左到右扫,解决右边的多余右括号
从左到右走一遍,用 left、right 数左右括号,right > left 就清零。
这个清零动作,恰恰是冲着"右边多余右括号"去的:右括号一多出来,说明前面这段已经配平、到此为止,顺势清零重新起头。所以从左到右这一遍,能把"右边挂多余右括号"和"两边都干净"这两种情况都处理掉。
但它处理不了"左边挂多余左括号"。左边那串左括号让 left 从头就领先 right,既不会触发 right > left 的清零,也走不到 left == right------这一段就被盖住了。(() 就是这种:从左扫到底,max 还是 0。
第三步:从右到左扫,解决左边的多余左括号
换个方向就反过来了:从右到左扫,把左右括号的角色对调,改成数右括号、left > right 就清零。这样一来,"左括号多"才触发清零,正好处理"左边挂多余左括号"。
回到 ((),从右往左扫:
) → right = 1
( → left = 1,left == right,max = max(0, 1 * 2) = 2 ✓
( → left = 2,左括号多了,但 max 已经记下 2
那串垫在左边的多余左括号,从右往左看就成了"扫到末尾才遇到的、可忽略的尾巴";真正的合法子串 () 先一步被 left == right 捕捉到,长度 2。
两遍一合:从左到右解决"右边多余",从右到左解决"左边多余",加上"两边都干净",三种情况全覆盖。每遍都对同一个 max 取最大,最后 max 就是答案。
第四步:完整执行过程(示例 2)
")()())",答案是 4。
第一遍从左到右:
i=0 ()):right(0) 不多于 left(0) → 重置 left = right = 0
i=1 (():left = 1
i=2 ()):right < left → right = 1,left == right → max = 2
i=3 (():left = 2
i=4 ()):right < left → right = 2,left == right → max = 4
i=5 ()):right(2) 不多于 left(2) → 重置 left = right = 0
第一遍得到 max = 4,已经正确
这个示例第一遍就出结果,因为它的多余 ) 都压在合法子串两侧,从左到右正好被捕捉。真正依赖第二遍的是 (() 这种"多余左括号垫左边"的情况,前面已演示过。
第五步:代码细节
两遍遍历结构几乎一样,只有三处对调:
- 关心哪半边:第一遍看
'('让 left 增加,第二遍看')'让 right 增加,且遍历方向反过来 - 谁触发重置:第一遍右括号变多重置,第二遍左括号变多重置
- 记长度用谁:都是
left == right,第一遍写right * 2,第二遍写left * 2,两者那时相等,只是随主动方各有各的写法
两遍之间要把 left、right 清零,否则第二遍会带上第一遍的残留计数。
3. 复杂度分析
时间复杂度 O(n):字符串正反各扫一遍,共 O(2n)。
空间复杂度 O(1):只用两个计数器,不借助任何额外结构。
对比方法一,双指针把"显式存断点的栈"换成了"用计数器失衡隐式识别断点",空间从 O(n) 降到 O(1)。
六、总结
| 维度 | 方法一 栈 | 方法二 双指针 |
|---|---|---|
| 核心 | 栈存下标,栈顶就是断点 | 左右计数器,失衡即断点 |
| 空间 | O(n) | O(1) |
| 时间 | O(n) | O(n) |
| 关键难点 | 为什么减断点、为什么初始压 -1 | 最长合法子串长什么样、左右多余为什么只在一侧 |
两种方法其实是同一个想法的两种表达:用"断点"把字符串切开,相邻断点之间的合法部分取最长。栈把这个断点显式存进栈里;双指针则靠"某个方向括号变多就清零"隐式定位断点。
双指针的精髓不在计数器本身,而在先想明白答案长什么样:最长合法子串的两侧,多余括号只会出现在一侧,左边多左括号、右边多右括号不可能同时存在。既然只有一侧,就从左扫一遍解决右边的多余,再从右扫一遍解决左边的多余,两遍取最大,剩下的就是正确答案。