LeetCode 股票买卖系列:一个动态规划状态机模板通吃四题

四道股票买卖题(121 / 122 / 123 / 188)本质上是同一个动态规划状态机:状态从 2 个推广到 2k 个,一个模板通吃,还能统一做空间压缩。

关键词:动态规划 · 状态机 · 空间压缩 · LeetCode

写在前面

LeetCode 上有四道股票买卖题,难度从简单到困难,但它们本质上是同一个动态规划(DP)状态机:

核心思想:按天做 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. 买卖股票的最佳时机(简单)

题目链接:121. 买卖股票的最佳时机 - 力扣(LeetCode)

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 注意点

  1. 只能交易一次 :买入转移用 0 - prices[i],而不是 dp[i-1][0] - prices[i]------这是和 122 的唯一区别;
  2. 卖出必须在买入之后且不同日子:状态机天然保证(卖出用的是昨天的买入状态,不会当天买当天卖);
  3. 全程下跌时返回 0:卖出状态初始为 0,max 保证不会变成负数;
  4. 补充:本题其实有更简单的单变量做法------边遍历边维护「历史最低买入价」,用当天价格减它取最大。这里为了系列统一使用状态机模板。

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(中等)

题目链接:122. 买卖股票的最佳时机 II - 力扣(LeetCode)

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 注意点

  1. 与 121 的唯一区别就在买入那一行;
  2. 同一天「先卖后买」天然成立:卖出用的是昨天持有的状态、买入用的也是昨天的现金状态,两者互不干扰;
  3. 本题有等价贪心:把所有 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(困难)

题目链接:123. 买卖股票的最佳时机 III - 力扣(LeetCode)

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 注意点

  1. 状态必须按交易顺序转移:买入 2 只能从卖出 1 来,不能从买入 1 跳过去;
  2. 初始化:买入状态都是 -prices[0],卖出状态都是 0;
  3. 返回 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(困难)

题目链接:188. 买卖股票的最佳时机 IV - 力扣(LeetCode)

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 注意点

  1. 边界 k = 0 :直接返回 0,否则 2k-2 下标会越界;
  2. k >= n // 2 的捷径 :一笔交易至少跨 2 天,k 足够大时等价于 122 的不限次数,可直接贪心 sum(max(prices[i] - prices[i-1], 0)),省掉无谓的 O(nk);
  3. 滚动数组更新顺序:若压成单行 一维数组,必须从后往前 更新,保证用的是上一天的值;i % 2 两行版没有这个顾虑;
  4. 状态下标容易写错:买入 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。


相关推荐
圣保罗的大教堂2 小时前
leetcode 3756. 连接非零数字并乘以其数字和 II 中等
leetcode
All for pursuit.6 小时前
【回溯-7】494.目标和
数据结构·c++·算法·leetcode
Tisfy8 小时前
LeetCode 0678.有效的括号字符串:O(n)+O(1)一次遍历
算法·leetcode·字符串·题解·贪心·括号匹配
随意起个昵称19 小时前
【背包dp】输出方案路径
c++·动态规划
Navigator_Z21 小时前
LeetCode //C - 1278. Palindrome Partitioning III
c语言·算法·leetcode
青山木1 天前
Hot 100 --- 编辑距离
java·数据结构·算法·leetcode·动态规划
All for pursuit.1 天前
【回溯-4】46.全排列
数据结构·c++·算法·leetcode
随意起个昵称1 天前
【分组背包】洛谷刷题合集
算法·动态规划
All for pursuit.1 天前
【回溯-5】78.子集
数据结构·c++·算法·leetcode