多维动态规划:在更高维度思考
当一个维度不够用时,就多加一个维度!
什么是多维动态规划
多维动态规划 是动态规划的扩展,使用多个维度的状态来描述问题。如果说一维DP是一条线,那么二维DP就是一个平面,三维DP则是一个立体空间。
核心思想 :当问题需要多个参数才能描述清楚状态时,就需要使用多维DP
形象理解
想象你在玩一个游戏:
1 2 3 一维DP: 在一条路上前进,只需要记录"到达第i个位置" 二维DP: 在一个网格中移动,需要记录"到达坐标(i,j)" 三维DP: 在多个物品中选择,需要记录"前i个物品,容量为j,价值为k"
就像从一维数轴进化到二维平面,再到三维空间!
从一维到多维的思维跃迁
维度增加的必要性
graph TD
A[问题复杂度] --> B{一个参数够吗?}
B -->|够| C[一维DP]
B -->|不够| D{两个参数够吗?}
D -->|够| E[二维DP]
D -->|不够| F[三维/更高维DP]
style A fill:#FFE4B5
style E fill:#90EE90
style F fill:#87CEEB
什么时候用多维DP?
场景
维度
示例
路径问题
二维
网格中的最短路径
双序列匹配
二维
最长公共子序列
背包问题
二维/三维
01背包、多重背包
区间问题
二维
戳气球、合并石头
状态机问题
多维
买卖股票
二维DP经典问题
1. 不同路径 (LeetCode 62)
一个机器人位于一个 m×n 网格的左上角,每次只能向下或向右移动,问到达右下角有多少条不同的路径?
问题分析
1 2 3 4 5 网格: 3×7 起点: (0,0) 终点: (2,6) 每次只能: 向右 或 向下
可视化理解
1 2 3 4 5 起点→ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ □ ← 终点 到达(i,j)的方式 = 从(i-1,j)向下 + 从(i,j-1)向右
状态定义
1 dp[i][j]: 到达位置(i,j)的不同路径数
状态转移方程
1 2 3 dp[i][j] = dp[i-1][j] + dp[i][j-1] ↑ ↑ 从上方来 从左方来
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 int uniquePaths (int m, int n) { vector<vector<int >> dp (m, vector <int >(n, 1 )); for (int i = 1 ; i < m; ++i) { for (int j = 1 ; j < n; ++j) { dp[i][j] = dp[i-1 ][j] + dp[i][j-1 ]; } } return dp[m-1 ][n-1 ]; } int uniquePaths (int m, int n) { vector<int > dp (n, 1 ) ; for (int i = 1 ; i < m; ++i) { for (int j = 1 ; j < n; ++j) { dp[j] = dp[j] + dp[j-1 ]; ↑ ↑ 上方 左方 } } return dp[n-1 ]; }
时间复杂度 : O(m×n)
空间复杂度 : O(m×n) → 优化后 O(n)
执行过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 m=3, n=7 初始状态 (第一行和第一列都是1): 1 1 1 1 1 1 1 1 ? ? ? ? ? ? 1 ? ? ? ? ? ? 计算过程: 1 1 1 1 1 1 1 1 2 3 4 5 6 7 1 3 6 10 15 21 28 ↑ dp[1][2] = dp[0][2] + dp[1][1] = 1 + 2 = 3 答案: 28种路径
2. 最小路径和 (LeetCode 64)
给定一个包含非负整数的 m×n 网格,找到一条从左上角到右下角的路径,使得路径上的数字总和最小
问题分析
1 2 3 4 5 6 7 输入: 1 3 1 1 5 1 4 2 1 输出: 7 路径: 1→3→1→1→1 = 7
状态定义
1 dp[i][j]: 从(0,0)到(i,j)的最小路径和
状态转移方程
1 2 3 dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]) ↑ ↑ 从上方来 从左方来
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 int minPathSum (vector<vector<int >>& grid) { int m = grid.size (); int n = grid[0 ].size (); vector<vector<int >> dp (m, vector <int >(n)); dp[0 ][0 ] = grid[0 ][0 ]; for (int i = 1 ; i < m; ++i) { dp[i][0 ] = dp[i-1 ][0 ] + grid[i][0 ]; } for (int j = 1 ; j < n; ++j) { dp[0 ][j] = dp[0 ][j-1 ] + grid[0 ][j]; } for (int i = 1 ; i < m; ++i) { for (int j = 1 ; j < n; ++j) { dp[i][j] = grid[i][j] + min (dp[i-1 ][j], dp[i][j-1 ]); } } return dp[m-1 ][n-1 ]; }
时间复杂度 : O(m×n)
空间复杂度 : O(m×n)
执行过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 grid: 1 3 1 1 5 1 4 2 1 DP过程: 1 4 5 ← 第一行: 累加 ↓ 2 7 6 ← dp[1][1] = 5 + min(4,2) = 7 ↓ 6 8 7 ← dp[2][2] = 1 + min(6,8) = 7 ↑ 答案 最小路径和: 7
3. 最长公共子序列 (LeetCode 1143)
给定两个字符串,求它们的最长公共子序列长度
问题分析
1 2 3 4 text1 = "abcde" text2 = "ace" 最长公共子序列: "ace" (长度为3)
双序列DP的精髓
这是典型的双序列匹配问题 ,需要同时追踪两个序列的位置!
状态定义
1 dp[i][j]: text1前i个字符 与 text2前j个字符 的最长公共子序列长度
状态转移方程
1 2 3 4 5 6 if (text1[i-1] == text2[j-1]): dp[i][j] = dp[i-1][j-1] + 1 else: dp[i][j] = max(dp[i-1][j], dp[i][j-1]) ↑ ↑ 跳过text1[i] 跳过text2[j]
可视化理解
1 2 3 4 5 6 7 "" a c e "" 0 0 0 0 a 0 1 1 1 ← text1[0]='a' == text2[0]='a', dp[1][1]=1 b 0 1 1 1 c 0 1 2 2 ← text1[2]='c' == text2[1]='c', dp[3][2]=2 d 0 1 2 2 e 0 1 2 3 ← text1[4]='e' == text2[2]='e', dp[5][3]=3
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 int longestCommonSubsequence (string text1, string text2) { int m = text1. size (); int n = text2. size (); vector<vector<int >> dp (m + 1 , vector <int >(n + 1 , 0 )); for (int i = 1 ; i <= m; ++i) { for (int j = 1 ; j <= n; ++j) { if (text1[i-1 ] == text2[j-1 ]) { dp[i][j] = dp[i-1 ][j-1 ] + 1 ; } else { dp[i][j] = max (dp[i-1 ][j], dp[i][j-1 ]); } } } return dp[m][n]; }
时间复杂度 : O(m×n)
空间复杂度 : O(m×n)
完整执行过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 text1 = "abcde" text2 = "ace" DP表: "" a c e "" 0 0 0 0 a 0 1 1 1 b 0 1 1 1 c 0 1 2 2 d 0 1 2 2 e 0 1 2 3 关键步骤: - dp[1][1]: 'a'=='a', dp[1][1]=dp[0][0]+1=1 - dp[3][2]: 'c'=='c', dp[3][2]=dp[2][1]+1=2 - dp[5][3]: 'e'=='e', dp[5][3]=dp[4][2]+1=3 答案: 3
4. 编辑距离 (LeetCode 72)
给定两个单词,计算从一个单词转换成另一个单词所需的最少操作数。允许的操作:插入、删除、替换
问题分析
1 2 3 4 5 6 7 8 9 word1 = "horse" word2 = "ros" 操作序列: horse → rorse (替换 h→r) rorse → rose (删除 r) rose → ros (删除 e) 共3步
状态定义
1 dp[i][j]: word1前i个字符 转换为 word2前j个字符 的最少操作数
状态转移方程
1 2 3 4 5 6 7 8 if (word1[i-1] == word2[j-1]): dp[i][j] = dp[i-1][j-1] // 字符相同,不需要操作 else: dp[i][j] = 1 + min({ dp[i-1][j], // 删除word1[i] dp[i][j-1], // 插入word2[j] dp[i-1][j-1] // 替换word1[i]为word2[j] })
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 int minDistance (string word1, string word2) { int m = word1. size (); int n = word2. size (); vector<vector<int >> dp (m + 1 , vector <int >(n + 1 )); for (int i = 0 ; i <= m; ++i) { dp[i][0 ] = i; } for (int j = 0 ; j <= n; ++j) { dp[0 ][j] = j; } for (int i = 1 ; i <= m; ++i) { for (int j = 1 ; j <= n; ++j) { if (word1[i-1 ] == word2[j-1 ]) { dp[i][j] = dp[i-1 ][j-1 ]; } else { dp[i][j] = 1 + min ({ dp[i-1 ][j], dp[i][j-1 ], dp[i-1 ][j-1 ] }); } } } return dp[m][n]; }
时间复杂度 : O(m×n)
空间复杂度 : O(m×n)
执行过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 word1 = "horse" word2 = "ros" DP表: "" r o s "" 0 1 2 3 h 1 1 2 3 o 2 2 1 2 r 3 2 2 2 s 4 3 3 2 e 5 4 4 3 ↑ 答案 关键步骤: - dp[2][2]: 'o'=='o', dp[2][2]=dp[1][1]=1 - dp[3][1]: 'r'=='r', dp[3][1]=dp[2][0]=2 - dp[4][3]: 's'=='s', dp[4][3]=dp[3][2]=2 答案: 3步
5. 买卖股票的最佳时机III (LeetCode 123)
给定一个数组,最多可以完成两笔 交易,求最大利润
问题分析
这是一个状态机DP 问题,需要追踪:
当前第几天
当前处于什么状态(持有/不持有股票)
完成了几次交易
状态定义
1 2 3 4 buy1[i]: 第i天,第一次买入后的最大利润 sell1[i]: 第i天,第一次卖出后的最大利润 buy2[i]: 第i天,第二次买入后的最大利润 sell2[i]: 第i天,第二次卖出后的最大利润
状态转移方程
1 2 3 4 buy1[i] = max(buy1[i-1], -prices[i]) sell1[i] = max(sell1[i-1], buy1[i-1] + prices[i]) buy2[i] = max(buy2[i-1], sell1[i-1] - prices[i]) sell2[i] = max(sell2[i-1], buy2[i-1] + prices[i])
状态转移图
graph LR
A[初始] -->|买入| B[持有1]
B -->|卖出| C[完成1笔]
C -->|买入| D[持有2]
D -->|卖出| E[完成2笔]
style A fill:#FFE4B5
style C fill:#90EE90
style E fill:#87CEEB
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int maxProfit (vector<int >& prices) { int n = prices.size (); if (n == 0 ) return 0 ; int buy1 = -prices[0 ], sell1 = 0 ; int buy2 = -prices[0 ], sell2 = 0 ; for (int i = 1 ; i < n; ++i) { buy1 = max (buy1, -prices[i]); sell1 = max (sell1, buy1 + prices[i]); buy2 = max (buy2, sell1 - prices[i]); sell2 = max (sell2, buy2 + prices[i]); } return sell2; }
时间复杂度 : O(n)
空间复杂度 : O(1)
执行过程
1 2 3 4 5 6 7 8 9 10 11 12 prices = [3,3,5,0,0,3,1,4] Day 0: buy1=-3, sell1=0, buy2=-3, sell2=0 Day 1: buy1=-3, sell1=0, buy2=-3, sell2=0 Day 2: buy1=-3, sell1=2, buy2=-1, sell2=2 Day 3: buy1=-3, sell1=2, buy2=2, sell2=2 Day 4: buy1=-3, sell1=2, buy2=2, sell2=2 Day 5: buy1=-3, sell1=2, buy2=2, sell2=5 Day 6: buy1=-3, sell1=2, buy2=2, sell2=5 Day 7: buy1=-3, sell1=2, buy2=2, sell2=6 答案: 6 (在0买入,在3卖出,再在3买入,在4卖出)
三维DP问题
01背包问题 (经典)
有n个物品和一个容量为W的背包,每个物品有重量w[i]和价值v[i],每个物品只能选一次,求最大价值
问题分析
1 2 3 4 5 6 7 物品: [(重量,价值)] 物品1: (2, 3) 物品2: (3, 4) 物品3: (4, 5) 背包容量: 5 最优方案: 选物品1和2,价值=7
状态定义
1 dp[i][j]: 前i个物品,容量为j时的最大价值
状态转移方程
1 2 3 4 dp[i][j] = max( dp[i-1][j], // 不选第i个物品 dp[i-1][j-w[i]] + v[i] // 选第i个物品 )
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 int knapsack (vector<int >& w, vector<int >& v, int W) { int n = w.size (); vector<vector<int >> dp (n + 1 , vector <int >(W + 1 , 0 )); for (int i = 1 ; i <= n; ++i) { for (int j = 1 ; j <= W; ++j) { dp[i][j] = dp[i-1 ][j]; if (j >= w[i-1 ]) { dp[i][j] = max (dp[i][j], dp[i-1 ][j-w[i-1 ]] + v[i-1 ]); } } } return dp[n][W]; } int knapsack (vector<int >& w, vector<int >& v, int W) { int n = w.size (); vector<int > dp (W + 1 , 0 ) ; for (int i = 0 ; i < n; ++i) { for (int j = W; j >= w[i]; --j) { dp[j] = max (dp[j], dp[j - w[i]] + v[i]); } } return dp[W]; }
时间复杂度 : O(n×W)
空间复杂度 : O(n×W) → 优化后 O(W)
为什么要从后向前?
1 2 3 4 5 6 7 8 从前向后: dp[2] = dp[2-w[0]] + v[0] // 使用了w[0] dp[4] = dp[4-w[0]] + v[0] // 又使用了w[0]! ❌重复使用 从后向前: dp[4] = dp[4-w[0]] + v[0] // 使用旧的dp[2] dp[2] = dp[2-w[0]] + v[0] // 使用旧的dp[0] ✓ 每个物品只用一次
最长回文子序列 (LeetCode 516)
给定一个字符串,找到其中最长的回文子序列长度
问题分析
1 2 3 输入: "bbbab" 输出: 4 解释: 最长回文子序列是 "bbbb"
区间DP思想
这是区间DP 的典型问题,需要思考:
状态定义
1 dp[i][j]: 字符串[i,j]区间内的最长回文子序列长度
状态转移方程
1 2 3 4 if (s[i] == s[j]): dp[i][j] = dp[i+1][j-1] + 2 else: dp[i][j] = max(dp[i+1][j], dp[i][j-1])
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 int longestPalindromeSubseq (string s) { int n = s.size (); vector<vector<int >> dp (n, vector <int >(n, 0 )); for (int i = 0 ; i < n; ++i) { dp[i][i] = 1 ; } for (int len = 2 ; len <= n; ++len) { for (int i = 0 ; i + len - 1 < n; ++i) { int j = i + len - 1 ; if (s[i] == s[j]) { dp[i][j] = dp[i+1 ][j-1 ] + 2 ; } else { dp[i][j] = max (dp[i+1 ][j], dp[i][j-1 ]); } } } return dp[0 ][n-1 ]; }
时间复杂度 : O(n²)
空间复杂度 : O(n²)
执行过程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 s = "bbbab" DP表 (下三角未使用): 0 1 2 3 4 0 1 2 3 4 4 1 - 1 2 3 3 2 - - 1 2 2 3 - - - 1 1 4 - - - - 1 计算顺序 (按长度递增): len=1: 对角线全为1 len=2: dp[0][1], dp[1][2], dp[2][3], dp[3][4] len=3: dp[0][2], dp[1][3], dp[2][4] len=4: dp[0][3], dp[1][4] len=5: dp[0][4] 答案: dp[0][4] = 4 ("bbbb")
多维DP优化技巧
1. 滚动数组
当dp[i][j]只依赖dp[i-1][…]时:
1 2 3 4 5 6 7 8 9 10 11 vector<vector<int >> dp (m, vector <int >(n)); vector<int > prev (n) , curr (n) ;for (int i = 0 ; i < m; ++i) { for (int j = 0 ; j < n; ++j) { curr[j] = ...; } swap (prev, curr); }
2. 倒序遍历 (01背包)
1 2 3 4 5 6 7 8 9 for (int j = 0 ; j <= W; ++j) { dp[j] = max (dp[j], dp[j-w] + v); } for (int j = W; j >= w; --j) { dp[j] = max (dp[j], dp[j-w] + v); }
3. 状态压缩
某些问题可以用位运算压缩状态:
多维DP问题分类
路径问题
graph LR
A[路径问题] --> B[不同路径]
A --> C[最小路径和]
A --> D[地下城游戏]
style A fill:#FFE4B5
style B fill:#90EE90
特点 : 在网格中移动,dp[i][j]表示到达(i,j)的状态
双序列匹配
graph LR
A[双序列] --> B[最长公共子序列]
A --> C[编辑距离]
A --> D[不同的子序列]
style A fill:#87CEEB
特点 : 两个字符串/数组,dp[i][j]表示处理到第i和第j个字符
区间DP
graph LR
A[区间DP] --> B[最长回文子序列]
A --> C[戳气球]
A --> D[合并石头]
style A fill:#DDA0DD
特点 : dp[i][j]表示区间[i,j]的答案,按区间长度递增计算
背包DP
graph LR
A[背包DP] --> B[01背包]
A --> C[完全背包]
A --> D[多重背包]
style A fill:#F0E68C
特点 : 选或不选,dp[i][j]表示前i个物品,容量为j的最优解
解题思路总结
步骤1: 确定维度
1 2 3 4 问题需要几个参数描述? - 1个参数 → 一维DP - 2个参数 → 二维DP - 更多 → 三维/更高维DP
步骤2: 定义状态
1 2 3 4 5 6 dp[i][j] 代表什么含义? 常见定义: - dp[i][j]: 到达(i,j)的... - dp[i][j]: 前i个...前j个...的... - dp[i][j]: 区间[i,j]的...
步骤3: 找状态转移
1 2 3 4 5 6 当前状态从哪些状态转移而来? 常见转移: - 最优化: max/min - 计数: 累加 - 判断: 逻辑或/与
步骤4: 初始化
1 2 3 4 确定边界条件: - dp[0][0] = ? - dp[i][0] = ? - dp[0][j] = ?
步骤5: 确定计算顺序
1 2 3 4 保证计算dp[i][j]时,依赖的状态已经计算: - 从小到大 (大多数情况) - 按长度递增 (区间DP) - 倒序 (01背包)
常见陷阱与技巧
陷阱1: 索引混淆
1 2 3 4 5 6 7 8 9 10 11 12 dp[i][j] = dp[i-1 ][j-1 ] + 1 ; for (int i = 1 ; i <= m; ++i) { for (int j = 1 ; j <= n; ++j) { if (text1[i-1 ] == text2[j-1 ]) { dp[i][j] = dp[i-1 ][j-1 ] + 1 ; } } }
陷阱2: 初始化错误
1 2 3 4 5 6 vector<vector<int >> dp (m, vector <int >(n)); vector<vector<int >> dp (m, vector <int >(n, 0 ));
陷阱3: 遍历顺序错误
1 2 3 4 5 6 7 8 9 for (int j = 0 ; j <= W; ++j) { dp[j] = max (dp[j], dp[j-w] + v); } for (int j = W; j >= w; --j) { dp[j] = max (dp[j], dp[j-w] + v); }
技巧1: 画表格辅助思考
1 2 3 4 5 6 7 8 9 对于双序列问题,画出DP表格: "" a c e "" 0 0 0 0 a 0 ? ? ? b 0 ? ? ? c 0 ? ? ? 思考每个?如何填
技巧2: 从简单例子入手
1 2 3 4 不要一开始就想复杂情况: 1. 先考虑n=1, n=2的情况 2. 找规律,推导状态转移 3. 验证边界条件
技巧3: 记住经典模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 for (int i = 1 ; i <= m; ++i) { for (int j = 1 ; j <= n; ++j) { if (条件) { dp[i][j] = ...; } else { dp[i][j] = ...; } } } for (int len = 2 ; len <= n; ++len) { for (int i = 0 ; i + len - 1 < n; ++i) { int j = i + len - 1 ; dp[i][j] = ...; } }
思维导图
graph TD
A[多维DP] --> B[问题类型]
A --> C[核心技巧]
A --> D[优化方法]
B --> B1[路径问题]
B --> B2[双序列匹配]
B --> B3[区间DP]
B --> B4[背包DP]
C --> C1[确定维度]
C --> C2[定义状态]
C --> C3[状态转移]
C --> C4[初始化]
D --> D1[滚动数组]
D --> D2[倒序遍历]
D --> D3[状态压缩]
style A fill:#FFE4B5
style B fill:#90EE90
style C fill:#87CEEB
style D fill:#DDA0DD
总结
核心要点
多维DP = 多个参数描述状态
二维是最常见的 : 路径、双序列、背包
画表格 : 辅助理解状态转移
注意边界 : 初始化和索引处理
空间优化 : 滚动数组、倒序遍历
代码模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 int solve (string s1, string s2) { int m = s1. size (), n = s2. size (); vector<vector<int >> dp (m + 1 , vector <int >(n + 1 , 0 )); for (int i = 0 ; i <= m; ++i) dp[i][0 ] = ...; for (int j = 0 ; j <= n; ++j) dp[0 ][j] = ...; for (int i = 1 ; i <= m; ++i) { for (int j = 1 ; j <= n; ++j) { if (s1[i-1 ] == s2[j-1 ]) { dp[i][j] = dp[i-1 ][j-1 ] + ...; } else { dp[i][j] = max (dp[i-1 ][j], dp[i][j-1 ]); } } } return dp[m][n]; } int knapsack (vector<int >& w, vector<int >& v, int W) { vector<int > dp (W + 1 , 0 ) ; for (int i = 0 ; i < n; ++i) { for (int j = W; j >= w[i]; --j) { dp[j] = max (dp[j], dp[j - w[i]] + v[i]); } } return dp[W]; } int solve (string s) { int n = s.size (); vector<vector<int >> dp (n, vector <int >(n, 0 )); for (int i = 0 ; i < n; ++i) { dp[i][i] = 1 ; } for (int len = 2 ; len <= n; ++len) { for (int i = 0 ; i + len - 1 < n; ++i) { int j = i + len - 1 ; if (s[i] == s[j]) { dp[i][j] = dp[i+1 ][j-1 ] + 2 ; } else { dp[i][j] = max (dp[i+1 ][j], dp[i][j-1 ]); } } } return dp[0 ][n-1 ]; }
推荐练习 :
推荐阅读 :