文章出处:极客时间《数据结构和算法之美》-作者:王争。该系列文章是本人的学习笔记。
题目
假设我们有一个 n 乘以 n 的矩阵 w[n][n]。矩阵存储的都是正整数。棋子起始位置在左上角,终止位置在右下角。我们将棋子从左上角移动到右下角。每次只能向右或者向下移动一位。从左上角到右下角,会有很多不同的路径可以走。我们把每条路径经过的数字加起来看作路径的长度。那从左上角移动到右下角的最短路径长度是多少呢?
回溯法
从 (0, 0) 走到 (n-1, n-1),每一步都有向下或者向右2种选择方式。当走到 (n-1, n-1)停止。
写回溯代码中,递归函数f的参数,仅仅与状态有关,与状态无关的变量(例如n、m、grid)都作为类的实例变量保存起来。
public class MatrixShortestPath {private int n;private int m;private int min;private int[][] grid;public int minPathSum(int[][] grid) {if(grid==null || grid.length==0) return 0;n = grid.length;m = grid[0].length;this.grid = grid;min = Integer.MAX_VALUE;f(0,0,0);return min;}private void f(int i,int j,int currentSum){//System.out.println(i+" " +j);if(i==n-1 && j>=m || i>=n && j==m-1){min = Math.min(min,currentSum);return;}if(i>=n || j>=m) return;f(i,j+1,currentSum+grid[i][j]);f(i+1,j,currentSum+grid[i][j]);}
}
我们根据上面这个特殊的例子,把回溯求解问题的递归树画出来。
递归树的每一个节点表示一个状态,用(i,j,currentPathSum),表示当前将要处理第i行第j列的数据,在处理之前已经走过的路径长度是currentPathSum。
在递归树上能看到虽然(i,j,currentPathSum)重复的不存在,但是这道题目要求的是到达(n-1,n-1)的时候最短路径长度,所以在处理的时候只需要知道到达(i,j)的时候最短的路径长度是多少,其余节点就无需向下扩散了。例如f(1,2,9),f(1,2,5),f(1,2,3)。那只需要保留f(1,2,3),f(1,2,9),f(1,2,5)无需向下扩散。因为达到(1,2)节点之后,仍然是向右、向下走,与currentPathSum等于5、9,3都无关。这样就保证了节点不会指数级增长。
对递归树剪枝——加缓存
int[][] memo,memo[i][j]=currentSum。当调用f(1,2,3)的时候,如果memo[1][2]值为0 ,那就设置memo[1][2]=3,向下计算。当遇到f(1,2,9)的时候,发现memo[1][2]=3,而9>3,则不再计算。
代码的变化就是加了判断:memo[i][j]==0∣∣memo[i][j]>currentSummemo[i][j]==0 || memo[i][j]>currentSummemo[i][j]==0∣∣memo[i][j]>currentSum。
public class MatrixShortestPath {private int n;private int m;private int min;private int[][] grid;private int[][] memo;public int minPathSum(int[][] grid) {if(grid==null || grid.length==0) return 0;n = grid.length;m = grid[0].length;this.grid = grid;min = Integer.MAX_VALUE;memo = new int[n][m];f(0,0,0);return min;}private void f(int i,int j,int currentSum){//System.out.println(i+" " +j+" "+currentSum);if(i==n-1 && j>=m || i>=n && j==m-1){min = Math.min(min,currentSum);return;}if(i>=n || j>=m) return;if(memo[i][j]==0 || memo[i][j]>currentSum){memo[i][j] = currentSum;f(i,j+1,currentSum+grid[i][j]);f(i+1,j,currentSum+grid[i][j]);}}
}
对递归树剪枝——动态规划
状态转移表
接下来我们按照这种方式,计算状态转移表。我们画出一个二维状态表,表中的行、列表示棋子所在的位置,表中的数值表示从起点到这个位置的最短路径。注意:这里状态表的值的含义与递归树中的值的含义发生了变化。我们看到在递归树中到达最后一个位置,还需要currentPathSum+matrix[i][j]。但在表中是不需要的。
如果把表定义为int[][] dp ,dp[i][j]=到达(i,j)位置的最短路径。我们想要的返回值就是dp[n-1][m-1]。
我们按照决策过程,通过不断状态递推演进,将状态表填好。为了方便代码实现,我们按行来进行依次填充。
初始化这一步是很重要的。
对dp[0][0]=grid[0][0]。
对于第0行,大于0的列,只能从左侧的位置转移到当前位置:dp[0][j-1]+grid[0][j]。
对于第0列,只能从上面的位置转移到当前位置:dp[i][0]=dp[i-1][0]+grid[i][0]。
代码:
public int minDistDP(int[][] matrix ,int n){int[][] states = new int[n][n];//第一行for(int j=0;j<n;j++){if(j==0){states[0][j] = matrix[0][j];}else{states[0][j] = states[0][j-1]+matrix[0][j];}}//第一列for(int i=1;i<n;i++){states[i][0] = states[i-1][0]+matrix[i][0];}for(int i=1;i<n;i++){for(int j=1;j<n;j++){states[i][j] = Math.min(states[i-1][j],states[i][j-1])+matrix[i][j];}}return states[n-1][n-1];}
状态转移方程
定义int[][] dp ,dp[i][j]=到达(i,j)位置的最短路径。
我们知道可以从(i-1,j)或者(i,j-1)两个状态到达(i,j)。
从 (i-1,j)到达(i,j),路径和需要增加grid[i][j],也就是说dp[i][j]=dp[i-1][j]+grid[i][j]。
从(i,j-1)到达(i,j),路径和需要增加grid[i][j],也就是说dp[i][j]=dp[i][j-1]+grid[i][j]。
那么转移方程就是
dp[i][j] = min(dp[i-1][j]+grid[i][j],dp[i][j-1]+grid[i][j])
=min(dp[i-1][j],dp[i][j-1])+grid[i][j]
初始化:
对dp[0][0]=grid[0][0]。
对于第0行,大于0的列,只能从左侧的位置转移到当前位置:dp[0][j-1]+grid[0][j]。
对于第0列,只能从上面的位置转移到当前位置:dp[i][0]=dp[i-1][0]+grid[i][0]。
实现代码:
public int minPathSum(int[][] grid) {if(grid==null || grid.length==0) return 0;int n = grid.length;int m = grid[0].length; int[][] dp = new int[n][m];for(int j=0;j<m;j++){dp[0][j] = (j==0?grid[0][0]:dp[0][j-1]+grid[0][j]);}for(int i=1;i<n;i++){dp[i][0] = dp[i-1][0]+grid[i][0];}for(int i=1;i<n;i++){for(int j=1;j<m;j++){dp[i][j] = Math.min(dp[i-1][j],dp[i][j-1])+grid[i][j];}}return dp[n-1][m-1];}
类似题目
可以用相同的思路处理 LeetCode 322 零钱兑换。