动态规划(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]
的值有哪些可能性。每种可能性都依赖于之前的某个状态。在所有可能性中取一个最优解(通常是 max
或 min
)。
第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
循环。
总结
动态规划 = 找到最优子结构 + 记住子问题解
当你遇到一个看似复杂、求最优解的问题时,可以尝试套用这个四步法:
- 定义状态 :
dp[i]
是什么意思? - 状态转移 :
dp[i]
怎么从dp[i-1]
算出来? - 初始化 :
dp[0]
是多少? - 遍历 :
for
循环怎么写?
通过这个框架,你就可以把一个大问题分解成一系列清晰、可执行的步骤,从而系统地解决它。
算法设计:动态规划
在第 i
天,我们可能处于以下三种状态之一:
- 持有股票状态 (
hold
): 当天结束时,我们手中持有一支股票。 - 不持有股票状态(刚卖出,处于冷冻期) (
sold
): 当天我们卖出了股票,因此第二天不能买入。 - 不持有股票状态(已度过冷冻期) (
rest
): 当天我们手中没有股票,并且不是因为当天卖出导致的(即可以随时买入)。
我们的目标是找出最后一天结束后,不持有股票状态(sold
或 rest
)下的最大利润。
状态转移方程
对于第 i
天,我们可以根据第 i-1
天的状态来推导出当前状态的最大利润:
-
hold[i]
: 今天持有股票- 可能是昨天就持有,今天没操作:
hold[i-1]
- 也可能是今天买入的(那么昨天必须是
rest
状态):rest[i-1] - prices[i]
- 所以:
hold[i] = max(hold[i-1], rest[i-1] - prices[i])
- 可能是昨天就持有,今天没操作:
-
sold[i]
: 今天卖出股票- 今天卖出,意味着昨天一定持有股票。
- 所以:
sold[i] = hold[i-1] + prices[i]
-
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)的一般原理,并且其实现方式具有一些值得注意的特点。
符合动态规划原理之处
-
最优子结构 (Optimal Substructure) :问题可以被分解为子问题。第
i
天的最大利润,依赖于第i-1
天的各种状态(持有、卖出、冷冻)下的最大利润。最终问题的最优解(第n-1
天的最大利润)可以由子问题的最优解推导出来。 -
重叠子问题 (Overlapping Subproblems) :在计算过程中,第
i-1
天的状态会被第i
天的计算重复使用。DP通过存储这些子问题的解(在buy
,sell
,cooldown
数组中)来避免重复计算。 -
状态定义 (State Definition) :代码清晰地定义了解决问题所需的所有状态。在任意一天
i
,你只可能处于三种状态之一:buy[i]
: 当天结束时持有股票。sell[i]
: 当天结束时因卖出而不持有股票。cooldown[i]
: 当天结束时不持有股票且可以自由买入(非卖出当天,也非冷冻期)。
-
状态转移方程 (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解法的特殊之处在于其状态机的思想。
-
多状态并行推进 :与许多DP问题(如斐波那契数列、最长递增子序列)在每个步骤
i
只维护一个dp[i]
值不同,此问题在每个步骤i
需要同时维护和更新多个并行的状态 (buy
,sell
,cooldown
)。 -
清晰的状态流转:这三个状态构成了一个微型的状态机。每一天的决策都会导致从一个状态转移到另一个状态。例如:
cooldown
状态可以转移到buy
状态。buy
状态可以转移到sell
状态。sell
状态强制转移到cooldown
状态(这就是"冷冻期"的体现)。buy
和cooldown
状态可以维持自身不变。
这种将问题抽象为状态机模型,并为每个状态设计转移方程的思路,是解决这类序列决策问题的强大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 题目:
-
- 特点 :最基础的版本,整个过程只允许进行一次买卖。
-
- 特点 :不限制交易次数,可以进行多次买卖,但再次买入前必须卖出。
-
- 特点 :限制最多只能完成两笔交易。这通常需要更复杂的 DP 状态定义。
-
- 特点 :是第三题的泛化版本,限制最多只能完成 k 笔交易。
-
- 特点 :可以进行多次交易,但每次卖出时需要支付一笔固定的手续费。
这个系列是学习和掌握动态规划,特别是状态机模型的绝佳练习材料。
买卖股票的动态规划问题确实存在一个非常通用且强大的模板
这些买卖股票的动态规划问题确实存在一个非常通用且强大的模板。这个模板的核心思想是定义一个统一的状态,然后根据不同题目的约束条件对状态转移方程进行微调。
通用动态规划模板
我们可以定义一个三维的 DP 状态 dp[i][k][state]
:
i
: 代表第i
天 (从 0 到 n-1)。k
: 代表允许的最大交易次数。state
: 代表当前是否持有股票(0
表示不持有,1
表示持有)。
dp[i][k][state]
的值表示在第 i
天,最多允许 k
次交易,且处于 state
状态下所能获得的最大利润。
状态转移方程
-
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])
- 可能是昨天就不持有,今天休息:
-
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])
- 可能是昨天就持有,今天休息:
套用模板到具体问题
下面展示如何将这个通用模板应用到各个具体问题上。
-
122. 买卖股票的最佳时机 II (k 为无穷大)
- 当
k
无穷大时,k
和k-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])
- 当
-
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])
- 这是
-
123. 买卖股票的最佳时机 III (k = 2) & 188. 买卖股票的最佳时机 IV (k 为定值)
- 这两题直接使用通用模板,只需将
k
的循环范围设置为1
到K
即可。
- 这两题直接使用通用模板,只需将
-
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])
- 与 II 类似,
-
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])
- 这正是您代码中
rest
或cooldown
状态所做的事情:rest[i-1]
实际上就代表了max(sell[i-2], rest[i-2])
,即dp[i-2][0]
的信息。
- 此题是模板的一个变体。
掌握这个通用模板后,解决股票系列问题就会变得思路清晰,只需根据题目的特殊约束(k
的限制、手续费、冷冻期)对状态转移方程进行微调即可。
动态规划(DP)在更广泛领域
动态规划的主要应用领域
动态规划的核心是解决最优化问题。它的应用场景通常具备以下特征:问题可以被分解成一系列的决策步骤,并且每一步的决策都依赖于之前的状态,最终目标是找到一个最优的决策序列。
主要应用领域包括:
-
路径规划与图算法:
- 应用:在地图软件中寻找两个地点间的最短路径(如Dijkstra算法),或在网络中寻找最快的通信路由。
- 思路:从起点开始,逐步计算到每个中间节点的最短路径,并记录下来,直到到达终点。
-
生物信息学:
- 应用:比较两段DNA或蛋白质序列的相似度(序列比对)。
- 思路:通过DP计算出一个"编辑距离",即把一个序列变成另一个序列所需的最少操作(插入、删除、替换)次数。
-
资源分配与调度:
- 应用:经典的"背包问题"(在有限的承重下,如何选择物品以使总价值最大)、云计算中的任务调度和资源分配。
- 思路:在有限的资源(背包容量、CPU时间)下,决策每一步是否要"装入"某个任务或物品,以达到全局最优。我们讨论的股票问题就是一种时间序列上的资源(资金)分配问题。
-
字符串处理:
- 应用:计算两个字符串的"最长公共子序列",拼写检查中的"最小编辑距离"。
- 思路 :
dp[i][j]
通常表示第一个字符串的前i
个字符和第二个字符串的前j
个字符之间的某种最优解。