题目
矩阵m,从左上角开始每次只能向右或者向下走,最后到达右下角的位置,路径上所有的数字累加起来就是路径和,返回所有路径中最小的路径和。
举例
给定m如下:
1 3 5 9
8 1 3 4
5 0 6 1
8 8 4 0
路径1,3,1,0,6,1,0是所有路径中路径和最小的,所以返回12
思路
- 先考虑最优子结构,即能否进行子问题划分。从顶点开始的矩阵,求最小路径和,因为只能往下或者往右,那么可以考虑两个新的局面,即视下方的点和右方的点为两个新矩阵的顶点,只需求出这两个矩阵的最小路径和,再从中找出较小的那个,加上当前顶点的值就是整个矩阵的最小路径和。
- 递归解法写好后,会发现有重叠的子问题,那就适合用动规来解决问题了,作为过度可以使用记忆型递归。
- 分析记忆的数据的依赖关系,可以定义计算的顺序,先计算被依赖的值,逐步求得最终需要的值。
暴力递归
伪代码
minSum(int[][] arr, int x, int y)
return arr[x][y] + Math.min(minSum(arr,x+1,y),minSum(arr,x,y+1));
思路很简单,整个问题的解是当前位置的值加往下走与往右走形成的路径和中较小的那个。
x和y会不停地增加,边界就是x到达最后一行或者y达到最后一列,因此代码改写为:
minSum(int[][] arr, int x, int y)
int sum=0;
if(x==arr.length-1)//到达右下角只有一条路可走
for i=y...arr.length-1
sum+=arr[x][i]
return sum;
if(y==arr.length-1)//到达右下角只有一条路可走
for i=x...arr.length-1
sum+=arr[i][y]
return sum;
return arr[x][y] + Math.min(minSum(arr,x+1,y),minSum(arr,x,y+1));
Java实现
public class 数字矩阵的最小路径和 {
public static int minSum(int[][] arr, int x, int y) {
int sum = 0;
if (x == arr.length - 1) {//到达右下角只有一条路可走
for (int i = y; i < arr[0].length; i++) {
sum += arr[x][i];
}
return sum;
}
if (y == arr[0].length - 1) {//到达右下角只有一条路可走
for (int i = x; i < arr.length; i++) {
sum += arr[i][y];
}
return sum;
}
return arr[x][y] + Math.min(minSum(arr, x + 1, y), minSum(arr, x, y + 1));
}
public static void main(String[] args) {
System.out.println(minSum(new int[][]{
{1, 3, 5, 9},
{8, 1, 3, 4},
{5, 0, 6, 1},
{8, 8, 4, 0},
},0,0));
}
}
记忆搜索型
按照以前介绍的思路,考察递归函数的变化的参数,有两个就可以用一个二维数组来缓存计算过的值,非常简单,掌握套路之后几乎不用思考就可以改写:
public static int minSumMemory(int[][] arr, int x, int y, int[][] map) {
int sum = 0;
if (x == arr.length - 1) {//到达右下角只有一条路可走
for (int i = y; i < arr[0].length; i++) {
sum += arr[x][i];
}
map[x][y] = sum; // 缓存
return sum;
}
if (y == arr[0].length - 1) {//到达右下角只有一条路可走
for (int i = x; i < arr.length; i++) {
sum += arr[i][y];
}
map[x][y] = sum;//缓存
return sum;
}
//=====判断缓存,没有值再递归,保证一个xy组合只计算一次=====
int v1 = map[x + 1][y];
if (v1 == 0)
v1 = minSum(arr, x + 1, y);
int v2 = map[x][y + 1];
if (v2 == 0)
v2 = minSum(arr, x, y + 1);
sum = arr[x][y] + Math.min(v1, v2);
map[x][y] = sum; // 缓存
return sum;
}
一般来说暴力递归时指数级的,记忆型递归是多项式(二维矩阵就是平方级)级别的,接下来改写为动态规划未必一定能提升效率,但是因为没有用递归,可以减少栈溢出的风险。
动态规划
在之前的套路中提出,考察缓存值的取得过程,当前递归要缓存的值依赖什么值,把过程逆过来就是动态规划了。
本例中,缓存表是一个二维数组,我们最终把每个点都要填写值,这个值就是这个点到达右下角的最小路径和。递归是自顶向下,动规可以自底向上,可以从x、y的边界开始,也就是最下面一行和最右侧一列开始,因为这些点到右下角的路径只有一条,可自然求解。
14
5
1
20 12 4 0
然后填中间的值就很简单了=自身+下方和右方较小的那个数字,最终计算到0-0位置,就是最后的答案了:
12 11 13 14
16 8 8 5
12 7 7 1
20 12 4 0
下面来看看代码:
public static int dp1(int[][] arr) {
final int rows = arr.length;
final int cols = arr[0].length;
int[][] dp = new int[rows][cols];
dp[rows - 1][cols - 1] = arr[rows - 1][cols - 1];
//打表:最右一列
for (int i = rows - 2; i >= 0; i--) {
dp[i][cols - 1] = arr[i][cols - 1] + dp[i + 1][cols - 1];
}
//打表:最后一行
for (int i = cols - 2; i >= 0; i--) {
dp[rows - 1][i] = arr[rows - 1][i] + dp[rows - 1][i + 1];
}
for (int i = rows - 2; i >= 0; i--) {
for (int j = cols - 2; j >= 0; j--) {
dp[i][j] = arr[i][j]+Math.min(dp[i+1][j],dp[i][j+1]);
}
}
return dp[0][0];
}
根据二维dp表,可以复原最小路径:
这就是从左上角到右下角依次找最小的过程,所过路径到原数组中去找对应的数字就好了。
空间压缩法:只用一维dp数组
本例只需返回最小路径和的值,要求输出路径,所以中间过程无需保留。考虑用一维数组来存储递推值,每计算一行就覆盖一行,最终有上图的0-0处的值就可以了。
对于本例,因为行数和列数相同,所以以哪个为数组的大小都是可以的,如果不一致,可以选行数或者列数作为数组大小,当然,推进方式也要随之变化,以行方式(行数更多)推进还是以列方式(列数更多)来推进。
下面我们来说明下这个过程:
dp=new int[4];
初始值全部为0- 根据源数据,将其更新为
[20 12 4 0]
,此时dp[i]代表着从矩阵(最后一行,i)到终点的最小路径和; - 现在开始更新,让dp[i]代表从矩阵(倒数第二行,i)到终点的最小路径和,先更新最后一个元素,
dp[3]=arr[倒数2行][最后一列]+dp[3];
此时dp变为[20 12 4 1]
;更新dp[2]:dp[2] = arr[倒数第二行][倒数第二列]+min(4,1)
,4是下方值,1是右方值,这样可以逐步更新 - 重复步骤3直到处理到第一行,返回dp[0]即可
代码:
/**
* 空间压缩优化
* @param arr
* @return
*/
public static int dp2(int[][] arr) {
final int rows = arr.length;
final int cols = arr[0].length;
int N = 0;
if (rows>=cols){
N = cols;
}
int[] dp = new int[N];
dp[N-1] = arr[rows - 1][N - 1];
//打表:第一次更新
for (int i = N - 2; i >= 0; i--) {
dp[i] = arr[rows-1][i] + dp[i + 1];
}
// 行
for (int i = rows-2; i >=0 ; i--) {
dp[N-1]=arr[i][N-1]+dp[N-1];
for (int j = N - 2; j >= 0; j--) {
dp[j] = arr[i][j] + Math.min(dp[j],dp[j + 1]);
}
// Util.print(dp);
}
return dp[0];
}
总结
本例的优化套路几乎可以运用于所有需要二维动态规划表的题目中,通过一个数组滚动更新的方式无疑节省了大量的空间。
但是空间压缩的方法也有局限性,就是无法复原最优解的具体路径。
参考书籍《程序员代码面试指南》。