一、题目
给定一个包含非负整数的 m × n 网格:
grid
要求从:
左上角
走到:
右下角
每次只能:
向右
或者
向下
求路径上的:
最小数字总和
例如:
grid =
1 3 1
1 5 1
4 2 1
最优路径:
1 → 3 → 1
↓
1
↓
1
路径和:
1 + 3 + 1 + 1 + 1 = 7
所以答案:
7
二、这道题考什么?
这是一道非常典型的:
二维动态规划 DP
关键在于:
到达当前格子
(i,j)时,只可能从上面或者左边过来。
也就是:
(i-1,j)
或者:
(i,j-1)
所以只需要比较:
上面的最小路径和
左边的最小路径和
取更小的一个。
三、DP 状态怎么定义?
定义:
dp[i][j]
表示:
从左上角走到
(i,j)的最小路径和。
例如:
dp[2][1]
表示:
从左上角走到第 2 行第 1 列这个位置时,最少需要多少路径和。
四、状态转移公式
因为 (i,j) 只能从:
上:
dp[i-1][j]
左:
dp[i][j-1]
过来。
所以:
dp[i][j]
=
Math.min(dp[i - 1][j], dp[i][j - 1])
+ grid[i][j];
翻译成人话:
到当前格子的最小路径和 = 上面和左边选一个更小的 + 当前格子的值。
这就是整道题最核心的一句。
五、为什么第一行和第一列要单独处理?
普通位置:
(i,j)
有两个来源:
上
左
但是第一行:
i = 0
上面已经没有格子。
所以第一行只能:
一直从左边走过来
例如:
1 3 1 2
那么:
dp[0][0] = 1
dp[0][1] = 1 + 3
dp[0][2] = 1 + 3 + 1
...
代码:
for (int j = 1; j < n; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
第一列也是一样。
第一列没有左边:
j = 0
所以只能:
从上往下
代码:
for (int i = 1; i < m; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
六、起点怎么初始化?
左上角:
(0,0)
就是起点。
所以:
dp[0][0] = grid[0][0];
因为:
到起点本身的路径和,就是起点自己的值。
七、LeetCode 标准写法
class Solution {
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];
}
// 其他格子
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] =
Math.min(dp[i - 1][j], dp[i][j - 1])
+ grid[i][j];
}
}
return dp[m - 1][n - 1];
}
}
八、ACM 模式和 LeetCode 有什么区别?
LeetCode 会帮我们自动准备:
int[][] grid
我们只需要写算法。
但是 ACM 模式需要自己负责:
读取输入
↓
构造二维数组
↓
调用算法
↓
输出答案
所以 ACM 一般写:
public class Main
并且有:
public static void main(String[] args)
九、假设 ACM 输入格式
例如输入:
3 3
1 3 1
1 5 1
4 2 1
第一行:
3 3
表示:
m = 3
n = 3
也就是:
3 行
3 列
后面:
1 3 1
1 5 1
4 2 1
就是二维数组。
十、ACM 完整版本
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
// 读取行数和列数
int m = in.nextInt();
int n = in.nextInt();
// 创建二维数组
int[][] grid = new int[m][n];
// 读取矩阵
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
grid[i][j] = in.nextInt();
}
}
// 调用算法
int ans = minPathSum(grid);
// 输出答案
System.out.println(ans);
}
private static 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];
}
// 其他格子:从上面和左边选更小的
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] =
Math.min(dp[i - 1][j], dp[i][j - 1])
+ grid[i][j];
}
}
return dp[m - 1][n - 1];
}
}
十一、ACM 二维数组输入模板
这一段建议直接背:
int m = in.nextInt();
int n = in.nextInt();
int[][] grid = new int[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
grid[i][j] = in.nextInt();
}
}
记住:
i → 行
j → 列
所以:
grid[i][j]
表示:
第 i 行、第 j 列。
十二、为什么 main 里面调用的方法写 static?
因为:
public static void main(...)
本身是一个静态方法。
如果想直接:
int ans = minPathSum(grid);
那么:
minPathSum
也最好写成:
private static int minPathSum(...)
否则需要:
Main obj = new Main();
obj.minPathSum(grid);
面试没必要这么麻烦。
所以 ACM 模式里可以统一:
main 是 static
辅助算法也写 static
十三、完整运行过程
输入:
3 3
1 3 1
1 5 1
4 2 1
首先:
m = 3
n = 3
构造:
grid =
1 3 1
1 5 1
4 2 1
初始化起点
dp[0][0] = 1
初始化第一行
dp[0][1]
=
dp[0][0] + grid[0][1]
=
1 + 3
=
4
dp[0][2]
=
dp[0][1] + grid[0][2]
=
4 + 1
=
5
第一行:
1 4 5
初始化第一列
dp[1][0]
=
1 + 1
=
2
dp[2][0]
=
2 + 4
=
6
于是:
dp =
1 4 5
2 ? ?
6 ? ?
十四、计算其他格子
计算:
dp[1][1]
上面:
dp[0][1] = 4
左边:
dp[1][0] = 2
选更小:
2
再加当前:
grid[1][1] = 5
所以:
dp[1][1] = 7
计算:
dp[1][2]
上面:
5
左边:
7
取:
5
加当前:
1
得到:
6
继续计算,最终:
dp =
1 4 5
2 7 6
6 8 7
所以:
dp[2][2] = 7
答案:
7
十五、为什么最终返回 dpm-1n-1?
因为数组下标从:
0
开始。
如果:
m 行
n 列
最后一个位置就是:
(m-1,n-1)
所以:
return dp[m - 1][n - 1];
就是右下角的最小路径和。
十六、复杂度分析
每个格子只计算一次。
一共有:
m × n
个格子。
所以时间复杂度:
O(mn)
我们又创建了:
m × n
的 dp 数组。
所以空间复杂度:
O(mn)
十七、这题怎么快速想到 DP?
看到题目:
从左上角走到右下角
只能向右或向下
求最小路径和
可以立刻问自己:
到当前格子之前,我可能从哪里来?
答案:
上面
左边
于是:
当前最优
=
min(上面的最优, 左边的最优)
+
当前值
这就是 DP。
十八、和零钱兑换有什么共同点?
零钱兑换:
dp[i]
=
凑 i 元最少需要几个硬币
当前状态来源:
dp[i-coin]
本题:
dp[i][j]
=
到达 (i,j) 的最小路径和
当前状态来源:
dp[i-1][j]
dp[i][j-1]
所以 DP 的通用思路是:
1. dp 表示什么?
2. 当前状态可以从哪些旧状态过来?
3. 初始状态是什么?
4. 最终答案在哪?
十九、最容易写错的地方
1. m 和 n 搞反
int m = grid.length;
int n = grid[0].length;
记:
grid.length
→ 行数
grid[0].length
→ 列数
2. 第一行忘记初始化
第一行不能直接使用:
dp[i - 1][j]
因为:
i = 0
会出现:
dp[-1][j]
所以必须单独处理。
3. 第一列忘记初始化
第一列:
j = 0
没有左边。
必须:
dp[i][0] = dp[i - 1][0] + grid[i][0];
4. 状态转移忘记加当前值
错误:
dp[i][j] =
Math.min(dp[i - 1][j], dp[i][j - 1]);
正确:
dp[i][j] =
Math.min(dp[i - 1][j], dp[i][j - 1])
+ grid[i][j];
因为路径经过当前格子,它自己的值也必须算进去。
5. ACM 中忘记自己读二维数组
LeetCode:
minPathSum(int[][] grid)
数组已经准备好了。
ACM:
必须自己读 m、n
再 new int[m][n]
再双层 for 读取
二十、ACM 万能二维数组模板
建议背下来:
import java.util.*;
public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int m = in.nextInt();
int n = in.nextInt();
int[][] grid = new int[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
grid[i][j] = in.nextInt();
}
}
int ans = solve(grid);
System.out.println(ans);
}
private static int solve(int[][] grid) {
return 0;
}
}
以后很多二维 ACM 题都可以直接套:
岛屿
矩阵搜索
最短路径
网格 DP
二维遍历
二十一、面试怎么讲?
可以这样回答:
我定义
dp[i][j]表示从左上角走到(i,j)的最小路径和。因为题目规定只能向右或向下,所以到达
(i,j)只可能从上方(i-1,j)或左方(i,j-1)过来。因此状态转移为
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]。第一行只能从左边过来,第一列只能从上面过来,所以单独初始化。
最终答案为
dp[m-1][n-1],时间复杂度O(mn),空间复杂度O(mn)。
如果是 ACM 模式:
我会先通过 Scanner 读取
m、n,再通过双层 for 构造二维数组,调用算法后输出结果。
二十二、30 秒速记
看到:
网格
只能向右 / 向下
求最小路径和
想到:
二维 DP
定义:
dp[i][j]
=
到当前格子的最小路径和
转移:
dp[i][j] =
Math.min(dp[i - 1][j], dp[i][j - 1])
+ grid[i][j];
初始化:
左上角:
grid[0][0]
第一行:
只能从左
第一列:
只能从上
答案:
dp[m - 1][n - 1]
ACM 输入:
int m = in.nextInt();
int n = in.nextInt();
int[][] grid = new int[m][n];
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
grid[i][j] = in.nextInt();
}
}
一句话记忆:
最小路径和 = 当前格子只能从上或左过来,所以取
min(上, 左) + 当前值;ACM 模式再在外面套Main + Scanner + 双层 for。