二维DP深度解析
二维DP深度解析
- 一、二维DP基础认知
- 1.1 与一维DP的核心区别
- 1.2 核心设计步骤
- 二、经典案例详解
- 2.1 案例1:最长公共子序列
- 问题描述
- 二维DP设计
- 代码实现
- 复杂度分析
- 2.2 案例2:编辑距离
- 问题描述
- 二维DP设计
- 代码实现
- 2.3 案例3:最小路径和
- 问题描述
- 二维DP设计
- 代码实现
- 空间优化:一维压缩
- 三、二维DP的优化技巧与常见误区
- 3.1 空间优化:从二维到一维
- 3.2 常见误区
在动态规划(DP)的学习中,一维DP是基础,但实际问题往往需要处理更复杂的依赖关系——比如两个字符串的对比、二维网格中的路径规划等,此时二维DP(使用二维数组存储状态)成为解决问题的关键,它通过定义“二维状态”来刻画子问题的特征,能更自然地表达“两个维度的依赖关系”(如“前i个字符”与“前j个字符”的关联)。
一、二维DP基础认知
1.1 与一维DP的核心区别
- 状态维度:一维DP用
dp[i]
表示“单一维度的子问题”(如“前i个元素的最优解”);二维DP用dp[i][j]
表示“两个维度的子问题”(如“第一个字符串前i个字符与第二个字符串前j个字符的最优解”)。 - 依赖关系:一维DP的
dp[i]
通常依赖dp[i-1]
等前驱状态;二维DP的dp[i][j]
可能依赖dp[i-1][j]
(上)、dp[i][j-1]
(左)、dp[i-1][j-1]
(左上)等多个方向的状态。 - 适用场景:当问题涉及两个独立维度(如两个字符串、二维网格的行和列),或子问题需要同时考虑“两个变量的规模”时,二维DP更具优势。
1.2 核心设计步骤
二维DP的设计遵循DP的通用逻辑,但状态定义和递推关系更复杂:
- 定义二维状态:明确
dp[i][j]
代表的具体含义(如“字符串s1前i个字符与s2前j个字符的最长公共子序列长度”)。 - 推导递推关系:分析
dp[i][j]
与dp[i-1][j]
、dp[i][j-1]
、dp[i-1][j-1]
的关系(核心难点)。 - 确定边界条件:初始化二维数组的第一行、第一列(对应“空序列”“网格起点”等最小子问题)。
- 填充DP表:按一定顺序(如从左到右、从上到下)计算所有
dp[i][j]
,最终结果通常在dp[n][m]
(n、m为两个维度的最大规模)。
二、经典案例详解
2.1 案例1:最长公共子序列
问题描述
给定两个字符串s1
和s2
,找到它们最长的公共子序列(子序列可非连续)的长度。例如:
- 输入:
s1 = "abcde",s2 = "ace"
- 输出:
3
(公共子序列为"ace"
)
二维DP设计
- 定义状态:
dp[i][j]
表示“s1
前i个字符(s1[0..i-1]
)与s2
前j个字符(s2[0..j-1]
)的最长公共子序列长度”。 - 递推关系:
- 若
s1[i-1] == s2[j-1]
(当前字符相同):
这两个字符可加入公共子序列,因此dp[i][j] = dp[i-1][j-1] + 1
(依赖左上状态)。 - 若
s1[i-1] != s2[j-1]
(当前字符不同):
最长子序列只能来自“s1
少一个字符”或“s2
少一个字符”,取最大值:
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
(依赖上、左状态)。
- 若
- 边界条件:
- 若
i=0
(s1
为空),则dp[0][j] = 0
(空序列与任何序列的公共子序列长度为0)。 - 若
j=0
(s2
为空),则dp[i][0] = 0
(同理)。
- 若
代码实现
public class LCS {public int longestCommonSubsequence(String text1, String text2) {int n = text1.length();int m = text2.length();// 定义二维DP数组,大小(n+1) x (m+1)(0行0列用于边界)int[][] dp = new int[n + 1][m + 1];// 填充DP表:从1开始遍历(0行0列已初始化为0)for (int i = 1; i <= n; i++) {for (int j = 1; j <= m; j++) {if (text1.charAt(i - 1) == text2.charAt(j - 1)) {// 当前字符相同,取左上+1dp[i][j] = dp[i - 1][j - 1] + 1;} else {// 当前字符不同,取上或左的最大值dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);}}}return dp[n][m]; // 最终结果在右下角}public static void main(String[] args) {LCS solution = new LCS();System.out.println(solution.longestCommonSubsequence("abcde", "ace")); // 输出3}
}
复杂度分析
- 时间复杂度:O(n×m)O(n \times m)O(n×m)(两层循环遍历所有
dp[i][j]
)。 - 空间复杂度:O(n×m)O(n \times m)O(n×m)(二维数组存储状态)。
2.2 案例2:编辑距离
问题描述
给定两个字符串s1
和s2
,计算将s1
转换为s2
所需的最少操作数(可插入、删除、替换一个字符)。例如:
- 输入:
s1 = "horse",s2 = "ros"
- 输出:
3
(horse → rorse(替换h→r)→ rose(删除r)→ ros(删除e))
二维DP设计
- 定义状态:
dp[i][j]
表示“将s1
前i个字符转换为s2
前j个字符的最少操作数”。 - 递推关系:
- 若
s1[i-1] == s2[j-1]
(当前字符相同):
无需操作,dp[i][j] = dp[i-1][j-1]
(直接复用之前的结果)。 - 若
s1[i-1] != s2[j-1]
(当前字符不同):
需从三种操作中选最少的:- 插入:
dp[i][j-1] + 1
(在s1
插入s2[j-1]
,对应左状态+1) - 删除:
dp[i-1][j] + 1
(删除s1[i-1]
,对应上状态+1) - 替换:
dp[i-1][j-1] + 1
(替换s1[i-1]
为s2[j-1]
,对应左上状态+1)
因此:dp[i][j] = min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1]) + 1
。
- 插入:
- 若
- 边界条件:
dp[0][j] = j
(s1
为空,需插入j个字符才能变为s2
前j个字符)。dp[i][0] = i
(s2
为空,需删除i个字符才能将s1
前i个字符变为空)。
代码实现
public class EditDistance {public int minDistance(String word1, String word2) {int n = word1.length();int m = word2.length();int[][] dp = new int[n + 1][m + 1];// 初始化边界:第一行(s1为空)for (int j = 0; j <= m; j++) {dp[0][j] = j;}// 初始化边界:第一列(s2为空)for (int i = 0; i <= n; i++) {dp[i][0] = i;}// 填充DP表for (int i = 1; i <= n; i++) {for (int j = 1; j <= m; j++) {if (word1.charAt(i - 1) == word2.charAt(j - 1)) {dp[i][j] = dp[i - 1][j - 1];} else {// 取三种操作的最小值+1dp[i][j] = Math.min(Math.min(dp[i][j - 1], dp[i - 1][j]),dp[i - 1][j - 1]) + 1;}}}return dp[n][m];}public static void main(String[] args) {EditDistance solution = new EditDistance();System.out.println(solution.minDistance("horse", "ros")); // 输出3}
}
2.3 案例3:最小路径和
问题描述
给定一个包含非负整数的m x n
网格,从左上角走到右下角(每次只能向下或向右移动),求路径上所有数字的最小和。例如:
- 输入网格:
[[1,3,1],[1,5,1],[4,2,1]]
- 输出:
7
(路径1→3→1→1→1)
二维DP设计
- 定义状态:
dp[i][j]
表示“从左上角(0,0)
走到(i,j)
的最小路径和”。 - 递推关系:
- 只能从上方
(i-1,j)
或左方(i,j-1)
到达(i,j)
,因此:
dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1])
(当前网格值+前一步的最小和)。
- 只能从上方
- 边界条件:
- 第一行(
i=0
):只能从左方到达,dp[0][j] = dp[0][j-1] + grid[0][j]
。 - 第一列(
j=0
):只能从上方到达,dp[i][0] = dp[i-1][0] + grid[i][0]
。
- 第一行(
代码实现
public class MinimumPathSum {public int minPathSum(int[][] grid) {int m = grid.length;int n = grid[0].length;int[][] dp = new int[m][n];// 初始化起点dp[0][0] = grid[0][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++) {dp[i][0] = dp[i - 1][0] + grid[i][0];}// 填充DP表for (int i = 1; i < m; i++) {for (int j = 1; j < n; j++) {dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);}}return dp[m - 1][n - 1]; // 右下角为结果}public static void main(String[] args) {MinimumPathSum solution = new MinimumPathSum();int[][] grid = {{1, 3, 1}, {1, 5, 1}, {4, 2, 1}};System.out.println(solution.minPathSum(grid)); // 输出7}
}
空间优化:一维压缩
观察发现,dp[i][j]
仅依赖dp[i-1][j]
(上一行)和dp[i][j-1]
(当前行左方),可压缩为一维数组:
public int minPathSumOptimized(int[][] grid) {int m = grid.length;int n = grid[0].length;int[] dp = new int[n]; // 仅用一行存储状态dp[0] = grid[0][0];// 初始化第一行for (int j = 1; j < n; j++) {dp[j] = dp[j - 1] + grid[0][j];}// 填充后续行for (int i = 1; i < m; i++) {dp[0] += grid[i][0]; // 第一列只能从上走for (int j = 1; j < n; j++) {// dp[j](左)和dp[j](上,未更新前即上一行的j)dp[j] = grid[i][j] + Math.min(dp[j - 1], dp[j]);}}return dp[n - 1];
}
- 空间复杂度:从O(m×n)O(m \times n)O(m×n)降至O(n)O(n)O(n)(或O(m)O(m)O(m),取决于压缩方向)。
三、二维DP的优化技巧与常见误区
3.1 空间优化:从二维到一维
多数二维DP可通过“滚动数组”压缩空间,核心是判断状态依赖的方向:
- 若
dp[i][j]
仅依赖dp[i-1][j]
(上)和dp[i][j-1]
(左):可压缩为一维数组(如最小路径和)。 - 若
dp[i][j]
依赖dp[i-1][j-1]
(左上):需额外变量保存“左上状态”(避免被覆盖)。
例如编辑距离的空间优化(压缩为一维数组):
public int minDistanceOptimized(String word1, String word2) {int n = word1.length();int m = word2.length();int[] dp = new int[m + 1];// 初始化第一行for (int j = 0; j <= m; j++) {dp[j] = j;}for (int i = 1; i <= n; i++) {int prev = dp[0]; // 保存左上角状态(dp[i-1][j-1])dp[0] = i; // 第一列(当前行)for (int j = 1; j <= m; j++) {int temp = dp[j]; // 暂存当前值(后续作为下一轮的prev)if (word1.charAt(i - 1) == word2.charAt(j - 1)) {dp[j] = prev;} else {dp[j] = Math.min(Math.min(dp[j - 1], dp[j]), prev) + 1;}prev = temp; // 更新prev为下一个j的左上}}return dp[m];
}
3.2 常见误区
- 状态定义模糊:若
dp[i][j]
的含义不明确(如未区分“前i个”与“第i个”),会导致递推关系错误。建议在定义时明确“范围”(如“前i个字符”)。 - 边界条件遗漏:二维DP的边界包括第一行和第一列,需单独初始化(如LCS中
i=0
或j=0
时结果为0)。 - 忽略空间优化可行性:很多问题看似需要二维数组,但通过观察依赖关系可压缩为一维,减少内存占用(尤其对大规模输入)。
总结
二维DP通过二维状态自然表达“两个维度的子问题关系”,是解决字符串对比、网格路径等复杂问题的核心工具。其设计的关键在于:
- 清晰定义
dp[i][j]
的含义(明确“前i个”与“前j个”的子问题);- 从“当前状态与周围状态的关联”推导递推关系;
- 初始化边界条件(第一行、第一列);
- 必要时通过“滚动数组”优化空间。
That’s all, thanks for reading~~
觉得有用就点个赞
、收进收藏
夹吧!关注
我,获取更多干货~