Chapter 05 · Algorithms

算法题:最长有效括号与股票交易

前四章覆盖了系统设计十三问(记忆、Agent 架构、Skill 机制、工程落地);本章处理面试最后的两道手写算法题 Q14 与 Q15,按白板作答的完整节奏展开:多解法递进、复杂度自报、测试用例自验。

§面试白板流程

白板算法题考核的是「沟通中解题」的过程,而非默写最优解;一套固定的作答流程能保证每一步都产生可被面试官评分的信号。

标准作答流程

  • 确认题意与边界:输入规模、字符集、空输入、返回值定义
  • 给暴力解并报复杂度:先给一个一定正确的基线,例如 O(n²) 枚举
  • 提出优化思路:指出暴力解中被重复计算的部分,引出更优结构
  • 写代码:边写边解释关键不变量
  • 自报复杂度:时间与空间分别说明
  • 主动走一个测试用例:选含边界的例子逐步演算,不等面试官开口

Q14最长有效括号(LeetCode 32 · Hard)

面试官在考什么

能否把「括号匹配」这一栈的经典语义扩展为「最长有效区间」的度量问题,并在栈、DP、双指针计数三条路线之间做出有依据的取舍。

题意与例子

给定只含 '(' 与 ')' 的字符串,求最长的格式正确且连续的括号子串长度。两个关键词都参与判定:「格式正确」意味着每个右括号都有匹配的左括号;「连续」意味着求的是子串而非子序列。

  • "(()" → 答案 2(最长有效子串为 "()")
  • ")()())" → 答案 4(最长有效子串为 "()()",下标 1–4)
  • "" → 答案 0

暴力基线:枚举所有子串并用栈验证合法性,O(n³);只枚举起点、扫描中维护计数器可降到 O(n²)。报完基线复杂度后引出线性解法。

解法一:栈(主推)

思路:栈中存下标而非字符,核心不变量是——栈底永远是「最后一个未匹配位置」。初始压入哨兵 -1,使该不变量从第 0 个字符起就成立。遇 '(' 压入其下标;遇 ')' 先 pop:若 pop 后栈空,说明这个右括号无法匹配,把当前下标压入作为新基准;否则当前位置到新栈顶之间的整段都有效,更新 ans = max(ans, i - stack[-1])。

解法一 · 栈存下标 python
def longestValidParentheses(s: str) -> int:
    stack = [-1]
    ans = 0
    for i, ch in enumerate(s):
        if ch == '(':
            stack.append(i)
        else:
            stack.pop()
            if not stack:
                stack.append(i)
            else:
                ans = max(ans, i - stack[-1])
    return ans

L2哨兵 -1 统一了「上一个未匹配位置」的语义 L9-10栈空说明右括号过剩,当前下标成为新基准 L12有效长度 = 当前下标 − 栈顶下标

复杂度:每个下标至多入栈、出栈各一次,时间 O(n),空间 O(n)。

逐步走例子 ")()())":

栈法在 ")()())" 上的逐步执行
i字符操作栈(处理后)ans
0)pop −1 后栈空,压入 0 作新基准[0]0
1(压入 1[0, 1]0
2)pop 1,ans = 2 − 0[0]2
3(压入 3[0, 3]2
4)pop 3,ans = 4 − 0[0]4
5)pop 0 后栈空,压入 5 作新基准[5]4

最终返回 4,与最长有效子串 "()()"(下标 1–4)一致。

初始压入哨兵 −1 0 1 2 3 4 5 ) ( ) ( ) ) 最长有效子串 ()(),长度 4 i=0 · 遇 ')' 0 pop −1 后栈空 压入 0 作新基准 i=2 · 遇 ')' 0 pop 1,栈顶为 0 ans = 2 − 0 = 2 i=4 · 遇 ')' 0 pop 3,栈顶为 0 ans = 4 − 0 = 4 i=5 · 遇 ')' 5 pop 0 后栈空 压入 5 作新基准 最终答案 ans = 4
图 1栈法图解:例串 ")()())" 的四个关键帧。栈底始终是「最后一个未匹配位置」,ans 在 i=2 与 i=4 处被更新。

解法二:动态规划

思路:定义 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[i-1] 段,与外层左括号配对,再拼接外层左括号之前的有效段——dp[i] = dp[i-1] + 2 + dp[i - dp[i-1] - 2]。

两条转移中下标越界的项按 0 处理。

解法二 · 动态规划 python
def longestValidParentheses(s: str) -> int:
    n = len(s)
    dp = [0] * n
    ans = 0
    for i in range(1, n):
        if s[i] == ')':
            if s[i-1] == '(':
                dp[i] = (dp[i-2] if i >= 2 else 0) + 2
            elif i - dp[i-1] - 1 >= 0 and s[i - dp[i-1] - 1] == '(':
                dp[i] = dp[i-1] + 2 + (dp[i - dp[i-1] - 2] if i - dp[i-1] - 2 >= 0 else 0)
            ans = max(ans, dp[i])
    return ans

复杂度:时间 O(n),空间 O(n)。与栈法同阶,但状态定义「以 i 结尾」是区间型 DP 的通用套路,值得在白板上点明。

解法三:双向计数(O(1) 空间)

思路:维护 left/right 两个计数器。从左向右扫:遇 '(' 加 left,遇 ')' 加 right;left == right 时出现一段有效串,更新 ans = 2 * right;right > left 时该前缀已不合法,两计数器清零。这一遍会漏掉 left > right 的情形(如 "(()",left 始终领先,永远等不到相等),因此再从右向左对称扫一遍:角色互换,left > right 时清零。

解法三 · 双向计数 python
def longestValidParentheses(s: str) -> int:
    ans = 0
    left = right = 0
    for ch in s:                  # 左 → 右
        if ch == '(':
            left += 1
        else:
            right += 1
        if left == right:
            ans = max(ans, 2 * right)
        elif right > left:
            left = right = 0
    left = right = 0
    for ch in reversed(s):        # 右 → 左
        if ch == ')':
            right += 1
        else:
            left += 1
        if left == right:
            ans = max(ans, 2 * left)
        elif left > right:
            left = right = 0
    return ans

复杂度:时间 O(n),空间 O(1)。当面试官追问「空间还能不能再省」时,这是栈法之后的标准升级答案。

加分点

  • 说清第二遍反向扫描的必要性:正向扫描只在 left == right 时结算,"(()" 这类左括号过剩的前缀永远满足 left > right,正扫不会触发结算也不会清零,长度被漏报;反向扫描以右括号视角对称处理,恰好补上这一半情形。
  • 说清栈底哨兵 −1 的作用:它把「上一个未匹配位置」的语义统一到栈底,使 i - stack[-1] 在任何时刻都直接给出以 i 结尾的有效长度,省去对空栈的特判分支。
  • 主动对比三个解法:栈法语义最直观、DP 展示状态设计能力、双向计数赢在空间——选「主推栈法、口述另两条路线」的策略本身就是工程判断力的展示。

追问预判

追问 1:要求返回最长有效子串本身,而不只是长度,怎么改?

栈法中 ans 更新时刻记录区间即可:有效段为 (stack[-1], i],即起点 stack[-1] + 1、终点 i。维护 best = (start, i) 随 ans 同步更新,最后切片返回。无需改动主体结构。

追问 2:扩展到多种括号类型(圆、方、花),哪个解法还成立?

栈法成立:栈中改存 (字符, 下标),遇右括号时先检查栈顶字符是否为对应左括号,不匹配则当前下标成为新基准。双向计数法失效——计数器无法表达类型间的嵌套约束;DP 的转移条件也需逐类型判断,复杂度上升。这正是栈法作为主推解的又一理由。

追问 3:DP 解法中为什么要看 s[i - dp[i-1] - 1] 这个位置?

s[i] == ')' 且 s[i-1] == ')' 时,内层 dp[i-1] 长度的有效段紧贴在 i−1 之前;当前右括号要匹配的左括号只会出现在这段有效段的左边一格,即 i - dp[i-1] - 1。该位置是 '(' 时整体闭合,再加上更左侧已闭合的 dp[i - dp[i-1] - 2] 形成连续拼接。

Q15股票交易(LeetCode 买卖股票系列)

面试官在考什么

题面刻意模糊——先澄清交易次数约束是哪一个变体本身就是得分点,之后看候选人能否用统一的状态机框架覆盖整个题族,而非逐题背诵。

第一步:澄清变体

「股票交易」是一个题族,约束不同解法完全不同。面试官说出题面后,正确的第一反应是反问:「允许交易几次?有冷冻期或手续费吗?」这表明候选人理解需求边界先于编码——与系统设计题的第一步完全一致。

买卖股票题族一览
题号约束解法
LC121仅一次交易一次遍历维护历史最低价
LC122不限次数贪心收集正向日差 / 状态机 DP
LC123 / LC188最多 2 次 / k 次状态机加「交易次数」维度
LC309含冷冻期状态机加 cooldown 状态
LC714含手续费状态机转移中扣除 fee

面试中通常按前两个主变体作答,再用统一框架收束全族。

变体一:LC121,仅一次交易

思路:一次遍历,维护两个量——到当前为止的历史最低价 min_price,以及「今天卖出」能得到的最大利润。每天先用今日价格刷新最低价,再用「今日价 − 历史最低价」刷新答案。

LC121 · 一次交易 python
def maxProfit(prices: list[int]) -> int:
    min_price = float('inf')
    ans = 0
    for p in prices:
        min_price = min(min_price, p)
        ans = max(ans, p - min_price)
    return ans

复杂度:时间 O(n),空间 O(1)。单调下跌的行情下答案为 0(不交易),代码无需特判即覆盖。

变体二:LC122,不限次数

思路:贪心收集所有正向日差——sum(max(0, prices[i] - prices[i-1])),等价于把所有上升段全部吃下。任何交易区间的利润都等于区间内相邻日差之和,删去负项不降低收益,因此正项之和既是上界又是可达方案。

LC122 · 贪心收集正向日差 python
def maxProfit(prices: list[int]) -> int:
    return sum(max(0, prices[i] - prices[i-1])
               for i in range(1, len(prices)))

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

统一框架:状态机 DP(强加分)

整个题族共享一个状态机:每天处于两个状态之一——hold(持股)与 empty(空仓),变量值为该状态下的最大利润。每天的价格 p 触发状态转移:

  • hold = max(hold, empty - p):继续持股,或今天买入;
  • empty = max(empty, hold + p):继续空仓,或今天卖出。

初始值 hold = -inf(第 0 天之前不存在持股状态,−inf 表示不可达)、empty = 0。遍历结束后答案取 empty——持仓未平不算落袋。

empty 空仓 hold 持股 买入:hold = max(hold, empty − p) 卖出:empty = max(empty, hold + p) 保持 保持 初始:hold = −inf,empty = 0 加交易次数维度 → LC123 / LC188 加 cooldown 状态 → LC309 · 卖出转移扣 fee → LC714
图 2股票状态机 DP:两个状态、四条转移边。状态 + 事件(每日价格)+ 转移函数,正是 02 章 FSM 的结构。
LC122 · 状态机 DP 写法 python
def maxProfit_unlimited(prices: list[int]) -> int:
    hold, empty = float('-inf'), 0
    for p in prices:
        hold = max(hold, empty - p)
        empty = max(empty, hold + p)
    return empty

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

这个状态机的扩展能力是它的真正价值:给状态加上「交易次数」维度即统一解 LC123 / LC188;在 empty 与 hold 之间插入 cooldown 状态解 LC309;卖出转移中扣除手续费解 LC714。画成状态转移图,正是 02 章 Agent 架构中 FSM 的「状态 + 事件 + 转移函数」三要素——在白板上点出这一呼应,算法题就接回了系统设计的主线。

加分点

  • 先澄清变体再动笔:题面模糊时反问约束,与系统设计题「先问需求边界」同源,面试官在等这个动作。
  • 报出题号族谱:LC121 / 122 / 123 / 188 / 309 / 714 是同一状态机在不同约束下的扩展——展示的是体系化认知而非刷题量。
  • 贪心正确性的一句话交换论证:任意交易方案的利润可拆为相邻日差之和,把方案中的负差项删去(拆成多段交易)收益不减,所以全部正差之和是最优值。

追问预判

追问 1:最多两次交易(LC123),状态机怎么扩?

状态从 2 个扩为 4 个:buy1 / sell1 / buy2 / sell2,分别表示「第一次持股 / 第一次卖出后 / 第二次持股 / 第二次卖出后」的最大利润。转移:buy1 = max(buy1, -p),sell1 = max(sell1, buy1 + p),buy2 = max(buy2, sell1 - p),sell2 = max(sell2, buy2 + p)。推广到 k 次即 LC188 的 O(nk) 解。

追问 2:含冷冻期(LC309),状态机怎么改?

empty 拆成两个状态:「今天刚卖出」(cooldown)与「可买入的空仓」。买入转移只允许从「可买入」状态发起;每天结束时 cooldown 流转为「可买入」。状态数从 2 变 3,转移方程仍是逐状态取 max 的同一套写法。

追问 3:为什么答案取 empty 而不是 max(hold, empty)?

对任意非负价格,最后一天的 empty >= hold 恒成立:持股状态总比对应的空仓状态少一次「卖出回血」,最优解的终态一定不持仓。直接返回 empty 既正确又省去比较。

本章自测

  1. 栈法中栈底哨兵 −1 的语义是什么?
    查看答案

    它代表「最后一个未匹配位置」,把该语义统一到栈底:任何时刻 i - stack[-1] 都直接给出以 i 结尾的有效括号长度,遇到无法匹配的右括号时只需把当前下标压入更新基准,无需对空栈做额外特判。

  2. 双向计数法为什么必须扫两遍?
    查看答案

    正向扫描只在 left == right 时结算,且只在 right > left 时清零;"(()" 这类左括号过剩的串始终满足 left > right,既不结算也不清零,长度被漏报。反向扫描把角色对调,以 left > right 为清零条件,恰好覆盖被正扫漏掉的另一半情形。

  3. 状态机 DP 中 hold 的初始值为什么是 −inf?
    查看答案

    第 0 天之前不存在「持股」状态,−inf 表示不可达,保证第一笔买入只通过 empty - p 这条合法转移进入 hold,而不会让一个虚构的初始持股参与 max 比较产生非法收益。

  4. LC122 贪心解成立的一句话论证是什么?
    查看答案

    任意交易方案的利润都能拆成区间内相邻日差之和;删去负差项(把交易拆成多段)收益不减,所以全部正向日差之和 sum(max(0, prices[i] - prices[i-1])) 既是上界又有合法方案达到。

延伸阅读