题目描述
给定一个包含非负整数的 m x n 网格 grid,请找出一条从左上角到右下角的路径,使得路径上的数字总和最小。
每次只能向下或者向右移动一步。
例如:
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:路径 1 -> 3 -> 1 -> 1 -> 1 的总和最小。
最初思路
一开始想到的是自顶向下的记忆化搜索:
dfs(i,j):从 (0,0) 到达坐标 (i,j) 的最小路径和
因为只能向右或向下走,所以反过来看,到达 (i,j) 之前只可能来自两个位置:
上方:(i - 1, j)
左方:(i, j - 1)
所以状态转移是:
dfs(i,j) = min(dfs(i - 1,j), dfs(i,j - 1)) + grid[i][j]
再用 memo[i][j] 缓存已经计算过的最小路径和,避免重复递归。
问题出在哪里
第一版代码把越界状态写成了:
if i < 0 or j < 0:
return 0
这个返回值会让"不存在的路径"参与 min,并且因为 0 很小,很容易被错误选中。
比如:
grid = [[1,2]]
计算 dfs(0,1) 时:
dfs(0,1) = min(dfs(-1,1), dfs(0,0)) + grid[0][1]
如果 dfs(-1,1) 返回 0,那么它会比真实路径 dfs(0,0) 更小,结果就会少算 grid[0][0]。
正确思路
继续沿用原来的记忆化搜索,核心是让边界状态的含义和状态定义一致:
dfs(i,j):从左上角 (0,0) 到坐标 (i,j) 的最小路径和
边界条件:
1. i < 0 或 j < 0:走出网格,是非法路径,返回一个足够大的值
2. i == 0 且 j == 0:到达起点,返回 grid[0][0]
3. memo[i][j] 已经计算过:直接返回缓存值
最终答案就是:
dfs(m - 1, n - 1)
手推过程
用只有一行的用例检查越界返回值:
grid = [[1,2]]
正确路径只能是:
1 -> 2
所以答案应该是:
3
如果越界返回 0:
dfs(0,1)
= min(dfs(-1,1), dfs(0,0)) + grid[0][1]
= min(0, 1) + 2
= 2
这里得到 2,说明非法路径被 min 选中了。
如果越界返回足够大的值:
dfs(0,1)
= min(很大的值, dfs(0,0)) + grid[0][1]
= min(很大的值, 1) + 2
= 3
这样才会保留真实路径。
状态定义
dfs(i,j):从 (0,0) 到达 (i,j) 的最小路径和。
这个定义的重点是:返回值已经包含当前位置 grid[i][j]。
所以起点不是返回 0,而是返回:
grid[0][0]
状态转移
到达 (i,j) 之前,只可能从上方或左方过来:
dfs(i,j) = min(dfs(i - 1,j), dfs(i,j - 1)) + grid[i][j]
其中:
dfs(i - 1,j):从上方走下来之前的最小路径和
dfs(i,j - 1):从左方走过来之前的最小路径和
grid[i][j]:当前位置也要加入路径和
初始化与递归边界
递归里有两个核心边界:
越界:返回足够大的值,表示非法路径
起点:返回 grid[0][0]
也就是:
if i < 0 or j < 0: return Integer.MAX_VALUE / 2
if i == 0 and j == 0: return grid[0][0]
这里用 Integer.MAX_VALUE / 2,是为了避免后面再加 grid[i][j] 时出现整数溢出。
另外,因为题目里的格子值是非负整数,最小路径和可能为 0。如果直接用 memo[i][j] > 0 判断是否计算过,在全是 0 的网格里缓存会失效。更稳的写法是先把 memo 初始化为 -1。
伪代码
下面的伪代码与后面的 Java 实现一一对应:
创建大小为 m x n 的 memo 数组
把 memo 中每个位置初始化为 -1
返回 dfs(m - 1, n - 1, grid, memo)
函数 dfs(i, j, grid, memo):
如果 i < 0 或 j < 0:
返回一个足够大的值
如果 i == 0 且 j == 0:
返回 grid[0][0]
如果 memo[i][j] 不等于 -1:
返回 memo[i][j]
memo[i][j] = min(dfs(i - 1, j), dfs(i, j - 1)) + grid[i][j]
返回 memo[i][j]
Java 代码
import java.util.Arrays;
class Solution {
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[][] memo = new int[m][n];
for (int[] row : memo) {
Arrays.fill(row, -1);
}
return dfs(m - 1, n - 1, grid, memo);
}
public int dfs(int i, int j, int[][] grid, int[][] memo) {
if (i < 0 || j < 0) {
return Integer.MAX_VALUE / 2;
}
if (i == 0 && j == 0) {
return grid[0][0];
}
if (memo[i][j] != -1) {
return memo[i][j];
}
return memo[i][j] = Math.min(
dfs(i - 1, j, grid, memo),
dfs(i, j - 1, grid, memo)
) + grid[i][j];
}
}
易错点
1. 越界不能返回 0
这题是在求最小值。
如果非法路径返回 0,它会变成一个很小的候选值,被 Math.min 错误选中。
越界状态应该表示"不可达",所以要返回一个足够大的值。
2. 起点要返回 grid[0][0]
dfs(i,j) 表示从 (0,0) 到 (i,j) 的最小路径和。
起点本身就在路径里,所以:
dfs(0,0) = grid[0][0]
如果返回 0,就会漏算起点的值。
3. 大数要避免溢出
如果越界直接返回:
Integer.MAX_VALUE
后面再执行:
Integer.MAX_VALUE + grid[i][j]
可能发生整数溢出。
所以更稳的是:
Integer.MAX_VALUE / 2
4. memo 的哨兵值要避开合法答案
因为 grid 里的数字可以是 0,所以某些位置的最小路径和也可能是 0。
如果用:
memo[i][j] > 0
判断是否计算过,那么答案为 0 的状态不会命中缓存。
更稳的做法是把 memo 初始化为 -1,再用:
memo[i][j] != -1
判断是否已经计算过。
建议测试用例
grid = [[1]]
输出:1
grid = [[1,2]]
输出:3
grid = [[1],[2]]
输出:3
grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
grid = [[0,0],[0,0]]
输出:0
复杂度分析
- 时间复杂度:
O(m * n),每个网格坐标最多被计算一次。 - 空间复杂度:
O(m * n),主要来自memo数组;递归调用栈最多为O(m + n)。
总结
这题的核心不是换解法,而是让边界返回值服务于 min 这个转移。
只要状态定义是:
dfs(i,j):到达坐标 (i,j) 的最小路径和
那么越界就应该返回"大数"来表示非法路径,起点就应该返回 grid[0][0] 来计入起点值。
用 [[1,2]] 这种一行网格手推一遍,很容易发现越界返回 0 会把非法路径选进来。