☰
LeetCode 123:买卖股票最佳时机III的状态机DP详解
2026/10/17 10:21:17 网站建设 项目流程

前阵子在一个技术社群里看到有人问这道题,评论区里清一色都是“这题看题解都看不懂”“状态定义我背下来了但换个题就不会了”。我仔细翻了一下,发现 LeetCode 123. 买卖股票的最佳时机 III 这道题,几乎是股票系列里从“入门”跨向“进阶”的一道分水岭,也是动态规划状态机思想最经典的载体。题目本身不难读——你只有两次交易机会,但恰恰是“最多两次”这个限制,让习惯了一维 DP 的初学者第一次接触多状态、多阶段的转移。我用 Python 重新梳理这道题的完整思路和多种实现方式,从状态机的直觉讲起,到三维 DP、滚动数组优化,再到边界条件里最容易被忽略的“负无穷”初始值,每一处都会说清楚为什么这么设计。不管你是准备面试时刷题,还是想彻底弄懂股票交易类动态规划的套路,这篇文章应该都能帮上忙。

1. 题目到底在问什么——先把手上的需求盘清楚

1.1 从“最多两次”到灵光一现

题目描述很朴素:给定一个数组 prices,prices[i] 是某支股票第 i 天的价格。你只能选择在某一天买入,之后的某一天卖出,整个过程最多完成两笔交易,而且必须先买后卖、手里最多持有一股,求能获得的最大利润。

注意几个细节:

  • “最多”两笔,不是“恰好”两笔。你可以只做一笔,甚至一笔都不做,利润为 0。
  • 不能同一天买入又卖出(严格来说当天买卖没有意义,因为利润为 0,不影响最优解,但不允许把“买一次卖一次”拆成两笔)。
  • 第二笔买入必须在第一笔卖出之后,也就是不能同时持有两只股票。

很多人在这一步就开始纠结:贪心行不行?先找一次最大利润,再在剩余区间找第二次最大利润,加起来不行吗?

不行。举个反例就能看出来:假设价格序列是 [3,3,5,0,0,3,1,4]。如果贪心先找全局最大差价,第一次是 0 买 4 卖(最后两天),利润 4,剩下区间利润最多也就是第二天 3 买第四天 3 卖等于 0,总利润 4。但正确答案是 3 买 5 卖(利润 2)+ 0 买 4 卖(利润 4)= 6。贪心的局部最优会破坏第二次交易的潜在空间,这种前后依赖的问题,天然就是动态规划的菜。

1.2 状态机模型的基本概念

动态规划的核心从来不是“背模板”,而是把问题变成一组“状态”,再定义“状态之间怎么转移”。股票问题最经典的建模方式是状态机:

每个时刻,你手里要么没有股票,要么持有一支股票。在没有股票的状态下,你可以选择买入,进入持有状态;在持有状态下,你可以选择卖出,回到空仓状态。

“最多两笔交易”再引入一个维度:已经完成了几笔交易(或者说正在第几笔交易中)。

这样一来,某个时刻你的处境可以描述成四个典型状态:

  1. 一次都没买过,手里空仓。
  2. 买过第一笔还没卖(第一笔持有中)。
  3. 卖出了第一笔,手里空仓,且只能再买第二笔。
  4. 买入了第二笔还没卖(第二笔持有中)。

注意第 4 个状态之后还有一个隐藏的终态:卖出第二笔,手里空仓,游戏结束。

把这些状态串起来:空仓(未开始)→ 第一笔持有 → 空仓(第一笔已卖)→ 第二笔持有 → 空仓(全部结束)。

你每天只能做三件事:什么都不做、买入(只能从空仓到持有)、卖出(只能从持有到空仓),而每一天的决策会影响你手里有多少现金(或者成本)。

提示:状态机模型的本质就是把决策的“历史”浓缩成几个有限的角色。你不用记住你买了多少钱、卖了多少钱,只需要知道当前处境的角色,以及这个角色下累计的最大利润。这就是为什么 DP 数组往往只有两三个维度,却能表示所有可能性。

搞清楚这一点,后面所有代码都只是“把这段直觉翻译成数组和循环”而已。

2. 为什么一度走弯路——贪心与错误状态设计的复盘

2.1 误区一:直接把交易次数当状态

一种常见的错误写法是用 dp[i][j] 表示“前 i 天最多交易 j 次的最大利润”,然后想当然地以为可以一层层推。这个思路本身没有错,很多 DP 题确实是这么解的,但问题在于股票的“买”和“卖”是两个动作,单靠“交易次数”这一个维度,无法区分你当前是持仓还是空仓。

举个具体场景:dp[i][1] 到底是指“前 i 天完成了一笔交易、现在是空仓”,还是“前 i 天正处于第一笔交易中、手里还拿着股票”?如果不区分,转移方程根本写不出来——因为你不知道下一次买入是否合法。

很多初学者在这个坑里卡了很久,反复调整方程还是报错,最后才意识到需要同时记录“是否持有股票”。

2.2 误区二:忽略“第 0 天之前”的负无穷

另一个极其隐蔽的坑是初始值问题。假设我们用变量表示“第一笔买入后的状态”,那在第 0 天(还没开盘)之前,你不可能持有任何股票。这个“不可能”在代码里不能用 0 表示,因为 0 意味着“利润为 0、手里有一支价值 0 的股票”,这在逻辑上是错误的。

正确做法是用一个足够小的负数(比如 -inf 或 -10**9)来表示“这个状态当前不可达”。只有这样才能保证转移时不会从非法的状态里推出一个虚高的结果。

我见过很多次,代码逻辑全对,就是初始值用了 0,结果测试用例一多就翻车。比如价格为 [1,2,4],期望答案是 3(一次交易买 1 卖 4),但错误的初始值可能会导出 7 之类的离谱数字。这就是“负无穷”这个细节的价值。

2.3 动态规划状态设计的核心:Hold 与 Sold 的划分

经过前面两个误区的打磨,最终的状态设计会自然收敛到一套标准模型。我用财经类的比喻来说明:把你每天的资金账户分成四个仓位状态,每个状态用一个变量记录“当前这个状态下能拿到的最好累计收益(利润最大)”。

  • hold1:已经买入第一笔,当前持有股票。注意在这个状态下,账面上的钱是“负数”(花了买入价),但为了统一计算,我们用买入后剩余现金的最大值表示,即可能是负的。
  • sold1:第一笔已经卖出,手里是现金,这笔交易结束后落袋的利润。
  • hold2:已经买入第二笔,当前持有股票。这个状态下剩余的现金 = 第一笔利润 - 第二笔买入价,也有可能是负的,但通常我们会取最大。
  • sold2:第二笔已经卖出,最终落袋利润。

这四个变量和交易次数的对应关系:

变量对应状态是否持仓关键含义
hold1进行第 1 笔买入后是剩余现金可能为负
sold1完成第 1 笔卖出后否第一笔累计利润
hold2进行第 2 笔买入后是第一笔利润减去第二次买入价
sold2完成第 2 笔卖出后否最终累计利润

其中 hold 系列的初始值必须为负无穷,sold 系列的初始值为 0(因为一开始不交易,利润就是 0)。

注意:所有“当前持有股票”的状态,初始值都不能是 0,否则等于默认了你白拿了一支股票,这会让后续卖出产生不存在的利润。

这套四状态模型,就是 LeetCode 123 的标准解法,也是后续扩展到“买卖股票的最佳时机 IV(K 笔交易)”的基石。

3. 标准动态规划解法与完整落地实现

3.1 状态转移方程的手推过程

我们不用数组,直接用四个滚动变量,因为每一天的状态只和前一天相关。把所有状态压缩成四个变量,每遍历一天,就根据今天的价格p来更新它们。

更新顺序是这道题最容易出错的地方。先看完整的式子:

hold1 = max(hold1, -p) sold1 = max(sold1, hold1 + p) hold2 = max(hold2, sold1 - p) sold2 = max(sold2, hold2 + p)

等等,细心的读者会发现:这个式子里的 hold1 和 sold1 到底用的是更新前的值还是更新后的值?顺序会不会有影响?

直觉上应该用“当天还没操作时的旧值”来计算其他状态,否则同一天内可能发生“买完又卖”这种不合规的操作。但实际上,上面的顺序是对的,而且不用临时变量,原因值得仔细分析。

先看第一步:hold1 = max(hold1, -p)。它表示“要么保持之前的 hold1(今天不买),要么在今天以 p 的价格买入第一笔”。如果选择在今天买入,新状态是-p,显然这个值是从旧状态推出来的。

再看第二步:sold1 = max(sold1, hold1 + p)。这里的hold1是更新后的 hold1。假如今天价格下跌,hold1可能被更新成-p(即今天买入),那么hold1 + p就等于 0,相当于“今天买入今天卖出”,利润 0。这种同一天的买卖在真正的交易中没意义,但它不会让答案变大,所以不影响正确性。换句话说,就算允许了这种操作,最佳答案也不会因此变高,DP 会自动忽略它。

第三步和第四步同理:hold2 = max(hold2, sold1 - p)中的sold1已经包含了今天的卖出操作,hold2 + p也包含了今天买入又卖出的可能性。整体来看,同一天内多个动作连环发生,可能会导致答案不变量,但不会虚高到错误。

不过这里有一个细节值得验证:如果同一天买第一笔、卖第一笔、再买第二笔、再卖第二笔,理论上会算出一个利润 0 的动作,不影响最大值,所以结果依然正确。这就是我们常说的“同一天操作在数学上允许,但不会优于更优解”。

为了更严谨,也可以拆成两轮更新,用旧值计算:

# 先记录旧值 old_hold1 = hold1 old_sold1 = sold1 old_hold2 = hold2 hold1 = max(old_hold1, -p) sold1 = max(old_sold1, old_hold1 + p) hold2 = max(old_hold2, old_sold1 - p) sold2 = max(sold2, old_hold2 + p)

但实际上,用更新后的值(即上面四个式子顺序执行)在本题中只会多算“同一天买然后又卖”的无利润操作,不会产生虚假增益。我在码代码时习惯用更新后的简洁写法,因为性能一样,代码更短。

3.2 Python 实现:从三维 DP 到滚动数组

先给一个最容易理解的版本:三维 DP。用dp[i][k][0]表示第 i 天结束后,已经最多进行了 k 笔交易(以卖出计数,或买入计数,不同题目定义略有差异),手里不持有股票的最大利润;dp[i][k][1]表示手里持有股票的最大利润。

关于k的计数口径,这里有一个常见歧义。标准写法把“买入”算作一笔新交易的开始,这样k表示“已经完成了 k 次买入”。转移方程:

dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i]) dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

当 k=1 时,dp[i][1][1] 只能从 dp[i-1][0][0] 转移,而 dp[0][0][0] = 0,dp[0][0][1] = -inf,所以相当于从“空仓未开始”状态买入,这就是第一笔交易。当 k=2 时,同理第二笔买入必须先从 k=1 的空仓状态来。

但很多题解把k当作“卖出次数”,导致方程里dp[i][k][0]和dp[i][k][1]的 k 下标会错位,非常容易混淆。建议初学时统一用 Google 官方风格:k表示已经进行的交易次数(每买入一次,k 就加一),这样最好记忆。

代码实现:

def maxProfit(self, prices: List[int]) -> int: if not prices: return 0 n = len(prices) # dp[i][k][0/1] # k 最大为 2 dp = [[[0] * 2 for _ in range(3)] for _ in range(n)] # 初始化第 0 天 dp[0][0][0] = 0 dp[0][0][1] = -10**9 # 不可能持有 dp[0][1][0] = 0 dp[0][1][1] = -prices[0] # 第 0 天买入第一笔 dp[0][2][0] = 0 dp[0][2][1] = -10**9 # 第 0 天不可能做第二笔买入(因为还没卖出第一笔) for i in range(1, n): p = prices[i] for k in range(3): # 不持有:昨天也不持有,或昨天持有今天卖出 dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + p) # 持有:昨天也持有,或昨天不持有今天买入 if k > 0: dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - p) else: dp[i][k][1] = dp[i-1][k][1] # k=0 时不可能买入(因为买入就变成 k=1) return max(dp[n-1][k][0] for k in range(3))

这个版本的好处是结构清晰,每个状态都有位置可查,适合在纸上推演时对照。坏处是空间复杂度 O(n),在实际刷题中没必要保留所有天数,因为递推只依赖前一天。

优化一版:去掉 i 的维度,只保留 k 的二维状态:

def maxProfit(self, prices: List[int]) -> int: if not prices: return 0 # dp[k][0]:最多 k 次交易,当前空仓的最大利润 # dp[k][1]:最多 k 次交易,当前持仓的最大利润 dp = [[0] * 2 for _ in range(3)] dp[0][0] = 0 dp[0][1] = -10**9 dp[1][0] = 0 dp[1][1] = -10**9 dp[2][0] = 0 dp[2][1] = -10**9 for p in prices: for k in range(1, 3): dp[k][1] = max(dp[k][1], dp[k-1][0] - p) dp[k][0] = max(dp[k][0], dp[k][1] + p) return max(dp[k][0] for k in range(3))

注意这里循环内先更新dp[k][1]再更新dp[k][0],顺序和之前四个变量的版本略有不同:先更新持仓状态(买入),再更新空仓状态(卖出)。这样dp[k][1] + p用到的dp[k][1]是当天更新后的持仓值,可能会算入“同一天买入又卖出”。按照前文的分析,这不影响最终最大值,因为同一天倒买倒卖的利润为 0。

不过,如果你想严格避免这种“同一天先买后卖”的中间状态,可以把内层循环改成倒序更新 k,或者直接用四个变量的写法。实际测试中,这种写法都能通过全部测试用例,因为题目中同一天买入卖出不会带来额外利润。

3.3 内存优化版本:6 个变量的写法

最后给出最常见也最推荐记忆的版本——四个变量(严格说是四个核心变量,但加上临时理解可以认为只有四个):

def maxProfit(self, prices: List[int]) -> int: if not prices: return 0 # 初始化:第 0 天之前的状态 hold1 = -prices[0] # 第 0 天买入第一笔 sold1 = 0 # 还没卖出,利润 0 hold2 = -prices[0] # 第 0 天不可能做第二笔买入,但为了统一用 -prices[0] sold2 = 0 # 终态利润 0 # 注意:hold2 的初始值其实应该为 -inf,这里用 -prices[0] 是否安全? # 如果第 0 天买入第二笔,利润是 -prices[0],这明显是不合理的, # 但等到第 1 天,hold2 = max(hold2, sold1 - p1), # 如果旧 hold2 = -prices[0],sold1 - p1 = -p1(因为 sold1=0), # 取最大值时,旧 hold2 可能不是全局最优,但最终不会影响 sold2 的正确性, # 因为第二笔买入必然发生在第一笔卖出之后,所以真正有用的 hold2 一定来自 sold1 - p。 # 但为了严谨,建议还是将 hold2 初始化为 -10**9。 hold1 = -10**9 sold1 = 0 hold2 = -10**9 sold2 = 0 for p in prices: # 注意顺序:先更新 hold1,再 sold1,再 hold2,最后 sold2 hold1 = max(hold1, -p) # 今天买入第一笔 or 继续持有 sold1 = max(sold1, hold1 + p) # 今天卖出第一笔 or 保持 hold2 = max(hold2, sold1 - p) # 今天买入第二笔 or 继续持有 sold2 = max(sold2, hold2 + p) # 今天卖出第二笔 or 保持 return sold2

这段代码里最需要留意的就是hold2的初始值。注释里详细讨论了用 -prices[0] 还是 -inf:

  • 如果用-prices[0],相当于默认“第 0 天买入第二笔”是合法状态,这会污染后续hold2的计算吗?
  • 我们看实际递推:第 1 天时,hold2 = max(旧hold2, sold1 - p1),如果旧 hold2 = -prices[0] = -3,而sold1 - p1 = 0 - 5 = -5,那么 max(-3, -5) = -3,好像没问题。
  • 但假如 prices = [3, 5, 1, 2],正确答案是 3 买 5 卖(利润 2)+ 1 买 2 卖(利润 1)= 3。如果 hold2 初始化为 -prices[0] = -3,到第二天它可能是 -3, 第三天sold1已经变成 2,sold1 - p2 = 2 - 1 = 1,所以第 3 天hold2 = max(-3, 1) = 1,最后还是能正确买入。问题在于:初始时 hold2 = -3 比 -inf 大,会不会让某一天的sold2提前从错误的hold2中卖出“不存在的第二笔”?

理论上存在风险。比如 prices = [3,5,1,2],假设第 0 天把 hold2 设为 -3,第 1 天 sold2 = max(0, -3 + 5) = 2?不对,我们再仔细推:

  • 第 0 天:hold1=-3, sold1=0, hold2=-3, sold2=0。
  • 第 1 天 p=5:hold1=max(-3,-5)=-3;sold1=max(0,-3+5)=2;hold2=max(-3,2-5=-3)=-3;sold2=max(0,-3+5)=2。
  • 第 2 天 p=1:hold1=max(-3,-1)=-1(因为-1>-3,买入价1更便宜);sold1=max(2,-1+1=0)=2;hold2=max(-3,2-1=1)=1;sold2=max(2,1+1=2)=2。
  • 第 3 天 p=2:hold1=max(-1,-2)=-1;sold1=max(2,-1+2=1)=2;hold2=max(1,2-2=0)=1;sold2=max(2,1+2=3)=3。

最后 sold2=3,结果没错。但如果把 hold2 初始值设为 -inf 呢?第 1 天 hold2=max(-inf, 2-5=-3)=-3,第 2 天 hold2=max(-3,1)=1,结果一样。所以看起来在多数情况下无伤大雅,但为了逻辑严谨,我推荐一律使用 -10**9(或 -inf)作为所有 hold 状态(包括 hold1 和 hold2)的初始值,除非你明确知道自己在做什么。

注意:hold1的初始值也建议用 -10**9 而不是 -prices[0],因为如果 prices[0] 是 0 或负数(现实中股价不会为负,但某些测试数据可能包含 0),用 -0 会让“不持有但可以买入”的状态混淆。统一用 -inf,最安全。

4. 边界条件与理解关键点再剖析

4.1 为什么要用负无穷而不是 0

用一个表格直观对比:

初始值方案hold1sold1hold2sold2风险
全部 00000可能在未买入时“卖出”,产生虚假利润
买入用 -prices[0]-p00-p00第二笔买入初始值过“高”,有轻微污染风险
买入用 -inf-inf0-inf0符合逻辑,不可达状态保持绝对不可达

为什么“负无穷”这么重要?因为 DP 的转移是取 max。如果不可达状态是 0,那么任何“从不可达状态卖出”都会得到 0 + p = p,这会凭空多出一笔利润。比如假设你还没有买入第一笔,就直接从 hold1 卖出,sell1 变成今天的股价,显然错误。使用 -inf 后,未买入状态的持有利润是负无穷,卖出后依然是负无穷附近的值,会被 max 自动淘汰。

这里给一个实际反例:prices = [1, 2, 3, 4, 5]。正确答案是一次交易利润 4(1 买 5 卖)或分两次利润也是 4(1买2卖 + 3买4卖都是 4?实际上是 1+1=2,最大是 4),但如果你把 hold1 初始化为 0,第一天 hold1 = max(0, -1) = 0,第二天 sold1 = max(0, 0 + 2) = 2,凭空出现了 2 的利润,整条链的错误就蔓延开了。

4.2 状态机遍历顺序为什么是按天数而不是按交易次数

很多人在背模板时会纠结一个问题:为什么外层循环是“每一天”,内层才是“交易次数”?如果交换内外层可不可以?

在三维 DP 版本中,状态转移只依赖前一天的相同 k 的旧值,所以按照天数从前往后遍历是自然的。如果先遍历 k 再遍历天,由于 k 增加时依赖dp[i-1][k-1][0],你依然需要记住所有天数上、上一档 k 的数据,内存反而更大,而且滚动数组不方便维护。所以实际编码都采用“天”作为外层循环。

更深层的原因是:股票买卖题目中,“次数”维度天生就小于“天数”维度,交易的发生以天为单位。按天推进状态机,每一天的决策天然覆盖所有 k,代码结构最紧凑。

4.3 案例手推:逐步走一遍 prices = [3,3,5,0,0,3,1,4]

用四变量法手动走一遍,对理解极有帮助。设初始:

  • hold1 = -inf, sold1 = 0, hold2 = -inf, sold2 = 0

第 1 天 p=3:

  • hold1 = max(-inf, -3) = -3
  • sold1 = max(0, -3+3=0) = 0
  • hold2 = max(-inf, 0-3=-3) = -3
  • sold2 = max(0, -3+3=0) = 0

第 2 天 p=3:

  • hold1 = max(-3, -3) = -3
  • sold1 = max(0, -3+3=0) = 0
  • hold2 = max(-3, 0-3=-3) = -3
  • sold2 = max(0, -3+3=0) = 0

第 3 天 p=5:

  • hold1 = max(-3, -5) = -3(选择在第 1 天买入,今天的 5 太贵,不买)
  • sold1 = max(0, -3+5=2) = 2
  • hold2 = max(-3, 0-5=-5) = -3(还是用第一天的成本当第二笔?不对,第二笔应依赖 sold1 - p = 2-5 = -3,旧 hold2 也是 -3,所以不变)
  • sold2 = max(0, -3+5=2) = 2

第 4 天 p=0:

  • hold1 = max(-3, -0) = -3
  • sold1 = max(2, -3+0=-3) = 2
  • hold2 = max(-3, 2-0=2) = 2
  • sold2 = max(2, 2+0=2) = 2

注意这里 hold2 从 -3 跳到了 2,含义是:第一笔赚了 2,然后以 0 的价格买入第二笔,所以剩余现金 = 2 - 0 = 2。这个状态表示“已经锁定了第一笔利润,并持有第二支股票”。

第 5 天 p=0:

  • hold1 = max(-3, 0) = 0(这里注意,今天 0 元买入,持有成本为 0,比之前的 -3 更大,所以更新成 0。这意味着最佳策略变成“以 0 买入持有第一笔”,虽然现实中不太可能,但算法认可)
  • sold1 = max(2, 0+0=0) = 2
  • hold2 = max(2, 2-0=2) = 2
  • sold2 = max(2, 2+0=2) = 2

第 6 天 p=3:

  • hold1 = max(0, -3) = 0
  • sold1 = max(2, 0+3=3) = 3
  • hold2 = max(2, 2-3=-1) = 2
  • sold2 = max(2, 2+3=5) = 5

第 7 天 p=1:

  • hold1 = max(0, -1) = 0
  • sold1 = max(3, 0+1=1) = 3
  • hold2 = max(2, 3-1=2) = 2
  • sold2 = max(5, 2+1=3) = 5

第 8 天 p=4:

  • hold1 = max(0, -4) = 0
  • sold1 = max(3, 0+4=4) = 4
  • hold2 = max(2, 4-4=0) = 2
  • sold2 = max(5, 2+4=6) = 6

最终返回 6。和前面贪心例子里算出的 6 吻合。

手动推一遍的最大价值,是让你看到hold2不是从hold1转移来的,而是从sold1转移来的。很多之前搞混关系的人,推完这个例子基本就通了。

5. 常见问题与排查技巧实录

5.1 常见错误类型与排查表

根据我刷题和看别人代码的经验,LeetCode 123 常见的错误高度集中在几个点上。直接整理成速查表:

症状原因修复方法
输出比正确答案大初始值用了 0 而不是 -inf,导致凭空卖出所有 hold 初始化为 -10**9
输出为 0 但预期大于 0循环顺序写错,导致买入永远发生在卖出之后检查更新顺序:hold → sold
结果依赖 prices[0] 符号hold1 初始化用了 -prices[0],当价格为 0 时出现歧义统一用 -10**9
交易次数限制没生效内层循环从 1 到 k 时,没有保证“最多两次”检查 k 维度的上限,是否可以出现第 3 笔
相邻案例能过,复杂的乱状态转移时使用了当天更新后的值,产生连环操作重构为四个变量或按旧值保存后再更新
返回 sold1 或 sold2 不对最终结果应该取所有空仓状态的最大值用 max(sold1, sold2, 0) 或直接返回 sold2(因为 sold2 一定 >= sold1)

其中“输出比正确答案大”是最常见的,几乎每个放弃这道题的人都栽在这里过。

5.2 面试时最容易被追问的两个问题

面试官常会问:“如果题目改成最多 K 次交易怎么办?”这就是 LeetCode 188 买卖股票的最佳时机 IV。解法就是把两个固定变量换成数组,外层循环天数,内层循环次数,转移方程不变,只是k变成动态上限。这个扩展几乎可以直接套用上一节的二维数组版本。

第二个高频追问是:“为什么要四个状态而不是两个?”回答思路是:两个状态(持有/空仓)只能表示一次交易,因为第二次买入时依赖“第一笔已经卖出的利润”,而持有空仓状态无法区分你到底是第几次的持有、第几次的空仓。引入 hold1/sold1/hold2/sold2 是为了把“交易进行到第几笔”这个信息编码进状态。

5.3 一个容易被忽略的直觉提速:所有交易次数大于天数半数的题目

顺着上面的思路,我还想分享一个偷懒技巧:如果你在解决 K 次交易问题时发现 k >= n // 2,那么其实可以当作无限次交易来处理,直接用贪心累加所有上涨段的差价。原因是每笔交易至少需要两天,最多能完成 n // 2 笔交易,如果 K 超过了这个上限,限制就形同虚设。这个技巧在 LeetCode 188 里非常实用,能让代码少算很多 DP。

6. 题目变体与扩展思考

6.1 从一次、两次到 K 次:股票系列的整体脉络

股票类问题的大家族里,至少有六道经典题。把它们放在一起看,状态机的思想完全统一:

题目编号描述核心限制解法思路
121最佳时机 I只能一次交易维护历史最低价,贪心扫描
122最佳时机 II不限次数累加所有上涨段
123最佳时机 III最多两次四状态 DP 或三维 DP
188最佳时机 IV最多 K 次二维滚动 DP,K 超大时贪心
309含冷冻期卖完后隔一天才能买状态机多一个“冷冻”状态
714含手续费每次交易有手续费买入时成本加上手续费即可

掌握了 123 题的状态机思想,其他题目基本是“加状态”或“改方程”的区别。

6.2 交易次数与冷却期的叠加

如果你把 123 的“最多两次”和 309 的“冷冻期”叠加起来,会得到更复杂的题目。思路依然不变:无非是在状态机中加入“冷冻中”这个角色。只要心里始终用“角色 + 累计利润”来描述每个时刻,就不会乱。

6.3 为什么动态规划适合这类问题

最后说点务虚的。股票问题看似是“决策问题”,每天买或卖,但一旦加上交易次数限制,就变成了“最优化组合”问题。贪婪不行,回溯太慢,只有动态规划能同时处理“时间顺序”和“次数限制”这两个约束。状态机把“次数限制”离散成几个状态角色,DP 再每天根据选择更新角色的最佳收益,一套组合拳完美契合问题的结构。

我在实际刷题中还有一个小习惯:把 123 题的四个变量打印出来,跑几个样例,观察每个变量的变化轨迹。基本上跑通两个用例,你对“sold1 是 hold2 的起点”这件事的印象就再也丢不掉了。这个方法比看十篇题解都管用,建议你也可以试试。

至于初始化为什么非要写成 -109 而不是 -float('inf'),这个其实取决于你对 Python 的熟悉程度。用 -float('inf') 的好处是可读性强,坏处是在某些运算中 inf 参与 max 不会出错,但在个别语言里整型和浮点混用会带来类型问题。LeetCode 的 Python 环境里两种写法都能过,我建议用 -109,因为题目给的价格上限是 10^4,天数上限 10^5,理论最大利润不超过 10^9,所以 -10**9 足够小、足够安全,而且全程整型运算更快。这话不完全严谨,但胜在实用。

总结一下我踩过的坑:第一次写这题时,我把卖出和买入的顺序写反了,导致 sold1 永远先于 hold1 更新,结果是每天都卖出“不存在的股票”,越算越大。调了半天才意识到,DP 必须先有“买入”这个动作,才轮到“卖出”来取最大值。从那以后,我养成了一个习惯:凡是涉及买入卖出的状态机,永远按“先买后卖”的顺序写更新语句,宁可多写几行,也要让逻辑顺序严格对应现实交易顺序。这个经验,希望对你能有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询