代码随想录 刷题记录-18 动态规划(1)基本理论及习题

一、基本理论

什么是动态规划

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的。

所以动态规划中每一个状态一定是由上一个状态推导出来的,这一点就区分于贪心,贪心没有状态推导,而是从局部直接选最优的,

动态规划的解题步骤

对于动态规划问题,拆解为如下五步曲,这五步都搞清楚了,才能说把动态规划真的掌握了

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式(状态转移方程)
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

为什么要先确定递推公式,然后在考虑初始化呢?

因为一些情况是递推公式决定了dp数组要如何初始化!

动态规划应该如何debug

找问题的最好方式就是把dp数组打印出来,看看究竟是不是按照自己思路推导的。

做动规的题目,写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果

然后再写代码,如果代码没通过就打印dp数组,看看是不是和自己预先推导的哪里不一样。

如果打印出来和自己预先模拟推导是一样的,那么就是自己的递归公式、初始化或者遍历顺序有问题了。

如果和自己预先模拟推导的不一样,那么就是代码实现细节有问题。

这样才是一个完整的思考过程。

二、习题

1.509. 斐波那契数

用这道简单题目加深对动态规划五部曲的理解。

动态规划

动规五部曲:

这里我们要用一个一维dp数组来保存递归的结果

1.确定dp数组及下标含义

dp[i]的定义为:第i个数的斐波那契数值是dp[i]

2.确定递推公式

dp[i] = dp[i-1] + dp[i-2]

3.确定dp数组如何初始化

dp[0] = 0;
dp[1] = 1;

4.确定遍历顺序

从递推公式:dp[i] = dp[i-1] + dp[i-2] 可以看出,后面的依赖于前面的,所以要从前往后。

5.举例推到dp数组

按照这个递推公式dp[i] = dp[i - 1] + dp[i - 2],我们来推导一下,当N为10的时候,dp数组应该是如下的数列:

0 1 1 2 3 5 8 13 21 34 55

如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的。

代码如下:

class Solution {
    public int fib(int n) {
        if(n==0) return 0;
        int[] dp = new int[n+1];
        dp[0] = 0;
        dp[1] = 1;
        for(int i = 2; i <= n ; i++){
            dp[i] = dp[i-1] + dp[i-2];
        }
        return dp[n];
    }
}

也可以滚动维护两个数:

class Solution {
    public int fib(int n) {
        if(n== 0) return 0;
        int[] dp = new int[2];
        dp[0] = 0;
        dp[1] = 1;
        int sum = 0;
        for(int i = 2; i <= n ; i++){
            sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;
        }
        return dp[1];

    }

}

递归解法

本题还可以使用递归解法来做

代码如下:

class Solution {
public:
    int fib(int N) {
        if (N < 2) return N;
        return fib(N - 1) + fib(N - 2);
    }
};

2.70. 爬楼梯

思路

爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。

那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。

所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了。

爬到第n层楼梯有两种可能:从第n-1层跨一步过来,或者 从第n-2层跨两步过来,那么第n层楼梯的爬法数应当为第n-1层爬法数+第n-2层爬法数。

动规五部曲:

定义一个一维数组来记录不同楼层的状态

1.dp数组及下标含义:

        dp[i]表示爬到第i层可以的方法。

2.确定递推公式:

        根据上面思路中的推导,可以知道 dp[i] = dp[i-1] + dp[i-2]

        在推导dp[i]的时候,一定要时刻想着dp[i]的定义,否则容易跑偏。

3.dp数组初始化:

        dp[1] = 1;

        dp[2] = 2;

        为了统一,dp[0] = 1;(题目中说n!=0,也可以完全不考虑 dp[0]初始化)

4.遍历顺序:

        根据递推公式,后面依赖前面,所以从前往后遍历

5.举例推导dp数组

        当n为5的时候,dp table(dp数组)应该是这样的

代码如下:

class Solution {
    public int climbStairs(int n) {
        if(n==1) return 1;
        if(n==2) return 2;
        int[] dp = new int[n+1];
        dp[1] = 1;
        dp[2] = 2;
        for(int i = 3; i <= n ; i++){
            dp[i] = dp[i-1] + dp[i-2]; 
        }
        return dp[n];
    }
}

优化空间复杂度:

class Solution {
    public int climbStairs(int n) {
        if(n==1) return 1;
        if(n==2) return 2;
        int[] dp = new int[2];
        dp[0] = 1;
        dp[1] = 2;
        int sum = 0;
        for(int i = 3; i <= n ; i++){
            sum = dp[0] + dp[1];
            dp[0] = dp[1];
            dp[1] = sum;
        }
        return dp[1];
    }
}

3.746. 使用最小花费爬楼梯

这是一个最优化问题,首先考虑贪心算法,经过简单的 1,100,200举例可知,如果采取贪心策略总选择局部最小的,那么不能达到全局最优。

考虑动态规划。

1.确定dp数组及下标含义

        dp[i]:到达i层,所需要的花费。

2.确定递推公式:

可以有两个途径得到dp[i],一个是dp[i-1] 一个是dp[i-2]

dp[i - 1] 跳到 dp[i] 需要花费 dp[i - 1] + cost[i - 1]。

dp[i - 2] 跳到 dp[i] 需要花费 dp[i - 2] + cost[i - 2]。

那么究竟是选从dp[i - 1]跳还是从dp[i - 2]跳呢?

一定是选最小的

        dp[i] = Math.min(dp[i-1] + cost[i-1] , dp[i-2] + cost[i-2])

3.dp数组初始化

        dp[0] = 0;

        dp[1] = 0;

4.确定遍历顺序:

        根据递推公式,可以知道应当从前往后比遍历

5.举例递推数组

拿示例2:cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1] ,来模拟一下dp数组的状态变化,如下:

注意本题要到达的时楼梯顶部,而不是最后一个台阶,要返回 dp[cost.length], dp长度应为 cost.length+1;

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int[] dp = new int[cost.length+1];
        dp[0] = 0;
        dp[1] = 0;
        for(int i = 2 ; i <= cost.length ; i++){
            dp[i] = Math.min(dp[i-1] + cost[i-1] , dp[i-2] + cost[i-2]);
        }
        return dp[cost.length];
    }
}

空间优化:

class Solution {
    public int minCostClimbingStairs(int[] cost) {
        int dp0 = 0 , dp1 = 0;
        int next = 0;
        for(int i = 2 ; i <= cost.length ; i++){
            next = Math.min(dp0 + cost[i-2], dp1 + cost[i-1]);
            dp0 = dp1;
            dp1 = next;
        }
        return dp1;
    }
}

小结

如果代码写出来了,一直AC不了,灵魂三问:

  1. 这道题目我举例推导状态转移公式了么?
  2. 我打印dp数组的日志了么?
  3. 打印出来了dp数组和我想的一样么?

4.62.不同路径

思路

深搜

这道题目,刚一看最直观的想法就是用图论里的深搜,来枚举出来有多少种路径。

注意题目中说机器人每次只能向下或者向右移动一步,那么其实机器人走过的路径可以抽象为一棵二叉树,而叶子节点就是终点!

如图举例:

此时问题就可以转化为求二叉树叶子节点的个数,代码如下:

class Solution {
private:
    int dfs(int i, int j, int m, int n) {
        if (i > m || j > n) return 0; // 越界了
        if (i == m && j == n) return 1; // 找到一种方法,相当于找到了叶子节点
        return dfs(i + 1, j, m, n) + dfs(i, j + 1, m, n);
    }
public:
    int uniquePaths(int m, int n) {
        return dfs(1, 1, m, n);
    }
};

会超时。

分析一下时间复杂度,这个深搜的算法,其实就是要遍历整个二叉树。

这棵树的深度其实就是m+n-1(深度按从1开始计算)。

那二叉树的节点个数就是 2^(m + n - 1) - 1。可以理解深搜的算法就是遍历了整个满二叉树(其实没有遍历整个满二叉树,只是近似而已)

所以上面深搜代码的时间复杂度为O(2^(m + n - 1) - 1),可以看出,这是指数级别的时间复杂度,是非常大的。

动态规划

机器人从(0 , 0) 位置出发,到(m - 1, n - 1)终点。

按照动规五部曲来分析:

1. dp[i][j] : 从 start 到 i,j有dp[i][j]种路径

2.状态转移方程 ,机器人只能向下或向右移动一步,所以只有可能从上面或者左边过来两种可能:

dp[i][j] = dp[i-1][j] + dp[i][j-1]

3.初始化:

首先dp[i][0]一定都是1,因为从(0, 0)的位置到(i, 0)的路径只有一条,那么dp[0][j]也同理。

所以初始化代码为:

for (int i = 0; i < m; i++) dp[i][0] = 1;
for (int j = 0; j < n; j++) dp[0][j] = 1;

4.遍历顺序:

根据递推公式,dp[i][j]从左边和上边得来,所以从左到右,从上到下遍历,这样可以保证计算dp[i][j]时一定有所需要的数据。

5.举例推导dp数组

如图所示:

代码如下:

class Solution {
    public int uniquePaths(int m, int n) {
        int[][] dp = new int[m][n];
        for(int i = 0; i < m ; i++) dp[i][0] = 1;
        for(int j = 0; j < n ; j++) dp[0][j] = 1;
        for(int i = 1 ; i < m ; i++){
            for(int j = 1 ; j < n ; j++){
                dp[i][j] = dp[i-1][j] + dp[i][j-1];
            }
        }
        return dp[m-1][n-1];
    }
}
  • 时间复杂度:O(m × n)
  • 空间复杂度:O(m × n)

数论方法

在这个图中,可以看出一共m,n的话,无论怎么走,走到终点都需要 m + n - 2 步。

62.不同路径

在这m + n - 2 步中,一定有 m - 1 步是要向下走的,不用管什么时候向下走。

那么有几种走法呢? 可以转化为,给你m + n - 2个不同的数,随便取m - 1个数,有几种取法。

那么这就是一个组合问题了。

那么答案,如图所示:

62.不同路径2

求组合的时候,要防止两个int相乘溢出! 所以不能把算式的分子都算出来,分母都算出来再做除法。

例如如下代码是不行的。

class Solution {
public:
    int uniquePaths(int m, int n) {
        int numerator = 1, denominator = 1;
        int count = m - 1;
        int t = m + n - 2;
        while (count--) numerator *= (t--); // 计算分子,此时分子就会溢出
        for (int i = 1; i <= m - 1; i++) denominator *= i; // 计算分母
        return numerator / denominator;
    }
};

需要在计算分子的时候,不断除以分母,代码如下:

class Solution {
public:
    int uniquePaths(int m, int n) {
        long long numerator = 1; // 分子
        int denominator = m - 1; // 分母
        int count = m - 1;
        int t = m + n - 2;
        while (count--) {
            numerator *= (t--);
            while (denominator != 0 && numerator % denominator == 0) {
                numerator /= denominator;
                denominator--;
            }
        }
        return numerator;
    }
};
  • 时间复杂度:O(m)
  • 空间复杂度:O(1)

计算组合问题的代码还是有难度的,特别是处理溢出的情况。

5.63. 不同路径 II

62.不同路径中我们已经详细分析了没有障碍的情况,有障碍的话,其实就是标记对应的dp table(dp数组)保持初始值(0)就可以了。

动规五部曲:

1.确定dp数组(dp table)以及下标的含义

dp[i][j] :表示从(0 ,0)出发,到(i, j) 有dp[i][j]条不同的路径。

2.确定递推公式

dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

如果有障碍,dp[i][j]保持初始状态0

3.初始化

如果(i, 0) 这条边有了障碍之后,障碍之后(包括障碍)都是走不到的位置了,所以障碍之后的dp[i][0]应该还是初始值0。

初始化代码为:

for (int i = 0; i < obstacleGrid.length && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
for (int j = 0; j < obstacleGrid[0].length && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;

4.确定遍历顺序

从左到右,从上到下,能保证计算时所需要的数据一定计算过了。

5.举例推导dp数组

拿示例1来举例如题:

63.不同路径II1

对应的dp table 如图:

63.不同路径II2

代码如下:

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int[][] dp = new int[obstacleGrid.length][obstacleGrid[0].length];
        for (int i = 0; i < obstacleGrid.length && obstacleGrid[i][0] == 0; i++) dp[i][0] = 1;
        for (int j = 0; j < obstacleGrid[0].length && obstacleGrid[0][j] == 0; j++) dp[0][j] = 1;
        for(int i = 1; i < obstacleGrid.length ; i++){
            for(int j = 1; j <obstacleGrid[0].length ; j++){
                if(obstacleGrid[i][j] == 1){
                    dp[i][j] = 0;
                }else{
                    dp[i][j] = dp[i-1][j] +dp[i][j-1];
                }
            }
        }
        return  dp[obstacleGrid.length-1][obstacleGrid[0].length -1];
    }
}

6.343. 整数拆分

思路

动态规划

动规五部曲,分析如下:

1.确定dp数组(dp table)以及下标的含义

dp[i]:分拆数字i,可以得到的最大乘积为dp[i]。

dp[i]的定义将贯彻整个解题过程,下面哪一步想不懂了,就想想dp[i]究竟表示的是啥。

2.确定递推公式

可以想 dp[i]最大乘积是怎么得到的呢?

其实可以从1遍历j,然后有两种渠道得到dp[i].

一个是j * (i - j) 直接相乘。

一个是j * dp[i - j],相当于是拆分(i - j),对这个拆分不理解的话,可以回想dp数组的定义。

那有同学问了,j怎么就不拆分呢?

j是从1开始遍历,拆分j的情况,在遍历j的过程中其实都计算过了。那么从1遍历j,比较(i - j) * j和dp[i - j] * j 取最大的。递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

也可以这么理解,j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘。

如果定义dp[i - j] * dp[j] 也是默认将一个数强制拆成4份以及4份以上了。

所以递推公式:dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j});

那么在取最大值的时候,为什么还要比较dp[i]呢?

因为在递推公式推导的过程中,每次计算dp[i],取最大的而已。

3.dp的初始化

dp[0] dp[1]应该初始化多少呢?

有的题解里会给出dp[0] = 1,dp[1] = 1的初始化,但解释比较牵强,主要还是因为这么初始化可以把题目过了。

严格从dp[i]的定义来说,dp[0] dp[1] 就不应该初始化,也就是没有意义的数值。

拆分0和拆分1的最大乘积是多少?

这是无解的。

这里我只初始化dp[2] = 1,从dp[i]的定义来说,拆分数字2,得到的最大乘积是1,这个没有任何异议。

4.确定遍历顺序

确定遍历顺序,先来看看递归公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));

dp[i] 是依靠 dp[i - j]的状态,所以遍历i一定是从前向后遍历,先有dp[i - j]再有dp[i]。

所以遍历顺序为:

for (int i = 3; i <= n ; i++) {
    for (int j = 1; j < i - 1; j++) {
        dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    }
}

注意 枚举j的时候,是从1开始的。从0开始的话,那么让拆分一个数拆个0,求最大乘积就没有意义了。

j的结束条件是 j < i - 1 ,其实 j < i 也是可以的,不过可以节省一步,例如让j = i - 1,的话,其实在 j = 1的时候,这一步就已经拆出来了,重复计算,所以 j < i - 1

至于 i是从3开始,这样dp[i - j]就是dp[2]正好可以通过我们初始化的数值求出来。

更优化一步,可以这样:

for (int i = 3; i <= n ; i++) {
    for (int j = 1; j <= i / 2; j++) {
        dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    }
}

因为拆分一个数n 使之乘积最大,那么一定是拆分成m个近似相同的子数相乘才是最大的。

例如 6 拆成 3 * 3, 10 拆成 3 * 3 * 4。 100的话 也是拆成m个近似数组的子数 相乘才是最大的。

只不过我们不知道m究竟是多少而已,但可以明确的是m一定大于等于2,既然m大于等于2,也就是 最差也应该是拆成两个相同的 可能是最大值。

那么 j 遍历,只需要遍历到 n/2 就可以,后面就没有必要遍历了,一定不是最大值。

5.举例推导dp数组

举例当n为10 的时候,dp数组里的数值,如下:

343.整数拆分

代码如下:

class Solution {
    public int integerBreak(int n) {
        int[] dp = new int[n+1];
        dp[2] = 1;
        for(int i = 3 ; i <= n ; i++){
            for(int j = 1; j < i ; j++){
                dp[i] = Math.max(dp[i],Math.max( j*(i-j) , j*dp[i-j]));
            }
        }
        return dp[n];
    }
}

贪心

本题也可以用贪心,每次拆成n个3,如果剩下是4,则保留4,然后相乘,但是这个结论需要数学证明其合理性!

我没有证明,而是直接用了结论。感兴趣的同学可以自己再去研究研究数学证明哈。

给出我的C++代码如下:

class Solution {
public:
    int integerBreak(int n) {
        if (n == 2) return 1;
        if (n == 3) return 2;
        if (n == 4) return 4;
        int result = 1;
        while (n > 4) {
            result *= 3;
            n -= 3;
        }
        result *= n;
        return result;
    }
};
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

7.96.不同的二叉搜索树

类似于多少种方案这种问题,考虑到要避免重复计算,后面依赖于前面,可以考虑动态规划思想。

1.dp数组及下标含义

dp[i] : 由i个节点组成且节点值分别为1到i的不同二叉搜索树的个数

2.递推方程

以j为头结点

dp[i] = sum(dp[j-1] * dp[i-j]) 其中 j从1到i

这得益于树的递归定义,比如:

dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量

元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量

元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量

元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量

有2个元素的搜索树数量就是dp[2]。

有1个元素的搜索树数量就是dp[1]。

有0个元素的搜索树数量就是dp[0]。

3.初始化

初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]。

那么dp[0]应该是多少呢?

从定义上来讲,空节点也是一棵二叉树,也是一棵二叉搜索树,这是可以说得通的。

从递归公式上来讲,dp[以j为头结点左子树节点数量] * dp[以j为头结点右子树节点数量] 中以j为头结点左子树节点数量为0,也需要dp[以j为头结点左子树节点数量] = 1, 否则乘法的结果就都变成0了。

所以初始化dp[0] = 1

4.遍历顺序

从左到右

5.举例推导dp数组

n为5时候的dp数组状态如图:

96.不同的二叉搜索树3

如果自己画图举例的话,基本举例到n为3就可以了,n为4的时候,画图已经比较麻烦了。

这里列到了n为5的情况,是为了方便 debug代码的时候,把dp数组打出来,看看哪里有问题。

代码如下:

class Solution {
    public int numTrees(int n) {
        if(n==1) return 1;
        int[] dp = new int[n+1];
        dp[0] = 1;
        for(int i = 1; i <= n ; i++){
            for(int j = 1; j <= i ; j++){
                dp[i] += dp[j-1]*dp[i-j];
            }
        }
        return dp[n];
    }
}

  • 50
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值