Hot 100 --- 最长有效括号

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


一、题目


二、题目分析

1. 题目要求

给你一个只包含 '('')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。

有效括号 :每个左括号都有对应的右括号将其闭合,如 "(()())"

示例 1s = "(()" → 2(最长有效括号子串是 "()"

示例 2s = ")()())" → 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. 栈里存下标:不放括号字符本身,因为要用下标算长度
  2. 右括号先弹再判:弹出栈顶尝试配对,剩下的栈顶就是"断点基准"
  3. 弹空则进断点:弹空说明这个右括号多余,把它的下标压入,当作新的断点
  4. 初始压 -1:充当"第 0 位之前"的哨兵断点
  5. 时间复杂度 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;
}

思路简要说明:

  1. 先想清答案长什么样:最长合法子串两侧的多余括号,最多只出现在一侧(左边多左括号与右边多右括号不能共存)
  2. 第一遍从左到右:右括号变多就清零,处理掉"右边多余的右括号"
  3. 第二遍从右到左:角色对调,左括号变多就清零,处理掉"左边多余的左括号"
  4. 时间复杂度 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 最长合法子串长什么样、左右多余为什么只在一侧

两种方法其实是同一个想法的两种表达:用"断点"把字符串切开,相邻断点之间的合法部分取最长。栈把这个断点显式存进栈里;双指针则靠"某个方向括号变多就清零"隐式定位断点。

双指针的精髓不在计数器本身,而在先想明白答案长什么样:最长合法子串的两侧,多余括号只会出现在一侧,左边多左括号、右边多右括号不可能同时存在。既然只有一侧,就从左扫一遍解决右边的多余,再从右扫一遍解决左边的多余,两遍取最大,剩下的就是正确答案。

相关推荐
君顾11 小时前
本地托管机构管理源码实战:需求拆解、数据建模与多端部署
java·开发语言·托管
xcl09251 小时前
本地电竞服务交易系统架构设计与实战:从同城匹配到订单履约
java·spring boot
曹牧1 小时前
SVN 上查找文件
java
加贝哥|usun1 小时前
maven项目从外网搬迁至内网-本地仓库(不搭建私服)
java
paopaokaka_luck2 小时前
小学非遗科普平台(AI 非遗科普问答,ECharts学情数据、非遗资源分类与审核,资源收藏下载与学习记录,学习任务发布和作品评分,活动报名签到与统计)
java·人工智能·spring·信息可视化·数据分析·echarts
这料鬼有毒2 小时前
二刷hot100-1143.最长公共子序列
算法·leetcode·动态规划
Yize.2 小时前
Spring Task定时任务全解析:从入门到实战
java·spring
政企项目老覃3 小时前
边缘 AI 推理部署:安防零售场景下的模型裁剪与端侧落地实践
人工智能·程序人生·算法·性能优化·vllm
爱吃苹果的日记本3 小时前
数据结构第五课
数据结构·学习