前阵子在一个技术社群里看到有人问这道题,评论区里清一色都是“这题看题解都看不懂”“状态定义我背下来了但换个题就不会了”。我仔细翻了一下,发现 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 状态机模型的基本概念
动态规划的核心从来不是“背模板”,而是把问题变成一组“状态”,再定义“状态之间怎么转移”。股票问题最经典的建模方式是状态机:
每个时刻,你手里要么没有股票,要么持有一支股票。在没有股票的状态下,你可以选择买入,进入持有状态;在持有状态下,你可以选择卖出,回到空仓状态。
“最多两笔交易”再引入一个维度:已经完成了几笔交易(或者说正在第几笔交易中)。
这样一来,某个时刻你的处境可以描述成四个典型状态:
- 一次都没买过,手里空仓。
- 买过第一笔还没卖(第一笔持有中)。
- 卖出了第一笔,手里空仓,且只能再买第二笔。
- 买入了第二笔还没卖(第二笔持有中)。
注意第 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
用一个表格直观对比:
| 初始值方案 | hold1 | sold1 | hold2 | sold2 | 风险 |
|---|---|---|---|---|---|
| 全部 0 | 0 | 0 | 0 | 0 | 可能在未买入时“卖出”,产生虚假利润 |
| 买入用 -prices[0] | -p0 | 0 | -p0 | 0 | 第二笔买入初始值过“高”,有轻微污染风险 |
| 买入用 -inf | -inf | 0 | -inf | 0 | 符合逻辑,不可达状态保持绝对不可达 |
为什么“负无穷”这么重要?因为 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 必须先有“买入”这个动作,才轮到“卖出”来取最大值。从那以后,我养成了一个习惯:凡是涉及买入卖出的状态机,永远按“先买后卖”的顺序写更新语句,宁可多写几行,也要让逻辑顺序严格对应现实交易顺序。这个经验,希望对你能有帮助。