一、题目描述
给定一个包含非负整数的 m × n 网格 grid,需要从左上角出发,到达右下角,并使经过路径上的数字总和最小。
每次只能执行以下两种操作之一:
-
向右移动一格;
-
向下移动一格。
例如:
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:最小路径为 1 → 3 → 1 → 1 → 1,路径和为 7。
另一个示例:
输入:grid = [[1,2,3],[4,5,6]]
输出:12
本题和"不同路径"很相似,但"不同路径"统计的是路径数量,而本题需要在所有可行路径中找到数字总和最小的一条路径。
二、为什么使用动态规划?
假设机器人已经到达位置 (i,j)。由于它只能向右或向下移动,所以进入当前位置前,只可能位于:
-
上方位置
(i-1,j),然后向下走一步; -
左方位置
(i,j-1),然后向右走一步。
如果已经知道从左上角到达这两个位置的最小路径和,那么只需要选择较小的一个,再加上当前格子的数字,就能得到到达当前位置的最小路径和。
较小位置的结果会被后续多个状态重复使用,因此可以利用动态规划保存已经计算出的最优结果。
三、定义动态规划状态
定义:
dp[i][j]:从左上角 (0,0) 到达位置 (i,j) 的最小路径和
这里使用 Java 数组的 0-based 下标:
-
左上角位置是
(0,0); -
右下角位置是
(row-1,col-1); -
最终答案是
dp[row-1][col-1]。
例如,dp[2][1] 表示从左上角走到第 3 行、第 2 列时,能够取得的最小路径和。
四、确定边界状态
1. 左上角
起点没有前置位置,路径和就是当前格子的值:
dp[0][0] = grid[0][0];
如果直接复用 grid 数组,这个位置不需要修改,因为 grid[0][0] 本身就是起点的最小路径和。
2. 初始化第一行
机器人位于第一行时不能从上方进入,只能从左侧一路向右走。因此:
dp[0][j] = dp[0][j - 1] + grid[0][j];
原地修改 grid 时写成:
for (int j = 1; j < col; j++) {
grid[0][j] += grid[0][j - 1];
}
3. 初始化第一列
机器人位于第一列时不能从左侧进入,只能从上方一路向下走。因此:
dp[i][0] = dp[i - 1][0] + grid[i][0];
原地修改代码为:
for (int i = 1; i < row; i++) {
grid[i][0] += grid[i - 1][0];
}
先处理第一行和第一列,可以为后续状态转移准备完整的边界数据。
五、推导状态转移公式
对于不在第一行、也不在第一列的位置 (i,j),存在两种进入方式。
1. 从上方进入
从左上角到上方位置 (i-1,j) 的最小路径和为:
dp[i - 1][j]
再向下进入当前位置,需要加上 grid[i][j]。
2. 从左方进入
从左上角到左方位置 (i,j-1) 的最小路径和为:
dp[i][j - 1]
再向右进入当前位置,同样需要加上 grid[i][j]。
为了让路径和最小,需要选择两个前置状态中较小的一个:
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1])
+ grid[i][j];
核心思路可以概括为:
当前位置的最小路径和,等于上方与左方最小路径和中的较小值,再加上当前位置的数字。
六、为什么可以直接修改 grid?
按照标准定义,通常需要另外创建一个 dp 数组。但观察状态转移可以发现:计算完成后,原始的 grid[i][j] 不会再单独使用,它只会作为当前状态的一部分参与一次计算。
因此,可以直接让:
grid[i][j] = dp[i][j]
此时,已经计算过的位置不再保存原始权重,而是保存从左上角到达该位置的最小路径和。状态转移可以改写为:
grid[i][j] += Math.min(grid[i - 1][j], grid[i][j - 1]);
这种写法不需要额外二维数组,将额外空间复杂度降为 O(1)。
但它有一个明显副作用:方法执行后,传入的 grid****会被修改 。如果调用方后续还需要使用原始网格,就应该创建独立的 dp 数组,或者先复制 grid,不能盲目使用原地修改。
七、示例完整推演
输入网格为:
1 3 1
1 5 1
4 2 1
1. 初始化第一行
第一行只能从左向右累加:
grid[0][1] = 3 + 1 = 4
grid[0][2] = 1 + 4 = 5
得到:
1 4 5
1 5 1
4 2 1
2. 初始化第一列
第一列只能从上向下累加:
grid[1][0] = 1 + 1 = 2
grid[2][0] = 4 + 2 = 6
得到:
1 4 5
2 5 1
6 2 1
3. 计算其他位置
计算 (1,1):
grid[1][1] = 5 + min(4, 2) = 7
计算 (1,2):
grid[1][2] = 1 + min(5, 7) = 6
计算 (2,1):
grid[2][1] = 2 + min(7, 6) = 8
计算 (2,2):
grid[2][2] = 1 + min(6, 8) = 7
最终网格变成:
1 4 5
2 7 6
6 8 7
右下角 grid[2][2] = 7,所以最小路径和为 7。
需要注意,修改后的每个数字表示的是"到达当前位置的最小路径和",已经不再是原网格中的数字。
八、Java 完整代码
class Solution {
public int minPathSum(int[][] grid) {
int row = grid.length;
int col = grid[0].length;
// 第一行只能从左边到达,因此累加左侧状态
for (int j = 1; j < col; j++) {
grid[0][j] += grid[0][j - 1];
}
// 第一列只能从上方到达,因此累加上方状态
for (int i = 1; i < row; i++) {
grid[i][0] += grid[i - 1][0];
}
// 其他位置可以从上方或左方到达
for (int i = 1; i < row; i++) {
for (int j = 1; j < col; j++) {
grid[i][j] += Math.min(
grid[i - 1][j],
grid[i][j - 1]
);
}
}
return grid[row - 1][col - 1];
}
}
代码执行过程如下:
-
获取网格的行数和列数;
-
累加第一行,建立只能向右走的边界状态;
-
累加第一列,建立只能向下走的边界状态;
-
从
(1,1)开始计算其余位置; -
返回右下角保存的最小路径和。
九、为什么遍历顺序必须从左上到右下?
计算 grid[i][j] 时依赖:
grid[i - 1][j] // 上方状态
grid[i][j - 1] // 左方状态
因此,在计算当前位置之前,上方和左方必须已经完成更新。按照从上到下、每行从左到右的顺序遍历,正好满足这个依赖关系。
如果从右下角反向遍历,却仍使用相同公式,就可能读取尚未转换成最小路径和的原始值,导致结果错误。
十、复杂度分析
时间复杂度
每个网格位置最多被访问一次,因此时间复杂度为:
O(m × n)
空间复杂度
代码直接复用输入数组,只使用了少量局部变量,因此额外空间复杂度为:
O(1)
这里的 O(1) 指额外空间,不代表输入网格本身只占常量空间。