1. 多状态动态规划
在需要“基于历史决策结果做当前选择”的问题中(如打家劫舍、买卖股票、序列匹配),若单状态(仅记录“当前最大/最小值”)无法承载“决策所需的关键依赖信息”,核心技巧是 “拆解决策依赖维度+设计多状态” ——先找到当前决策必须知道的历史条件(如是否持有股票、是否偷当前房屋),再将这些条件转化为明确的状态维度,确保状态转移时信息完整。
1.1 通用核心思路:先“找依赖”,再“拆状态”
所有多状态动态规划问题,都可按这两步突破单状态局限:
-
分析当前决策的“必须依赖”
先思考:计算当前步骤的结果时,需要知道哪些“历史选择的信息”?若这些信息无法用单状态表达,就需要拆分。- 例:二叉树打家劫舍,决策“偷当前节点”时,必须知道“子节点不偷的金额”(单状态只存最大金额,丢失该信息);
- 例:买卖股票,决策“今天是否买入”时,必须知道“昨天是否持有股票”(单状态只存利润,无法判断是否可买入);
- 例:不同子序列,决策“s的第i个字符是否匹配t的第j个字符”时,必须知道“t的前j个字符已匹配到哪一步”(单状态只存总个数,丢失匹配进度)。
-
按“依赖维度”定义多状态
将“必须依赖的信息”转化为状态的“维度”,确保每个状态都能明确回答“决策所需的条件”。常见维度类型:- 若依赖“二选一决策”(偷/不偷、持有/不持有)→ 拆“二元状态”;
- 若依赖“多选一互斥决策”(3种颜色、多种类型)→ 拆“多元状态”;
- 若依赖“多阶段进度”(两个序列的匹配进度、多步任务的完成度)→ 拆“二维状态”。
1.2 4个关键技巧
技巧1:按“二选一互斥决策”拆“二元状态”(适用“两种选择必居其一”场景)
当问题中每个步骤只有“两种互斥选择”(如“偷/不偷”“持有/不持有”“选A/选B”),且当前选择依赖“另一选择的历史结果”时,设计“二元状态”(如[状态A, 状态B]),分别记录两种选择的结果。
适用场景
- 二选一决策,且两种选择互斥(选A就不能选B,反之亦然);
- 当前选择的结果需依赖“另一选择的历史值”(如偷当前房屋需用“子节点不偷的金额”)。
设计方法
- 定义:
dp[i][0]代表“第i步选A的结果”,dp[i][1]代表“第i步选B的结果”(若问题无明显“步”,则按“节点/位置”定义,如二叉树的[not_rob, rob]); - 转移:分别推导两种选择的转移方程(选A时用选B的历史值,或反之)。
案例演示(二叉树打家劫舍)
- 状态定义:对每个节点,
[not_rob, rob](不偷当前节点的最大金额,偷当前节点的最大金额); - 转移方程:
- 偷当前节点(
rob):必须用子节点“不偷”的金额 →rob = 节点值 + 左子树not_rob + 右子树not_rob; - 不偷当前节点(
not_rob):可选子节点“偷或不偷”的最大值 →not_rob = max(左子树not_rob, 左子树rob) + max(右子树not_rob, 右子树rob);
- 偷当前节点(
- 结果:根节点的
max(not_rob, rob)即为答案(两种选择的最优解)。
代码片段(核心转移逻辑)
def dfs(node):
if not node:
return [0, 0] # 空节点:不偷0,偷0
left_not, left_rob = dfs(node.left)
right_not, right_rob = dfs(node.right)
# 偷当前节点:子节点必须不偷
curr_rob = node.val + left_not + right_not
# 不偷当前节点:子节点选最优
curr_not = max(left_not, left_rob) + max(right_not, right_rob)
return [curr_not, curr_rob]
技巧2:按“多选一互斥决策”拆“多元状态”
当问题中每个步骤有“三种及以上互斥选择”(如房屋染色的3种颜色、多类型任务的选择),且当前选择依赖“其他选择的历史结果”时,为每种选择设计一个独立状态,形成“多元状态”。
适用场景
- 多种选择互斥(选A就不能选B/C,选B就不能选A/C);
- 当前选择的结果需排除“相邻步骤的同一选择”(如染色相邻房屋颜色不同)。
设计方法
- 定义:
dp[i][k]代表“第i步选第k种选项的结果”(k为选项索引,如0=红色、1=蓝色、2=绿色); - 转移:对第i步的第k种选项,结果 = “第i-1步不选k的所有选项的最优值” + 当前选项的成本/收益。
案例演示(房屋染色)
- 状态定义:
dp[i][0](第i栋刷红色的最小成本)、dp[i][1](蓝色)、dp[i][2](绿色); - 转移方程:
- 第i栋刷红色:前一栋只能刷蓝色或绿色 →
dp[i][0] = min(dp[i-1][1], dp[i-1][2]) + costs[i][0]; - 第i栋刷蓝色:前一栋只能刷红色或绿色 →
dp[i][1] = min(dp[i-1][0], dp[i-1][2]) + costs[i][1]; - 第i栋刷绿色:前一栋只能刷红色或蓝色 →
dp[i][2] = min(dp[i-1][0], dp[i-1][1]) + costs[i][2];
- 第i栋刷红色:前一栋只能刷蓝色或绿色 →
- 结果:最后一栋房屋的
min(dp[n-1][0], dp[n-1][1], dp[n-1][2])。
代码片段(核心转移逻辑)
n = len(costs)
if n == 0:
return 0
# 初始化第0栋房屋的三种颜色成本
dp0, dp1, dp2 = costs[0][0], costs[0][1], costs[0][2]
for i in range(1, n):
# 第i栋选红色:前一栋选蓝/绿的最小值 + 当前红色成本
new_dp0 = min(dp1, dp2) + costs[i][0]
new_dp1 = min(dp0, dp2) + costs[i][1]
new_dp2 = min(dp0, dp1) + costs[i][2]
dp0, dp1, dp2 = new_dp0, new_dp1, new_dp2
return min(dp0, dp1, dp2)
技巧3:按“多阶段进度”拆“二维状态”
当问题需要“同时跟踪两个及以上序列的进度”(如s和t的匹配进度、两个任务的完成度),且当前决策依赖“两个进度的历史结果”时,用“二维状态”(dp[i][j])分别记录两个进度的当前结果。
适用场景
- 问题涉及两个独立的“进度维度”(如s的前i个字符、t的前j个字符);
- 当前结果需依赖“两个进度的部分历史组合”(如s的i-1和t的j,或s的i和t的j-1)。
设计方法
- 定义:
dp[i][j]代表“第一个序列推进到i、第二个序列推进到j时的结果”(如dp[i][j]=s前i个字符中t前j个字符的子序列个数); - 转移:按“两个进度是否匹配/推进”推导方程(如s[i-1] == t[j-1]时,可选择“用s[i-1]匹配”或“不用”)。
案例演示(不同的子序列)
- 状态定义:
dp[i][j](s的前i个字符中,包含t的前j个字符的子序列个数); - 转移方程:
- 若
s[i-1] == t[j-1](当前字符匹配):
子序列个数 = 不用s[i-1]的个数(dp[i-1][j]) + 用s[i-1]的个数(dp[i-1][j-1]); - 若
s[i-1] != t[j-1](当前字符不匹配):
子序列个数 = 不用s[i-1]的个数(dp[i-1][j]);
- 若
- 边界:
dp[i][0] = 1(t为空串时,任何s都有1种匹配方式),dp[0][j>0] = 0(s为空串时,无法匹配非空t)。
代码片段(核心转移逻辑)
def numDistinct(s: str, t: str) -> int:
m, n = len(s), len(t)
dp = [[0]*(n+1) for _ in range(m+1)]
# 边界:t为空串,所有s的前i个都有1种匹配方式
for i in range(m+1):
dp[i][0] = 1
for i in range(1, m+1):
for j in range(1, n+1):
if s[i-1] == t[j-1]:
dp[i][j] = dp[i-1][j] + dp[i-1][j-1]
else:
dp[i][j] = dp[i-1][j]
return dp[m][n]
技巧4:用“状态转移表”验证逻辑
多状态的转移方程易混淆(如二元状态的“偷/不偷”转移方向),手动列“状态转移表”(按步骤记录每个状态的取值),能快速发现逻辑漏洞。
适用场景
- 状态转移关系复杂(如多步依赖、条件分支多);
- 担心状态定义错误或转移方程推导错误。
操作方法
- 选“最小案例”(如n=2的打家劫舍、2天的买卖股票);
- 按步骤列出每个阶段的“所有状态值”;
- 对照转移方程,检查状态值是否符合预期。
案例演示(买卖股票II,n=2,prices=[1,2])
- 状态定义:
dp[i][0](第i天不持有)、dp[i][1](第i天持有); - 转移表:
天数i 价格 dp[i][0](不持有) dp[i][1](持有) 推导过程 0 1 0(初始不持有) -1(买入第0天股票) 初始状态 1 2 max(0, -1+2)=1 max(-1, 0-2)=-1 第1天不持有:要么前一天不持有(0),要么今天卖出(-1+2=1);持有:要么前一天持有(-1),要么今天买入(0-2=-2,取max(-1,-2)) - 结果:
dp[1][0] = 1(正确,利润1)。
1.3 多状态动态规划的“思考流程”
-
分析问题,找“决策依赖”
问自己:“计算当前步骤的结果时,我需要知道哪些历史选择的信息?”(如偷当前房屋需知道子节点是否偷,买卖股票需知道是否持有)。 -
判断“单状态是否足够”
若依赖的信息无法用“一个值”(如最大/最小值)表达(如“是否持有”是布尔值,无法用利润值代替),则需要拆多状态。 -
按依赖维度“定义多状态”
- 二选一决策→二元状态(如
[a, b]); - 多选一互斥→多元状态(如
[a, b, c]); - 多阶段进度→二维状态(如
dp[i][j]);
(关键:让每个状态的含义“唯一且明确”,避免模糊)。
- 二选一决策→二元状态(如
-
推导“状态转移方程”
对每个状态,思考“它能从哪些历史状态转移而来”,结合问题规则(如不偷相邻、不重复买卖)列出方程。 -
用“小案例验证”
手动列状态转移表,检查状态值是否正确,排除定义错误或转移错误。 -
代码实现(优化空间)
若状态只依赖“前一步”(如打家劫舍、买卖股票),可将二维数组优化为变量(如dp0, dp1代替dp[i][0], dp[i][1]),减少空间复杂度。
总结
多状态动态规划的核心不是“为了拆状态而拆状态”,而是“让每个状态承载足够的决策信息”——当单状态像“缺了零件的工具”无法完成任务时,多状态就是“补全零件”,让每个决策都有明确的历史依据。记住:状态的数量和维度,永远由“决策所需的依赖信息”决定,而非凭空设计。
浙公网安备 33010602011771号