动态规划(一)

一、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% 的用户

至此动态规划第一部分结束啦,没想到每日一题也好难坚持,好几天没更新了,今天就搞一个复杂一些的~也算是补上这些天的每日一题。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值