📖 第21课:最长有效括号

1 阅读16分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第21课:最长有效括号

模块:字符串 | 难度:Hard ⭐⭐ LeetCode 链接:leetcode.cn/problems/lo… 前置知识:第20课(回文子串)、第33课(有效的括号) 预计学习时间:35分钟


🎯 题目描述

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

示例 1:

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

示例 2:

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

示例 3:

输入:s = ""
输出:0

约束条件:

  • 0 <= s.length <= 3 * 10⁴
  • s[i]'('')'

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
空字符串""0基本边界
单个字符"("0无法配对
全部有效"(())"4完全嵌套
全部无效"((("0无配对
起始无效")()())"4跳过无效前缀
中间断开"()(()"2多个有效段
最大规模n=30000性能边界 O(n)

💡 思路引导

生活化比喻

想象你在整理一堆乱七八糟的括号夹子,每个左括号 ( 是一个开口的夹子,右括号 ) 是闭合的夹子。

🐌 笨办法:对每一个可能的子串(比如从位置2到位置5),你都要从头到尾数一遍,看看左右括号是否能完美配对。就像你每次都要把夹子拿出来重新数一遍,非常费时间!如果有1000个夹子,你要数百万次!

🚀 聪明办法:你可以一边走一边用一个"配对记录本"(栈),遇到左括号就记录位置,遇到右括号就去找最近的左括号配对,然后算出这段配对区间的长度。关键洞察是:你不需要重复检查已经配对的部分,只需要记录"哪些位置已经配对了"。

关键洞察

核心问题不是"判断是否有效",而是"找出最长的连续有效段"。用栈或DP都可以高效地记录"每个位置能形成的最长有效长度"。


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:字符串 s,只包含 '('')'
  • 输出:整数,表示最长有效括号子串的长度(注意是连续子串)
  • 限制:必须是连续的有效括号,不能跳过中间字符

Step 2:先想笨办法(暴力法)

枚举所有可能的子串(起点i,终点j),然后对每个子串检查是否有效(用栈或计数器)。

  • 时间复杂度:O(n³) - 枚举子串O(n²) × 检查有效性O(n)
  • 瓶颈在哪:对每个子串都要重新检查有效性,大量重复计算

Step 3:瓶颈分析 → 优化方向

暴力法中,我们对 "()())" 这样的字符串,会重复检查 "()" 很多次。

  • 核心问题:"能不能在遍历的过程中,直接计算出以每个位置结尾的最长有效长度?"
  • 优化思路:"用栈记录未配对的括号位置" 或 "用DP记录每个位置的答案"

Step 4:选择武器

  • 方案1:栈 - 记录未配对括号的索引,配对时计算长度
  • 方案2:动态规划 - dp[i] 表示以 i 结尾的最长有效括号长度
  • 方案3:双向扫描 - 左右各扫一遍,用计数器判断有效性

🔑 模式识别提示:当题目要求"连续子串的最值"且涉及配对关系时,优先考虑"栈"或"DP"


🔑 解法一:栈记录索引(推荐)

思路

用栈存储还未配对的括号的索引。遇到 '(' 就入栈,遇到 ')' 时:

  • 如果栈不为空,弹出栈顶(配对成功),当前有效长度 = 当前索引 - 新栈顶索引
  • 如果栈为空,说明当前 ')' 无法配对,把这个位置作为新的"分界点"入栈

核心技巧:栈底元素始终是"上一个未配对的右括号索引",作为计算长度的参照点

图解过程

示例: s = ")()())"

初始化:栈 = [-1] (虚拟分界点)

Step 1: i=0, s[0]=')'
  栈非空但栈顶=-1 → 弹出-1,栈空 → 当前')'无法配对,入栈0
  栈 = [0], max_len = 0

Step 2: i=1, s[1]='('
  入栈1
  栈 = [0, 1], max_len = 0

Step 3: i=2, s[2]=')'
  弹出1(配对成功) → 当前长度 = 2 - 栈顶0 = 2
  栈 = [0], max_len = 2

  可视化:
  ) ( )  ( ) )
  0 1 2  3 4 5
  ^---^ 有效长度=2

Step 4: i=3, s[3]='('
  入栈3
  栈 = [0, 3], max_len = 2

Step 5: i=4, s[4]=')'
  弹出3 → 当前长度 = 4 - 栈顶0 = 4
  栈 = [0], max_len = 4

  可视化:
  ) ( ) ( ) )
  0 1 2 3 4 5
  ^-------^ 有效长度=4

Step 6: i=5, s[5]=')'
  弹出0 → 栈空 → 无法配对,入栈5
  栈 = [5], max_len = 4

最终答案: 4

再看一个全部有效的例子:

示例: s = "(())"

初始化:栈 = [-1]

i=0, '(' → 栈=[−1, 0]
i=1, '(' → 栈=[−1, 0, 1]
i=2, ')' → 弹出1, 长度=20=2, 栈=[−1, 0], max=2
i=3, ')' → 弹出0, 长度=3−(−1)=4, 栈=[−1], max=4

答案: 4

Python代码

def longest_valid_parentheses(s: str) -> int:
    """
    解法一:栈记录索引
    思路:栈底保存上一个未配对的右括号位置,配对时计算区间长度
    """
    max_len = 0
    stack = [-1]  # 初始化一个虚拟分界点

    for i, char in enumerate(s):
        if char == '(':
            stack.append(i)  # 左括号入栈
        else:  # char == ')'
            stack.pop()  # 先弹出栈顶
            if not stack:
                # 栈空说明当前')'无法配对,作为新的分界点
                stack.append(i)
            else:
                # 计算当前有效长度
                max_len = max(max_len, i - stack[-1])

    return max_len


# ✅ 测试
print(longest_valid_parentheses("(()"))      # 期望输出: 2
print(longest_valid_parentheses(")()())"))  # 期望输出: 4
print(longest_valid_parentheses(""))         # 期望输出: 0
print(longest_valid_parentheses("()(()"))    # 期望输出: 2

复杂度分析

  • 时间复杂度:O(n) - 每个字符只遍历一次,栈操作都是O(1)
    • 具体地说:如果输入规模 n=10000,大约需要 10000 次操作(单次遍历)
  • 空间复杂度:O(n) - 最坏情况下栈存储所有左括号的索引(如 "(((((")

优缺点

  • ✅ 思路清晰,易于理解和实现
  • ✅ 一次遍历,性能最优
  • ✅ 面试中容易讲解和证明正确性
  • ❌ 需要额外O(n)空间(如果追求极致空间优化,可以用DP或双向扫描)

⚡ 解法二:动态规划

优化思路

定义状态:dp[i] 表示以索引 i 结尾的最长有效括号长度。

关键在于状态转移:

  • 如果 s[i] == '(',则 dp[i] = 0(左括号不能作为有效括号的结尾)
  • 如果 s[i] == ')':
    • 如果 s[i-1] == '(',形成 "...()"配对,dp[i] = dp[i-2] + 2
    • 如果 s[i-1] == ')',需要看 s[i - dp[i-1] - 1] 是否为 '('(跳过前面的有效段)

💡 关键想法:DP的精髓是"当前状态依赖之前的子问题答案"

图解过程

示例: s = ")()())"

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

i=0: s[0]=')' → 无法配对 → dp[0]=0
i=1: s[1]='(' → dp[1]=0
i=2: s[2]=')' && s[1]='(' → 配对 → dp[2] = dp[0]+2 = 2
i=3: s[3]='(' → dp[3]=0
i=4: s[4]=')' && s[3]='(' → 配对 → dp[4] = dp[2]+2 = 4
i=5: s[5]=')' && s[4]=')' → 跳过dp[4]=4 → 看s[0]=')',无法配对 → dp[5]=0

最终 dp = [0, 0, 2, 0, 4, 0]
答案 = max(dp) = 4

Python代码

def longest_valid_parentheses_dp(s: str) -> int:
    """
    解法二:动态规划
    思路:dp[i] 表示以 i 结尾的最长有效括号长度
    """
    if not s:
        return 0

    n = len(s)
    dp = [0] * n
    max_len = 0

    for i in range(1, n):
        if s[i] == ')':
            if s[i - 1] == '(':
                # 情况1: "...()" → dp[i] = dp[i-2] + 2
                dp[i] = (dp[i - 2] if i >= 2 else 0) + 2
            elif i - dp[i - 1] > 0 and s[i - dp[i - 1] - 1] == '(':
                # 情况2: "...)))" → 跳过前面的有效段,检查能否配对
                dp[i] = dp[i - 1] + 2 + (dp[i - dp[i - 1] - 2] if i - dp[i - 1] >= 2 else 0)

            max_len = max(max_len, dp[i])

    return max_len


# ✅ 测试
print(longest_valid_parentheses_dp("(()"))      # 期望输出: 2
print(longest_valid_parentheses_dp(")()())"))  # 期望输出: 4
print(longest_valid_parentheses_dp("()(()"))    # 期望输出: 2

复杂度分析

  • 时间复杂度:O(n) - 单次遍历
  • 空间复杂度:O(n) - dp数组

🚀 解法三:双向扫描(空间O(1))

优化思路

用两个计数器 leftright 分别统计左右括号数量:

  • 从左往右扫描:当 left == right 时更新最大长度;当 right > left 时重置(说明右括号过多,无法配对)
  • 从右往左扫描:当 left == right 时更新最大长度;当 left > right 时重置(说明左括号过多)

💡 关键想法:为什么需要两次扫描?因为从左往右会漏掉 "(()" 这种左括号过多的情况,需要从右往左补充

图解过程

示例: s = ")()())"

从左往右:
i=0: ')' → left=0, right=1 → right>left → 重置
i=1: '(' → left=1, right=0
i=2: ')' → left=1, right=1 → 相等 → max=2
i=3: '(' → left=2, right=1
i=4: ')' → left=2, right=2 → 相等 → max=4
i=5: ')' → left=2, right=3 → right>left → 重置

从右往左:
(这个例子从右往左结果相同,不再展开)

答案: 4

Python代码

def longest_valid_parentheses_scan(s: str) -> int:
    """
    解法三:双向扫描
    思路:用计数器统计左右括号数量,两次遍历避免遗漏
    """
    max_len = 0

    # 从左往右扫描
    left = right = 0
    for char in s:
        if char == '(':
            left += 1
        else:
            right += 1

        if left == right:
            max_len = max(max_len, 2 * right)
        elif right > left:
            left = right = 0  # 重置

    # 从右往左扫描
    left = right = 0
    for char in reversed(s):
        if char == '(':
            left += 1
        else:
            right += 1

        if left == right:
            max_len = max(max_len, 2 * left)
        elif left > right:
            left = right = 0  # 重置

    return max_len


# ✅ 测试
print(longest_valid_parentheses_scan("(()"))      # 期望输出: 2
print(longest_valid_parentheses_scan(")()())"))  # 期望输出: 4
print(longest_valid_parentheses_scan("()(()"))    # 期望输出: 2

复杂度分析

  • 时间复杂度:O(n) - 遍历两次
  • 空间复杂度:O(1) - 只用了常数个变量

🐍 Pythonic 写法

利用 Python 的 enumeratemax 函数简化栈法:

def longest_valid_parentheses_pythonic(s: str) -> int:
    """Pythonic 写法:栈法的简化版本"""
    max_len, stack = 0, [-1]
    for i, c in enumerate(s):
        if c == '(':
            stack.append(i)
        else:
            stack.pop()
            max_len = max(max_len, i - stack[-1]) if stack else (stack.append(i), max_len)[1]
    return max_len

⚠️ 面试建议:先写清晰版本展示思路(解法一),再提 Pythonic 写法展示语言功底。 面试官更看重你的思考过程,而非代码行数。上面的三元表达式嵌套虽然简洁,但可读性差,面试时不推荐。


📊 解法对比

维度解法一:栈解法二:DP解法三:双向扫描
时间复杂度O(n)O(n)O(n)
空间复杂度O(n)O(n)O(1) ⭐
代码难度简单中等中等
面试推荐⭐⭐⭐⭐⭐⭐⭐
适用场景通用,易于扩展适合DP思维训练追求极致空间优化

面试建议:优先讲解法一(栈),因为思路最清晰且容易证明正确性。如果面试官追问"能否优化空间",再介绍解法三(双向扫描)。解法二(DP)适合有DP基础的面试者,可以作为第二解法展示算法广度。


🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道题 - 最长有效括号。

:(审题30秒)好的,这道题要求找出最长的连续有效括号子串长度。让我先想一下...

我的第一个想法是暴力枚举所有子串,然后用栈检查每个子串是否有效,时间复杂度是 O(n³)。

不过我们可以用栈记录未配对括号的索引来优化到 O(n)。核心思路是:栈底始终保存"上一个未配对的右括号位置",遇到 '(' 就入栈,遇到 ')' 就配对并计算长度。

面试官:很好,请写一下代码。

:(边写边说)我先初始化栈为 [-1],这是一个虚拟分界点。然后遍历字符串...

  • 遇到左括号直接入栈
  • 遇到右括号先弹出栈顶:
    • 如果栈空了,说明这个右括号无法配对,把它的索引入栈作为新分界点
    • 如果栈不空,当前有效长度就是 i - stack[-1]

(写完代码)

面试官:测试一下?

:用示例 ")()())" 走一遍:

  • i=0,遇到 ')',弹出-1,栈空,入栈0
  • i=1,遇到 '(',入栈1
  • i=2,遇到 ')',弹出1,长度=2-0=2
  • ...最后得到答案4

再测一个边界情况 "(()":

  • 最终得到答案2

结果正确!

高频追问

追问应答策略
"还有更优解吗?"时间已经是O(n)最优,空间可以优化:用双向扫描法只需要O(1)空间,思路是从左往右和从右往左各扫一遍,用计数器判断有效性。
"DP怎么做?"定义 dp[i] 为以 i 结尾的最长有效长度。转移方程分两种情况:1) s[i-1]=='(' 则直接配对;2) s[i-1]==')' 则跳过前面的有效段再配对。
"如果允许删除部分字符呢?"那就变成了不同的问题(LeetCode 301),需要用BFS找最少删除次数,或者用DFS+剪枝枚举所有删除方案。
"能否用递归实现?"这道题不太适合递归,因为没有明显的子问题结构。强行递归会退化成暴力枚举,时间复杂度O(2^n)。

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:栈初始化技巧 — 设置虚拟分界点避免边界判断
stack = [-1]  # -1作为虚拟边界,简化后续逻辑

# 技巧2:enumerate 遍历索引和值 — 替代 range(len())
for i, char in enumerate(s):
    # 同时获取索引和字符,代码更简洁

# 技巧3:三元表达式嵌套 — 简洁但慎用
result = a if cond else (b, c)[flag]  # 可读性差,面试不推荐

💡 底层原理(选读)

为什么栈适合处理括号匹配?

栈的LIFO(后进先出)特性天然匹配括号的"最近配对"规则。当你看到一个右括号 ')' 时,你需要找离它最近的未配对左括号 '(',这正是栈顶元素!

DP为什么能优化?

DP的核心是"记忆化",避免重复计算。在这道题中,dp[i] 记录了"以 i 结尾的最长有效长度",后续计算 dp[i+1] 时可以直接复用这个结果,不需要重新检查前面的字符。

双向扫描为什么有效?

单向扫描的问题是:从左往右时,左括号过多会导致漏判(如 "(()" 永远无法配对完);从右往左时,右括号过多会导致漏判。两次扫描互补,覆盖所有情况。

算法模式卡片 📐

  • 模式名称:栈处理嵌套结构
  • 适用条件:题目涉及配对、嵌套、最近匹配关系(括号、标签、表达式求值)
  • 识别关键词:"有效括号"、"配对"、"嵌套"、"最近的"
  • 模板代码:
def stack_matching_template(s: str) -> int:
    """栈处理配对问题的通用模板"""
    stack = []
    max_result = 0

    for i, char in enumerate(s):
        if is_left_symbol(char):
            stack.append(i)  # 入栈
        else:
            if stack and can_match(stack[-1], char):
                stack.pop()  # 配对成功
                # 更新结果(长度、深度等)
                max_result = max(max_result, calculate_result(i, stack))
            else:
                stack.append(i)  # 无法配对,作为新边界

    return max_result

易错点 ⚠️

  1. 栈底边界处理

    • ❌ 错误:stack = [],导致第一个配对时栈空无法计算长度
    • ✅ 正确:stack = [-1],用虚拟分界点简化逻辑
  2. DP状态转移的索引越界

    • ❌ 错误:直接访问 dp[i-2]s[i - dp[i-1] - 1] 可能越界
    • ✅ 正确:加上边界判断 if i >= 2if i - dp[i-1] > 0
  3. 双向扫描只扫一次

    • ❌ 错误:只从左往右扫描,漏掉 "(()" 这种左括号过多的情况
    • ✅ 正确:必须两次扫描,互相补充

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:代码编辑器的括号高亮

    • 当你在 VSCode 中点击一个括号,编辑器会高亮匹配的另一半括号,背后就是用栈实时检测括号配对。同理,Lint工具检查代码中的括号是否闭合也用这个原理。
  • 场景2:表达式求值引擎

    • 计算器应用(如 Python 的 eval 函数底层)在解析 "((1+2)*3)" 这样的表达式时,需要先处理括号优先级,栈是核心数据结构。
  • 场景3:HTML/XML标签验证

    • 浏览器解析网页时,需要验证 <div><p></p></div> 这样的标签是否正确闭合,用的就是栈匹配算法的变体。

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 20. 有效的括号Easy栈基础本题的简化版,只需判断是否有效,不求长度
LeetCode 22. 括号生成Medium回溯+剪枝生成所有n对有效括号的组合,用回溯
LeetCode 301. 删除无效括号HardBFS/DFS最少删除几个括号使其有效,用BFS找最短路径
LeetCode 856. 括号的分数Medium栈+计数根据括号嵌套计算分数,栈+数学规律
LeetCode 1541. 平衡括号的最少插入Medium贪心计算需要插入多少括号使其有效,贪心思想

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定一个只包含 '('')' 的字符串,你可以删除任意个字符(可以不删),问最多能得到多长的有效括号子序列?(注意:不要求连续)

例如:"(()()" → 可以删除第一个 '(',得到 "()()",长度为4

💡 提示(实在想不出来再点开)

这是一个子序列问题,不是子串!可以用贪心:从左往右遇到 '(' 就累加计数,遇到 ')' 时如果有未配对的 '(' 就配对(计数-1,答案+2)

✅ 参考答案
def longest_valid_subsequence(s: str) -> int:
    """
    贪心法:从左往右配对,不要求连续
    """
    left_count = 0
    matched_pairs = 0

    for char in s:
        if char == '(':
            left_count += 1
        elif left_count > 0:  # char == ')' 且有未配对的'('
            left_count -= 1
            matched_pairs += 1

    return matched_pairs * 2  # 每对贡献长度2

核心思路:不需要连续,所以可以贪心地从左往右配对。每遇到一个 ')' 且前面有未配对的 '(',就立刻配对。

时间复杂度 O(n),空间复杂度 O(1)。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。