股票问题给我做的有一些混乱,因此本总结主要是借助GPT的帮助帮我解决下面的核心问题,也希望能通过这些示例与讲解,帮助各位快速厘清各种“股票问题”的通用 DP 思路。
经典股票问题:
不同股票问题之间的区别是什么?
尤其是为什么股票+冷冻期和股票二是变式,但是股票三四和股票二又不是同类了?
股票二理论上应该也能找到一个最大交易次数,
为什么股票二就只需要开两个状态,股票+冷冻期就只需要开三个或者四个状态,而股票三四就得开很多状态?
下面的答案中的dp数组设置仅供参考,作为理解用即可,实际上代码中的dp数组不一定使用这样的设置!(尤其是股票三、四)
以下是 LeetCode 上最常见、也最具代表性的几道股票交易题(中文名只是方便理解,不是官方名称):
买卖股票的最佳时机 I(LC 121)
买卖股票的最佳时机 II(LC 122)
dp[i][0 or 1]
。买卖股票的最佳时机 III(LC 123)
买卖股票的最佳时机 IV(LC 188)
买卖股票含冷冻期(LC 309)
买卖股票含手续费(LC 714)
dp
的转移时,把手续费体现在买入或卖出操作上即可。从上面可以看出,每道题变来变去的要点就是交易次数是否有限、是否有冷冻期、是否有手续费等等。由于限制不同,就导致了状态或转移方程不同。
股票 II:无限次交易,可以随时买卖(只要买在卖之前)。
股票 + 冷冻期:本质上也是可以进行无限次交易,只是额外加了一条限制:当你某天卖出之后,第二天不能马上买,要空过一天。所以它可以理解为“股票 II + 1 天冷冻期”。
因为交易次数不限制,所以我们不需要额外记录“还剩多少次交易可做”,不需要在 DP 里出现“第几次交易”的维度。
只需要在状态或转移中处理好 “卖出后下一天不能立即买” 的限制即可。于是出现了常见的 3 种(或 2+1 种)状态写法:
dp[i][0]
: 持股dp[i][1]
: 不持股,且今天正好卖出(意味着今天算冷冻日)dp[i][2]
: 不持股,且今天不处于冷冻期这就是为什么「股票 + 冷冻期」一般只用到三种或四种状态,而不需要再开一维记录交易次数。
股票 III(最多 2 次交易)、股票 IV(最多 k 次交易)与股票 II的主要差异是:交易次数是“有限制”的。
如果只需最多 1 次交易(LC 121),我们只要在一重循环里维护“最低价”和“当前利润”就行。
当允许最多 2 次或 3 次或 k 次……如果还想用 DP 去做,就需要在状态里增加一维,表示当前已经完成了多少笔交易或还能做多少笔交易。
比如股票 III,有几种常见写法之一是:
dp[i][j][0 or 1]
i
表示第 i 天j
表示已经完成的交易次数(0~2)这样每个 dp[i][j][0]
和 dp[i][j][1]
就能知道:到了第 i 天,在用了 j 次交易之后,我不持股/持股的最大利润是多少。
当 j 达到 2(对于“最多两次交易”),或者 j 达到 k(对于“最多 k 次交易”),就不能继续开新仓交易了,只能等待卖出或维持不变(具体看题意)。
为什么要多一维? 因为在每一次买入卖出的过程中,你需要知道“我还剩多少次买卖的配额”或“我当前是第一次交易还是第二次交易?”,否则就没法控制“最多只能做 2 笔、3 笔或 k 笔”。
对于「股票 II」,交易次数不限制:
因而「股票 II」只需要记录“当前是否持股”,即:
dp[i][0]
: 第 i 天结束后,不持股状态的最大利润dp[i][1]
: 第 i 天结束后,持股状态的最大利润这样写就够了,而且转移很简单:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i]) # 今天不操作 or 今天卖出
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i]) # 今天不操作 or 今天买入
“股票 + 冷冻期”其实就是「股票 II」在这个基础上再挖一个小坑:卖出后的隔天不能买,所以加一个额外的状态或额外逻辑去处理即可。
因为对「III、IV」而言,交易次数是有限的:
当交易次数受限时,“贪心”就不一定能随便用了。比如“碰到波谷就买,碰到波峰就卖”这种做法,如果你的交易次数只有 2 次,但是价格波动很多,哪一次该抓、哪一次该放,需要更多全局权衡。
无限次 vs 有限次:是否需要引入“剩余可用交易次数”或“已完成次数”这一维。
是否有冷冻期 / 手续费 / 交易税等特殊限制:
一些常见题的状态设计对比表,可以是这样(只是示意,具体实现可能略有差别):
题目 | 交易次数限制 | 是否有其他特殊限制 | 状态设计举例 |
---|---|---|---|
LC 121(股票 I) | 最多 1 次 | 无 | 不需要 DP,或dp[i] = maxProfit (仅存最低价) |
LC 122(股票 II) | 无限次 | 无 | dp[i][0 or 1] |
LC 309(冷冻期) | 无限次 | 卖出后冷冻 1 天 | dp[i][0 or 1 or 2] 或类似 |
LC 123(股票 III) | 最多 2 次 | 无 | dp[i][j][0 or 1] , j ∈ {0,1,2} |
LC 188(股票 IV) | 最多 k 次 | 无 | dp[i][j][0 or 1] , j ∈ {0..k} |
LC 714(含手续费) | 无限次 | 每笔卖出收手续费 | dp[i][0 or 1] ,在卖出操作或买入操作里扣费 |
简单来说:
「为什么股票 + 冷冻期(309)和股票 II(122)是同类,但是股票 III、IV 和股票 II 就又不是了?」
股票 + 冷冻期 其实就是 股票 II(无限次交易) 再加一个“卖出后下一天不能买入”的限制;两者都不限制交易次数,所以思路/状态比较相似,只不过 309 题里要额外处理“冷冻期”。
股票 III、IV 则是“有交易次数上限”的情形,它们主要用在“只允许最多 2 次 / k 次”买卖,如果你不记“多少次交易已经用完”,就没法限制你不能再交易了,必须在 DP 中多开一个“次数”维度。
「股票 II 理论上是不是也能算有最大交易次数?」
k = +∞
的情况。如果 k
无限大,那就不必再记录 k
了,因为你永远不会用完。k
会很大,就没必要;直接用一对 dp[i][0 or 1]
就行了,更简洁。「为什么‘股票二只需要开两个状态’,而‘股票+冷冻期’只需要开三个/四个状态,‘股票三四’就得开很多状态?」
股票 II(无限次、无手续费、无冷冻期)只需记录:
dp[i][0]
: 第 i 天不持股dp[i][1]
: 第 i 天持股股票 + 冷冻期(309)也是无限次,但**多了“冷冻期”**这一个限制,为了在“转移”时区分“今天能不能买”,就会多 1~2 个状态(看你怎么拆分)。
股票 III、IV 是“最多 k 次”交易,它跟“冷冻期”没关系,重点是必须记录还剩多少次交易才能控制不超过限额。所以要多开一个“交易次数”维度,如 dp[i][j][0 or 1]
,这样状态量就多了。
换言之:
因此,你会看到:
这就是为什么有时题目只需开 2 个或 3 个状态,而有时题目需要开很多状态/多一维度的根本原因。希望能帮你理清思路。
给出常见的各类股票问题代码模板,并比较说明增加了什么或者减少了什么。
下面的模板不一定完全正确!仅供我自己参考用
下面给你一个“常见股票问题”的代码模板+思路比较总览,涵盖以下几类:
下面的示例均以 Python 为例,演示自底向上的 DP 模板(部分题也有更简洁的写法,这里主要展示统一的“DP 思路”对比)。在每个模板后,会比较说明它比前面哪些题“多或少了什么限制”以及“在状态或转移中做了哪些变化”。
限制:
常见最简写法:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
_min = float('inf') # 从左向右遇到的目前的最小值
ans = 0 # 目前所能获取的最大利润
for i in range(len(prices)):
_min = min(_min, prices[i]) # 更新最小值
ans = max(ans, prices[i] - _min) # 更新最大利润
return ans
对比说明:
附一维dp:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
# dp0:表示当天 **持有股票** 时的最大收益(最优方案)。
# dp1:表示当天 **不持有股票** 时的最大收益(最优方案)。
# 初始化第0天的,以开始递推
dp0, dp1 = -prices[0], 0
for i in range(1, len(prices)): # 第i天,要从索引1天开始
old_dp0 = dp0
# 今天持有:要么昨天已经在持有,要么之前从来没买过(手上现金额没使用过,还是0)今天刚买入
dp0 = max(old_dp0, -prices[i])
# 今天不持有:要么昨天也不持有,要么昨天持有今天刚卖出
dp1 = max(dp1, old_dp0 + prices[i])
return max(dp0, dp1)
限制:
DP 思路(自底向上,二维):
dp[i][0]
: 第 i
天收盘后 不持股 时的最大收益;dp[i][1]
: 第 i
天收盘后 持股 时的最大收益。状态转移:
dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
代码模板:
def maxProfit_2(prices):
n = len(prices)
dp = [[0, 0] for _ in range(n)]
# 初始化
dp[0][0] = 0 # 第0天不持股
dp[0][1] = -prices[0] # 第0天持股(买入)
for i in range(1, n):
dp[i][0] = max(
dp[i-1][0], # 继续不持股
dp[i-1][1] + prices[i] # 今天卖出
)
dp[i][1] = max(
dp[i-1][1], # 继续持股
dp[i-1][0] - prices[i] # 今天买入
)
return dp[n-1][0] # 最后一天不持股时利润最大
对比说明:
限制:
DP 思路(自底向上,三维):
常见的一种写法:dp[i][j][0 or 1]
i
: 第 i
天 (0-based)j
: 已完成的交易次数 (0 ~ 2)注意上面这种写法可能有问题!只看分析就好,代码还是看后面股票四附上的题解!
状态转移:
dp[i][j][0]
):要么前一天也不持股,要么前一天持股并在今天卖掉dp[i][j][1]
):要么前一天就持股,要么前一天不持股并在今天买入对比说明:
j
表示已经用了多少次交易,从而保证最多只能进行 2 次交易。限制:
DP 思路:
j
的上限从 2 换成了 k
。对比说明:
for j in range(k+1)
.k
非常大时,可以用“股票 II”思路特判一下(因为相当于无限次交易)。附**灵茶山艾府的题解** :(直接涵盖了股票三)
1:1 翻译成递推
问:为什么第二维度的大小是 k+2?
答:在记忆化搜索中,j 的范围是 [−1,k],这一共有 k+2 个数。1:1 翻译成递推就需要 k+2 的数组大小。
问:f 数组中的 j=0 表示什么意思?
答:这对应着记忆化搜索中的 j=−1 的状态,也就是交易 −1 次的状态。注意这是不合法的,所以初始值一定是 −∞。
问:f 的初始值怎么确定?
答:f 的初始值来自记忆化搜索的递归边界,递归边界怎么写,初始值就怎么写。
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
n = len(prices)
f = [[[-inf] * 2 for _ in range(k + 2)] for _ in range(n + 1)]
for j in range(1, k + 2):
f[0][j][0] = 0
for i, p in enumerate(prices):
for j in range(1, k + 2):
f[i + 1][j][0] = max(f[i][j][0], f[i][j][1] + p)
f[i + 1][j][1] = max(f[i][j][1], f[i][j - 1][0] - p)
return f[-1][-1][0]
复杂度分析
时间复杂度:O(nk),其中 n 为 prices 的长度。
空间复杂度:O(nk)。
空间优化
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
f = [[-inf] * 2 for _ in range(k + 2)]
for j in range(1, k + 2):
f[j][0] = 0
for p in prices:
for j in range(k + 1, 0, -1): # 注意倒序
f[j][0] = max(f[j][0], f[j][1] + p)
f[j][1] = max(f[j][1], f[j - 1][0] - p)
return f[-1][0]
复杂度分析
时间复杂度:O(nk),其中 n 为 prices 的长度。
空间复杂度:O(k)。
上面的设置和思路其实并没有那么好理解,故也可以按照代码随想录的方法来:
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
# 注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
# 同一天,对应着(1 + 2 * k)个状态,也就是(1 + 2 * k)个dp值
# dp[j]表示当天处在状态j时,对应的最大利润
dp = [0] * (1 + 2 * k)
# 状态有如下:
# 0:无操作,啥也没干
# 1:从第1次买入开始持有的期间;2:从第1次卖出开始不持有的期间
# 3:从第2次买入开始持有的期间;4:从第2次卖出开始不持有的期间
# ...
# 2*k-1:从第k次买入开始持有的期间;2*k:从第k次卖出开始不持有的期间
# 初始化第0天
# 状态0,以及偶数状态:在第0天对应dp值都为0,不用再手动初始化
# 奇数状态:在第0天对应dp值都为买入第0天股票后的现金额
for j in range(1, 2*k, 2):
dp[j] = -prices[0]
# 状态转移
for i in range(1, len(prices)): # 遍历从索引1天开始的每一天
price = prices[i]
# 状态0列不需要转移
# 奇数状态j:对应从当次买入开始持有的期间
# 要么前一天已经是j,要么前一天是j-1今天买入(减去当天股价)变成j
# 偶数状态j:对应从当次卖出开始不持有的期间
# 要么前一天已经是j,要么前一天是j-1今天卖出(加上当天股价)变成j
# 因此可以统一为:用(-1) ** j控制当天股价到底是减还是加!
old_dp = dp[:] # 保证在更新过程中一直引用前一次的dp值
for j in range(1, 2 * k + 1): # 从状态1开始遍历更新dp
dp[j] = max(old_dp[j], old_dp[j-1] + (-1) ** j * prices[i])
return dp[-1]
# 或者标准一点
# return max(dp)
限制:
常见 DP 设计(三种状态):
dp[i][0]
: 第 i
天持股的最大收益dp[i][1]
: 第 i
天不持股,且当天正好卖出(处于冷冻日)的最大收益dp[i][2]
: 第 i
天不持股,且不处于冷冻期的最大收益代码模板:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
if n < 2:
return 0
# 定义三种状态的动态规划数组
dp = [[0] * 3 for _ in range(n)]
dp[0][0] = -prices[0] # 持有股票的最大利润
dp[0][1] = 0 # 不持有股票,且处于冷冻期的最大利润
dp[0][2] = 0 # 不持有股票,不处于冷冻期的最大利润
for i in range(1, n):
# 当前持有股票的最大利润等于前一天持有股票的最大利润或者前一天不持有股票且不处于冷冻期的最大利润减去当前股票的价格
dp[i][0] = max(dp[i-1][0], dp[i-1][2] - prices[i])
# 当前不持有股票且处于冷冻期的最大利润等于前一天持有股票的最大利润加上当前股票的价格
dp[i][1] = dp[i-1][0] + prices[i]
# 当前不持有股票且不处于冷冻期的最大利润等于前一天不持有股票的最大利润或者前一天处于冷冻期的最大利润
dp[i][2] = max(dp[i-1][2], dp[i-1][1])
# 返回最后一天不持有股票的最大利润
return max(dp[-1][1], dp[-1][2])
对比说明:
限制:
fee
即可(整个单次买入卖出交易只减去一次手续费)代码(与“股票 II”相似,只是卖出或买入时要多减一个 fee
):
class Solution:
def maxProfit(self, prices: List[int], fee: int) -> int:
# 设定买入时付手续费,卖出时不用
dp0 = 0 # 当天不持有股票,第0天初始化为0
dp1 = -prices[0] - fee # 当前持有股票,第0天初始化为-prices[0] - fee,相当于买入且付手续费
for i in range(1, len(prices)):
price = prices[i]
old_dp0 = dp0
dp0 = max(old_dp0, dp1 + price) # 今天不持有,则要么昨天不持有,要么昨天持有今天卖出
dp1 = max(dp1, dp0 - price - fee) # 今天持有,则要么昨天持有,要么昨天不持有今天买入
return max(dp0, dp1)
对比说明:
fee
。交易次数有限制(“最多 1 次”“最多 2 次”“最多 k 次”)时:
交易次数无限但有其他限制时(冷冻期、手续费):
fee
。股票 II(无限次) vs 股票 III/IV(有限次)
冷冻期 / 手续费
- 增加了什么:一旦题目说“有冷冻期”或者“有交易次数上限”等,就要在状态维度或转移中多出相应的逻辑;
- 减少了什么:如果题目取消交易次数限制,或者去掉冷冻期/手续费等特殊条件,状态就会更精简。
dp[i][0 or 1]
即可。dp[i][j][0 or 1]
。这些模板互相对比,核心区别就在:
希望这些示例与讲解,能帮助你快速掌握和对比各种“股票问题”的通用 DP 思路。