32:最长有效括号

47 阅读6分钟

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

左右括号匹配,即每个左括号都有对应的右括号将其闭合的字符串是格式正确的,比如 "(()())"

 

示例 1:

输入: s = "(()"
输出: 2
解释: 最长有效括号子串是 "()"

示例 2:

输入: s = ")()())"
输出: 4
解释: 最长有效括号子串是 "()()"

示例 3:

输入: s = ""
输出: 0

 

提示:

  • 0 <= s.length <= 3 * 104
  • s[i] 为 '(' 或 ')'

要点(一句话)

令 dp[i] 表示 以索引 i 结尾 的最长有效括号子串的长度(若 s[i] == '(' 则 dp[i] = 0)。
当 s[i] == ')' 时:

  • 如果 s[i-1] == '('dp[i] = (i >= 2 ? dp[i-2] : 0) + 2
  • 否则(s[i-1] == ')')且 i - dp[i-1] - 1 >= 0 且 s[i - dp[i-1] - 1] == '('
    dp[i] = dp[i-1] + 2 + ( (i - dp[i-1] - 2 >= 0) ? dp[i - dp[i-1] - 2] : 0 )

答案是 max(dp[i])

下面详细解释每一条为什么成立,并做逐步演示。


直观与状态定义(为什么用 dp[i]

我们关注“以 i 结尾 的最长有效括号长度”,因为一个有效括号子串要么包含位置 i(结尾在 i),要么不包含,考虑“以 i 结尾”能把问题分解为更小子问题。

dp[i] 的意义(牢记):

  • dp[i] = 以索引 i 为结尾的最长有效括号子串的字符长度(不是个数、不是下标差)。
  • 如果 s[i] 是 '(',不可能以 i 结尾形成合法的 “以 ) 结尾” 子串,所以 dp[i] = 0

转移公式的推导(分两种情况)

情况 A:s[i] == ')' 且 s[i-1] == '(' —— 形如 "..." + "()"

  • 末尾是 "()",这对 () 自身贡献 2 个长度。
  • 在这对 () 之前,可能有一段以 i-2 结尾的合法子串(长度为 dp[i-2]),它可以直接拼接在这对 () 左侧。
  • 所以:dp[i] = dp[i-2] + 2(当 i-2 < 0 时 dp[i-2] 视为 0)。

图示(索引):
... [valid ending at i-2] ( )
i-1 i

因此公式成立。


情况 B:s[i] == ')' 且 s[i-1] == ')' —— 形如 "..." + "))"(要看更前面是否有 '(' 来匹配最右边的 ')'

  • 假设 dp[i-1] 表示以 i-1 结尾的最长有效长度,形象上 i-1 之前有一段 [...] 是合法的,长度为 dp[i-1],那么这段合法子串的起始索引是 i-1 - dp[i-1] + 1,所以最左端的索引就是 i - dp[i-1] 减去 1 等等 —— 我们只需要知道,在 i - dp[i-1] - 1 这个位置是否有一个 '(',如果有,那么这个 '(' 正好可以与当前的 s[i](即最右的 ')')匹配,形成 (...) 的包裹,从而把 dp[i-1] 扩展并加上 2。

更清楚地:

  • 末尾 dp[i-1] 覆盖了从 i - dp[i-1] 到 i-1 的一段(含)。
  • 检查 pos = i - dp[i-1] - 1:如果 pos >= 0 且 s[pos] == '(',那么 s[pos] 与 s[i] 匹配,形成一对,把 dp[i-1] 扩展为 dp[i-1] + 2
  • 还可能在 pos-1 之前有一段合法子串(即 dp[pos-1]),它们也能与当前这段拼接,所以还要加上 dp[pos-1](即 dp[i - dp[i-1] - 2])作为前缀贡献。

因此:

dp[i] = dp[i-1] + 2 + dp[i - dp[i-1] - 2]   (若 pos = i - dp[i-1] -1 >=0 且 s[pos]=='(')

举个小图(下标)帮助记忆:

... [A]  (  [B]  )  )   <- 当前i在最右 ) 
        pos      i-dp[i-1] ... i-1  i

[B] 是 dp[i-1] 那段,( at pos 匹配当前 ) at i[A] 是 dp[pos-1]


边界与索引条件

  • 在上面公式中要确保索引合法:

    • i - dp[i-1] - 1 >= 0 才能检查 s[pos] 是否 '('
    • i - dp[i-1] - 2 >= 0 才能取 dp[i - dp[i-1] - 2],否则视为 0
  • 初始 dp 全为 0,因为单个位置单独不能有以它结尾的有效长度(若为 ')' 则存在可能 >0)。


代码(通俗、注释清晰版)

class Solution {
    public int longestValidParentheses(String s) {
        int n = s.length();
        if (n <= 1) return 0;

        int[] dp = new int[n]; // dp[i] = 以 i 结尾的最长有效括号长度
        int max = 0;

        for (int i = 1; i < n; i++) {
            if (s.charAt(i) == ')') {
                // 情况 A: ...()  (i-1 是 '(')
                if (s.charAt(i - 1) == '(') {
                    dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
                } 
                // 情况 B: ...))  (i-1 是 ')')
                else {
                    int prevLen = dp[i - 1]; // 以 i-1 结尾的有效长度
                    int pos = i - prevLen - 1; // 可能与 i 匹配的 '(' 的位置
                    if (pos >= 0 && s.charAt(pos) == '(') {
                        dp[i] = prevLen + 2;
                        // 如果 pos-1 >= 0,还要把 pos-1 之前的合法长度加上
                        if (pos - 1 >= 0) dp[i] += dp[pos - 1];
                    }
                }
                if (dp[i] > max) max = dp[i];
            }
            // 如果 s[i] == '(',dp[i] 保持 0
        }
        return max;
    }
}

逐字符详细演示(两个例子)

下面用 表格方式 逐位展示 is[i]dp[i] 如何计算,帮助你一步步“看见”算法在做什么。


例子 1:s = "(()())"(期望结果 6)

字符串下标与字符:

i : 0 1 2 3 4 5
s : ( ( ) ( ) )

初始化:dp = [0,0,0,0,0,0]max = 0

  • i=1, s[1]='(' → dp[1]=0

  • i=2, s[2]=')' 且 s[1]='(' → 情况 A:dp[2] = dp[0] + 2 = 0 + 2 = 2 → max=2
    dp = [0,0,2,0,0,0]

  • i=3, s[3]='(' → dp[3]=0

  • i=4, s[4]=')' 且 s[3]='(' → 情况 A:dp[4] = dp[2] + 2 = 2 + 2 = 4 → max=4
    dp = [0,0,2,0,4,0]

  • i=5, s[5]=')' 且 s[4]=')' → 情况 B:

    • prevLen = dp[4] = 4
    • pos = 5 - 4 - 1 = 0
    • s[0] == '(' 成立 → dp[5] = prevLen + 2 = 4 + 2 = 6
    • pos-1 = -1 < 0,所以不用加 dp[pos-1]
      → dp[5] = 6,max=6
      dp = [0,0,2,0,4,6]

最终 max = 6


例子 2:s = ")()())"(LeetCode 常见例子,期望结果 4)

下标与字符:

i : 0 1 2 3 4 5
s : ) ( ) ( ) )

初始化:dp=[0,0,0,0,0,0], max=0

  • i=1, s[1]='(' → dp[1]=0

  • i=2, s[2]=')' 且 s[1]='(' → 情况 A:dp[2] = dp[0] + 2 = 0 + 2 = 2 → max=2
    dp=[0,0,2,0,0,0]

  • i=3, s[3]='(' → dp[3]=0

  • i=4, s[4]=')' 且 s[3]='(' → 情况 A:dp[4] = dp[2] + 2 = 2 + 2 = 4 → max=4
    dp=[0,0,2,0,4,0]

  • i=5, s[5]=')' 且 s[4]=')' → 情况 B:

    • prevLen = dp[4] = 4
    • pos = 5 - 4 - 1 = 0
    • s[0] == ')',不是 '(',所以不能匹配 → dp[5]=0
      dp=[0,0,2,0,4,0]

最终 max = 4(来自 dp[4])。

这说明最后的 ) 并不能和最前面的 ) 之前的 ( 配对。


为什么这个 DP 正确(直观证明)

  1. dp[i] 只依赖于之前更小的 dp 值(如 dp[i-1]dp[i-2]dp[pos-1]),这是典型的“最优子结构”,即以 i结尾的最长有效括号子串要么:

    • 以 i-1 结尾并被新的 ) 扩展(情况 B),或者
    • 直接由 () 结尾并附加 dp[i-2](情况 A)。
  2. 公式覆盖了所有可能使 s[i] 成为 ')' 的合法结尾场景。若无法构成合法结尾(例如 s[i]=='(' 或找不到要匹配的 '('),dp[i]=0 恰当。

  3. 每次我们在 i 时更新 max = max(max, dp[i]),因此最终 max 为整串中任意结尾位置的最大值,即最长有效括号。


复杂度

  • 时间复杂度:O(n)(一次线性遍历,常数时间更新 dp[i])。
  • 空间复杂度:O(n)(一个长度为 n 的 dp 数组)。
    (如果想空间 O(1) 实现可以用栈或双向扫描,但这里不展开——DP 是直观且常用的)

常见易错点(提醒)

  1. 忘记 i - dp[i-1] - 1 >= 0 的边界判断会越界。
  2. 在情况 B 中,除了 dp[i-1] + 2,还要记得把 pos-1 之前的 dp[pos-1] 加上(可能为 0)。
  3. dp[i] 存的是长度(字符数),不是数量或起始索引。
  4. 对 s[i-1]=='('(情况 A)要用 dp[i-2] 而不是 dp[i-1],因为 i-1 是 (