四道股票买卖题(121 / 122 / 123 / 188)本质上是同一个动态规划状态机:状态从 2 个推广到 2k 个,一个模板通吃,还能统一做空间压缩。
关键词:动态规划 · 状态机 · 空间压缩 · LeetCode
写在前面
LeetCode 上有四道股票买卖题,难度从简单到困难,但它们本质上是同一个动态规划(DP)状态机:
- 121. 买卖股票的最佳时机(只能交易 1 次)
- 122. 买卖股票的最佳时机 II(不限交易次数)
- 123. 买卖股票的最佳时机 III(最多 2 次)
- 188. 买卖股票的最佳时机 IV(最多 k 次)
核心思想:按天做 DP,每天结束时只关心两种大状态------持有现金 (刚卖完或从未买过)和持有股票 (刚买入或一直拿着)。交易次数多的题,就把状态从 2 个扩展成 2k 个,转移规律完全一样。
一、统一模板
1.1 状态定义
对最多 k 笔交易,定义 2k 个状态,两两一组(j = 1..k):
| 下标 | 状态 | 含义 |
|---|---|---|
2j-2 |
卖出 j | 完成第 j 次卖出后的最大收益(持有现金) |
2j-1 |
买入 j | 完成第 j 次买入后的最大收益(持有股票) |
1.2 转移方程
卖出 j:dp[i][2j-2] = max(dp[i-1][2j-2], dp[i-1][2j-1] + prices[i])
买入 1:dp[i][1] = max(dp[i-1][1], -prices[i])
买入 j:dp[i][2j-1] = max(dp[i-1][2j-1], dp[i-1][2j-4] - prices[i]) (j ≥ 2)
- 「什么都不做」就是继承昨天的状态;
- 「卖出 j」用昨天「买入 j」的状态加上当天股价;
- 「买入 j」(j ≥ 2)用昨天「卖出 j-1」赚到的钱减去当天股价;第 1 次买入之前没买过,成本从 0 开始。
1.3 通用代码(即 188 题的模板形式)
python
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
n = len(prices)
if k == 0 or n < 2:
return 0
# 2k 个状态,下标 2j-2 = 第 j 次卖出,2j-1 = 第 j 次买入
dp = [[0] * (2 * k) for _ in range(n)]
# 第 0 天:买入状态都是 -prices[0],卖出状态都是 0(什么都不做)
for j in range(k):
dp[0][2 * j + 1] = -prices[0]
for i in range(1, n):
# 第 1 次卖出 / 买入
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
dp[i][1] = max(dp[i - 1][1], -prices[i])
# 第 2..k 次交易
for j in range(2, k + 1):
dp[i][2 * j - 2] = max(dp[i - 1][2 * j - 2],
dp[i - 1][2 * j - 1] + prices[i])
dp[i][2 * j - 1] = max(dp[i - 1][2 * j - 1],
dp[i - 1][2 * j - 4] - prices[i])
# 每个状态都允许"什么都不做",所以第 k 次卖出 >= 第 k-1 次卖出,
# 返回最后一次卖出即可(对应"最多 k 笔"而非"恰好 k 笔")
return dp[n - 1][2 * k - 2]
1.4 四道题与模板的对应关系
| 题目 | k | 状态数 | 买入成本来源 |
|---|---|---|---|
| 121 | 1 | 2 | 0(之前没买过) |
| 122 | ∞ | 2 | 昨天持有现金 dp[i-1][0] |
| 123 | 2 | 4 | 第 2 次用第 1 次卖出的钱 |
| 188 | k | 2k | 第 j 次用第 j-1 次卖出的钱 |
二、空间压缩套路
每个题都能把 dp 从 n × 状态数 压成 2 × 状态数:dp[i] 只依赖 dp[i-1],用 i % 2 在两行之间来回写。
python
dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] + prices[i])
dp[i % 2][1] = max(dp[(i - 1) % 2][1], dp[(i - 1) % 2][0] - prices[i])
第 i 天写 i % 2 行、读 (i - 1) % 2 行,两行互不污染,正确性不变;空间从 O(n·S) 降到 O(S)(S 为状态数)。下面每题都会先给出非压缩版本(正常思路),再给出优化的压缩版本。
若再进一步压成单行 (从后往前更新),空间能到
O(S)的一半,但容易写错下标;i % 2两行版更好理解、更不容易出错,推荐优先使用。
三、121. 买卖股票的最佳时机(简单)
3.1 题目大意
给定价格数组,只能买卖一次(先买后卖,且必须是在不同的日子),求最大利润;无利润返回 0。
3.2 解题思路
两个状态即可:
- 持有现金
dp[i][0]:什么都不做,或把持有的股票按prices[i]卖出; - 持有股票
dp[i][1]:什么都不做,或当天买入。因为只能交易一次,之前没买过,买入成本恒为 0。
3.3 非压缩版本(正常思路)
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
# 0:持有现金;1:持有股票
dp = [[0] * 2 for _ in range(n)]
dp[0][0] = 0
dp[0][1] = -prices[0]
for i in range(1, n):
# 什么都不做,或者卖出
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
# 什么都不做,或者买入(只能交易一次,之前没买过,所以是 0)
dp[i][1] = max(dp[i - 1][1], -prices[i])
return dp[n - 1][0]
复杂度:时间 O(n),空间 O(n)。
3.4 注意点
- 只能交易一次 :买入转移用
0 - prices[i],而不是dp[i-1][0] - prices[i]------这是和 122 的唯一区别; - 卖出必须在买入之后且不同日子:状态机天然保证(卖出用的是昨天的买入状态,不会当天买当天卖);
- 全程下跌时返回 0:卖出状态初始为 0,
max保证不会变成负数; - 补充:本题其实有更简单的单变量做法------边遍历边维护「历史最低买入价」,用当天价格减它取最大。这里为了系列统一使用状态机模板。
3.5 空间压缩版(优化)
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
# 0:持有现金;1:持有股票
dp = [[0] * 2 for _ in range(2)]
dp[0][0] = 0
dp[0][1] = -prices[0]
for i in range(1, n):
# 什么都不做,或者卖出
dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] + prices[i])
# 什么都不做,或者买入(只能交易一次,成本从 0 开始)
dp[i % 2][1] = max(dp[(i - 1) % 2][1], -prices[i])
return dp[(n - 1) % 2][0]
复杂度:时间 O(n),空间 O(1)。
四、122. 买卖股票的最佳时机 II(中等)
4.1 题目大意
不限交易次数,但任何时候最多持有一股;同一天可以先卖后买。求最大利润。
4.2 解题思路
状态还是 2 个,但买入转移从 0 - prices[i] 改成 dp[i-1][0] - prices[i]:用之前交易赚到的现金去买,买卖可以无限循环。
4.3 非压缩版本(正常思路)
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
# 0:持有现金;1:持有股票
dp = [[0] * 2 for _ in range(n)]
dp[0][0] = 0
dp[0][1] = -prices[0]
for i in range(1, n):
# 什么都不做,或者卖出
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
# 什么都不做,或者买入,dp[i-1][0] 是之前交易赚的钱
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i])
return dp[n - 1][0]
复杂度:时间 O(n),空间 O(n)。
4.4 注意点
- 与 121 的唯一区别就在买入那一行;
- 同一天「先卖后买」天然成立:卖出用的是昨天持有的状态、买入用的也是昨天的现金状态,两者互不干扰;
- 本题有等价贪心:把所有
prices[i] - prices[i-1] > 0的差累加就是答案(不限次数时每个上升段都能低买高卖),可用作对拍验证。
4.5 空间压缩版(优化)
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
dp = [[0] * 2 for _ in range(2)]
dp[0][0] = 0
dp[0][1] = -prices[0]
for i in range(1, n):
# 什么都不做,或者卖出
dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] + prices[i])
# 什么都不做,或者买入,dp[(i-1)%2][0] 是之前交易赚的钱
dp[i % 2][1] = max(dp[(i - 1) % 2][1], dp[(i - 1) % 2][0] - prices[i])
return dp[(n - 1) % 2][0]
复杂度:时间 O(n),空间 O(1)。
五、123. 买卖股票的最佳时机 III(困难)
5.1 题目大意
最多完成两笔交易,且必须在再次购买前卖掉之前的股票。求最大利润。
5.2 解题思路
4 个状态:卖出 1、买入 1、卖出 2、买入 2。第 2 次买入必须用第 1 次卖出的钱 (dp[i-1][0] - prices[i]),这是从 121 递进到 123 的关键一步。
5.3 非压缩版本(正常思路)
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
# 0:第 1 次卖出;1:第 1 次买入;2:第 2 次卖出;3:第 2 次买入
dp = [[0] * 4 for _ in range(n)]
dp[0] = [0, -prices[0], 0, -prices[0]]
for i in range(1, n):
# 第 1 次卖出:什么都不做,或者卖出
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
# 第 1 次买入:什么都不做,或者买入(之前没买过)
dp[i][1] = max(dp[i - 1][1], -prices[i])
# 第 2 次卖出:什么都不做,或者卖出
dp[i][2] = max(dp[i - 1][2], dp[i - 1][3] + prices[i])
# 第 2 次买入:什么都不做,或者买入,dp[i-1][0] 是第 1 次赚的钱
dp[i][3] = max(dp[i - 1][3], dp[i - 1][0] - prices[i])
return dp[n - 1][2]
复杂度:时间 O(n),空间 O(n)。
5.4 注意点
- 状态必须按交易顺序转移:买入 2 只能从卖出 1 来,不能从买入 1 跳过去;
- 初始化:买入状态都是
-prices[0],卖出状态都是 0; - 返回
dp[n-1][2](第 2 次卖出):因为每个状态都允许「什么都不做」,第 2 次卖出 ≥ 第 1 次卖出,天然对应「最多两笔」。
5.5 空间压缩版(优化)
python
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
# 0:第 1 次卖出;1:第 1 次买入;2:第 2 次卖出;3:第 2 次买入
dp = [[0] * 4 for _ in range(2)]
dp[0] = [0, -prices[0], 0, -prices[0]]
for i in range(1, n):
# 第 1 次卖出:什么都不做,或者卖出
dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] + prices[i])
# 第 1 次买入:什么都不做,或者买入(之前没买过)
dp[i % 2][1] = max(dp[(i - 1) % 2][1], -prices[i])
# 第 2 次卖出:什么都不做,或者卖出
dp[i % 2][2] = max(dp[(i - 1) % 2][2], dp[(i - 1) % 2][3] + prices[i])
# 第 2 次买入:什么都不做,或者买入,dp[(i-1)%2][0] 是第 1 次赚的钱
dp[i % 2][3] = max(dp[(i - 1) % 2][3], dp[(i - 1) % 2][0] - prices[i])
return dp[(n - 1) % 2][2]
复杂度:时间 O(n),空间 O(1)。
六、188. 买卖股票的最佳时机 IV(困难)
6.1 题目大意
最多完成 k 笔交易(最多买 k 次、卖 k 次),且必须在再次购买前卖掉之前的股票。求最大利润。
6.2 解题思路
把 123 的 4 个状态推广成 2k 个状态:转移方程一字不改,用循环处理 j = 2..k 即可,123 就是本模板 k = 2 的特例。
6.3 非压缩版本(正常思路)
python
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
n = len(prices)
if k == 0 or n < 2:
return 0
# 2k 个状态:下标 2j-2 = 第 j 次卖出,2j-1 = 第 j 次买入
dp = [[0] * (2 * k) for _ in range(n)]
# 第 0 天:买入状态都是 -prices[0],卖出状态都是 0
for j in range(k):
dp[0][2 * j + 1] = -prices[0]
for i in range(1, n):
# 第 1 次卖出 / 买入
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i])
dp[i][1] = max(dp[i - 1][1], -prices[i])
# 第 2..k 次交易
for j in range(2, k + 1):
dp[i][2 * j - 2] = max(dp[i - 1][2 * j - 2],
dp[i - 1][2 * j - 1] + prices[i])
dp[i][2 * j - 1] = max(dp[i - 1][2 * j - 1],
dp[i - 1][2 * j - 4] - prices[i])
return dp[n - 1][2 * k - 2]
复杂度:时间 O(nk),空间 O(nk)。
6.4 注意点
- 边界
k = 0:直接返回 0,否则2k-2下标会越界; k >= n // 2的捷径 :一笔交易至少跨 2 天,k 足够大时等价于 122 的不限次数,可直接贪心sum(max(prices[i] - prices[i-1], 0)),省掉无谓的O(nk);- 滚动数组更新顺序:若压成单行 一维数组,必须从后往前 更新,保证用的是上一天的值;
i % 2两行版没有这个顾虑; - 状态下标容易写错:买入 j 用的是下标
2j-4(即卖出 j-1 的位置),建议先写 123 的 4 个状态再推广。
6.5 空间压缩版(优化)
python
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
n = len(prices)
if k == 0 or n < 2:
return 0
# 2k 个状态:下标 2j-2 = 第 j 次卖出,2j-1 = 第 j 次买入
dp = [[0] * (2 * k) for _ in range(2)]
# 第 0 天:买入状态都是 -prices[0],卖出状态都是 0
for j in range(k):
dp[0][2 * j + 1] = -prices[0]
for i in range(1, n):
# 第 1 次卖出:什么都不做,或者卖出
dp[i % 2][0] = max(dp[(i - 1) % 2][0], dp[(i - 1) % 2][1] + prices[i])
# 第 1 次买入:什么都不做,或者买入(之前没买过)
dp[i % 2][1] = max(dp[(i - 1) % 2][1], -prices[i])
for j in range(2, k + 1):
# 第 j 次卖出:什么都不做,或者卖出
dp[i % 2][2 * j - 2] = max(dp[(i - 1) % 2][2 * j - 2],
dp[(i - 1) % 2][2 * j - 1] + prices[i])
# 第 j 次买入:什么都不做,或者买入,用第 j-1 次卖股票赚的钱
dp[i % 2][2 * j - 1] = max(dp[(i - 1) % 2][2 * j - 1],
dp[(i - 1) % 2][2 * j - 4] - prices[i])
return dp[(n - 1) % 2][2 * k - 2]
复杂度:时间 O(nk),空间 O(k)。
七、总结对比
| 题目 | k | 状态数 | 买入成本 | 答案状态 | 时间 | 未压缩空间 | 压缩后空间 |
|---|---|---|---|---|---|---|---|
| 121 | 1 | 2 | 0 | dp[n-1][0] |
O(n) | O(n) | O(1) |
| 122 | ∞ | 2 | dp[i-1][0] |
dp[n-1][0] |
O(n) | O(n) | O(1) |
| 123 | 2 | 4 | 卖出 1 的钱 | dp[n-1][2] |
O(n) | O(n) | O(1) |
| 188 | k | 2k | 卖出 j-1 的钱 | dp[n-1][2k-2] |
O(nk) | O(nk) | O(k) |
一句话记忆:状态只有「现金 / 股票」两种;每多一次交易,就把这两个状态复制一份;买入永远花「上一次卖出赚的钱」------第 0 次卖出的钱就是 0。