想系统提升编程能力、查看更完整的学习路线,欢迎访问 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, 长度=2−0=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))
优化思路
用两个计数器 left 和 right 分别统计左右括号数量:
- 从左往右扫描:当
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 的 enumerate 和 max 函数简化栈法:
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
易错点 ⚠️
-
栈底边界处理
- ❌ 错误:
stack = [],导致第一个配对时栈空无法计算长度 - ✅ 正确:
stack = [-1],用虚拟分界点简化逻辑
- ❌ 错误:
-
DP状态转移的索引越界
- ❌ 错误:直接访问
dp[i-2]或s[i - dp[i-1] - 1]可能越界 - ✅ 正确:加上边界判断
if i >= 2或if i - dp[i-1] > 0
- ❌ 错误:直接访问
-
双向扫描只扫一次
- ❌ 错误:只从左往右扫描,漏掉
"(()"这种左括号过多的情况 - ✅ 正确:必须两次扫描,互相补充
- ❌ 错误:只从左往右扫描,漏掉
🏗️ 工程实战(选读)
这个算法思想在真实项目中的应用,让你知道"学了有什么用"。
-
场景1:代码编辑器的括号高亮
- 当你在 VSCode 中点击一个括号,编辑器会高亮匹配的另一半括号,背后就是用栈实时检测括号配对。同理,Lint工具检查代码中的括号是否闭合也用这个原理。
-
场景2:表达式求值引擎
- 计算器应用(如 Python 的
eval函数底层)在解析"((1+2)*3)"这样的表达式时,需要先处理括号优先级,栈是核心数据结构。
- 计算器应用(如 Python 的
-
场景3:HTML/XML标签验证
- 浏览器解析网页时,需要验证
<div><p></p></div>这样的标签是否正确闭合,用的就是栈匹配算法的变体。
- 浏览器解析网页时,需要验证
🏋️ 举一反三
完成本课后,试试这些同类题目来巩固知识:
| 题目 | 难度 | 相关知识点 | 提示 |
|---|---|---|---|
| LeetCode 20. 有效的括号 | Easy | 栈基础 | 本题的简化版,只需判断是否有效,不求长度 |
| LeetCode 22. 括号生成 | Medium | 回溯+剪枝 | 生成所有n对有效括号的组合,用回溯 |
| LeetCode 301. 删除无效括号 | Hard | BFS/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 学习资料都在这里,后续复习和拓展会更省时间。