【动态规划】路径问题模型
前言
本文将从基础的不同路径问题开始,逐步深入到更复杂的最小路径和等问题,最终探讨DP在其他方面的应用。每个例题都将详细介绍状态表示、状态转移方程、初始化过程、填表顺序和返回值,以确保读者能够清晰地理解DP的核心原理。
一、不同路径
一个机器人位于一个
m x n
网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
算法流程:
- 状态表示
由于题中所问要从左上角到右下角一共有多少种不同路径,所以二维dp数组的第 i 行第 j 列就直接认定为从左上角到达了该位置一共多少种方式。
dp[i] [j] 表示:走到 [i, j] 位置,一共有多少种方式
- 状态转移方程
由于题目中给定每一步都只能往下走或往右走,所以到达 [i, j] 位置的前一步所在的位置也只能是 [i - 1, j] 和 [i, j - 1],那么我们就可以得到状态转移方程:
dp[i] [j] = dp [i - 1] [j] + dp [i] [j - 1]
- 初始化
当我们考虑从左上角开始对dp数组进行赋值时,由于 [i, j] 位置的值需要用到 [i - 1, j] 和 [i, j - 1] 位置,所以在对第一行和第一列直接使用循环赋值会造成访问越界。于是,给原dp数组上面加一行、左面加一列,这样就能避开对首行首列赋值需要考虑的特殊情况。
由于我们需要先给出起始位置的值和首行首列的值,所以将首行首列所有值都初始化为0,接着将dp[0] [1]或dp[1] [0] 位置的值设为 1 即可。
- 填表顺序
从左往右、从上往下
- 返回值
根据新dp数组,返回原数组右下角位置值变为 dp[m] [n] 的值。
示例代码:
int uniquePaths(int m, int n)
{
// 1.构建dp表 dp[i][j]表示到该位置一共多少种不同路径
vector<vector<int>> vv(m + 1, vector<int>(n + 1, 0));
// 2.初始化 为递推赋值准备
vv[0][1] = 1;
// 3.填表
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
vv[i][j] = vv[i - 1][j] + vv[i][j - 1];
}
}
// 4.返回结果
return vv[m][n];
}
二、不同路径-2
题目链接:63. 不同路径 II - 力扣(LeetCode)
一个机器人位于一个
m x n
网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
网格中的障碍物和空位置分别用
1
和0
来表示。
算法流程:
- 状态表示
对于此题要求到达右下角一共有多少种路径,我们不妨采用与地图对应的 dp 数组,那么数组中元素就有了相应的意义
dp[i] [j] 表示:走到 [i, j] 位置处,一共有多少种方式
- 状态转移方程
由于题目中给定每一步都只能往下走或往右走,所以到达 [i, j] 位置的前一步所在的位置也只能是 [i - 1, j] 和 [i, j - 1],但是 [i - 1, j] 和 [i, j - 1] 位置都可能是有障碍物的,从上往下或者从左往右相应都有可能是到不了 [i, j] 位置的,所以此时 [i, j] 的方法数应当为 0,从实际代码中表示时就需要体现判断是否有障碍物这个逻辑,接着才能进行叠加。
- 初始化
同样的在首行和首列添加辅助行列,相应的 i 位置变为 i + 1, j 同理。将新添加的首行和首列直接初始化为 0 ,接着将dp[0] [1]或dp[1] [0] 位置的值设为 1 即可。
- 填表顺序
从左往右、从上往下
- 返回值
根据新dp数组,返回原数组右下角位置值变为 dp[m] [n] 的值。
示例代码:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
size_t m = obstacleGrid.size();
size_t n = obstacleGrid.at(0).size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
// 初始化
dp[0][1] = 1;
for (int i = 1; i < m + 1; i++)
{
for (int j = 1; j < n + 1; j++)
{
if (obstacleGrid[i - 1][j - 1] != 1)
{
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
else
{
dp[i][j] = 0;
}
}
}
return dp[m][n];
}
三、珠宝的最高价值
现有一个记作二维矩阵
frame
的珠宝架,其中frame[i][j]
为该位置珠宝的价值。拿取珠宝的规则为:
- 只能从架子的左上角开始拿珠宝
- 每次可以移动到右侧或下侧的相邻位置
- 到达珠宝架子的右下角时,停止拿取
注意:珠宝的价值都是大于 0 的。除非这个架子上没有任何珠宝,比如
frame = [[0]]
。
算法流程:
- 状态表示
dp[i] [j]表示:走到 [i, j] 位置处,此时的最大价值。
- 状态转移方程
对于dp[i] [j] 而言,能到达 [i, j] 位置的只有 [i - 1] [j] 和 [i] [j - 1] 两个位置,由于需要到达 [i, j] 位置只能存在一条最大价值路径(金额相同算一条),所以
- 从 [i - 1, j] 到 [i, j] 位置的价值:dp[i - 1] [j] + grid[i] [j]
- 从 [i, j - 1] 到 [i, j] 位置的价值:dp[i] [j - 1] + grid[i] [j]
那么状态转移方程为:
dp[i] [j] = max(dp[i - 1] [j], dp[i] [j - 1]) + grid[i] [j]
- 初始化
由于每个当前位置都与上面、左面位置的值有关系,所以在上面和左面各自新加辅助行和辅助列用于帮助我们赋值。将新添加的首行和首列直接初始化为 0 即可
- 填表顺序
从左往右、从上往下
- 返回值
根据新dp数组,返回原数组右下角位置值变为 dp[m] [n] 的值。
示例代码:
int jewelleryValue(vector<vector<int>>& frame) {
size_t m = frame.size();
size_t n = frame.at(0).size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
// 初始化
dp[1][1] = frame[0][0];
// 填表
for (size_t i = 1; i < m + 1; i++)
{
for (size_t j = 1; j < n + 1; j++)
{
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + frame[i - 1][j - 1];
}
}
return dp[m][n];
}
四、下降路径最小和
题目链接:931. 下降路径最小和 - 力扣(LeetCode)
给你一个
n x n
的 方形 整数数组matrix
,请你找出并返回通过matrix
的下降路径 的 最小和 。下降路径 可以从第一行中的任何元素开始,并从每一行中选择一个元素。在下一行选择的元素和当前行所选元素最多相隔一列(即位于正下方或者沿对角线向左或者向右的第一个元素)。具体来说,位置
(row, col)
的下一个元素应当是(row + 1, col - 1)
、(row + 1, col)
或者(row + 1, col + 1)
。
算法流程:
- 状态表示
dp[i] [j] 表示:到达 [i, j] 位置时,所有下降路径中的最小和。
- 状态转移方程
对于普遍位置 [i, j] ,根据题意得,到达 [i, j] 位置可能有三种情况:
- 从正上方 [i - 1, j] 位置转移到 [i, j] 位置;
- 从左上方 [i - 1, j - 1] 位置转移到 [i, j] 位置;
- 从右上方 [i - 1, j + 1] 位置转移到 [i, j] 位置;
我们所要的是上面三种情况下的最小值,再加上自身 [i, j] 位置矩阵的对应值,所以状态转移方程:
dp[i] [j] = min(dp[i - 1] [j], min(dp[i - 1] [j - 1], dp[i - 1] [j + 1])) + matrix[i] [j]
- 初始化
由于每个当前位置都与上面三个位置(正上方、左上方、右上方)的值有关系,所以在上面和两侧各自新加辅助行和辅助列用于帮助我们赋值,即增加一行两列。因为我们既定的dp[i] [j] 表示最小值,那么我们就应当把新加行列中的值都初始化为无穷大,然后将第一行初始化为 0 即可
- 填表顺序
从上往下
- 返回值
题目要求只要到达最后一行没有指定具体位置,因此这里应该返回 dp 表中最后一行的最小值
示例代码:
int minFallingPathSum(vector<vector<int>>& matrix) {
int m = matrix.size();
int n = matrix.at(0).size();
vector<vector<int>> dp(m + 1, vector<int>(n + 2, INT_MAX));
// # # # # # #
// # #
// # #
// # #
// # #
// 初始化
for (int j = 0; j < n + 2; j++)
{
dp[0][j] = 0;
}
// 填表
for (int i = 1; i < m + 1; i++)
{
for (int j = 1; j < n + 1; j++)
{
dp[i][j] = min(min(dp[i - 1][j - 1], dp[i - 1][j]),
dp[i - 1][j + 1]) + matrix[i - 1][j - 1];
}
}
// 取dp表最后一行的最小值
int min = INT_MAX;
for (int j = 1; j < n + 1; j++)
{
min = dp[m][j] < min ? dp[m][j] : min;
}
return min;
}
五、最小路径和
给定一个包含非负整数的
*m* x *n*
网格grid
,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。**说明:**每次只能向下或者向右移动一步。
算法流程:
- 状态表示
到达 [i, j] 位置时,所有从左上角到该位置的路径中的最小和。
- 状态转移方程
对于普遍位置 [i, j] ,由于前一个位置到当前 [i, j] 位置只有从上向下和从左往右两种可能,所以到达该位置可能有两种情况:
- 从左边 [i, j - 1] 位置转移到 [i, j] 位置;
- 从上方 [i - 1, j] 位置转移到 [i, j] 位置;
我们所要的是上面两种情况下的最小值,再加上自身 [i, j] 位置矩阵的对应值,所以状态转移方程:
dp[i] [j] = min(dp[i] [j - 1], dp[i - 1] [j]) + grid[i] [j]
- 初始化
在首行和首列添加辅助行列,相应的 i 位置变为 i + 1, j 同理。由于求最小和,为了避免辅助行列中的值对后续填表赋值产生负面影响,将新添加的首行和首列直接初始化为 INT_MAX,接着将 dp[0] [1] 和 dp[1] [0] 位置的值都设为 0 即可。
- 填表顺序
从左上角到右下角
- 返回值
返回新 dp 表中最后一个元素的值,即 dp[m] [n],它代表了从左上角到右下角的最小路径和。
示例代码:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid.at(0).size();
// 初始化
vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT_MAX)); // dp数组内的值由min()决定,不能受辅助方格的影响,所以辅助方格初始化为极大值
dp[1][0] = dp[0][1] = 0; // 为了正确初始化dp[1][1]的值
for (int i = 1; i < m + 1; i++)
{
for (int j = 1; j < n + 1; j++)
{
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + grid[i - 1][j - 1];
}
}
return dp[m][n];
}
六、地下城游戏
题目链接:地下城游戏
恶魔们抓住了公主并将她关在了地下城
dungeon
的 右下角 。地下城是由m x n
个房间组成的二维网格。我们英勇的骑士最初被安置在 左上角 的房间里,他必须穿过地下城并通过对抗恶魔来拯救公主。骑士的初始健康点数为一个正整数。如果他的健康点数在某一时刻降至 0 或以下,他会立即死亡。
有些房间由恶魔守卫,因此骑士在进入这些房间时会失去健康点数(若房间里的值为负整数,则表示骑士将损失健康点数);其他房间要么是空的(房间里的值为 0),要么包含增加骑士健康点数的魔法球(若房间里的值为正整数,则表示骑士将增加健康点数)。
为了尽快解救公主,骑士决定每次只 向右 或 向下 移动一步。
返回确保骑士能够拯救到公主所需的最低初始健康点数。
注意: 任何房间都可能对骑士的健康点数造成威胁,也可能增加骑士的健康点数,包括骑士进入的左上角房间以及公主被监禁的右下角房间。
算法流程:
- 状态表示
到达 [i, j] 位置时,骑士为了能够继续前进至少需要的健康点数
- 状态转移方程
对于普遍位置 [i, j],骑士到达该位置可能有两种情况:
- 从左边 [i, j - 1] 位置转移到 [i, j] 位置;
- 从上方 [i - 1, j] 位置转移到 [i, j] 位置;
我们所要的是上面两种情况下所需健康点数的较小值,再减去当前房间的将要扣除的血量值(如果是负数则设为1),所以状态转移方程:
dp[i] [j] = min( dp[i] [j - 1], dp[i - 1] [j] ) - dungeon[i] [j]
- 初始化
由于骑士每次只向右或者向下行进且其自身健康点数不能小于等于0,所以我们将辅助行和辅助列初始化为无穷大,以确保骑士在任何情况下都不会因为这些辅助值而死亡。同时,将右下角的初始值设为1,表示骑士至少需要1点健康点数才能拯救公主。
- 填表顺序
从下往上,从右往左
- 返回值
由于骑士是从左上角出发,所以我们需要返回 dp[0] [0],即骑士在出发点至少需要的健康点数
示例代码:
int calculateMinimumHP(vector<vector<int>>& dungeon) {
int m = dungeon.size();
int n = dungeon.at(0).size();
// 初始化
vector<vector<int>> dp(m + 1, vector<int>(n + 1, INT_MAX));
dp[m][n - 1] = dp[m - 1][n] = 1;
// 由右下往左上 填表
for (int i = m - 1; i >= 0; i--)
{
for (int j = n - 1; j >= 0; j--)
{
dp[i][j] = min(dp[i + 1][j], dp[i][j + 1]) - dungeon[i][j];
if (dp[i][j] <= 0) { dp[i][j] = 1; } // 所需血量为负数,说明血量太充足,至少需要一滴血作为初始生命值
}
}
return dp[0][0];
}
总结
通过本文的学习,我们了解了DP的基本组成部分,包括状态的定义、状态转移的逻辑、初始化的重要性、填表顺序的确定以及最终结果的获取。我们还探讨了DP在不同路径、最小/最优路径选择以及其他相关问题中的应用。