10、动态规划相关

文章目录

  • 动态规划
  • 1、理论
    • 定义
    • 解题步骤
    • debug
  • 2、基础问题
    • leetcode 509. 斐波那契数
    • leetcode 70. 爬楼梯
    • leetcode 746. 使用最小花费爬楼梯
    • leetcode 62. 不同路径
    • leetcode 63. 不同路径 II
    • leetcode 343. 整数拆分
    • leetcode 96. 不同的二叉搜索树
  • 3、背包问题
    • 01背包(二维数组)
    • 01背包(一维数组)
      • leetcode 416. 分割等和子集(01背包)
      • leetcode 1049. 最后一块石头的重量 II(01背包)
      • leetcode 494. 目标和(01背包)
      • leetcode 474. 一和零(01背包)
    • 完全背包(一维数组)
      • leetcode 518. 零钱兑换 II(求组合数)
      • leetcode 377. 组合总和 Ⅳ(求排列数)
      • leetcode 70. 爬楼梯(求排列数)
      • leetcode 322. 零钱兑换(求装满背包最小物品个数,组合排列均不影响最小个数,即两个均可)
      • leetcode 279. 完全平方数(求装满背包最小物品个数,组合排列均不影响最小个数,即两个均可)
      • leetcode 139. 单词拆分(求背包能否被装满,组合排列均不影响)
    • 多重背包
  • 4、打家劫舍问题
    • leetcode 198. 打家劫舍
    • leetcode 213. 打家劫舍 II
    • leetcode 337. 打家劫舍 III
  • 5、股票问题
    • leetcode 121. 买卖股票的最佳时机(买卖一次)
    • leetcode 122. 买卖股票的最佳时机 II(买卖多次)
    • leetcode 123. 买卖股票的最佳时机 III(最多买卖两次)
    • leetcode 188. 买卖股票的最佳时机 IV(最多买卖k次)
    • leetcode 309. 最佳买卖股票时机含冷冻期(买卖多次含冷冻期)
    • leetcode 714. 买卖股票的最佳时机含手续费(买卖多次含手续费)
  • 6、子序列问题
    • 子序列(不连续)
      • leetcode 300. 最长递增子序列
      • leetcode 1143. 最长公共子序列
      • leetcode 1035. 不相交的线
    • 子序列(连续)
      • leetcode 674. 最长连续递增序列
      • leetcode 718. 最长重复子数组
      • leetcode 53. 最大子序和
    • 编辑距离
      • leetcode 392. 判断子序列
      • leetcode 115. 不同的子序列
      • leetcode 583. 两个字符串的删除操作
      • leetcode 72. 编辑距离
        • 1. 确定dp数组(dp table)以及下标的含义
        • 2. 确定递推公式
        • 3. dp数组如何初始化
        • 4. 确定遍历顺序
        • 5. 举例推导dp数组
    • 回文子序列
      • leetcode 647. 回文子串
      • leetcode 516. 最长回文子序列

上一节:9、贪心算法相关

动态规划

1、理论

定义

动态规划,英文:Dynamic Programming,简称DP,如果某一问题有很多重叠子问题,使用动态规划是最有效的,所以动态规划中每一个状态一定是由上一个状态推导出来的。

解题步骤

对于动态规划问题,将其拆解为如下五步曲:

  • 确定dp数组(dp table)以及下标的含义
  • 确定递推公式
  • dp数组如何初始化
  • 确定遍历顺序
  • 举例推导dp数组

debug

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

2、基础问题

leetcode 509. 斐波那契数

leetcode 509. 斐波那契数
10、动态规划相关_第1张图片
动态规划五部曲:
这里我们要用一个一维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];中可以看出,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数组打印出来看看和我们推导的数列是不是一致的。
代码如下:

int fib(int n) {
    /* 0、特判
     * 当你为0、1时直接返回即可 
     */
    if (n < 2) {
        return n;
    }
    /* 1、dp数组含义
     * dp[i]为F(i)的斐波那契函数值
     */
    //int* dp = (int*)calloc(n + 1, sizeof(int)); 
    int dp[2] = {0, 1};
    /* 2、确定状态转移方程
     * dp[i] dp[i - 1] + dp[i - 2]
     */
    /* 3、初始化dp数组
     * dp[0] = 0, dp[1] = 1
     */
    // dp[0] = 0;
    // dp[1] = 1;
     /* 4、遍历顺序
      * 从前向后i = [2。。。n] 
      */
    for (int i = 2; i <= n; i++) {
        int cur = dp[1] + dp[0];
        dp[0] = dp[1];
        dp[1] = cur;
    }
    return dp[1];
}

leetcode 70. 爬楼梯

leetcode 70. 爬楼梯
10、动态规划相关_第2张图片
这里需要注意dp[0]的初始化问题,因为dp[i]表示爬到第i层楼顶的方法,且题目中没有说明0层楼的情况,那么不考虑dp[0],直接考虑dp[1]、dp[2]即可,代码如下:

int climbStairs(int n){
    /* 0、特判 */
    if (n < 3) {
        return n;
    }
    /* 1、dp数组,dp[i]表示第i层共dp[i]中方法 */
    //int* dp = (int*)calloc(n + 1, sizeof(int));
    int pre = 1, cur = 2;
    /* 2、dp数组初始化
     * dp[0]不考虑,dp[1] = 1, dp[2] = 2
     */
    //dp[1] = 1, dp[2] = 2;
    /* 3、遍历顺序:从前向后 */
    for (int i = 3; i <= n; i++) {
        int sum = pre + cur;
        pre = cur;
        cur = sum;
    }
    return cur;
}

leetcode 746. 使用最小花费爬楼梯

leetcode 746. 使用最小花费爬楼梯
10、动态规划相关_第3张图片
本题中递推公式为:
dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i];
初值为:
dp[0] = cost[0];
dp[1] = cost[1];
题意为爬上i阶楼梯需要支付对应的体力值,并且可以选择向上爬1或2个楼梯,那么初始值一开始就支付体力值,最后就不需要支付体力值了;
也可以初始值不支付体力值,最有支付体力值:
dp[0] = 0; // 默认第一步都是不花费体力的
dp[1] = 0;
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
选择初始值支付体力值,代码如下:

int minCostClimbingStairs(int* cost, int costSize) {
    int* dp = (int*)calloc(costSize, sizeof(int));
    dp[0] = cost[0];
    dp[1] = cost[1];
    for (int i = 2; i < costSize; i++) {
        dp[i] = (int)fmin(dp[i - 1], dp[ i - 2]) + cost[i];
    }
    return (int)fmin(dp[costSize - 1], dp[costSize - 2]);
}

leetcode 62. 不同路径

leetcode 62. 不同路径
10、动态规划相关_第4张图片
10、动态规划相关_第5张图片
五部曲:

  • 1、dp数组含义
    dp[i][j]表示从(0, 0)到(i, j)的不同路径数量
  • 2、状态转移方程
    dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
    当前的dp由左边和上边而来
  • 3、dp初值
    dp[0][j] = 1;
    dp[i][0] = 1;
    第一行只能从左边来,路劲数目只能为1。
    第一列只能从上边来,路劲数目只能为1。
  • 4、遍历顺序
    从左上遍历至右下
  • 5、举例

代码如下:

int uniquePaths(int m, int n) {
    int dp[m][n];
    dp[0][0] = 1;
    for (int j = 1; j < n; j++) {
        dp[0][j] = dp[0][j - 1];
    }
    for (int i = 1; i < m; i++) {
        dp[i][0] = dp[i - 1][0];
    }
    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];
}

状态压缩:

  • 1、dp数组含义
    dp[j]表示从(0, 0)到当前行第j列的不同路径数量
  • 2、状态转移方程
    dp[j] += [j - 1]
    当前行的的第j列 = “上一行的第j列(dp[j]: 相当于dp[i - 1][j])” + “当前行的j-1列(dp[j - 1]: 相当于dp[i][j - 1])”
  • 3、dp初值
    dp[j] = 1;
    第一行只能从左边来,路径数目只能为1。
  • 4、遍历顺序
    从左上遍历至右下

代码如下:

int uniquePaths(int m, int n) {
    int dp[n];
    dp[0] = 1;
    for (int j = 1; j < n; j++) {
        dp[j] = dp[j - 1];
    }
    for (int i = 1; i < m; i++) {
        for (int j = 1; j < n; j++) {
            dp[j] += dp[j - 1];
        }
    }
    return dp[n - 1];
}

leetcode 63. 不同路径 II

leetcode 63. 不同路径 II
10、动态规划相关_第6张图片
10、动态规划相关_第7张图片
和 leetcode 62. 不同路径相同,只需要考虑一个障碍即可:

int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize, int* obstacleGridColSize) {
    /* 1、dp数组含义:从(0, 0)到(i, j)的路径数 */
    int dp[obstacleGridSize][*obstacleGridColSize];
    /* 2、状态转移方程: 
    *  if (obstacleGrid[i][j] == 1) {
    *       dp[i][j] = 0;
    *  } else {
    *       dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
    *  }
    */
    /* 3、dp初值
     * 第一行第一列路障之前均为1,路障之后均为0
     */
    if (obstacleGrid[0][0] == 1) {
       dp[0][0] = 0; 
    } else {
        dp[0][0] = 1;
    }
    for (int j = 1; j < *obstacleGridColSize; j++) {
        if (obstacleGrid[0][j] == 1) {
            dp[0][j] = 0;
        } else {
            dp[0][j] = dp[0][j - 1];
        }
    }
    for (int i = 1; i < obstacleGridSize; i++) {
        if (obstacleGrid[i][0] == 1) {
            dp[i][0] = 0;
        } else {
            dp[i][0] = dp[i - 1][0];
        }
    }
    /* 4、遍历顺序:左上到右下 */
    for (int i = 1; i < obstacleGridSize; i++) {
        for (int j = 1; j < *obstacleGridColSize; j++) {
            if (obstacleGrid[i][j] == 1) {
                dp[i][j] = 0;
            } else {
                dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
            }
        }
    }
    return dp[obstacleGridSize - 1][*obstacleGridColSize - 1];
}

状态压缩:

int uniquePathsWithObstacles(int** obstacleGrid, int obstacleGridSize, int* obstacleGridColSize) {
    /* 1、dp数组含义:从(0, 0)到当前行的第j列的路径数 */
    int dp[*obstacleGridColSize];
    /* 2、状态转移方程: 
     * if (obstacleGrid[i][j] == 1) {
     *     dp[j] = 0;
     * } else {
     *     dp[j] += dp[j - 1];
     * } 
     */
    /* 3、dp初值
     * 第一行当前列路障之前均为1,路障之后均为0
     */
    if (obstacleGrid[0][0] == 1) {
        dp[0] = 0; 
    } else {
        dp[0] = 1;
    }
    for (int j = 1; j < *obstacleGridColSize; j++) {
        if (obstacleGrid[0][j] == 1) {
            dp[j] = 0;
        } else {
            dp[j] = dp[j - 1];
        }
    }
    /* 4、遍历顺序:从左到右一层一层遍历 */
    for (int i = 1; i < obstacleGridSize; i++) {
        for (int j = 0; j < *obstacleGridColSize; j++) {
            if (j == 0) {   /* 开头 */
                if (obstacleGrid[i][j] == 1) {
                    /* 当前路径上有路障:dp为0 */
                    dp[j] = 0;
                } else {
                    /* 当前路径上没有路障:跳过 */
                    continue;
                }
            } else {    /* 非开头 */
                if (obstacleGrid[i][j] == 1) {
                    /* 当前路径上有路障:dp为0 */
                    dp[j] = 0;
                } else {
                    /* 当前路径上没有路障:计算路径数 */
                    dp[j] += dp[j - 1];
                } 
            }
        }
    }
    return dp[*obstacleGridColSize - 1];
}

leetcode 343. 整数拆分

leetcode 343. 整数拆分
10、动态规划相关_第8张图片
五部曲:

  • 1、确定dp数组含义:dp[i][j]表示i拆分之后的最大乘积
  • 2、确定递推公式:dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
    这里的 j 是从1开始的,所以 j 的拆分之前已经做过了,这一步只需要拆分 i - j 即可
  • 3、dp初始化:dp初值:dp[2] = 1,dp[0]、dp[1]无意义,pass掉
  • 4、遍历顺序:当前dp[i]从之前的状态得来,从前向后遍历

代码如下:

int integerBreak(int n) {
    /* 1、dp数组含义:
     * dp[i][j]表示i拆分之后的最大乘积
     */
    int* dp = (int*)calloc(n + 1, sizeof(int));
    /* 3、dp初值:dp[2] = 1 */
    dp[2] = 1;
    /* 4、遍历顺序:i从小到n */
    for (int i = 3; i <= n; i++) {
        /* j从 1 到 n - 1 */
        for (int j = 1; j <= i - 1; j++) {
            /* 2、状态转移方程
             * dp[i] = max(dp[i], max((i - j) * j, dp[i - j] * j));
             * 因为dp[i]计算多次,取最大;每次取(int)fmax((i - j) * j, dp[i - j] * j)
             */
            dp[i] = (int)fmax(dp[i], (int)fmax((i - j) * j, dp[i - j] * j));
        }
    }
    return dp[n];
}

leetcode 96. 不同的二叉搜索树

leetcode 96. 不同的二叉搜索树
10、动态规划相关_第9张图片
状态转移方程:
n = 1个节点的二叉搜索树有1种;
10、动态规划相关_第10张图片

n = 2个节点的二叉搜索树有2种;
10、动态规划相关_第11张图片

n = 3个节点的二叉搜索树有5种;
10、动态规划相关_第12张图片

当1为头结点的时候,其右子树有两个节点,和n为2的时候两棵树的布局一样;
当3为头结点的时候,其左子树有两个节点,和n为2的时候两棵树的布局一样;
当2位头结点的时候,其左右子树都只有一个节点,和n为1的时候只有一棵树的布局一样的。
dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量
元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量
元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量
元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量
有2个元素的搜索树数量就是dp[2]。
有1个元素的搜索树数量就是dp[1]。
有0个元素的搜索树数量就是dp[0]。
所以dp[3] = dp[2] * dp[0] + dp[1] * dp[1] + dp[0] * dp[2]
10、动态规划相关_第13张图片
那么当有i个节点时,以j为头节点的数量为:
左子树的数量 * 右子树的数量
dp[j - 1] * ddp[i - i]
(0 ~ j - 1)个 (j + 1 ~ i)个

代码如下:

int numTrees(int n) {
    /* 1、dp数组含义:1到n组成的二叉搜索树数量 */
    int* dp = (int*)calloc(n + 1, sizeof(int));
    /* 3、dp初值:0个节点即为空树,也算二叉搜索树 */
    dp[0] = 1;
    /* 4、遍历顺序:从前向后,后面的dp根据前面的dp而来 */
    for (int i = 1; i <= n; i++) {
        /* 2、状态转移方程:dp[i]为i个节点组成的数量 */
        for (int j = 1; j <= i; j++) {
            /* 2、状态转移方程:dp[j-1]所有以j为头节点的左子树数量 
             * dp[i - j]所有以j为头节点的右子树数量 
             */
            dp[i] += dp[j - 1] * dp[i - j];
        }
    }
    return dp[n];
}

3、背包问题

10、动态规划相关_第14张图片

01背包(二维数组)

参考代码随想录 的讲解
有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
背包最大重量为4。
物品为:

项目 Value 价值
物品0 1 15
物品1 3 20
物品2 4 30

动规五部曲:
1、确定dp数组以及下标的含义
使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
2、确定递推公式
那么可以有两个方向推出来dp[i][j],

  • 由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以被背包内的价值依然和前面相同。)
  • 由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值

所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3、dp数组如何初始化
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。

dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
代码如下:

// 正序遍历
for (int j = weight[0]; j <= bagWeight; j++) {
    dp[0][j] = value[0];
}

此时dp数组初始化情况如图所示:
10、动态规划相关_第15张图片
dp[0][j] 和 dp[i][0] 都已经初始化了,那么其他下标应该初始化多少呢?

dp[i][j]在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,因为0就是最小的了,不会影响取最大价值的结果。

如果题目给的价值有负数,那么非0下标就要初始化为负无穷了。例如:一个物品的价值是-2,但对应的位置依然初始化为0,那么取最大值的时候,就会取0而不是-2了,所以要初始化为负无穷。

而背包问题的物品价值都是正整数,所以初始化为0,就可以了。

这样才能让dp数组在递归公式的过程中取最大的价值,而不是被初始值覆盖了。

如图:
10、动态规划相关_第16张图片
最后初始化代码如下:

// 初始化 dp
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
for (int j = weight[0]; j <= bagWeight; j++) {
    dp[0][j] = value[0];
}

4、确定遍历顺序
先遍历物品,然后遍历背包重量的代码。

// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
    for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
        if (j < weight[i]) dp[i][j] = dp[i - 1][j]; // 这个是为了展现dp数组里元素的变化
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

    }
}

先遍历背包,再遍历物品,也是可以的!(注意我这里使用的二维dp数组)
例如这样:

// weight数组的大小 就是物品个数
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
    }
}

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。

dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正左和正上两个方向)所以遍历顺序都可以
5、举例推导dp数组

10、动态规划相关_第17张图片
完整代码:

void test_2_wei_bag_problem1() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;

    // 二维数组
    vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));

    // 初始化
    for (int j = weight[0]; j <= bagWeight; j++) {
        dp[0][j] = value[0];
    }

    // weight数组的大小 就是物品个数
    for(int i = 1; i < weight.size(); i++) { // 遍历物品
        for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
            if (j < weight[i]) dp[i][j] = dp[i - 1][j];
            else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
        }
    }
    cout << dp[weight.size() - 1][bagWeight] << endl;
}

int main() {
    test_2_wei_bag_problem1();
}

01背包(一维数组)

参考代码随想录 的讲解
对于背包问题其实状态都是可以压缩的。

在使用二维数组的时候,递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

其实可以发现如果把dp[i - 1]那一层拷贝到dp[i]上,表达式完全可以是:dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);

与其把dp[i - 1]这一层拷贝到dp[i]上,不如只用一个一维数组了,只用dp[j](一维数组,也可以理解是一个滚动数组)。

这就是滚动数组的由来,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

读到这里估计大家都忘了 dp[i][j]里的i和j表达的是什么了,i是物品,j是背包容量。

dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

动规五部曲分析如下:
1、确定dp数组的定义
在一维dp数组中,dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。

2、一维dp数组的递推公式
dp[j]为 容量为j的背包所背的最大价值,那么如何推导dp[j]呢?

dp[j]可以通过dp[j - weight[i]]推导出来,dp[j - weight[i]]表示容量为j - weight[i]的背包所背的最大价值。

dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j])

此时dp[j]有两个选择,一个是取自己dp[j],一个是取dp[j - weight[i]] + value[i],指定是取最大的,毕竟是求最大价值,

所以递归公式为:

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
可以看出相对于二维dp数组的写法,就是把dp[i][j]中i的维度去掉了。

3、一维dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。

dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。

那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢?

看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。

那么我假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。

4、一维dp数组遍历顺序
代码如下:

for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

这里大家发现和二维dp的写法中,遍历背包的顺序是不一样的!

二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。

为什么呢?

倒叙遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15

如果正序遍历

dp[1] = dp[1 - weight[0]] + value[0] = 15

dp[2] = dp[2 - weight[0]] + value[0] = 30

此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

为什么倒叙遍历,就可以保证物品只放入一次呢?

倒叙就是先算dp[2]

dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0)

dp[1] = dp[1 - weight[0]] + value[0] = 15

所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

那么问题又来了,为什么二维dp数组历的时候不用倒叙呢?

因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!

(如何这里读不懂,大家就要动手试一试了,空想还是不靠谱的,实践出真知!)

再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?

不可以!

因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每次更新dp[j]就只会放入一个物品,即:背包里只放入了一个物品。

 	for (int j = target; j > 0; --j) {
    	for (int i = 0; i < numsSize; ++i) {
    		if (j >= nums[i]) {
	            dp[j] = dp[j - nums[i]] + nums[i];
	        }
        }
    }

从代码中看到每次都是从后向前更新dp[j],每一个dp[j]都被更新了numsSize次,但是每一次都是覆盖上一次的值,由于dp初值为0,这样就导致每一次dp[j]中存放的只是当前nums[i]一个物品了,既背包里只放了一个物品

(这里如果读不懂,就在回想一下dp[j]的定义,或者就把两个for循环顺序颠倒一下试试!)

所以一维dp数组的背包在遍历顺序上和二维其实是有很大差异的!,这一点大家一定要注意。

5、举例推导dp数组
一维dp,分别用物品0,物品1,物品2 来遍历背包,最终得到结果如下:
10、动态规划相关_第18张图片
完整代码:

void test_1_wei_bag_problem() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;

    // 初始化
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}

int main() {
    test_1_wei_bag_problem();
}

leetcode 416. 分割等和子集(01背包)

leetcode 416. 分割等和子集
10、动态规划相关_第19张图片
二维数组:

bool canPartition(int* nums, int numsSize){
    /* 0、取数组和的一半作为背包的容量target */
    int target = 0;
    for (int i = 0; i< numsSize; i++) {
        target += nums[i];
    }
    /* 如果和为奇数,则不可能分割成两部分,返回false 
     * 如果和为偶数,设置target为和的一半 
     */
    if (target % 2 == 1) {
        return false;
    } else {
        target /= 2;
    }
    /* 1、dp数组含义:背包容量为j时,物品0到i任意取,可以装载的最大价值为dp[i][j] */
    int dp[numsSize][target + 1];
    memset(dp, 0, sizeof(dp)); //需要初始化为0,否则里面的值不确定
    /* 3、dp数组初值:第一行只有背包容量大于等于nums[0]的才为nums[0],其余为0 */
    for (int j = target; j >= nums[0]; j--) {
        dp[0][j] = dp[0][j - nums[0]] + nums[0];
    }
    /* 第一列背包容量为0,获得价值均为0 */
    for (int i = 0; i < numsSize; i++) {
        dp[i][0] = 0;
    }
    /* 4、遍历顺序: 外层物品i从前向后,内层背包j从前向后 */
    for (int i = 1; i < numsSize; i++) {
        for (int j = 1; j <= target; j++) {
            /* 2、状态转移方程 :其中物品的体积和价值均为nums[i];
             * 不选当前物品i:与上一层物品(0~i-1)相同均为:dp[i - 1][j]
             * 选择当前物品i:为:dp[i - 1][j - nums[i]] + nums[i]
             * 两者取最大
             */
            if (j < nums[i]) {
                dp[i][j] = dp[i - 1][j];
            } else {
                dp[i][j] = (int)fmax(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]);
            }  
        }
    }
    /* 若最后背包的价值为target,则表示数组可以分割为两部分 */
    if (dp[numsSize - 1][target] == target) {
        return true;
    }
    return false;
}

一维数组:

bool canPartition(int* nums, int numsSize) {
    /* 0、取数组和的一半作为背包的容量target */
    int target = 0;
    for (int i = 0; i< numsSize; i++) {
        target += nums[i];
    }
    /* 如果和为奇数,则不可能分割成两部分,返回false 
     * 如果和为偶数,设置target为和的一半 
     */
    if (target % 2 == 1) {
        return false;
    } else {
        target /= 2;
    }
    /* 1、dp数组含义:背包容量为j时,可以装载的最大价值为dp[j]
     * 3、dp数组初值:均为0,表示还没有物品
     */
    int* dp = (int*)calloc(target + 1, sizeof(int));
    /* 4、遍历顺序
     * 外层物品i从前向后,内层背包从后向前
     * 因为当前dp[j]选哟用到上一层的dp[0~j-1]的值
     */
    for (int i = 0; i < numsSize; i++) {
        for (int j = target; j >= nums[i]; j--) {
            /* 2、状态转移方程
             * 物品的体积和价值均为nums[i]
             * 不选当前物品i:与上一层物品(0~i-1)相同
             * 选择物品i:为 dp[j - nums[i]] + nums[i]
             * 两者取最大
             */
            dp[j] = (int)fmax(dp[j], dp[j - nums[i]] + nums[i]);
        }
    }
    /* 若最后背包的价值为target,则表示数组可以分割为两部分 */
    if (dp[target] == target) {
        return true;
    }
    return false;
}

leetcode 1049. 最后一块石头的重量 II(01背包)

leetcode 1049. 最后一块石头的重量 II
10、动态规划相关_第20张图片
二维dp:

int lastStoneWeightII(int* stones, int stonesSize) {
    int sum = 0;
    for (int i = 0; i < stonesSize; i++) {
        sum += stones[i];
    }
    int target = sum / 2;
    int dp[stonesSize][target + 1];
    memset(dp, 0, sizeof(dp));
    for (int j = target; j >= stones[0]; j--) {
        dp[0][j] = dp[0][j - stones[0]] + stones[0];
    }
    for (int i = 0; i < stonesSize; i++) {
        dp[i][0] = 0;
    }
    for (int i = 1; i < stonesSize; i++) {
        for (int j = 1; j <= target; j++) {
            if (j < stones[i]) {
                dp[i][j] = dp[i - 1][j];
            } else {
                dp[i][j] = (int)fmax(dp[i - 1][j], dp[i - 1][j - stones[i]] + stones[i]);
            }
        }
    }
    return sum - 2 * dp[stonesSize - 1][target];
}

一维dp:

int lastStoneWeightII(int* stones, int stonesSize) {
    int sum = 0;
    for (int i = 0; i < stonesSize; i++) {
        sum += stones[i];
    }
    int target = sum / 2;
    int dp[target + 1];
    memset(dp, 0, sizeof(dp));
    for (int i = 0; i < stonesSize; i++) {
        for (int j = target; j >= stones[i]; j--) {
            dp[j] = (int)fmax(dp[j], dp[j - stones[i]] + stones[i]);
        }
    }
    return sum - 2 * dp[target];
}

leetcode 494. 目标和(01背包)

leetcode 494. 目标和

10、动态规划相关_第21张图片

添加了正负号之后:正元素为Left个、负元素为right个,那么有
 left + right = target
 left - right = sum
 这样left = (sum + target) / 2
 即转化为0-1背包问题,背包容量 bagSize = left;
 物品为nums数组中的各个元素,各物品的容量和价值均为nums[i];
 dp数组为装满背包的所有方法

代码如下:

int findTargetSumWays(int* nums, int numsSize, int target) {
    /* 添加了正负号之后:正元素为Left个、负元素为right个,那么有
     * left + right = target
     * left - right = sum
     * 这样left = (sum + target) / 2
     * 即转化为0-1背包问题,背包容量 bagSize = left;
     * 物品为nums数组中的各个元素,各物品的容量和价值均为nums[i];
     * dp数组为装满背包的所有方法
     */
    /* 计算数组元素和 */
    int sum = 0;
    for (int i = 0; i <numsSize; i++) {
        sum += nums[i];
    }
    /* 若和为奇数则不会装满背包,返回0 */
    if ((sum + target) % 2 == 1) {
        return 0;
    }
    /* 背包容量 */
    int bagSize = (sum + target) / 2;
    /* 1、dp含义:dp[j]为装满容量j的背包的方法数量 */
    int* dp = (int*)calloc(bagSize + 1, sizeof(int));
    /* 3、dp初值:dp[0]为1(表示容量为0的背包被装满的方法数量)
     * 其他为0:表示没有装任何物品时的方法数量
     */
    dp[0] = 1;
    /* 4、遍历顺序:外层物品从低到高、内层背包容量从高到低 */
    for (int i = 0; i < numsSize; i++) {
        for (int j = bagSize; j >= nums[i]; j--) {
            /* 2、状态转移方程
             * 物品从0到i时,dp[j]为容量为j的背包被装满的方法总和相加
             * 如物品为0时,dp[j]为容量为j的背包被装满的方法为a种
             * 如物品为0...numsSize - 1时,dp[j]为容量为j的背包被装满的方法为b种
             * 那么dp[j]为a + ... + b
             */
            dp[j] += dp[j - nums[i]];
        }
    }
    return dp[bagSize];
}

leetcode 474. 一和零(01背包)

leetcode 474. 一和零
10、动态规划相关_第22张图片

此题为 两个背包的 一维 0-1背包问题
物品为:数组中的每个字符串
物品的重量为:0的个数、1的个数
物品的价值为:可以看做全部都为1
背包0:装有0字符的背包,即dp的一维数据
背包1:装有1字符的背包,即dp的二维数据

状态转移方程:
若这是 1个背包的 一维 0-1背包问题时,转移方程为:
    dp[j] = dp[j - weight[i]] + vlaue[i]; //i从0到i
2个背包的 一维 0-1背包问题时,转移方程为:
    dp[j][k] = dp[j - weight0[i]][k - weight1[i]] + vlaue[i];
    其中weight0[i]、weight1[i]为当前字符串strs[i]中0、1的个数。

代码为:

int findMaxForm(char ** strs, int strsSize, int m, int n) {
    /* 1、dp数组含义
     * dp[i][j]表示字符串可以从0到i中任意选时,最多有i个0、j个1的最大子集字符串数量
     */
    int dp[m + 1][n + 1];
    /* 3、dp初值
     * 全部为0:表示当前没有选择字符串时,最大子集字符串的数量均为0 
     */
    memset(dp, 0, sizeof(dp));    
    /* 4、遍历顺序 
     * 就是一维dp的0-1背包问题
     * 外层物品从前向后,内层两个背包从后向前
     */
    for (int i = 0; i < strsSize; i++) {
        int zeroNums = 0, oneNums = 0;
        int index = 0;
        /* 统计当前字符串中0、1的个数 */
        while (strs[i][index] != '\0') {
            if (strs[i][index] == '0') {
                zeroNums++;
            } else {
                oneNums++;
            }
            index++;
        }
        for (int j = m; j >= zeroNums; j--) {
            for (int k = n; k >= oneNums; k--) {
                /* 2、状态转移方程
                 * 因为有两个背包,所以j - zeroNums为0的个数、k - oneNums为1的个数
                 * 选择当前字符串:dp[j - zeroNums][k - oneNums] + 1
                 * 不选当前字符串:dp[i][j]
                 */
                dp[j][k] = (int)fmax(dp[j][k], dp[j - zeroNums][k - oneNums] + 1);
            }
        }
    }
    return dp[m][n];
}

完全背包(一维数组)

有N件物品和一个最多能被重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品可使用无数次,求解将哪些物品装入背包里物品价值总和最大。
背包最大重量为4。
物品为:

项目 Value 价值
物品0 1 15
物品1 3 20
物品2 4 30

如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。

如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!

01背包和完全背包唯一不同就是体现在遍历顺序上
首先在回顾一下01背包的核心代码

for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。

而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j < bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}

为什么遍历物品在外层循环,遍历背包容量在内层循环?

01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一位dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序同样无所谓!

因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。

// 先遍历背包,再遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
    cout << endl;
}

完整代码:
先遍历物品,在遍历背包

void test_CompletePack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}
int main() {
    test_CompletePack();
}

先遍历背包,再遍历物品

void test_CompletePack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    int bagWeight = 4;

    vector<int> dp(bagWeight + 1, 0);

    for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
        for(int i = 0; i < weight.size(); i++) { // 遍历物品
            if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
    }
    cout << dp[bagWeight] << endl;
}
int main() {
    test_CompletePack();
}

leetcode 518. 零钱兑换 II(求组合数)

leetcode 518. 零钱兑换 II10、动态规划相关_第23张图片
完全背包的两个for循环的先后顺序都是可以的。

但本题就不行了!

因为纯完全背包求得是能否凑成总和,和凑成总和的元素有没有顺序没关系,即:有顺序也行,没有顺序也行!

而本题要求凑成总和的组合数,元素之间要求没有顺序。

所以纯完全背包是能凑成总和就行,不用管怎么凑的。

本题是求凑出来的方案个数,且每个方案个数是为组合数。

那么本题,两个for循环的先后顺序可就有说法了。

我们先来看 外层for循环遍历物品(钱币),内层for遍历背包(金钱总额)的情况。

代码如下:

for (int i = 0; i < coins.size(); i++) { // 遍历物品
    for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
        dp[j] += dp[j - coins[i]];
    }
}

假设:coins[0] = 1,coins[1] = 5。

那么就是先把1加入计算,然后再把5加入计算,得到的方法数量只有{1, 5}这种情况。而不会出现{5, 1}的情况。

所以这种遍历顺序中dp[j]里计算的是组合数!

如果把两个for交换顺序,代码如下:

for (int j = 0; j <= amount; j++) { // 遍历背包容量
    for (int i = 0; i < coins.size(); i++) { // 遍历物品
        if (j - coins[i] >= 0) dp[j] += dp[j - coins[i]];
    }
}

背包容量的每一个值,都是经过 1 和 5 的计算,包含了{1, 5} 和 {5, 1}两种情况。

此时dp[j]里算出来的就是排列数!

二维dp:

int change(int amount, int* coins, int coinsSize) {
    /* 1、dp含义
     * dp[i][j]表示物品0到物品i任意选时,能装满容量为j的背包的方法数
     */
    int dp[coinsSize][amount + 1];
    /* 3、dp初值
     * 当背包容量为0时,dp[i][0] = 1,表示装满背包容量为0的背包只有一种方法就是不放
     * 只有物品0时,dp[i][0] = 1,表示装满背包容量为i的背包只有一种方法(能装的下时dp[0][j - coins[0]],装不下时就是0)
     */
    memset(dp, 0, sizeof(dp));
    for (int i = 0; i < coinsSize; i++) {
        dp[i][0] = 1;
    }
    for (int j = 1; j <= amount; j++) {
        if (j < coins[0]) {
            dp[0][j] = 0;
        } else {
            dp[0][j] = dp[0][j - coins[0]];
        }
    }
    /* 4、遍历顺序
     * 外层物品从小到大
     * 内层背包从小到大
     */
    for (int i = 1; i < coinsSize; i++) {
        for (int j = 1; j <= amount; j++) {
            /* 2、状态转移方程
             * 2.1、当背包容量j小于当前物品i时,dp[i][j] = dp[i - 1][j];
             *      表示等于物品0到i-1任意取时,装满容量为j的背包的方法数
             * 2.2、当背包容量j大于等于当前物品i时,dp[i - 1][j] + dp[i][j - coins[i]];
             *      表示等于物品0到i-1任意取时,装满容量为j的背包的方法数 + 
             *      表示等于物品0到i任意取时,装满容量为j - coins[i]的背包的方法数
             */
            if (j < coins[i]) {
                dp[i][j] = dp[i - 1][j];
            } else {
                dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
            }
        }
    }
    // /* 调试 */
    // for (int i = 0; i < coinsSize; i++) {
    //     printf("物品%d:  ", i);
    //     for (int j = 0; j < amount + 1; j++) {
    //         printf("%d  ", dp[i][j]);
    //     }
    //     printf("\n");
    // }
    return dp[coinsSize - 1][amount];
}

一维dp:

int change(int amount, int* coins, int coinsSize) {
    /* 1、dp含义
     * dp[j]表示物品0到物品i任意选时,能装满容量为j的背包的方法数 
     */
    int dp[amount + 1];
    /* 3、dp初值
     * dp[0] = 1,表示物品为0个,容量为0的背包被填满的方法数为1种
     * dp[1~amount] = 0,表示物品为0个,容量为j的背包被填满的方法数为0中
     */
    memset(dp, 0, sizeof(dp));
    dp[0] = 1;
    /* 4、遍历顺序
     * 外层物品从前向后,
     * 内层背包从前向后:因为完全背包中物品可以任意取多次,
     * 而0-1背包中物品只能取1次,需要从后向前。
     */
    for (int i = 0; i < coinsSize; i++) {
        for (int j = coins[i]; j <= amount; j++) {
            /* 2、状态转移方程
             * 2.1、物品0任意取时,装满容量为j的背包的数量 为 装满容量为j - coins[0]的数量
             * 2.2、物品0、1任意取时,装满容量为j的背包的数量 为 装满容量为j - coins[1]的数量 + 2.1的数量
             * 2.3、物品0、1、2任意取时,装满容量为j的背包的数量 为 装满容量为j - coins[1]的数量 + 2.2的数量
             * dp[j]                 = dp[j]                      +  dp[j - coins[i]]
             * 物品0到i装满背包j的数量 = 物品0到i - 1装满背包j的数量  +  物品0到i装满背包j - coins[i]的数量
             */
            dp[j] += dp[j - coins[i]];
        }
    }
    return dp[amount];
}

leetcode 377. 组合总和 Ⅳ(求排列数)

leetcode 377. 组合总和 Ⅳ
10、动态规划相关_第24张图片
一维dp完全背包,求排列数,遍历顺序:外层背包、内层物品,代码如下:

int combinationSum4(int* nums, int numsSize, int target) {
    int* dp = (int*)calloc(target + 1, sizeof(int));
    dp[0] = 1;
    for (int j = 0; j <= target; j++) {
        for (int i = 0; i < numsSize; i++) {
            if (j >= nums[i] && dp[j] < INT_MAX - dp[j - nums[i]]) {
                dp[j] += dp[j - nums[i]];
            }
        }
    }
    return dp[target];
}

leetcode 70. 爬楼梯(求排列数)

leetcode 70. 爬楼梯
10、动态规划相关_第25张图片
背包就是楼顶n、物品就是1、2两个台阶(重量和价值都是1、2),求排列数,一维dp完全背包,外层背包(升序)、内层物品(升序)

int climbStairs(int n) {
    int* dp = (int*)calloc(n + 1, sizeof(int));
    dp[0] = 1;
    int value[2] = {1, 2};
    for (int j = 0; j <= n; j++) {
        for (int i = 0; i < 2; i++) {
            if (j >= value[i]) {
                dp[j] += dp[j - value[i]];    
            }
        }
    }
    return dp[n];
}

leetcode 322. 零钱兑换(求装满背包最小物品个数,组合排列均不影响最小个数,即两个均可)

leetcode 322. 零钱兑换
10、动态规划相关_第26张图片
背包为总金额,物品为硬币(重量和价值都为coins[i]),求凑齐总金额的最小硬币数,那么组合或者排列均不影响最小硬币的个数,即组合排列均可,即外层物品(升序)内层背包(升序)、外层背包(升序)内层物品(升序)均可。
外层物品,内层背包:

int coinChange(int* coins, int coinsSize, int amount) {
    int* dp = (int*)malloc(sizeof(int) * (amount + 1));
    for (int j = 0; j < amount + 1; j++) {
        dp[j] = INT_MAX;
    }
    dp[0] = 0;
    for (int i = 0; i < coinsSize; i++) {
        for (int j = coins[i]; j <= amount; j++) {
            if (dp[j - coins[i]] != INT_MAX) {
                dp[j] = (int)fmin(dp[j], dp[j - coins[i]] + 1);
            }
        }
    }
    if (dp[amount] == INT_MAX) {
        return -1;
    }
    return dp[amount];
}

外层背包,内层物品:

int coinChange(int* coins, int coinsSize, int amount) {
    int* dp = (int*)malloc(sizeof(int) * (amount + 1));
    for (int j = 0; j < amount + 1; j++) {
        dp[j] = INT_MAX;
    }
    dp[0] = 0;
    for (int j = 0; j <= amount; j++) {
        for (int i = 0; i < coinsSize; i++) {
            if (j >= coins[i] && dp[j - coins[i]] != INT_MAX) {
                dp[j] = (int)fmin(dp[j], dp[j - coins[i]] + 1);
            }
        }
    }
    if (dp[amount] == INT_MAX) {
        return -1;
    }
    return dp[amount];
}

leetcode 279. 完全平方数(求装满背包最小物品个数,组合排列均不影响最小个数,即两个均可)

leetcode 279. 完全平方数
10、动态规划相关_第27张图片
背包就是n,物品就是小于n的 ii ,每一个物品的价值和重量均为 ii,求的是装满背包n的最小物品数量,和leetcode 322. 零钱兑换一样。
外层物品、内层背包:

int numSquares(int n) {
    int *dp =(int*)malloc(sizeof(int) * (n + 1));
    for (int j = 0; j < n + 1; j++) {
        dp[j] = INT_MAX;
    }
    dp[0] = 0;
    for (int i = 0; i * i <= n; i++) {
        for (int j = i * i; j <= n; j++) {
            if (dp[j - i * i] < INT_MAX) {
                dp[j] = (int)fmin(dp[j], dp[j - i * i] + 1);
            }
        }
    }
    return dp[n];
}

外层背包、内层物品:

int numSquares(int n) {
    int *dp =(int*)malloc(sizeof(int) * (n + 1));
    for (int j = 0; j < n + 1; j++) {
        dp[j] = INT_MAX;
    }
    dp[0] = 0;
    for (int j = 0; j <= n; j++) {
        for (int i = 0; i * i <= n; i++) {
            if (j >= i * i && dp[j - i * i] < INT_MAX) {
                dp[j] = (int)fmin(dp[j], dp[j - i * i] + 1);
            }
        }
    }
    return dp[n];
}

leetcode 139. 单词拆分(求背包能否被装满,组合排列均不影响)

leetcode 139. 单词拆分
10、动态规划相关_第28张图片
背包就是字符串s,物品就是每个单词,求单词能否被凑出既背包能否被装满。那么组合排列均可。

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        /* 1、dp数组含义
         * dp[i]表示从0到i组成的字符串能否被拆分
         */
        vector<bool> dp(s.size() + 1, false);
        /* 3、dp初值
         * dp[0] = true, 其余为false
         * dp[0]在题目中没有含义,为了后面计算正确将其赋值为true
         */
        dp[0] = true;
        /* 4、遍历顺序
         * 外层背包从小到大,内层物品从小到大
         */
        for (int i = 1; i <= s.size(); i++) {   // 遍历背包
            for (int j = 0; j < i; j++) {       // 遍历物品
                string word = s.substr(j, i - j); //substr(起始位置,截取的个数)
                /* 2、状态转移方程
                 * dp[i]若为true,则dp[j]为true、且[j+1 到 i]组成的字符串也在字典列表中。
                 */
                if (wordSet.find(word) != wordSet.end() && dp[j]) {
                    dp[i] = true;
                }
            }
        }
        return dp[s.size()];      
    }
};

多重背包

参考 代码随想录 的讲解
有N种物品和一个容量为V 的背包。第i种物品最多有Mi件可用,每件耗费的空间是Ci ,价值是Wi 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

多重背包和01背包是非常像的, 为什么和01背包像呢?

每件物品最多有Mi件可用,把Mi件摊开,其实就是一个01背包问题了。

例如:

背包最大重量为10。

物品为:

重量 价值 数量
物品0 1 15 2
物品1 3 20 3
物品2 4 30 2

问背包能背的物品最大价值是多少?

和如下情况有区别么?

重量 价值 数量
物品0 1 15 1
物品0 1 15 1
物品1 3 20 1
物品1 3 20 1
物品1 3 20 1
物品2 4 30 1
物品2 4 30 1

毫无区别,这就转成了一个01背包问题了,且每个物品只用一次。

这种方式来实现多重背包的代码如下:

void test_multi_pack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    vector<int> nums = {2, 3, 2};
    int bagWeight = 10;
    for (int i = 0; i < nums.size(); i++) {
        while (nums[i] > 1) { // nums[i]保留到1,把其他物品都展开
            weight.push_back(weight[i]);
            value.push_back(value[i]);
            nums[i]--;
        }
    }

    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
        }
        for (int j = 0; j <= bagWeight; j++) {
            cout << dp[j] << " ";
        }
        cout << endl;
    }
    cout << dp[bagWeight] << endl;

}
int main() {
    test_multi_pack();
}

  • 时间复杂度:O(m * n * k) m:物品种类个数,n背包容量,k单类物品数量

也有另一种实现方式,就是把每种商品遍历的个数放在01背包里面在遍历一遍。

代码如下:(详看注释)

void test_multi_pack() {
    vector<int> weight = {1, 3, 4};
    vector<int> value = {15, 20, 30};
    vector<int> nums = {2, 3, 2};
    int bagWeight = 10;
    vector<int> dp(bagWeight + 1, 0);


    for(int i = 0; i < weight.size(); i++) { // 遍历物品
        for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
            // 以上为01背包,然后加一个遍历个数
            for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
                dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
            }
        }
        // 打印一下dp数组
        for (int j = 0; j <= bagWeight; j++) {
            cout << dp[j] << " ";
        }
        cout << endl;
    }
    cout << dp[bagWeight] << endl;
}
int main() {
    test_multi_pack();
}
  • 时间复杂度:O(m * n * k) m:物品种类个数,n背包容量,k单类物品数量

从代码里可以看出是01背包里面在加一个for循环遍历一个每种商品的数量。 和01背包还是如出一辙的。

4、打家劫舍问题

leetcode 198. 打家劫舍

leetcode 198. 打家劫舍
10、动态规划相关_第29张图片
代码如下:

int rob(int* nums, int numsSize) {
    /* 特判:题目 numsSize > 0,当为1时,直接返回nums[0]即可 */
    if (numsSize < 2) {
        return nums[0];
    }
    /* 1、dp数组含义
     * dp[i]表示从房屋0偷到房屋i能获得的最大金额
     */
    int*dp = (int*)calloc(numsSize, sizeof(int));
    /* 3、dp初值
     * dp[0] = nums[0];表示只有房屋0时可以偷到的金额为最大为nums[0]
     * dp[1] = (int)fmax(nums[0], nums[1]);表示有房屋0、1时能偷到的最大金额
     */
    dp[0] = nums[0];
    dp[1] = (int)fmax(nums[0], nums[1]);
    /* 4、遍历顺序 
     * 房屋从小到大遍历
     */
    for (int i = 2; i < numsSize; i++) {
        /* 2、递推公式
         * dp[i - 2] + nums[i]:表示偷房屋i
         * dp[i - 1]:表示不偷房屋i
         */
        dp[i] = (int)fmax(dp[i - 2] + nums[i], dp[i - 1]);
    }
    return dp[numsSize - 1];
}

leetcode 213. 打家劫舍 II

leetcode 213. 打家劫舍 II
10、动态规划相关_第30张图片
代码如下:

int robRange(int *nums, int numsSize, int start, int end) 
{
    /* 特判:题目 numsSize > 0,当为1时,直接返回nums[0]即可 */
    if (start == end)
        return nums[start];
    /* 1、dp数组含义
     * dp[i]表示从房屋0偷到房屋i能获得的最大金额
     */
    int*dp = (int*)calloc(numsSize, sizeof(int));
    /* 3、dp初值
     * dp[0] = nums[0];表示只有房屋0时可以偷到的金额为最大为nums[0]
     * dp[1] = (int)fmax(nums[0], nums[1]);表示有房屋0、1时能偷到的最大金额
     */
    dp[start] = nums[start];
    dp[start + 1] = (int)fmax(nums[start], nums[start + 1]);
    /* 4、遍历顺序 
     * 房屋从小到大遍历
     */
    for (int i = start + 2; i <= end; i++) {
        /* 2、递推公式
         * dp[i - 2] + nums[i]:表示偷房屋i
         * dp[i - 1]:表示不投房屋i
         */
        dp[i] = (int)fmax(dp[i - 2] + nums[i], dp[i - 1]);
    }
    return dp[end];
}

int rob(int *nums, int numsSize) 
{
    if (numsSize == 0)
        return 0;
    if (numsSize == 1)
        return nums[0];
    /* 不考虑房屋numsSize - 1 */
    int result1 = robRange(nums, numsSize, 0, numsSize - 2);
    /* 不考虑房屋0 */
    int result2 = robRange(nums, numsSize, 1, numsSize - 1);
    return (int)fmax(result1, result2);
}

leetcode 337. 打家劫舍 III

leetcode 337. 打家劫舍 III
10、动态规划相关_第31张图片
需要使用后序遍历来递归搜索二叉树,因为当前节点的状态需要根据左右孩子的返回值进行判断。

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     struct TreeNode *left;
 *     struct TreeNode *right;
 * };
 */

int *robRange(struct TreeNode *root) 
{
    int *res = (int*)calloc(2, sizeof(int));
    if (!root)
        return res;
    int *leftValue   = (int*)calloc(2, sizeof(int));
    int *righttValue = (int*)calloc(2, sizeof(int));
    leftValue   = robRange(root->left);
    righttValue = robRange(root->right);
    /* res[0]表示偷当前节点、res[1]表示不偷当前节点 */
    res[0] = root->val + leftValue[1] + righttValue[1];
    res[1] = (int)fmax(leftValue[0], leftValue[1]) + (int)fmax(righttValue[0], righttValue[1]);
    return res;
}

int rob(struct TreeNode *root) 
{
    int *dp = (int*)calloc(2, sizeof(int));
    dp = robRange(root);
    return (int)fmax(dp[0], dp[1]);
}

5、股票问题

参考 代码随想录 的讲解
10、动态规划相关_第32张图片

leetcode 121. 买卖股票的最佳时机(买卖一次)

leetcode 121. 买卖股票的最佳时机
10、动态规划相关_第33张图片
动规五部曲分析如下:

1、确定dp数组(dp table)以及下标的含义
dp[i][0] 表示第i天持有股票所得最多现金 ,这里可能有同学疑惑,本题中只能买卖一次,持有股票之后哪还有现金呢?

其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。

dp[i][1] 表示第i天不持有股票所得最多现金

注意这里说的是“持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态

很多同学把“持有”和“买入”没分区分清楚。

在下面递推公式分析中,我会进一步讲解。

2、确定递推公式
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来

  • 第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
  • 第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]

那么dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);

如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来

  • 第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
  • 第i天卖出股票,所得现金就是按照今天股票佳价格卖出后所得现金即:prices[i] + dp[i - 1][0]

同样dp[i][1]取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);

这样递归公式我们就分析完了

3、dp数组如何初始化
由递推公式 dp[i][0] = max(dp[i - 1][0], -prices[i]); 和 dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);可以看出

其基础都是要从dp[0][0]和dp[0][1]推导出来。

那么dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];

dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0;

5、确定遍历顺序
从递推公式可以看出dp[i]都是有dp[i - 1]推导出来的,那么一定是从前向后遍历。

5、举例推导dp数组

代码如下:

int maxProfit(int* prices, int pricesSize) {
    /* 1、dp数组含义
     * dp[i][0]表示第i天持有股票所得现金
     * dp[i][1]表示第i天不持有股票所得现金
     */
    int dp[pricesSize][2];
    memset(dp, 0, sizeof(int) * pricesSize * 2);
    /* 3、dp初值
     * dp[0][0] = -prices[0]; 表示第0天买入,持有现金-prices[0]
     * dp[0][1] = 0; 表示第0天不买入,持有现金0
     */
    dp[0][0] = -prices[0];
    dp[0][1] = 0;
    /* 4、遍历顺序:天数从小到大 */
    for (int i = 1; i < pricesSize; i++) {
        /* 2、递推公式
         * 第i天持有股票所得现金:dp[i][0] = max(今天不买就相当于昨天持有保持 ,
         *                                     今天买入就相当于“-prices[i]”)
         * 第i天不持有股票所得现金:dp[i][1] = max(今天不持有股票就相当于前一天不持有所得现金, 
         *                                       今天卖出股票就相当于今天股价+昨天持有股票)
         * 并且更新最大利润:maxProfit
         */
        dp[i][0] = (int)fmax(dp[i - 1][0], -prices[i]);
        dp[i][1] = (int)fmax(dp[i - 1][1], prices[i] + dp[i - 1][0]);
    }
    return dp[pricesSize - 1][1];
}

leetcode 122. 买卖股票的最佳时机 II(买卖多次)

leetcode 122. 买卖股票的最佳时机 II
在这里插入图片描述
这题比leetcode 121. 买卖股票的最佳时机题区别就是股票可以多次买卖,那么主要体现在递推公式上面,具体代码如下:

int maxProfit(int* prices, int pricesSize) {
    /* 1、dp数组含义
     * dp[i][0]表示第i天持有股票所得最多现金
     * dp[i][1]表示第i天不持有股票所得最多现金
     */
    int dp[pricesSize][2];
    memset(dp, 0, sizeof(int) * pricesSize * 2);
    /* 3、dp初值
     * dp[0][0] = -prices[0]; 表示第0天买入,持有现金-prices[0]
     * dp[0][1] = 0; 表示第0天不买入,持有现金0
     */
    dp[0][0] = -prices[0];
    dp[0][1] = 0;
    /* 4、遍历顺序:天数从小到大 */
    for (int i = 1; i < pricesSize; i++) {
        /* 2、递推公式
         * 第i天持有股票所得现金:dp[i][0] = max(今天不买就相当于昨天持有保持 ,
         *                                     今天买入就相当于前一天不持有股票所得现金 - 今天股票价格)
         * 第i天不持有股票所得现金:dp[i][1] = max(今天不卖就相当于前一天不持有所得现金, 
         *                                       今天卖出就相当于今天股价+昨天持有股票所得现金(这是个负数))
         * 并且更新最大利润:maxProfit
         */
        dp[i][0] = (int)fmax(dp[i - 1][0], dp[i - 1][1] - prices[i]);
        dp[i][1] = (int)fmax(dp[i - 1][1], prices[i] + dp[i - 1][0]);
    }
    return dp[pricesSize - 1][1];
}

leetcode 123. 买卖股票的最佳时机 III(最多买卖两次)

leetcode 123. 买卖股票的最佳时机 III
10、动态规划相关_第34张图片
10、动态规划相关_第35张图片
关键在于至多买卖两次,这意味着可以买卖一次,可以买卖两次,也可以不买卖。

接来下我用动态规划五部曲详细分析一下:

1、确定dp数组以及下标的含义
一天一共就有五个状态,
0. 没有操作
1.第一次买入
2.第一次卖出
3.第二次买入
4.第二次卖出
dp[i][j]中 i表示第i天,j为 [0 - 4] 五个状态,dp[i][j]表示第i天状态j所剩最大现金。

2、确定递推公式
需要注意:dp[i][1],表示的是第i天,买入股票的状态,并不是说一定要第i天买入股票,这是很多同学容易陷入的误区。

达到dp[i][1]状态,有两个具体操作:

操作一:第i天买入股票了,那么dp[i][1] = dp[i-1][0] - prices[i]
操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i][1] = dp[i - 1][1]
那么dp[i][1]究竟选 dp[i-1][0] - prices[i],还是dp[i - 1][1]呢?

一定是选最大的,所以 dp[i][1] = max(dp[i-1][0] - prices[i], dp[i - 1][1]);

同理dp[i][2]也有两个操作:

  • 操作一:第i天卖出股票了,那么dp[i][2] = dp[i - 1][1] + prices[i]
  • 操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i][2] = dp[i - 1][2]

所以dp[i][2] = max(dp[i - 1][1] + prices[i], dp[i - 1][2])

同理可推出剩下状态部分:

dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]); dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);

3、dp数组如何初始化
第0天没有操作,这个最容易想到,就是0,即:dp[0][0] = 0;

第0天做第一次买入的操作,dp[0][1] = -prices[0];

第0天做第一次卖出的操作,这个初始值应该是多少呢?

首先卖出的操作一定是收获利润,整个股票买卖最差情况也就是没有盈利即全程无操作现金为0,

从递推公式中可以看出每次是取最大值,那么既然是收获利润如果比0还小了就没有必要收获这个利润了。

所以dp[0][2] = 0;

第0天第二次买入操作,初始值应该是多少呢?应该不少同学疑惑,第一次还没买入呢,怎么初始化第二次买入呢?

第二次买入依赖于第一次卖出的状态,其实相当于第0天第一次买入了,第一次卖出了,然后在买入一次(第二次买入),那么现在手头上没有现金,只要买入,现金就做相应的减少。

所以第二次买入操作,初始化为:dp[0][3] = -prices[0];

同理第二次卖出初始化dp[0][4] = 0;

4、确定遍历顺序
从递归公式其实已经可以看出,一定是从前向后遍历,因为dp[i],依靠dp[i - 1]的数值。

5、举例推导dp数组

代码如下:

int maxProfit(int* prices, int pricesSize){
    /* 1、dp数组含义
     * 0-无操作、1-第一次买入、2-第一次卖出、3-第二次买入、4-第二次卖出
     * dp[i][j]:i表示第i天、j表示上面的五种状态、dp[i][j]表示第i天第j种状态之后所得最大利润
     */
    int dp[pricesSize][5];
    memset(dp, 0, sizeof(int) * pricesSize * 5);
    /* 3、dp初值 
     * dp[0][0] = 0; 无操作。
     * dp[0][1] = -prices[0]; 第0天第一次买入股票之后所得利润。
     * dp[0][2] = 0; 第0天第一次卖出股票之后所得最大利润,
     *               因为第0天所得利润为0,所以这一天第一次卖出之后利润为负,当做0即可。
     * dp[0][3] = -prices[0]; 第0天第二次买入股票之后所得利润,
     *                        不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。
     * dp[0][4] = 0; 第0天第二次卖出股票之后所得最大利润,
     *               因为第0天所得利润为0,所以这一天第二次卖出之后利润为负,当做0即可。
     */
    dp[0][1] = -prices[0];
    dp[0][3] = -prices[0];
    /* 4、遍历顺序
     * 天数从小到大
     */
    for (int i = 1; i < pricesSize; i++) {
        /* 2、递推公式
         * 上面的五种状态中买入和卖出股票并不是一定要当天做的决定;
         * 决定可以是之前做的,可以是当前做的。
         */
        /**********今天无操作等于上一天无操作**************************/
        dp[i][0] = dp[i - 1][0];
        /********************今天买了*******************今天没买******/
        dp[i][1] = (int)fmax(dp[i - 1][0] - prices[i], dp[i - 1][1]);
        /********************今天卖了*******************今天没卖******/
        dp[i][2] = (int)fmax(dp[i - 1][1] + prices[i], dp[i - 1][2]);
        /********************今天买了*******************今天没买******/
        dp[i][3] = (int)fmax(dp[i - 1][2] - prices[i], dp[i - 1][3]);
        /********************今天卖了*******************今天没卖******/
        dp[i][4] = (int)fmax(dp[i - 1][3] + prices[i], dp[i - 1][4]);
    }
    return dp[pricesSize - 1][4];
}

leetcode 188. 买卖股票的最佳时机 IV(最多买卖k次)

leetcode 188. 买卖股票的最佳时机 IV
10、动态规划相关_第36张图片
与leetcode 123. 买卖股票的最佳时机 III题类似,这次最多买卖k次,状态由5个(2 * 2 + 1)变为(2 * k + 1)个;其中除0之外奇数就是买入、偶数就是卖出。
参考上一题代码写出:

int maxProfit(int k, int* prices, int pricesSize){
    /* 特判 */
    if (pricesSize == 0) {
        return 0;
    }
    /* 1、dp数组含义
     * 0-无操作、1-第一次买入、2-第一次卖出。。。、第k次买入、第k次卖出
     * k为奇数就是买入、偶数就是卖出
     * dp[i][j]:i表示第i天、j表示上面的五种状态、dp[i][j]表示第i天第j种状态之后所得最大利润
     */
    int dp[pricesSize][2 * k + 1];
    memset(dp, 0, sizeof(int) * pricesSize * (2 * k + 1));
    /* 3、dp初值 
     * dp[0][0] = 0; 无操作。
     * dp[0][1] = -prices[0]; 第0天第一次买入股票之后所得利润。
     * dp[0][2] = 0; 第0天第一次卖出股票之后所得最大利润,
     *               因为第0天所得利润为0,所以这一天第一次卖出之后利润为负,当做0即可。
     * dp[0][3] = -prices[0]; 第0天第二次买入股票之后所得利润,
     *                        不用管第几次,现在手头上没有现金,只要买入,现金就做相应的减少。
     * dp[0][4] = 0; 第0天第二次卖出股票之后所得最大利润,
     *               因为第0天所得利润为0,所以这一天第二次卖出之后利润为负,当做0即可。
     * 。。。
     * 。。。
     * dp[0][k] = -prices[0]、0; k为奇数就是买入(就是-prices[0])、偶数就是卖出(就是0)
     */
    for (int i = 0; i < k; i++) {
        dp[0][2 * i + 1] = -prices[0];
    }
    /* 4、遍历顺序
     * 天数从小到大
     */
    for (int i = 1; i < pricesSize; i++) {
        /* 2、递推公式
         * 其中dp[i][1],表示的是第i天,第一次买入股票的状态,并不是说一定要第i天买入股票。
         */
        /**********今天无操作等于上一天无操作**************************/
        dp[i][0] = dp[i - 1][0];        
        for (int j = 1; j <= 2 * k; j++) {
            if (j % 2 == 1) {
                /********************今天买了***********************今天没买******/
                dp[i][j] = (int)fmax(dp[i - 1][j - 1] - prices[i], dp[i - 1][j]); 
            } else {
                /********************今天卖了***********************今天没卖******/
                dp[i][j] = (int)fmax(dp[i - 1][j - 1] + prices[i], dp[i - 1][j]); 
            }
        }
    }
    return dp[pricesSize - 1][2 * k];
}

leetcode 309. 最佳买卖股票时机含冷冻期(买卖多次含冷冻期)

leetcode 309. 最佳买卖股票时机含冷冻期
10、动态规划相关_第37张图片
动规五部曲,分析如下:

1、确定dp数组以及下标的含义
dp[i][j],第i天状态为j,所剩的最多现金为dp[i][j]。

具体可以区分出如下四个状态:

  • 状态一:买入股票状态(今天买入股票,或者是之前就买入了股票然后没有操作)

卖出股票状态,这里就有两种卖出股票状态

  • 状态二:两天前就卖出了股票,度过了冷冻期,一直没操作,今天保持卖出股票状态
  • 状态三:今天卖出了股票
  • 状态四:今天为冷冻期状态,但冷冻期状态不可持续,只有一天!

j 的状态为:

  • 0:状态一
  • 1:状态二
  • 2:状态三
  • 3:状态四

2、确定递推公式
达到买入股票状态(状态一)即:dp[i][0],有两个具体操作:

  • 操作一:前一天就是持有股票状态(状态一),dp[i][0] = dp[i - 1][0]

操作二:今天买入了,有两种情况

  • 前一天是冷冻期(状态四),dp[i - 1][3] - prices[i]
  • 前一天是保持卖出股票状态(状态二),dp[i - 1][1] - prices[i]

所以操作二取最大值,即:max(dp[i - 1][3], dp[i - 1][1]) - prices[i]

那么dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i]);

达到保持卖出股票状态(状态二)即:dp[i][1],有两个具体操作:

  • 操作一:前一天就是状态二
  • 操作二:前一天是冷冻期(状态四)

dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);

达到今天就卖出股票状态(状态三),即:dp[i][2] ,只有一个操作:

  • 操作一:昨天一定是买入股票状态(状态一),今天卖出

即:dp[i][2] = dp[i - 1][0] + prices[i];

达到冷冻期状态(状态四),即:dp[i][3],只有一个操作:

  • 操作一:昨天卖出了股票(状态三)

p[i][3] = dp[i - 1][2];

综上分析,递推代码如下:

dp[i][0] = max(dp[i - 1][0], max(dp[i - 1][3], dp[i - 1][1]) - prices[i];
dp[i][1] = max(dp[i - 1][1], dp[i - 1][3]);
dp[i][2] = dp[i - 1][0] + prices[i];
dp[i][3] = dp[i - 1][2];

3、dp数组如何初始化
这里主要讨论一下第0天如何初始化。

如果是持有股票状态(状态一)那么:dp[0][0] = -prices[0],买入股票所省现金为负数。

保持卖出股票状态(状态二),第0天没有卖出dp[0][1]初始化为0就行,

今天卖出了股票(状态三),同样dp[0][2]初始化为0,因为最少收益就是0,绝不会是负数。

同理dp[0][3]也初始为0。

4、确定遍历顺序
从递归公式上可以看出,dp[i] 依赖于 dp[i-1],所以是从前向后遍历。

5、举例推导dp数组

代码如下:

int maxProfit(int* prices, int pricesSize)
{
    if (pricesSize == 0)
        return 0;
    int dp[pricesSize][4];
    memset(dp, 0, sizeof(dp));
    dp[0][0] = -prices[0];
    for (int i = 1; i < pricesSize; i++) {
        dp[i][0] = (int)fmax((int)fmax(dp[i - 1][1] - prices[i], dp[i - 1][3] - prices[i]), dp[i - 1][0]);
        dp[i][1] = (int)fmax(dp[i - 1][1], dp[i - 1][3]);
        dp[i][2] = dp[i - 1][0] + prices[i];
        dp[i][3] = dp[i - 1][2];
    }
    return (int)fmax(dp[pricesSize - 1][3], (int)fmax(dp[pricesSize - 1][2], dp[pricesSize - 1][1]));
}

leetcode 714. 买卖股票的最佳时机含手续费(买卖多次含手续费)

leetcode 714. 买卖股票的最佳时机含手续费
10、动态规划相关_第38张图片
与leetcode 122. 买卖股票的最佳时机 II类似,此题就在卖出时付一个手续费即可
代码如下:

int maxProfit(int* prices, int pricesSize, int fee) {
    /* 1、dp数组含义
     * dp[i][0]表示第i天持有股票所得最多现金
     * dp[i][1]表示第i天不持有股票所得最多现金
     */
    int dp[pricesSize][2];
    memset(dp, 0, sizeof(dp));
    /* 3、dp初值
     * dp[0][0] = -prices[0]; 表示第0天买入,持有现金-prices[0]
     * dp[0][1] = 0; 表示第0天不买入,持有现金0
     */
    dp[0][0] = -prices[0];
    dp[0][1] = 0;
    /* 4、遍历顺序:天数从小到大 */
    for (int i = 1; i < pricesSize; i++) {
        /* 2、递推公式
         * 第i天持有股票所得现金:dp[i][0] = max(今天不买就相当于昨天持有保持 ,
         *                                     今天买入就相当于前一天不持有股票所得现金 - 今天股票价格)
         * 第i天不持有股票所得现金:dp[i][1] = max(今天不卖就相当于前一天不持有所得现金, 
         *                                       今天卖出就相当于今天股价+昨天持有股票所得现金(这是个负数))
         * 并且更新最大利润:maxProfit
         */
        dp[i][0] = (int)fmax(dp[i - 1][0], dp[i - 1][1] - prices[i]);
        dp[i][1] = (int)fmax(dp[i - 1][1], prices[i] + dp[i - 1][0] - fee);
    }
    return dp[pricesSize - 1][1];
}

6、子序列问题

子序列(不连续)

leetcode 300. 最长递增子序列

300. 最长递增子序列
10、动态规划相关_第39张图片
dp五部曲:
1、dp[i]定义
dp[i]表示 i 之前包括 i 的最长上升子序列。
2、状态转移方程
位置i的最长升序子序列等于j从0到i-1各个位置的最长升序子序列 + 1 的最大值。

所以:if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);

注意这里不是要dp[i] 与 dp[j] + 1进行比较,而是我们要取dp[j] + 1的最大值。

3、dp[i]的初始化
每一个i,对应的dp[i](即最长上升子序列)起始大小至少都是是1.

4、确定遍历顺序
dp[i] 是有0到i-1各个位置的最长升序子序列 推导而来,那么遍历i一定是从前向后遍历。

j其实就是0到i-1,遍历i的循环里外层,遍历j则在内层,代码如下:

for (int i = 1; i < nums.size(); i++) {
    for (int j = 0; j < i; j++) {
        if (nums[i] > nums[j]) dp[i] = max(dp[i], dp[j] + 1);
    }
    if (dp[i] > result) result = dp[i]; // 取长的子序列
}

5、举例推导dp数组

代码如下:

int lengthOfLIS(int* nums, int numsSize) {
    int dp[numsSize + 1];
    for (int i = 0; i <= numsSize; i++) {
        dp[i] = 1;
    }
    int res = 0;
    for (int i = 1; i < numsSize; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[i] > nums[j])
                dp[i] = (int)fmax(dp[i], dp[j] + 1);
        }
        if (dp[i] > res)
            res = dp[i];
    }
    return res;
}

leetcode 1143. 最长公共子序列

leetcode 1143. 最长公共子序列
10、动态规划相关_第40张图片
与leetcode 718. 最长重复子数组类似,此题不要求子序列连续。
继续动规五部曲分析如下:

1、确定dp数组(dp table)以及下标的含义
dp[i][j]:长度为[0, i - 1]的字符串text1与长度为[0, j - 1]的字符串text2的最长公共子序列为dp[i][j]

有同学会问:为什么要定义长度为[0, i - 1]的字符串text1,定义为长度为[0, i]的字符串text1不香么?

这样定义是为了后面代码实现方便,如果非要定义为为长度为[0, i]的字符串text1也可以,大家可以试一试!

2、确定递推公式
主要就是两大情况: text1[i - 1] 与 text2[j - 1]相同,text1[i - 1] 与 text2[j - 1]不相同

如果text1[i - 1] 与 text2[j - 1]相同,那么找到了一个公共元素,所以dp[i][j] = dp[i - 1][j - 1] + 1;

如果text1[i - 1] 与 text2[j - 1]不相同,那就看看text1[0, i - 2]与text2[0, j - 1]的最长公共子序列 和 text1[0, i - 1]与text2[0, j - 2]的最长公共子序列,取最大的。

即:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);

代码如下:

if (text1[i - 1] == text2[j - 1]) {
    dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
    dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}

3、dp数组如何初始化
先看看dp[i][0]应该是多少呢?

test1[0, i-1]和空串的最长公共子序列自然是0,所以dp[i][0] = 0;

同理dp[0][j]也是0。

其他下标都是随着递推公式逐步覆盖,初始为多少都可以,那么就统一初始为0。

代码:

vector dp(text1.size() + 1, vector(text2.size() + 1, 0));
4、确定遍历顺序
从递推公式,可以看出,有三个方向可以推出dp[i][j],如图:
10、动态规划相关_第41张图片
那么为了在递推的过程中,这三个方向都是经过计算的数值,所以要从前向后,从上到下来遍历这个矩阵。

5、举例推导dp数组
以输入:text1 = “abcde”, text2 = “ace” 为例,dp状态如图:
10、动态规划相关_第42张图片
最后红框dp[text1.size()][text2.size()]为最终结果

C语言代码如下:

int longestCommonSubsequence(char * text1, char * text2) {
    int len1 = strlen(text1);
    int len2 = strlen(text2);
    int dp[len1 + 1][len2 + 1];
    int res = 0;
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= len1; i++) {
        for (int j = 1; j <= len2; j++) {
            if(text1[i - 1] == text2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = (int)fmax(dp[i - 1][j], dp[i][j - 1]);
            }
            if (dp[i][j] > res) {
                res = dp[i][j];
            }
        }
    }
    return res;
}

leetcode 1035. 不相交的线

leetcode 1035. 不相交的线
10、动态规划相关_第43张图片
与leetcode 1143. 最长公共子序列一模一样。

代码如下:

int maxUncrossedLines(int* nums1, int nums1Size, int* nums2, int nums2Size) {
    /* 1、dp数组含义
     * dp[i][j]表示数组1以nums[i - 1]结尾、数组2以nums[j - 1]结尾
     * 两数组的最大连线数
     */
    int dp[nums1Size + 1][nums2Size + 1];
    /* 3、dp数组初值
     * dp[i][0] = dp[0][j] = 0;表示没有字符时连接数为0
     */
    memset(dp, 0, sizeof(dp));
    /* 4、遍历顺序
     * 内外层从小到大、且内外层可交换 
     */
    for (int i = 1; i <= nums1Size; i++) {
        for (int j = 1; j <= nums2Size; j++) {
            /* 2、状态转移方程 */
            if (nums1[i - 1] == nums2[j - 1]) {
                /* 当前两字符相等,则dp值为上一dp加一 */
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                /* 当前两字符不等,则dp值为 
                 * 数组1从0到i-2,数组2从0到j-1;
                 * 数组1从0到i-1,数组2从0到j-2;两者的较大值
                 */
                dp[i][j] = (int)fmax(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[nums1Size][nums2Size];
}

子序列(连续)

leetcode 674. 最长连续递增序列

leetcode 674. 最长连续递增序列
10、动态规划相关_第44张图片
本题与300. 最长递增子序列类似,本题要求递增子序列连续,那么主要区别在于递推公式dp[i + 1] = dp[i] + 1;
代码如下:

int findLengthOfLCIS(int* nums, int numsSize) {
    if (numsSize == 0)
        return 0;
    int* dp = (int*)calloc(numsSize, sizeof(int));
    int result = 1;
    dp[0] = 1;
    for (int i = 0; i < numsSize - 1; i++) {
        dp[i + 1] = 1;
        if (nums[i + 1] > nums[i])
            dp[i + 1] = dp[i] + 1;
        if (dp[i + 1] > result)
            result = dp[i + 1];
    }
    return result;
}

leetcode 718. 最长重复子数组

leetcode 718. 最长重复子数组
10、动态规划相关_第45张图片
代码如下:

int findLength(int* nums1, int nums1Size, int* nums2, int nums2Size) {
    /* 1、dp数组含义
     * dp[i][j]表示数组1以i-1结尾、数组2以j-1结尾时两数组的最长公共子序列
     */
    int dp[nums1Size + 1][nums2Size + 1];
    /* 3、dp初值
     * dp[0][0] = 0; 本题无意义,因为两数组长度均大于0
     * dp[0][j] = 0; 本题无意义,因为两数组长度均大于0
     * dp[i][0] = 0; 本题无意义,因为两数组长度均大于0
     */
    memset(dp, 0, sizeof(int) * (nums1Size + 1) * (nums2Size + 1));
    int res = 0;
    /* 4、遍历顺序
     * 外层数组1从小到大、内层数组2从小到大
     * 内外层是数组1还是数组2均可 
     */
    for (int i = 1; i <= nums1Size; i++) {
        for (int j = 1; j <= nums2Size; j++) {
            /* 若当前两数组元素相等,则当前dp[i][j]
             * 为dp[i - 1][j - 1] + 1;这样才能保证两个子序列的长度一致
             */
            if (nums1[i - 1] == nums2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            }
            if (dp[i][j] > res) {
                res = dp[i][j];
            }
        }
    }
    return res;
}

leetcode 53. 最大子序和

leetcode 53. 最大子序和
10、动态规划相关_第46张图片
此题要求子序列连续,那么递推公式为:
dp[i] = (int)fmax(dp[i - 1] + nums[i], nums[i]);
代码如下:

int maxSubArray(int* nums, int numsSize) {
    /* 1、dp数组含义
     * dp[i]表示从0到i的字符串中的最大子序和
     */
    int* dp = (int*)calloc(numsSize, sizeof(int));
    /* 3、dp初值
     * dp[0] = nums[0];
     */
    dp[0] = nums[0];
    int res = INT_MIN;
    /* 4、遍历顺序:从前向后 */
    for (int i = 1; i < numsSize; i++) {
        /* 2、状态转移方程
         * 前一个dp + 当前nums[i] 和 当前nums[i]比较
         * 加上当前元素大就继续加,小就重新开始,这样保证是连续的子序列
         */
        dp[i] = (int)fmax(dp[i - 1] + nums[i], nums[i]);
        if (dp[i] > res) {
            res = dp[i];
        }
    }
    return res;
}

因为当前dp[i]至于dp[i-1]有关,所以只需要保存另个dp值即可,进行状态压缩:

int maxSubArray(int* nums, int numsSize) {
    /* 1、若数组为空,返回0 */
    //if (numsSize == 0) {
    //    return 0;
    //}
    /* 2、确定base case */
    int dpCur = nums[0], dpPre = nums[0];
    /* 3、定义子序列的最大和 */
    int res = dpCur;    
    /* 4、确定状态转移方程
     * 状态:以当前节点结束的最大子序列和
     * 选择:每个节点
     * 状态转移方程:dp[i] = dp[i] > dp[i - 1] + nums[i] ? dp[i] : dp[i - 1] + nums[i]
     */
    for (int i = 1; i < numsSize; i++) {
        /* 4.1、计算当前节点的dp值 */
        dpCur = nums[i] > (dpPre + nums[i]) ? nums[i] : (dpPre + nums[i]);
        /* 4.2、更新上以节点的dp值 */
        dpPre = dpCur;
        /* 4.3、更新最大子序列和 */
        if (res < dpCur) {
            res = dpCur;
        }
    }
    /* 5、返回最大子序列的和 */
    return res;
}

编辑距离

leetcode 392. 判断子序列

leetcode 392. 判断子序列
10、动态规划相关_第47张图片
动态规划五部曲分析如下:

1、确定dp数组(dp table)以及下标的含义
dp[i][j] 表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。

注意这里是判断s是否为t的子序列。即t的长度是大于等于s的。

有同学问了,为啥要表示下标i-1为结尾的字符串呢,为啥不表示下标i为结尾的字符串呢?

用i来表示也可以!

但我统一以下标i-1为结尾的字符串来计算,这样在下面的递归公式中会容易理解一些,如果还有疑惑,可以继续往下看。

2、确定递推公式
在确定递推公式的时候,首先要考虑如下两种操作,整理如下:

  • if (s[i - 1] == t[j - 1])
    t中找到了一个字符在s中也出现了
  • if (s[i - 1] != t[j - 1])
    相当于t要删除元素,继续匹配

if (s[i - 1] == t[j - 1]),那么dp[i][j] = dp[i - 1][j - 1] + 1;,因为找到了一个相同的字符,相同子序列长度自然要在dp[i-1][j-1]的基础上加1

if (s[i - 1] != t[j - 1]),此时相当于t要删除元素,t如果把当前元素t[j - 1]删除,那么dp[i][j] 的数值就是 看s[i - 1]与 t[j - 2]的比较结果了,即:dp[i][j] = dp[i][j - 1];

3、dp数组如何初始化
从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],所以dp[0][0]和dp[i][0]是一定要初始化的。

这里大家已经可以发现,在定义dp[i][j]含义的时候为什么要表示以下标i-1为结尾的字符串s,和以下标j-1为结尾的字符串t,相同子序列的长度为dp[i][j]。

因为这样的定义在dp二维矩阵中可以留出初始化的区间,如图:
10、动态规划相关_第48张图片
如果要是定义的dp[i][j]是以下标i为结尾的字符串s和以下标j为结尾的字符串t,初始化就比较麻烦了。

这里dp[i][0]和dp[0][j]是没有含义的,仅仅是为了给递推公式做前期铺垫,所以初始化为0。

其实这里只初始化dp[i][0]就够了,但一起初始化也方便,所以就一起操作了,代码如下:

vector dp(s.size() + 1, vector(t.size() + 1, 0));
4、确定遍历顺序
同理从从递推公式可以看出dp[i][j]都是依赖于dp[i - 1][j - 1] 和 dp[i][j - 1],那么遍历顺序也应该是从上到下,从左到右

如图所示:
10、动态规划相关_第49张图片
5、举例推导dp数组
以示例一为例,输入:s = “abc”, t = “ahbgdc”,dp状态转移图如下:
10、动态规划相关_第50张图片
dp[i][j]表示以下标i-1为结尾的字符串s和以下标j-1为结尾的字符串t 相同子序列的长度,所以如果dp[s.size()][t.size()] 与 字符串s的长度相同说明:s与t的最长相同子序列就是s,那么s 就是 t 的子序列。

图中dp[s.size()][t.size()] = 3, 而s.size() 也为3。所以s是t 的子序列,返回true。

代码如下:

bool isSubsequence(char * s, char * t) {
    int sLen = strlen(s);
    int tLen = strlen(t);
    if (sLen == 0)
        return true;
    if (tLen == 0)
        return false;
    int dp[sLen + 1][tLen + 1];
    memset(dp, 0, sizeof(dp));
    for (int i = 1; i <= sLen; i++) {
        for (int j = 1; j <= tLen; j++) {
            if(s[i - 1] == t[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = dp[i][j - 1];
            }
        }
    } 
    if (dp[sLen][tLen] == sLen)
        return true;
    return false;
}

leetcode 115. 不同的子序列

leetcode 115. 不同的子序列
10、动态规划相关_第51张图片
10、动态规划相关_第52张图片
动规五部曲分析如下:
1、确定dp数组(dp table)以及下标的含义
dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。

2、确定递推公式
这一类问题,基本是要分析两种情况

  • s[i - 1] 与 t[j - 1]相等
  • s[i - 1] 与 t[j - 1] 不相等

当s[i - 1] 与 t[j - 1]相等时,dp[i][j]可以有两部分组成。

一部分是用s[i - 1]来匹配,那么个数为dp[i - 1][j - 1]。

一部分是不用s[i - 1]来匹配,个数为dp[i - 1][j]。

这里可能有同学不明白了,为什么还要考虑 不用s[i - 1]来匹配,都相同了指定要匹配啊。

例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不用s[3]来匹配,即用s[0]s[1]s[2]组成的bag。

当然也可以用s[3]来匹配,即:s[0]s[1]s[3]组成的bag。

所以当s[i - 1] 与 t[j - 1]相等时,dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];

当s[i - 1] 与 t[j - 1]不相等时,dp[i][j]只有一部分组成,不用s[i - 1]来匹配,即:dp[i - 1][j]

所以递推公式为:dp[i][j] = dp[i - 1][j];

3、dp数组如何初始化
从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][0] 和dp[0][j]是一定要初始化的。

每次当初始化的时候,都要回顾一下dp[i][j]的定义,不要凭感觉初始化。

dp[i][0]表示什么呢?

dp[i][0] 表示:以i-1为结尾的s可以随便删除元素,出现空字符串的个数。

那么dp[i][0]一定都是1,因为也就是把以i-1为结尾的s,删除所有元素,出现空字符串的个数就是1。

再来看dp[0][j],dp[0][j]:空字符串s可以随便删除元素,出现以j-1为结尾的字符串t的个数。

那么dp[0][j]一定都是0,s如论如何也变成不了t。

最后就要看一个特殊位置了,即:dp[0][0] 应该是多少。

dp[0][0]应该是1,空字符串s,可以删除0个元素,变成空字符串t。

初始化分析完毕,代码如下:

vector<vector<long long>> dp(s.size() + 1, vector<long long>(t.size() + 1));
for (int i = 0; i <= s.size(); i++) dp[i][0] = 1;
for (int j = 1; j <= t.size(); j++) dp[0][j] = 0; // 其实这行代码可以和dp数组初始化的时候放在一起,但我为了凸显初始化的逻辑,所以还是加上了。

4、确定遍历顺序
从递推公式dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j]; 和 dp[i][j] = dp[i - 1][j]; 中可以看出dp[i][j]都是根据左上方和正上方推出来的。

所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

代码如下:

for (int i = 1; i <= s.size(); i++) {
    for (int j = 1; j <= t.size(); j++) {
        if (s[i - 1] == t[j - 1]) {
            dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
        } else {
            dp[i][j] = dp[i - 1][j];
        }
    }
}

5、举例推导dp数组
以s:“baegg”,t:"bag"为例,推导dp数组状态如下:
10、动态规划相关_第53张图片
代码如下:

int numDistinct(char *s, char *t) {
    int sLen = strlen(s);
    int tLen = strlen(t);
    /* 1、dp数组含义
     * dp[i][j]表示s以i-1结尾,t以j-1结尾时,s中出现t的次数
     */
    int dp[sLen + 1][tLen + 1];
    memset(dp, 0, sizeof(dp));
    /* 3、dp数组初值
     * 从状态转移方程中可以看出dp[i][0]和dp[0][j]都要初始化
     * dp[0][0] = 1; 空字符串s中出现空字符串t的次数为1次
     * dp[i][0] = 1; 字符串s中出现空字符串的次数为1次
     * dp[0][j] = 0; 空字符串s中出现字符串t的次数为0次
     */
    for (int i = 0; i <= sLen; i++) {
        dp[i][0] = 1;
    }
    /* 4、遍历顺序 
     * 从状态转移方程可以看出当前dp从左上和正上而来
     * 那么外层s从上到下、内层t从左到右
     * 且内外层顺序可调换
     */
    for (int i = 1; i <= sLen; i++) {
        for (int j = 1; j <= tLen; j++) {
            /* 2、状态转移方程 */
            if (s[i - 1] == t[j - 1]) {
                /* 当前两元素相等时
                 * 使用当前s[i - 1]时dp[i][j] = dp[i - 1][j - 1]
                 * 不使用当前s[i - 1]时dp[i][j] = dp[i - 1][j]
                 * 若当前元素s[i - 1]和s[i - 2]相等,那么都可以和t[j - 1]匹配
                 */
                if (dp[i - 1][j - 1] < INT_MAX - dp[i - 1][j]) {
                    dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j];
                }
            } else {
                /* 当前两元素不相等时
                 * 相当于不使用当前元素s[i - 1],即使用s[0 ~ i-2]和t[0 ~ j-1]匹配
                 */
                dp[i][j] = dp[i - 1][j];
            }
        }
    }
    return dp[sLen][tLen];
}

leetcode 583. 两个字符串的删除操作

leetcode 583. 两个字符串的删除操作
10、动态规划相关_第54张图片
动规五部曲,分析如下:
1、确定dp数组(dp table)以及下标的含义
dp[i][j]:以i-1为结尾的字符串word1,和以j-1位结尾的字符串word2,想要达到相等,所需要删除元素的最少次数。

这里dp数组的定义有点点绕,大家要撸清思路。

2、确定递推公式

  • 当word1[i - 1] 与 word2[j - 1]相同的时候
  • 当word1[i - 1] 与 word2[j - 1]不相同的时候

当word1[i - 1] 与 word2[j - 1]相同的时候,dp[i][j] = dp[i - 1][j - 1];

当word1[i - 1] 与 word2[j - 1]不相同的时候,有三种情况:

  • 情况一:删word1[i - 1],最少操作次数为dp[i - 1][j] + 1
  • 情况二:删word2[j - 1],最少操作次数为dp[i][j - 1] + 1
  • 情况三:同时删word1[i - 1]和word2[j - 1],操作的最少次数为dp[i - 1][j - 1] + 2

那最后当然是取最小值,所以当word1[i - 1] 与 word2[j - 1]不相同的时候,递推公式:dp[i][j] = min({dp[i - 1][j - 1] + 2, dp[i - 1][j] + 1, dp[i][j - 1] + 1});

3、dp数组如何初始化
从递推公式中,可以看出来,dp[i][0] 和 dp[0][j]是一定要初始化的。

dp[i][0]:word2为空字符串,以i-1为结尾的字符串word2要删除多少个元素,才能和word1相同呢,很明显dp[i][0] = i。

dp[0][j]的话同理,所以代码如下:

vector dp(word1.size() + 1, vector(word2.size() + 1));
for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;

4、确定遍历顺序
从递推公式 dp[i][j] = min(dp[i - 1][j - 1] + 2, min(dp[i - 1][j], dp[i][j - 1]) + 1); 和dp[i][j] = dp[i - 1][j - 1]可以看出dp[i][j]都是根据左上方、正上方、正左方推出来的。

所以遍历的时候一定是从上到下,从左到右,这样保证dp[i][j]可以根据之前计算出来的数值进行计算。

5、举例推导dp数组
以word1:“sea”,word2:"eat"为例,推导dp数组状态图如下:
10、动态规划相关_第55张图片
代码如下:

int minDistance(char * word1, char * word2) {
    int len1 = strlen(word1);
    int len2 = strlen(word2);
    /* 1、dp数组含义
     * dp[i][j]表示s以i-1结尾、t以j-1结尾时
     * 两串达到相等时最少要删除的元素的个数
     */
    int dp[len1 + 1][len2 + 1];
    memset(dp, 0, sizeof(int) * (len1 + 1) * (len2 + 1));
    /* 3、dp数组初值
     * 从状态转移方程可以看出dp[i][j]
     * 从左上、正上、正左而来
     * 那么dp[i][0] = i表示串t为空,串s需要删除i个元素才能与t相等
     * 那么dp[0][j] = j表示串s为空,串t需要删除j个元素才能与s相等
     */
     for (int i = 0; i <= len1; i++) {
         dp[i][0] = i;
     }
     for (int j = 0; j <= len2; j++) {
         dp[0][j] = j;
     }
    /* 4、遍历顺序
     * 从状态转移方程可以看出dp[i][j]
     * 从左上、正上、正左而来
     * 所以外层s从上到下、内层t从左到右
     * 且内外层顺序可交换
     */
    for (int i = 1; i <= len1; i++) {
        for (int j = 1; j <= len2; j++) {
            /* 2、状态转移方程 */
            if (word1[i - 1] == word2[j - 1]) {
                /* 当前两元素相等时
                 * 两串中的元素都不需要删除,删除次数与上一次相同 
                 */
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                /* 当前两元素不等时,当前两串s[0 ~ i-1]、t[0 ~ j-1]
                 * 有三种删除方法:
                 * 1、删除s[i-1]: dp[i - 1][j] + 1
                 * 2、删除t[j-1]: dp[i][j - 1] + 1
                 * 3、删除s[i-1]和t[j-1]:dp[i - 1][j - 1] + 2
                 * 三者取最小值
                 */
                dp[i][j] = (int)fmin(dp[i - 1][j - 1] + 2, (int)fmin(dp[i - 1][j], dp[i][j - 1]) + 1);
            }
        }
    }
    return dp[len1][len2];
}

leetcode 72. 编辑距离

leetcode 72. 编辑距离
10、动态规划相关_第56张图片

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

dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]

这里在强调一下:为啥要表示下标i-1为结尾的字符串呢,为啥不表示下标i为结尾的字符串呢?

用i来表示也可以! 但我统一以下标i-1为结尾的字符串,在下面的递归公式中会容易理解一点。

2. 确定递推公式

在确定递推公式的时候,首先要考虑清楚编辑的几种操作,整理如下:

if (word1[i - 1] == word2[j - 1])
    不操作
if (word1[i - 1] != word2[j - 1])
    增
    删
    换

也就是如上4种情况。

if (word1[i - 1] == word2[j - 1]) 那么说明不用任何编辑,dp[i][j] 就应该是 dp[i - 1][j - 1],即dp[i][j] = dp[i - 1][j - 1];

此时可能有同学有点不明白,为啥要即dp[i][j] = dp[i - 1][j - 1]呢?

那么就在回顾上面讲过的dp[i][j]的定义,word1[i - 1]word2[j - 1]相等了,那么就不用编辑了,以下标i-2为结尾的字符串word1和以下标j-2为结尾的字符串word2的最近编辑距离dp[i - 1][j - 1]就是 dp[i][j]了。

在下面的讲解中,如果哪里看不懂,就回想一下dp[i][j]的定义,就明白了。

在整个动规的过程中,最为关键就是正确理解dp[i][j]的定义!

if (word1[i - 1] != word2[j - 1]),此时就需要编辑了,如何编辑呢?

操作一:word1增加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-2为结尾的word1 与 j-1为结尾的word2的最近编辑距离 加上一个增加元素的操作。

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

操作二:word2添加一个元素,使其word1[i - 1]与word2[j - 1]相同,那么就是以下标i-1为结尾的word1 与 j-2为结尾的word2的最近编辑距离 加上一个增加元素的操作。

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

这里有同学发现了,怎么都是添加元素,删除元素去哪了。

word2添加一个元素,相当于word1删除一个元素,例如 word1 = "ad" ,word2 = "a"word1删除元素'd'word2添加一个元素'd',变成word1="a", word2="ad", 最终的操作数是一样! dp数组如下图所示意的:

            a                         a     d
   +-----+-----+             +-----+-----+-----+
   |  0  |  1  |             |  0  |  1  |  2  |
   +-----+-----+   ===>      +-----+-----+-----+
 a |  1  |  0  |           a |  1  |  0  |  1  |
   +-----+-----+             +-----+-----+-----+
 d |  2  |  1  |
   +-----+-----+

操作三:替换元素,word1替换word1[i - 1],使其与word2[j - 1]相同,此时不用增加元素,那么以下标i-2为结尾的word1j-2为结尾的word2的最近编辑距离 加上一个替换元素的操作。

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

综上,当 if (word1[i - 1] != word2[j - 1]) 时取最小的,即:dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;

递归公式代码如下:

if (word1[i - 1] == word2[j - 1]) {
    dp[i][j] = dp[i - 1][j - 1];
}
else {
    dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
}
3. dp数组如何初始化

再回顾一下dp[i][j]的定义:

dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]

那么dp[i][0] 和 dp[0][j] 表示什么呢?

dp[i][0] :以下标i-1为结尾的字符串word1,和空字符串word2,最近编辑距离为dp[i][0]。

那么dp[i][0]就应该是i,对word1里的元素全部做删除操作,即:dp[i][0] = i;

同理dp[0][j] = j;

所以C++代码如下:

for (int i = 0; i <= word1.size(); i++) dp[i][0] = i;
for (int j = 0; j <= word2.size(); j++) dp[0][j] = j;
4. 确定遍历顺序

从如下四个递推公式:

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

可以看出dp[i][j]是依赖左方,上方和左上方元素的,如图:
72.编辑距离
所以在dp矩阵中一定是从左到右从上到下去遍历。
代码如下:

for (int i = 1; i <= word1.size(); i++) {
    for (int j = 1; j <= word2.size(); j++) {
        if (word1[i - 1] == word2[j - 1]) {
            dp[i][j] = dp[i - 1][j - 1];
        }
        else {
            dp[i][j] = min({dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]}) + 1;
        }
    }
}
5. 举例推导dp数组

以示例1为例,输入:word1 = "horse", word2 = "ros"为例,dp矩阵状态图如下:

72.编辑距离1
代码入下:

int minDistance(char * word1, char * word2) {
    int len1 = strlen(word1);
    int len2 = strlen(word2);
    /* 1、dp数组含义 
     * dp[i][j]表示串word1以i-1结尾、串Word2以j-1结尾
     * word1转换为word2的次数
     */
    int dp[len1 + 1][len2 + 1];
    memset(dp, 0, sizeof(int) * (len1 + 1) * (len2 + 1));
    /* 3、dp数组初值
     * dp[i][0] = i;表示串word2为空,word1转换为空的次数为word1的长度
     * dp[0][j] = j;表示串word1为空,word1转换为word2的次数为word2的长度
     */
    for (int i = 0; i <= len1; i++) {
        dp[i][0] = i;
    }
    for (int j = 0; j <= len2; j++) {
        dp[0][j] = j;
    }
    /* 4、遍历顺序
     * 从状态转移方程来看dp[i][j]从正上、左上、正左而来
     * 所以外层word1从上到下、内层Word2从左到右
     */
    for (int i = 1; i <= len1; i++) {
        for (int j = 1; j <= len2; j++) {
            /* 2、状态转移方程 */
            if (word1[i - 1] == word2[j - 1]) {
                /* 当前两元素相等
                 * 相等就不需要插入、删除、替换的操作
                 * 和上一个状态一样
                 */
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                /* 当前两元素不相等
                 * 插入(增加)word1[i - 1],将 word1[i - 1]增加一个元素变成word2[j - 1]:dp[i - 1][j] + 1
                 * 增加(删除word1[i - 1])word2[j - 1],将 word2[j - 1]添加成word1[i - 1]:dp[i][j - 1] + 1
                 * 替换word1[i - 1],将word1[i - 1]替换为word2[j - 1]: dp[i - 1][j - 1] + 1
                 * 三种取最小值
                 */
                dp[i][j] = (int)fmin(dp[i - 1][j - 1], (int)fmin(dp[i - 1][j], dp[i][j - 1])) + 1;
            }
        }
    }
    return dp[len1][len2];
}

回文子序列

leetcode 647. 回文子串

leetcode 647. 回文子串
10、动态规划相关_第57张图片

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

布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。

  1. 确定递推公式

在确定递推公式时,就要分析如下几种情况。

整体上是两种,就是s[i]与s[j]相等,s[i]与s[j]不相等这两种。

当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]一定是false。

当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况

  • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
  • 情况二:下标i 与 j相差为1,例如aa,也是文子串
  • 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。

以上三种情况分析完了,那么递归公式如下:

if (s[i] == s[j]) {
    if (j - i <= 1) { // 情况一 和 情况二
        result++;
        dp[i][j] = true;
    } else if (dp[i + 1][j - 1]) { // 情况三
        result++;
        dp[i][j] = true;
    }
}

result就是统计回文子串的数量。

注意这里我没有列出当s[i]与s[j]不相等的时候,因为在下面dp[i][j]初始化的时候,就初始为false。

  1. dp数组如何初始化

dp[i][j]可以初始化为true么? 当然不行,怎能刚开始就全都匹配上了。

所以dp[i][j]初始化为false。

  1. 确定遍历顺序

遍历顺序可有有点讲究了。

首先从递推公式中可以看出,情况三是根据dp[i + 1][j - 1]是否为true,在对dp[i][j]进行赋值true的。

dp[i + 1][j - 1] 在 dp[i][j]的左下角,如图:

647.回文子串

如果这矩阵是从上到下,从左到右遍历,那么会用到没有计算过的dp[i + 1][j - 1],也就是根据不确定是不是回文的区间[i+1,j-1],来判断了[i,j]是不是回文,那结果一定是不对的。

所以一定要从下到上,从左到右遍历,这样保证dp[i + 1][j - 1]都是经过计算的

有的代码实现是优先遍历列,然后遍历行,其实也是一个道理,都是为了保证dp[i + 1][j - 1]都是经过计算的。
代码如下:

for (int i = s.size() - 1; i >= 0; i--) {  // 注意遍历顺序
    for (int j = i; j < s.size(); j++) {
        if (s[i] == s[j]) {
            if (j - i <= 1) { // 情况一 和 情况二
                result++;
                dp[i][j] = true;
            } else if (dp[i + 1][j - 1]) { // 情况三
                result++;
                dp[i][j] = true;
            }
        }
    }
}
  1. 举例推导dp数组

举例,输入:“aaa”,dp[i][j]状态如下:
647.回文子串1

图中有6个true,所以就是有6个回文子串。

注意因为dp[i][j]的定义,所以j一定是大于等于i的,那么在填充dp[i][j]的时候一定是只填充右上半部分

代码如下:

int countSubstrings(char * s) {
    int len = strlen(s);
    /* 1、dp数组含义
     * dp[i][j]表示s[i~j]是否为回文子串
     */
    bool dp[len][len];
    /* 3、dp初值
     * dp[i][j]全为flase,因为还开始计算
     */
    memset(dp, 0, sizeof(dp));
    /* 回文子串个数 */
    int res = 0;
    /* 4、遍历顺序
     * 从状态转移方程来看,当前dp从右下而来
     * 外层i从下到上、内层j从左到右 
     */
    for (int i = len - 1; i >= 0; i--) {
        for (int j = i; j < len; j++) {
            /* 2、状态转移方程 */
            if (s[i] == s[j]) { /* 当前元素相等 */
                if (j - i <= 1) {
                    /* 元素之间相差一位、或0位 */
                    res++;
                    dp[i][j] = true;
                } else if (dp[i + 1][j - 1] == true) {
                    /* 元素之间相差多位,查看左右各向里移动一位的dp */
                    res++;
                    dp[i][j] = true;
                }
            } else { /* 当前元素不等 */
                dp[i][j] = false;
            }
        }
    }
    return res;
}
  • 时间复杂度:O(n^2)
  • 空间复杂度:O(n^2)

此题也可使用双指针求解:
首先确定回文串,就是找中心然后想两边扩散看是不是对称的就可以了。

在遍历中心点的时候,要注意中心点有两种情况

一个元素可以作为中心点,两个元素也可以作为中心点。

那么有人同学问了,三个元素还可以做中心点呢。其实三个元素就可以由一个元素左右添加元素得到,四个元素则可以由两个元素左右添加元素得到。

所以我们在计算的时候,要注意一个元素为中心点和两个元素为中心点的情况。

这两种情况可以放在一起计算,但分别计算思路更清晰,我倾向于分别计算,代码如下:

int extend(char* s, int left, int right, int sLen) {
    int res = 0;
    while (left >= 0 && right < sLen && s[left] == s[right]) {
        res++;
        left--;
        right++;
    }
    return res;
}

int countSubstrings(char * s) {
    int len = strlen(s);
    int result = 0;
    for (int i = 0; i < len; i++) {
        result += extend(s, i, i, len);
        result += extend(s, i, i + 1, len);
    }
    return result;
}
  • 时间复杂度:O(n^2)
  • 空间复杂度:O(1)

leetcode 516. 最长回文子序列

516. 最长回文子序列
10、动态规划相关_第58张图片
动规五部曲分析如下:

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

dp[i][j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i][j]

  1. 确定递推公式

在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。

如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2;

如图:
516.最长回文子序列
如果s[i]与s[j]不相同,说明s[i]和s[j]的同时加入 并不能增加[i,j]区间回文子串的长度,那么分别加入s[i]、s[j]看看哪一个可以组成最长的回文子序列。

加入s[j]的回文子序列长度为dp[i + 1][j]。

加入s[i]的回文子序列长度为dp[i][j - 1]。

那么dp[i][j]一定是取最大的,即:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);

516.最长回文子序列1

代码如下:

if (s[i] == s[j]) {
    dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
    dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
  1. dp数组如何初始化

首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2; 可以看出 递推公式是计算不到 i 和j相同时候的情况。

所以需要手动初始化一下,当i与j相同,那么dp[i][j]一定是等于1的,即:一个字符的回文子序列长度就是1。

其他情况dp[i][j]初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]); 中dp[i][j]才不会被初始值覆盖。

vector<vector<int>> dp(s.size(), vector<int>(s.size(), 0));
for (int i = 0; i < s.size(); i++) dp[i][i] = 1;
  1. 确定遍历顺序

从递推公式dp[i][j] = dp[i + 1][j - 1] + 2 和 dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 可以看出,dp[i][j]是依赖于dp[i + 1][j - 1] 和 dp[i + 1][j],

也就是从矩阵的角度来说,dp[i][j] 下一行的数据。 所以遍历i的时候一定要从下到上遍历,这样才能保证,下一行的数据是经过计算的

递推公式:dp[i][j] = dp[i + 1][j - 1] + 2,dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]) 分别对应着下图中的红色箭头方向,如图:

516.最长回文子序列2

代码如下:

for (int i = s.size() - 1; i >= 0; i--) {
    for (int j = i + 1; j < s.size(); j++) {
        if (s[i] == s[j]) {
            dp[i][j] = dp[i + 1][j - 1] + 2;
        } else {
            dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
        }
    }
}
  1. 举例推导dp数组

输入s:“cbbd” 为例,dp数组状态如图:

516.最长回文子序列3红色框即:dp[0][s.size() - 1]; 为最终结果。

代码如下:

int longestPalindromeSubseq(char * s) {
    int len = strlen(s);
    int dp[len][len];
    memset(dp, 0, sizeof(dp));
    for (int i = 0; i < len; i++) {
        dp[i][i] = 1;
    }
    for (int i = len - 2; i >= 0; i--) {
        for (int j = i + 1; j < len; j++) {
            if (s[i] == s[j]) {
                dp[i][j] = dp[i + 1][j - 1] + 2;
            } else {
                dp[i][j] = (int)fmax(dp[i + 1][j], dp[i][j - 1]);
            }
        }
    }
    return dp[0][len - 1];
}

上一节:9、贪心算法相关

你可能感兴趣的:(leetcode刷题,c语言,数据结构,算法,动态规划,DP)