【动态规划】309. 买卖股票的最佳时机含冷冻期及动态规划模板

动态规划(Dynamic Programming, DP)的解题思路和套路

  • 用一个通俗易懂的方式来拆解动态规划(Dynamic Programming, DP)的解题思路和套路。你可以把它想象成一个"填表格"的游戏,只要遵循固定的步骤,大部分问题都能迎刃而解。

动态规划的核心思想

想象一下你要爬一个很长的楼梯,每次只能爬一阶或两阶,问到达第100阶有多少种方法?

如果你直接想第100阶,会很复杂。但你可以换个思路:

  • 要到第100阶,你必然是从第99阶或者第98阶上来的。
  • 所以,到达第100阶的方法数 = (到达第99阶的方法数) + (到达第98阶的方法数)。

你看,一个大问题(到100阶)被拆解成了两个一模一样、但规模更小的子问题(到99阶和98阶)。我们只需要把每个小问题的答案记录下来,后面的大问题就能直接使用,避免重复计算。

这就是DP的核心:"记住"过去的结果,并利用这些结果推导出当前的结果。


解题四步法(通用套路)

几乎所有的动态规划问题,都可以通过以下四个步骤来解决:

第1步:定义状态(确定 dp 数组的含义)

这是最关键的一步。你需要创建一个数组(或几个变量),我们通常叫它 dp 数组。然后你要明确 dp[i] 究竟代表什么。

  • 一维 dp 数组 : dp[i] 通常表示"在处理到第 i 个元素时,我们要求解的那个值"。
    • 例如:在爬楼梯问题中,dp[i] 就代表"到达第 i 阶楼梯的总方法数"。
  • 二维 dp 数组 : dp[i][j] 通常表示"在处理到第 i 个元素和第 j 个元素时,我们要求解的那个值"。
  • 对于股票问题 : 状态可能更复杂。比如我们之前定义的 buy[i],它的含义就是"在第 i 天结束时,处于'持有股票'这个状态下的最大利润"。

套路 :问自己,"为了做出当前(第 i 天)的决策,我需要知道哪些来自过去(第 i-1 天)的信息?"。这些信息就是你要定义的状态。

第2步:找出状态转移方程(写出递推公式)

这一步是寻找不同状态之间的关系,也就是如何从已经计算出的子问题(比如 dp[i-1])推导出当前问题(dp[i])的解。

  • 爬楼梯问题 : dp[i] = dp[i-1] + dp[i-2]
  • 股票问题 :
    • buy[i] = max(buy[i-1], cooldown[i-1] - prices[i])
    • sell[i] = buy[i-1] + prices[i]
    • ...等等

套路 :思考 dp[i] 的值有哪些可能性。每种可能性都依赖于之前的某个状态。在所有可能性中取一个最优解(通常是 maxmin)。

第3步:初始化(确定"基本盘")

你的递推公式需要一个起点。这个起点就是 dp 数组的初始值,也叫"基本情况"或"边界条件"。它们是无需计算就能直接知道的答案。

  • 爬楼梯问题 : dp[0] = 1 (在第0阶,不动,算1种方法),dp[1] = 1 (到第1阶只有1种方法)。
  • 股票问题 : buy[0] = -prices[0] (第一天只能买入),sell[0] = 0 (第一天不可能卖出),cooldown[0] = 0 (第一天休息)。

套路 :思考 dp[0]dp[0][0] 的值应该是什么。如果无法确定,就从实际意义出发,给它一个合理的值。

第4步:确定遍历顺序

这一步是确定你"填表格"的方向。通常是从小问题到大问题。

  • 如果 dp[i] 依赖于 dp[i-1],那么你的 for 循环就应该是从 1 遍历到 n
  • 如果 dp[i][j] 依赖于 dp[i-1][j]dp[i][j-1],那么你就需要嵌套的 for 循环。

套路 :绝大多数情况都是一个从左到右的 for 循环。


总结

动态规划 = 找到最优子结构 + 记住子问题解

当你遇到一个看似复杂、求最优解的问题时,可以尝试套用这个四步法:

  1. 定义状态dp[i] 是什么意思?
  2. 状态转移dp[i] 怎么从 dp[i-1] 算出来?
  3. 初始化dp[0] 是多少?
  4. 遍历for 循环怎么写?

通过这个框架,你就可以把一个大问题分解成一系列清晰、可执行的步骤,从而系统地解决它。

算法设计:动态规划

在第 i 天,我们可能处于以下三种状态之一:

  1. 持有股票状态 (hold): 当天结束时,我们手中持有一支股票。
  2. 不持有股票状态(刚卖出,处于冷冻期) (sold): 当天我们卖出了股票,因此第二天不能买入。
  3. 不持有股票状态(已度过冷冻期) (rest): 当天我们手中没有股票,并且不是因为当天卖出导致的(即可以随时买入)。

我们的目标是找出最后一天结束后,不持有股票状态(soldrest)下的最大利润。

状态转移方程

对于第 i 天,我们可以根据第 i-1 天的状态来推导出当前状态的最大利润:

  1. hold[i]: 今天持有股票

    • 可能是昨天就持有,今天没操作:hold[i-1]
    • 也可能是今天买入的(那么昨天必须是 rest 状态):rest[i-1] - prices[i]
    • 所以:hold[i] = max(hold[i-1], rest[i-1] - prices[i])
  2. sold[i]: 今天卖出股票

    • 今天卖出,意味着昨天一定持有股票。
    • 所以:sold[i] = hold[i-1] + prices[i]
  3. rest[i]: 今天不持有股票且不在冷冻期

    • 可能是昨天就是 rest 状态,今天继续休息:rest[i-1]
    • 也可能是昨天是 sold 状态,今天冷冻期结束,进入 rest 状态:sold[i-1]
    • 所以:rest[i] = max(rest[i-1], sold[i-1])
空间优化

由于第 i 天的状态只与第 i-1 天有关,我们不需要一个完整的 DP 数组,只需用三个变量来记录前一天的状态,从而将空间复杂度从 O(n) 优化到 O(1)。

Python 代码实现

下面是使用空间优化后的动态规划算法实现。

python 复制代码
from typing import List

class Solution:
    def maxProfit(self, prices: List[int]) -> int:
        """
        使用动态规划解决带冷冻期的股票买卖问题。

        状态定义:
        - hold: 当天结束时持有股票的最大利润。
        - sold: 当天结束时因卖出股票而不持有股票的最大利润(进入冷冻期)。
        - rest: 当天结束时不持有股票且不在冷冻期的最大利润。
        """
        if not prices:
            return 0

        # 初始化状态
        # 第一天买入,所以持有状态的利润是 -prices[0]
        hold = -prices[0]
        # 第一天不可能卖出,利润为 0
        sold = 0
        # 第一天可以休息,利润为 0
        rest = 0

        for i in range(1, len(prices)):
            price = prices[i]
            
            # 暂存前一天的 sold 状态,因为 rest 的计算需要它
            prev_sold = sold

            # 状态转移
            # 今天卖出:昨天持有 + 今天价格
            sold = hold + price
            # 今天持有:max(昨天就持有, 昨天休息今天买入)
            hold = max(hold, rest - price)
            # 今天休息:max(昨天就休息, 昨天卖了今天冷冻期结束)
            rest = max(rest, prev_sold)

        # 最终最大利润是不持有股票的两种状态中的较大者
        return max(sold, rest)

是的,这段代码完全符合动态规划(DP)的一般原理,并且其实现方式具有一些值得注意的特点。

符合动态规划原理之处

  1. 最优子结构 (Optimal Substructure) :问题可以被分解为子问题。第 i 天的最大利润,依赖于第 i-1 天的各种状态(持有、卖出、冷冻)下的最大利润。最终问题的最优解(第 n-1 天的最大利润)可以由子问题的最优解推导出来。

  2. 重叠子问题 (Overlapping Subproblems) :在计算过程中,第 i-1 天的状态会被第 i 天的计算重复使用。DP通过存储这些子问题的解(在 buy, sell, cooldown 数组中)来避免重复计算。

  3. 状态定义 (State Definition) :代码清晰地定义了解决问题所需的所有状态。在任意一天 i,你只可能处于三种状态之一:

    • buy[i]: 当天结束时持有股票。
    • sell[i]: 当天结束时因卖出而不持有股票。
    • cooldown[i]: 当天结束时不持有股票且可以自由买入(非卖出当天,也非冷冻期)。
  4. 状态转移方程 (State Transition Equation) :代码的核心 for 循环精确地实现了状态之间的转移逻辑:

    • buy[i] = max(buy[i-1], cooldown[i-1] - prices[i])
    • sell[i] = buy[i-1] + prices[i]
    • cooldown[i] = max(cooldown[i-1], sell[i-1])
      这些方程构成了从前一个状态推导出当前状态的完整规则。

特殊之处

这个DP解法的特殊之处在于其状态机的思想

  1. 多状态并行推进 :与许多DP问题(如斐波那契数列、最长递增子序列)在每个步骤 i 只维护一个 dp[i] 值不同,此问题在每个步骤 i 需要同时维护和更新多个并行的状态buy, sell, cooldown)。

  2. 清晰的状态流转:这三个状态构成了一个微型的状态机。每一天的决策都会导致从一个状态转移到另一个状态。例如:

    • cooldown 状态可以转移到 buy 状态。
    • buy 状态可以转移到 sell 状态。
    • sell 状态强制转移到 cooldown 状态(这就是"冷冻期"的体现)。
    • buycooldown 状态可以维持自身不变。

这种将问题抽象为状态机模型,并为每个状态设计转移方程的思路,是解决这类序列决策问题的强大DP范式。虽然它使用了O(n)的空间复杂度,但逻辑清晰,易于理解。通过空间优化,可以将 buy, sell, cooldown 数组压缩为几个变量,将空间复杂度降至O(1)。

过程拆解

cpp 复制代码
# 实现solution类的maxProfit方法,能够给出动态规划的每一步
# 计算过程,详细注释每一步的逻辑和状态转移。
# 该方法计算在给定的股票价格列表中,买卖股票的最佳
# 时机,考虑冷冻期的影响。
# 通过动态规划方法,维护三个状态数组:
# 1. buy[i]:第i天持有股票的最大利润
# 2. sell[i]:第i天不持有股票且当天卖出的
# 最大利润
# 3. cooldown[i]:第i天不持有股票且当天处于
# 冷冻期的最大利润
# 最终返回最后一天的最大利润,即在sell[n - 1]和cooldown[n - 1]之间的最大值。
# 如果价格列表为空,则返回0。
class Solution3(object):
    def maxProfit(self, prices):
        """
        :type prices: List[int]
        :rtype: int
        """
        if not prices:
            return 0

        n = len(prices)
        # 初始化三个状态数组
        buy = [0] * n  # 第i天持有股票的最大利润
        sell = [0] * n  # 第i天不持有股票且当天卖出的最大利润
        cooldown = [0] * n  # 第i天不持有股票且当天处于冷冻期的最大利润

        buy[0] = -prices[0]  # 第一天买入股票,利润为负

        for i in range(1, n):
            # 更新持有股票的最大利润
            buy[i] = max(buy[i - 1], cooldown[i - 1] - prices[i])
            # 更新卖出股票的最大利润
            sell[i] = buy[i - 1] + prices[i]
            # 更新冷冻期的最大利润
            cooldown[i] = max(cooldown[i - 1], sell[i - 1])

            # 输出每一步的状态
            print(f"Day {i}: Buy={buy[i]}, Sell={sell[i]}, Cooldown={cooldown[i]}")

        # 返回最后一天的最大利润,取卖出和冷冻期中的较大值
        return max(sell[n - 1], cooldown[n - 1])

# 示例用法
if __name__ == "__main__":
    prices = [1, 2, 3, 0, 2]
    solution = Solution3()
    max_profit = solution.maxProfit(prices)
    print("最大利润:", max_profit)  # 输出: 最大利润: 3

309. 买卖股票的最佳时机含冷冻期

(309. 买卖股票的最佳时机含冷冻期)是 LeetCode 上一个非常经典的"买卖股票"系列问题之一。这个系列通过改变交易次数、手续费、冷冻期等限制条件,考察对动态规划不同状态定义的理解。

以下是与此问题高度相关的同系列 LeetCode 题目:

  1. 121. 买卖股票的最佳时机

    • 特点 :最基础的版本,整个过程只允许进行一次买卖。
  2. 122. 买卖股票的最佳时机 II

    • 特点 :不限制交易次数,可以进行多次买卖,但再次买入前必须卖出。
  3. 123. 买卖股票的最佳时机 III

    • 特点 :限制最多只能完成两笔交易。这通常需要更复杂的 DP 状态定义。
  4. 188. 买卖股票的最佳时机 IV

    • 特点 :是第三题的泛化版本,限制最多只能完成 k 笔交易。
  5. 714. 买卖股票的最佳时机含手续费

    • 特点 :可以进行多次交易,但每次卖出时需要支付一笔固定的手续费

这个系列是学习和掌握动态规划,特别是状态机模型的绝佳练习材料。


买卖股票的动态规划问题确实存在一个非常通用且强大的模板

这些买卖股票的动态规划问题确实存在一个非常通用且强大的模板。这个模板的核心思想是定义一个统一的状态,然后根据不同题目的约束条件对状态转移方程进行微调。

通用动态规划模板

我们可以定义一个三维的 DP 状态 dp[i][k][state]

  • i: 代表第 i 天 (从 0 到 n-1)。
  • k: 代表允许的最大交易次数。
  • state: 代表当前是否持有股票(0 表示不持有,1 表示持有)。

dp[i][k][state] 的值表示在第 i 天,最多允许 k 次交易,且处于 state 状态下所能获得的最大利润。

状态转移方程
  1. dp[i][k][0] (不持有股票):

    • 可能是昨天就不持有,今天休息:dp[i-1][k][0]
    • 也可能是昨天持有,今天卖出:dp[i-1][k][1] + prices[i]
    • 方程 : dp[i][k][0] = max(dp[i-1][k][0], dp[i-1][k][1] + prices[i])
  2. dp[i][k][1] (持有股票):

    • 可能是昨天就持有,今天休息:dp[i-1][k][1]
    • 也可能是昨天不持有,今天买入。注意:买入会消耗一次交易次数,所以要从 k-1 次交易的状态转移过来:dp[i-1][k-1][0] - prices[i]
    • 方程 : dp[i][k][1] = max(dp[i-1][k][1], dp[i-1][k-1][0] - prices[i])

套用模板到具体问题

下面展示如何将这个通用模板应用到各个具体问题上。

  1. 122. 买卖股票的最佳时机 II (k 为无穷大)

    • k 无穷大时,kk-1 可以视为相等,因为交易次数不受限制。
    • 因此,k 这个维度可以被忽略。
    • 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])
  2. 121. 买卖股票的最佳时机 (k = 1)

    • 这是 k=1 的特例。
    • dp[i][1][0] = max(dp[i-1][1][0], dp[i-1][1][1] + prices[i])
    • dp[i][1][1] = max(dp[i-1][1][1], dp[i-1][0][0] - prices[i])
    • 因为 k 最多为1,所以 dp[i-1][0][0] (交易0次,不持有) 的利润永远是 0
    • 简化后:dp[i][1] = max(dp[i-1][1], 0 - prices[i]) -> max(dp[i-1][1], -prices[i])
  3. 123. 买卖股票的最佳时机 III (k = 2) & 188. 买卖股票的最佳时机 IV (k 为定值)

    • 这两题直接使用通用模板,只需将 k 的循环范围设置为 1K 即可。
  4. 714. 含手续费 (k 为无穷大)

    • 与 II 类似,k 无穷大。只需在卖出时减去手续费。
    • dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i] - fee)
    • dp[i][1] = max(dp[i-1][1], dp[i-1][0] - prices[i])
  5. 309. 含冷冻期 (k 为无穷大)

    • 此题是模板的一个变体。k 同样无穷大。
    • 主要区别在于,今天买入 (dp[i][1]) 的前提是昨天必须处于冷冻期结束的状态,而不是任意不持有的状态。
    • 即买入时,要从 dp[i-2][0] (两天前不持有) 的状态转移而来,因为 i-1 天是冷冻期。
    • dp[i][1] = max(dp[i-1][1], dp[i-2][0] - prices[i])
    • 这正是您代码中 restcooldown 状态所做的事情:rest[i-1] 实际上就代表了 max(sell[i-2], rest[i-2]),即 dp[i-2][0] 的信息。

掌握这个通用模板后,解决股票系列问题就会变得思路清晰,只需根据题目的特殊约束(k的限制、手续费、冷冻期)对状态转移方程进行微调即可。


动态规划(DP)在更广泛领域

动态规划的主要应用领域

动态规划的核心是解决最优化问题。它的应用场景通常具备以下特征:问题可以被分解成一系列的决策步骤,并且每一步的决策都依赖于之前的状态,最终目标是找到一个最优的决策序列。

主要应用领域包括:

  1. 路径规划与图算法

    • 应用:在地图软件中寻找两个地点间的最短路径(如Dijkstra算法),或在网络中寻找最快的通信路由。
    • 思路:从起点开始,逐步计算到每个中间节点的最短路径,并记录下来,直到到达终点。
  2. 生物信息学

    • 应用:比较两段DNA或蛋白质序列的相似度(序列比对)。
    • 思路:通过DP计算出一个"编辑距离",即把一个序列变成另一个序列所需的最少操作(插入、删除、替换)次数。
  3. 资源分配与调度

    • 应用:经典的"背包问题"(在有限的承重下,如何选择物品以使总价值最大)、云计算中的任务调度和资源分配。
    • 思路:在有限的资源(背包容量、CPU时间)下,决策每一步是否要"装入"某个任务或物品,以达到全局最优。我们讨论的股票问题就是一种时间序列上的资源(资金)分配问题。
  4. 字符串处理

    • 应用:计算两个字符串的"最长公共子序列",拼写检查中的"最小编辑距离"。
    • 思路dp[i][j] 通常表示第一个字符串的前 i 个字符和第二个字符串的前 j 个字符之间的某种最优解。
相关推荐
花火|13 分钟前
算法训练营day62 图论⑪ Floyd 算法精讲、A star算法、最短路算法总结篇
算法·图论
GuGu202421 分钟前
新手刷题对内存结构与形象理解的冲突困惑
算法
汤永红23 分钟前
week4-[二维数组]平面上的点
c++·算法·平面·信睡奥赛
晴空闲雲40 分钟前
数据结构与算法-字符串、数组和广义表(String Array List)
数据结构·算法
颜如玉2 小时前
位运算技巧总结
后端·算法·性能优化
冷月半明3 小时前
时间序列篇:Prophet负责优雅,LightGBM负责杀疯
python·算法
秋难降3 小时前
聊聊 “摸鱼式” 遍历 —— 受控遍历的小心机
数据结构·算法·程序员
小xin过拟合7 小时前
day20 二叉树part7
开发语言·数据结构·c++·笔记·算法
lxmyzzs8 小时前
【图像算法 - 23】工业应用:基于深度学习YOLO12与OpenCV的仪器仪表智能识别系统
人工智能·深度学习·opencv·算法·计算机视觉·图像算法·仪器仪表识别