一、Wikipedia的定义
动态规划的定义是:将原问题拆解成若干子问题,同时保存子问题的答案,使得每个子问题只求解一次,最终获得原问题的答案。
大多数动态规划问题的本质是递归问题,在这个递归问题中会产生重叠子问题,解决重叠子问题的方法有两种,记忆化搜索(自顶向下)和动态规划(自底向上)。记忆化搜索的方法是更容易想到的,可以由记忆化搜索再拓展出动态规划的方法。
例如求解斐波那契数列的问题,递归的正常解法如下。
int fib(int n){
if(n == 0 )
return 0;
if(n == 1)
return 1;
return fib(n-1)+fib(n-2);
}
在这个求解方法中,为了求得fib(4),则需要计算fib(3)和fib(2),计算fib(3)需要计算fib(2)和fib(1),计算fib(2)需要计算fib(1)和fib(0),这时就涉及到多次计算fib(2),就像下图中粉色方框内的计算被反复进行。
这时可以很自然的想到,如果设置一个memory数组保存这些被反复计算的结果,则效率会有很大提升,如下代码所示。
int fib(int n){
if(n == 0 )
return 0;
if(n == 1)
return 1;
if(memo[n] == -1)
memo[n] = fib(n-1)+fib(n-2);
return memo[n];
}
维护一个memo数组,第i个位置则代表n=i的斐波那契数列结果。这个解决方法便是记忆化搜索,稍作改进则可以写出动态规划的代码如下。代码更加简洁和清晰。
int fib_dynamic(int n){
memo[0] = 0;
memo[1] = 1;
for(int i=2;i<n+1;i++)
memo[i] = memo[i-1] + memo[i-2];
return memo[n];
}
二、LeetCode中的动态规划问题
1、70号问题 爬楼梯
(1)题目描述:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
输入: 2
输出: 2
解释: 有两种方法可以爬到楼顶。
1. 1 阶 + 1 阶
2. 2 阶
(2)思路解析
可以绘制树形结构帮助理解,与上斐波那契数列类似,计算爬上n阶台阶的方法,即是爬上n-1阶台阶的方法数+爬上n-2阶台阶的方法数,由此往下延展,可以看出与斐波那契数列的原理是完全相同的。
(3)递归解法
class Solution{
public int calNum(int n){
if(n == 1)
return 1;
if(n == 2)
return 2;
return calNum(n-1)+calNum(n-2);
}
public int climbStairs(int n) {
return calNum(n);
}
}
提交结果超出时间限制,由此可见递归的时间复杂度较高。
(4)记忆化搜索解法与上斐波那契解法类似。
(5)动态规划解法
class Solution {
int[] memo;
public int climbStairs(int n) {
memo = new int[n+1];
if(n==1)
return 1;
memo[1] = 1;
memo[2] = 2;
for(int i=3;i<=n;i++)
memo[i] = memo[i-1] + memo[i-2];
return memo[n];
}
}
执行用时:0 ms, 在所有 Java 提交中击败了100.00% 的用户
内存消耗:35.3 MB, 在所有 Java 提交中击败了21.33% 的用户
2、120号问题 - 三角形最小路径和
(1)题目描述
给定一个三角形 triangle ,找出自顶向下的最小路径和。
每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。
示例 1:
输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]]
输出:11
解释:如下面简图所示:
2
3 4
6 5 7
4 1 8 3
自顶向下的最小路径和为 11(即,2 + 3 + 5 + 1 = 11)。
(2)思路解析
很经典的动态规划问题,最初的思路是使用memo数组记录到每层的最短路径,发现思路并不对,到第二层最小的数可能加上第三层的就不再正确了,因此memo数组保存的应该是对每层数据来说,到该点的最短路径数值。例如从三角形顶部开始,memo数组记录的依次为[2],[2+3,2+4],[5+6,min(5+5,6+5),6+7],[11+4,min(11+1,10+1),min(10+8,13+8),13+3],最后数组中保存的是[15,11,18,16],对应的就是到4,1,8,3这四个点的最小值,其中最小的11即为结果。但是这种思路又会导致最后获得的数组需要再找一下最小值,增加了复杂度,因此考虑从三角形底部向上进行推理,则最后memo[0]保存的即为最短路径,从下往上推理也不会产生数据还没计算完就被覆盖的情况,拿一个例子在纸上尝试一下即可看出来。
class Solution {
int[] memo;
public int minimumTotal(List<List<Integer>> triangle) {
int length = triangle.size();
memo = new int[length+1];
for(int i=length-1;i>=0;i--){
for(int j=0;j<=i;j++){
memo[j] = Math.min(memo[j],memo[j+1])+triangle.get(i).get(j);
}
}
return memo[0];
}
}
执行用时:2 ms, 在所有 Java 提交中击败了95.72% 的用户
内存消耗:38.4 MB, 在所有 Java 提交中击败了77.51% 的用户
(3)与递归的联系
在这个例子中,进行递归的公式如下所示
f(i,j)=min(f(i+1,j),f(i+1,j+1))+triangle[i][j]
public int minimumTotal(List<List<Integer>> triangle) {
int i=0,j=0;
return digui(i,j,triangle);
}
private int digui(int i, int j,List<List<Integer>> triangle) {
if(i == triangle.size())
return 0;
return Math.min(digui(i+1,j,triangle),digui(i+1,j+1,triangle))+
triangle.get(i).get(j);
}
对于一个问题来说,递归方法是最直接的,设置递归的终止条件,然后按照公式写即可,但是递归的时间复杂度是很高的,也会产生许多重复的子问题,比如计算triangle[2][1]的值,则需要计算triangle[1][0]和triangle[1][1]的值,而triangle[1][1]的值在之前已经被计算过了,现在却重新计算了。
如果将其改为记忆化搜索问题,则定义一个memo数组,memo[i][j]保存计算过的值,进一步降低复杂度,进一步优化则为上述的解法。
3、64号问题 -- 最小路径和
(1)题目描述:
给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。
示例 1:
1 3 1
1 5 1
4 2 1
输入:grid = [[1,3,1],[1,5,1],[4,2,1]]
输出:7
解释:因为路径 1→3→1→1→1 的总和最小。
(2)思路解析:
同样是一道经典的动态规划问题,先从递归入手,递推公式:
dp[i][j]=min(dp[i-1][j]+dp[i][j-1])+grid[i][j]
需要注意的是边界问题,当递推到左上角元素时,不应该再返回min(dp[i-1][j]+dp[i][j-1])+grid[i][j],因为i和j都已经走到头了,直接返回grid[i][j]即可;当递推到第一列时,不应该再对j进行减一,因此返回dp[i-1][j]+grid[i][j]即可;同理,递推到第一行时,返回dp[i][j-1]+grid[i][j]。
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
return minpath(grid,m-1,n-1);
}
private int minpath(int[][] grid,int i,int j) {
if ((i == 0 ) && (j == 0))
return grid[i][j];
if (i == 0 )
return minpath(grid,i,j-1)+grid[i][j];
if (j == 0)
return minpath(grid,i-1,j)+grid[i][j];
return Math.min(minpath(grid, i-1, j),minpath(grid,i,j-1))+grid[i][j];
}
递归方法一定会超时,因此加入记忆化搜索方式。
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[][] memo = new int[m][n];
return minpath(memo,grid,m-1,n-1);
}
private int minpath(int[][] memo,int[][] grid,int i,int j) {
if(memo[i][j] != 0)
return memo[i][j];
if ((i == 0 ) && (j == 0)) {
memo[i][j] = grid[i][j];
return memo[i][j];
}
if (i == 0 ) {
memo[i][j] = minpath(memo, grid, i, j - 1) + grid[i][j];
return memo[i][j];
}
if (j == 0) {
memo[i][j] = minpath(memo, grid, i - 1, j) + grid[i][j];
return memo[i][j];
}
return Math.min(minpath(memo,grid, i-1, j),minpath(memo,grid,i,j-1))+grid[i][j];
}
依然超时...由此可见不同方法的复杂度差异很大。最后则采用复杂度最低的动态规划方法。
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[][] memo = new int[m][n];
for(int i=0;i<m;i++){
for(int j=0;j<n;j++){
if(i==0 && j==0)
memo[i][j] = grid[0][0];
else if(i == 0)
memo[i][j] = memo[i][j-1]+grid[i][j];
else if(j == 0)
memo[i][j] = memo[i-1][j]+grid[i][j];
else
memo[i][j] = Math.min(memo[i][j-1],memo[i-1][j])+grid[i][j];
}
}
return memo[m-1][n-1];
}
执行用时:3 ms, 在所有 Java 提交中击败了88.26% 的用户
内存消耗:41 MB, 在所有 Java 提交中击败了73.00% 的用户
经过前面的铺垫,这种方法很好理解,但是比较浪费空间,使用了额外的二维数组,是否有方法能使用一维数组呢?使用一维数组dp[size=grid[0].size()]记录数据,一行一行的记录数值,首先遍历grid的第一行,记录从原点一直向右走时,各个点的结果。再对第二行进行记录,第二行第一列单独计算,计算第二行第j列时,dp[j]表示从上一个点向右走的长度,dp[j-1]表示从上一个点向下走的长度,比较这两个长度,再加上第二行第j列的数值即可。
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[] memo = new int[n];
memo[0] = grid[0][0];
for(int i=1;i<n;i++)
memo[i] = memo[i-1]+grid[0][i];
for(int i=1;i<m;i++){
for(int j=0;j<n;j++){
if(j==0)
memo[j] = memo[0] + grid[i][0];
else
memo[j] = Math.min(memo[j-1],memo[j])+grid[i][j];
}
}
return memo[n-1];
}
执行用时:2 ms, 在所有 Java 提交中击败了98.22% 的用户
内存消耗:40.9 MB, 在所有 Java 提交中击败了88.70% 的用户
至此动态规划第一部分结束啦,没想到每日一题也好难坚持,好几天没更新了,今天就搞一个复杂一些的~也算是补上这些天的每日一题。