数据结构:动态规划基础

数据结构:动态规划基础

(代码随想录学习笔记 代码随想录)

基础概念

什么是动态规划?

动态规划,简称DP,如果某一个问题有很多重叠子问题,并且子问题和子问题之间有依赖关系,使用动态规划是最有效的;


贪心和动态规划的区别

同样是一个问题可以分为多个子问题,不同的是:

  • 贪心算法中,子问题之间可以没有强关联,即在取钞票问题中,拿最大面值钞票这一策略在任何局部都是一样的;并且每个局部都可以找到一个最优解,该最优解和上一个局部如何选择没有关系,即上一个局部即使没有选择局部最优,本局部的局部最优解法没有改变;
  • 动态规划中,子问题之间有强关联,即一个子问题的解法要依靠前面的子问题选择的解法,每一个状态一定是由之前的状态推导出来的;在此类问题中,我们未必可以确定局部最优,只能不断从一个状态转移到另一个状态,然后推导出全局解,选择最优的全局解,即只有全局最优;

贪心是从局部最优推导出全局最优;动态规划是从局部推导出全局

所以贪心能解决的问题,一般动态规划都能解决,但是选择贪心可能更高效一点;贪心解决不了的问题(即局部最优推不出全局最优),动态规划也可以解决;

背包问题的例子:

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

动态规划中dp[j]是由dp[j-weight[i]]推导出来的,然后取max(dp[j], dp[j - weight[i]] + value[i])

但如果是贪心呢,每次拿物品选一个最大的或者最小的就完事了,和上一个状态没有关系。所以贪心解决不了动态规划的问题。

动态规划是由前一个状态推导出来的,而贪心算法是局部直接选最优;


解题步骤

动态规划五部曲:

  1. 确定dp数组以及下标的含义;
  2. 确定递推公式;
  3. dp数组如何初始化;
  4. 确定遍历顺序;
  5. 举例推导dp数组;(即debug,看dp数组运行过程是否和我们推导的一样)

背包理论

416.分割等和子集1

背包问题:背包容量有限,要让背包中装的物品的价值总量最大;


01背包

n件物品和一个最多能背重量为w的背包。第i件物品的重量是weight[i],得到的价值是value[i]。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

暴力解法:每一件物品其实只有两个状态,取或者不取,所以可以使用回溯法搜索出所有的情况,那么时间复杂度就是 O ( 2 n ) O(2^n) O(2n),这里的n表示物品数量。

动态规划五部曲:

  • 确定dp数组及其含义:二维dp[][]数组,其中dp[i][j]代表从下标为[0~i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
  • 递推公式:看有几条途径可以到达dp[i][j]
    • 不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。(其实就是当加入物品i之后背包重量大于背包容量j时,物品i无法放进背包中,所以背包内的价值依然和前面相同。)
    • 放物品i
      • 要放入物品i,则放入之前背包容量应该为j - weight[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得到的最大价值;
    • 递归公式:取不放物品i的最大值和放物品i的最大值中的最大值,即dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
  • 初始化dp[][]数组:对于二维数组,一般初始化一行和一列,即初始化dp[0][j]dp[i][0]
    • 初始化dp[i][0]:背包容量为0,所以全部初始化为0;
    • 初始化dp[0][j]:对于j < weight[0]的部分,初始化为0,对于j >= weight[0]的部分,初始化为weight[0],即只选择0号物品装入;
    • 其余位置初始化为0,最后都会被覆盖;
  • 确定遍历顺序:从左上角开始,向右下角遍历;有两个遍历的维度:物品和背包重量;
    • 先遍历物品,后遍历背包重量;
    • 先遍历背包重量,后遍历物品;
    • 无论先遍历哪一个都可以,因为都是从左上角到右下角,只要左边和上边的元素都先求出来就可以,但是先遍历物品更好,因为更好理解;
  • 模拟dp数组执行过程;
// 初始化
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]);
    }
}

滚动数组实现01背包

之前实现的01背包中,使用了二维数组dp[][],空间效率差,其实可以用滚动数组实现,让二维数组dp[][]变成一维数组dp[],可以提高空间效率;

将二维数组转换为一维滚动数组,关键在于状态的压缩;压缩过程如下:

  • 二维数组递推公式: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][j]时,本行左边的dp[i][0 ~ j-1]内的结果其实还没开始确认(都是初始化时时的值,大部分都是0),而dp[i][j]右边的虽然确认了,但是dp[i - 1][j]右边的和dp[i][j]没有关系;
    • 既然dp[i - 1][j]左边的用于推导dp[i][j],而dp[i][j]左边的又还没有开始确认,那么将dp[i - 1][j]左边的覆盖到dp[i][j]左边没有任何问题;
  • 既然可以拷贝到一层上,就没必要用二维数组了,直接使用一维数组,之前二维数组向下遍历一层,现在一维数组直接覆盖一层;
  • 之前dp[i][j]的含义:i代表物品,j代表背包容量;
  • 此时dp[i]的含义:i代表背包容量,假设当前遍历轮次为第k轮,则从[0~k]中选取物品加入容量为j的背包中;(如此一来中间状态在最后没办法保存,即如果找到结果后,突然减小j,我们又要重新运行一遍代码)
  • 改进后的递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);就是将之前i - 1的部分全部去掉,而ij的部分不变;

滚动数组实现的01背包,动态规划五部曲:

  1. dp[j]代表容量为j的背包最大可以放多少价值的货物;
  2. 状态转移方程:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
  3. 初始化dp[j]数组,如果物品价值均大于等于0,则数组全部初始化为0;
  4. 遍历顺序(尤其注意,从二维数组看):从右向左遍历;(因为数组是滚动的,dp[j]左边的是上一轮的结果,并且上一轮结果才能推出dp[j],所以左边的不能被本轮结果覆盖,所以遍历从右向左,从后往前)
    • 之前二维数组先背包容量或者先物品都可以,但是换成滚动数组不行;(所以之前说先物品好,因为可以方便地改为滚动数组)
    • 只能先遍历物品,后遍历背包容量;即外层循环递增i,内层循环递减j
  5. 模拟dp[]的过程;
动态规划-背包问题9
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]);
    }
}

状态转移方程
  1. 求最大值:当我们需要求最大值时,通常使用max函数来比较当前状态和新状态之间的大小,并选择较大的值作为新的状态。例如:dp[i] = max(dp[i], dp[i-w] + v),表示比较当前状态dp[i]和选择当前物品后的新状态dp[i-w] + v,将较大的值赋给dp[i]
  2. 累加:在一些问题中,我们需要将多个状态值累加起来。例如:dp[i] = dp[i-1] + dp[i-2],表示将前两个状态的值相加,并将结果赋给当前状态。
    1. 尤其是对于最多方法数之类的问题,一般要累加;
  3. 加上nums[i]:在01背包中,有时候dp[i][j]dp[i - 1][j]dp[i - 1][j - weight[i]] + nums[i]共同推出;
    1. 如果dp[i][j]代表最大物品价值,则要加;
  4. 不加nums[i]:在01背包中,有时候dp[i][j]dp[i - 1][j]dp[i - 1][j - weight[i]]共同推出,不用加nums[i]
    1. 如果dp[i][j]代表方法数量,选择了i物品,也不应该加上价值,而是相当于dp[i - 1][j - weight[i]] * 1,即选择了i物品,只代表一种方法;

状态转移方程多种多样,一定要根据实际问题处理,而不是单纯套公式;尤其是分清楚dp[i][j]代表的含义和性质,是方法数还是价值总量,是求最大值还是累加;

常见的情况:

  • 问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])
    • 初始化应该初始化一个最大值(除了dp[0]),以便于状态转移时可以覆盖初始值;
  • 问装满背包有几种方法:dp[j] += dp[j - nums[i]]
    • dp[0] = 1,初始化虽然没有意义,但是有用;
  • 问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);(和第一个本质一样)
    • 初始化应该初始化一个最小值(除了dp[0]),以便于状态转移时可以覆盖初始值;
  • 问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j])
    • 初始化应该初始化一个最大值(除了dp[0]),以便于状态转移时可以覆盖初始值;

完全背包

N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

例如:

物品重量价值
0115
1320
2430

当背包最大重量为4时,选择4件物品0放入背包,总价值为60,此时总价值最大;

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]);
    }
}

// 完全背包(物品可以被添加多次)
// 先遍历物品,再遍历背包
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循环嵌套顺序是无所谓的!

01背包问题中,我们需要在每个背包容量下决定是否选择当前物品放入背包。为了保证在计算dp[j]时,我们使用的是上一行或上一轮计算出来的dp[j-w],即在选择当前物品时,我们需要使用的是上一行或上一轮计算出来的dp[j-w]。因此,在01背包问题中,我们需要先遍历物品,然后再遍历背包容量,以确保在计算dp[j]时,我们使用的是正确的上一行或上一轮的dp[j-w]

完全背包问题中,我们需要在每个背包容量下决定是否选择当前物品放入背包。不同于01背包问题,完全背包问题允许我们选择多次使用同一个物品。因此,我们不再需要考虑上一行或上一轮计算出来的dp[j - w]。在完全背包问题中,我们只需要确保在计算dp[j]时,我们使用的是当前行或当前轮计算出来的dp[j - w]即可。

所以,在完全背包问题中,对于一维dp数组来说,两个嵌套的循环的顺序是无所谓的,因为我们不再依赖上一行或上一轮的计算结果。我们只需要确保在计算dp[j]时,我们使用的是当前行或当前轮计算出来的dp[j - w]即可(虽然循环顺序无所谓,但是背包容量必须从小到大而不是01背包的从大到小)。

动态规划-完全背包2

dp[1][4]:在物品0和物品1中选择,并且可以重复选择,找到重量小于4的最大价值;

dp[1][4] = max(dp[0][4], dp[1][4 - 3] + 20) = 45;其中3代表物品1的重量,20代表物品1的价值;

但是如果不是问最大价值,而是问装满背包的方法数量,则要考虑内外层循环的顺序;所以最好还是和01背包内外层循环顺序保持一致;


背包问题遍历顺序总结
  • 01背包:背包容量必须从大到小遍历,从targetnums[i]
    • 二维数组:内外层遍历顺序无所谓;
    • 滚动数组:只能先遍历物品再遍历背包容量;
  • 完全背包:背包容量必须从小到大遍历,从nums[i]或0target
    • 组合:外层for循环遍历物品,内层for遍历背包
    • 排列:外层for遍历背包,内层for循环遍历物品

对于背包问题,其实递推公式算是容易的,难是难在遍历顺序上,如果把遍历顺序搞透,才算是真正理解了


多重背包

N N N种物品和一个容量为 V V V的背包。第 i i i种物品最多有 M i M_i Mi件可用,每件耗费的空间是 C i C_i Ci ,价值是 W i W_i Wi​ 。求解将哪些物品装入背包可使这些物品的耗费的空间 总和不超过背包容量,且价值总和最大。

01背包是每个物品只能使用1次,完全背包是每个物品能够使用无限次,多重背包是每个物品都规定了使用次数;

如果将第 i i i种物品最多有 M i M_i Mi件可用摊开,即把每一种物品都展开为 M i M_i Mi个物品;这样每个物品都只能只用1次,就变成了01背包;

例如:

物品重量价值数量
01151
13201
24302

展开为01背包问题

物品重量价值
0115
1320
2430
2430

转换为01背包之后使用动态规划:

#include<iostream>
#include<vector>
using namespace std;
int main() {
    int bagWeight,n;
    cin >> bagWeight >> n;
    vector<int> weight(n, 0);
    vector<int> value(n, 0);
    vector<int> nums(n, 0);
    for (int i = 0; i < n; i++) cin >> weight[i];
    for (int i = 0; i < n; i++) cin >> value[i];
    for (int i = 0; i < n; i++) cin >> nums[i];
    vector<int> dp(bagWeight + 1, 0);
    for(int i = 0; i < n; 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]);
            }
        }
    }
    cout << dp[bagWeight] << endl;
}

多重背包转换01背包问题就是把每种物品的数量,打包成一个个独立的包。多重背包在面试中基本不会出现,力扣上也没有对应的题目,大家对多重背包的掌握程度知道它是一种01背包,并能在01背包的基础上写出对应代码就可以了。


例题

斐波那契数列

力扣题目链接

// 动态规划解法(迭代)
class Solution {
public:
    int fib(int n) {
        if (n <= 1) return n;
        vector<int> dp(n + 1); // 确定动态规划数组dp;
        // dp中第i个数代表第i个斐波那契数;
        // 初始化dp;
        dp[0] = 0;
        dp[1] = 1;
        // 确定遍历顺序,dp[i]依赖于dp[i-1]和dp[i-2],所以从前往后遍历;
        for (int i = 2; i <= n; i++) {
            // 举例推导dp数组的值;
            dp[i] = dp[i - 1] + dp[i - 2]; //确定递归公式;
        }
        return dp[n];
    }
};

// 递归解法
class Solution {
    public:
    int fib(int N) {
        if (N < 2) return N;
        return fib(N - 1) + fib(N - 2);
    }
};

用动态规划五部曲分析上述过程:

  • dp[i]代表第i个斐波那契数;
  • 递推公式:dp[i] = dp[i - 1] + dp[i - 2]
  • dp[]初始化,初态应该有两个,因为递推公式也要两个状态才能推出下一个状态;所以初始化dp[0] = 0, dp[1] = 1
  • 确定遍历顺序:dp[i]从小到大,前面的数知道了才能知道后面的数,所以从前往后遍历;
  • 举例推导dp[]数组内容,验证代码;

斐波那契数列的递推公式已经给出了,所以最难的部分其实已经解决了;动态规划最难的部分就是写出递推公式,注意递推公式可能根据情况不同,公式也不同,即一个动态规划题目可能有多个递推公式;


爬楼梯

力扣题目链接

class Solution {
public:
    int climbStairs(int n) {
        // 动态规划5部曲;
        if (n <= 1) return n;
        vector<int> dp(n + 1); // 初值1、2
        // 确定dp数组含义;爬100层楼梯的方法个数和爬99层楼梯方法个数有关系;和爬98层方法个数也有关系;
        // 之前的行为和本次行为有关系;
        // dp数组:dp[i]代表爬i层楼梯的方法数;
        // 确定递归公式:dp[i] = dp[i-1] + dp[i-2]; 
        // 递归公式含义:爬i层可能是爬i-1层然后再爬一次1层,也可能是爬i-2层后再爬一次2层;
        // 确定dp初值;
        // dp[0] = 0; 最好不讨论dp[0],而是设置返回条件;
        dp[1] = 1;
        dp[2] = 2;
        // 确定遍历顺序:从前往后;
        for (int i = 3; i <= n; i++) {
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
};

分析为什么使用动态规划:考虑用动态规划,因为爬3层楼梯和爬1层楼梯有关系,也和爬2层楼梯有关系;爬3层楼梯等于爬1层楼梯后下一次直接爬2层楼梯,也相当于爬2层楼梯后下一次直接爬1层楼梯;即爬三层楼的方法数等于爬两层楼的方法数(选择爬两层再爬一层)加上爬一层楼的方法数(选择爬一层楼之后爬两层);

即:爬n层楼的方法数 = 爬n-2层楼的方法数(选择爬n-2层之后再爬1层)+ 爬n-1层楼的方法数(选择爬n-1层之后再爬2层)

当前状态对前面状态有依赖关系时,考虑使用动态规划;

动态规划五部曲:

  • 确定dp数组含义:dp[i]代表爬i层的方法个数;
  • 确定递归公式:dp[i] = dp[i - 1] + dp[i - 2]
  • 确定递归初值:dp[1] = 1,dp[2] = 2;
  • 确定递归顺序:后面的值对前面的值有依赖,从前往后;
  • 模拟dp数组,对比结果;

不应该讨论dp[0],因为爬0层楼没有定义!其实递推公式和斐波那契数列一样,但是dp[]的含义却不一样;


使用最小花费爬楼梯

力扣题目链接

class Solution {
public:
    int minCostClimbingStairs(vector<int>& cost) {
        if (cost.size() <= 0) return 0;
        // 爬i层楼梯的最低花费和之前有什么关系?能否写出递归公式?
        vector<int> dp(cost.size() + 1);
        // 递归公式:dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);
        // 初始化dp数组;从第0个台阶开始?从第1个台阶开始?;
        // 从0层开始一次爬1层;cost[0]
        // 从0层开始一次爬2层;cost[0]
        // 从1层开始爬
        dp[0] = 0; // 从0层开始爬;
        dp[1] = 0; // 从1层开始爬;
        // dp[2] = min(dp[0] + cost[0], dp[1] + cost[1]);
        // 递归顺序:从前往后;
        for (int i = 2; i <= cost.size(); i++) {
            // dp: 0、0、10、15
            dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]);
        }
        return dp[cost.size()];
    }
};

每当你爬上一个阶梯你都要花费对应的体力值,一旦支付了相应的体力值,你就可以选择向上爬一个阶梯或者爬两个阶梯。

动态规划五部曲:

  • dp[i]代表爬到第i层楼梯所花费的最小体力值;
  • 递归公式:dp[i] = min (dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])
  • 初始化dp[]数组,dp[0] = 0, dp[1] = 0
  • 遍历顺序:从前往后;
  • 模拟dp[]数组过程;

可以有两个途径得到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]的值(上一道爬楼梯中,也是两个途径,但是选择的是累加),所以递推公式是dp[i] = min (dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2])


不同路径

力扣题目链接

class Solution {
public:
    int uniquePaths(int m, int n) {
        // 动态规划五部曲:(为什么动态规划,和爬楼梯对比,dp[i][j]前面只有两个可能位置;)
        // 1. 确定dp数组含义,此处使用二维数组;
        vector<vector<int>> dp(m, vector<int>(n, 0)); // 注意初始化方式;
        // dp[i][j] 代表到达i行j列处共有多少路径;
        // 2. 确定递归公式:dp[i][j] = dp[i-1][j] + dp[i][j-1]; 和爬楼梯一样,dp[i][j]可以由前面的完全确定;
        // 3. 初始化;从(0, 0)出发,到达(m-1, n-1),初始化哪些元素?第0行所有元素,第0列所有元素;
        for (int i = 0; i < m; i++) dp[i][0] = 1; // 唯一路径;
        for (int i = 0; i < n; i++) dp[0][i] = 1;
        // 4. 确定遍历顺序,i递增并且j也递增,所以要二重循环嵌套;
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                // 5. 模拟验证;以m=3,n=2模拟:
                // 1 1
                // 1 2
                // 1 3 // 终点为3符合;
                dp[i][j] = dp[i-1][j] + dp[i][j-1];
            }
        }
        return dp[m-1][n-1];
    }
};

思路分析:其实就是二维的爬楼梯,原来只能向上爬,现在多了一个方向,所以也可以用动态规划,并且爬楼梯是一重循环,现在多了一个方向,本题应该是二重循环;也可以用深度优先遍历,因为只有两个方向,所以可以想象为一颗二叉树,使用深度优先遍历会超时。

62.不同路径1

动态规划五部曲:

  • 确定dp数组含义,此处使用二维数组;dp[i][j]代表到达第ij列处共有多少路径;
  • 确定递归公式:dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
    • 首先看有几种方式可以到达dp[i][j],明显有两种方式,一种是通过dp[i][j - 1]向下走到达,一种是通过dp[i - 1][j]向右走到达;
    • 然后判断这两种方式和结果直接的关系,两种方式累加获得总方法数,所以选择累加;
  • 初始化;从(0, 0)出发,到达(m - 1, n - 1),初始化第0行所有元素,第0列所有元素;
  • 确定遍历顺序,i递增并且j也递增,所以要二重循环嵌套;
  • 模拟验证;以m = 3, n = 2模拟:

dp[][]是一个二维数组,机器人在走网格的过程中,每一个网格点都有一个路径数;即所有中间状态都会被保存;这也是动态规划的特点,如果现在更改终点,则直接找dp数组对应位置返回,不用重新动态规划;


不同路径II

力扣题目链接

class Solution {
public:
    int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
        // 动态规划5部曲;
        // 1. 确定dp数组及其含义;
        vector<vector<int>> dp(obstacleGrid.size(), vector<int>(obstacleGrid[0].size()));
        // dp[i][j]代表到达坐标(i, j)的路径总数;
        // 2. 确定递归公式:dp[i][j] = dp[i-1][j] + dp[i][j-1];
        // 3. 初始化dp数组;
        for (int i = 0; i < obstacleGrid.size(); i++) { 
            if (obstacleGrid[i][0] == 1) {dp[i][0] = 0; break;} // 一个障碍挡了后面所有路;
            else dp[i][0] = 1;
        }
        for (int j = 0; j < obstacleGrid[0].size(); j++) { 
            if (obstacleGrid[0][j] == 1) {dp[0][j] = 0; break;} // 一个障碍挡了后面所有路;
            else dp[0][j] = 1;
        }
        // 4. 确定遍历顺序:i和j都递增;
        for (int i = 1; i < obstacleGrid.size(); i++) {
            for (int j = 1; j < obstacleGrid[0].size(); j++) {
                // 注意如果有阻碍,则直接设置可达路径为0;
                if (obstacleGrid[i][j] == 1) dp[i][j] = 0;
                else dp[i][j] = dp[i-1][j] + dp[i][j-1];
            }
        }
        return dp[obstacleGrid.size() - 1][obstacleGrid[0].size() - 1];
    }
};

思路分析:和之前的不同路径一样,就是多了障碍物的处理,如果一个坐标有障碍物,则该坐标的可达路径直接为0;此外在初始化的时候要注意,初始化第0行和第0列时,一旦出现阻碍,则后面的坐标都被挡住了,未被挡住的初始化为1,挡住的为0无需初始化;

到达dp[i][j]的途径:

  1. 如果有障碍物,则dp[i][j]直接为0,代表没有路径可以到达;
  2. dp[i - 1][j]出发,向右走一步;
  3. dp[i][j - 1]出发,向下走一步;

即动态转移方程要分情况来设置:

  • 如果该点有障碍物,动态转移方程为dp[i][j] = 0
  • 如果没有障碍物,则动态转移方程为dp[i][j] = dp[i - 1][j] + dp[i][j - 1]

整数拆分

力扣题目链接

class Solution {
public:
    int integerBreak(int n) {
        // 动态规划,分析当前最大能否由之前的最大推导出;分析乘积最大化情况?让数字尽量相等?
        // 2=1+1,3=2+1,4=2+2,2如果再拆就不行了,2拆开乘积最大为1;
        vector<int> dp(n + 1);
        // 1. dp[i]代表i的最大乘积;
        // 2. 确定递推公式:dp[i] = max(max(dp[k], k) * max(dp[i - k], i - k));其中k从1到i/2;
        // 3. 初始化dp数组;
        dp[2] = 1;
        dp[1] = 1; // dp[1] = 1, 取Max之后相当于没有改变;
        // 4. 确定遍历顺序,从小到大;
        for (int i = 3; i <= n; i++) {
            for (int k = 1; k <= i/2; k++) {
                if (dp[i] < max(dp[k], k) * max(dp[i - k], i - k)) {
                    dp[i] = max(dp[k], k) * max(dp[i - k], i - k);
                }
            }
        }
        return dp[n];
    }
};

给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积,比如10 = 3 + 3 + 4, 3 × 3 × 4 = 36。

确定到达dp[i]要几种途径,然后推导出递推公式:

  • 一个数可以拆分为多个数,并且有多种拆法;
  • 有多种方式到达,但是并不是累加各种方式,而是选择一种方式,选择所有方式中的最大值;
  • 每种方式都满足dp[i] = max(dp[i - k], i - k) * max(dp[k], k)
    • 注意不是dp[i] = dp[i - k] * dp[k];因为比如3,作为拆分结果应该3 = 2 + 1, 2 * 1 = 2,而3 > 2,所以用3比用2好;一个数作为被拆分对象和一个数作为拆分出来的对象的取值范围并不同!
  • 不断调整kk的范围从1到i / 2(注意是i / 2而不是i,因为i - kik > i / 2之后会互换);

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


不同的二叉搜索树

力扣题目链接

class Solution {
public:
    int numTrees(int n) {
        // 使用动态规划;
        // 1. 确定dp数组;
        vector<int> dp(n + 1);
        // dp[i]代表有i个节点的搜索二叉树的棵树;
        // 2. 确定递推公式:dp[i] = dp[0]*dp[i-1] + dp[1]*dp[i-2] + ... + dp[i-1]*dp[0];
        // 原因:若根节点为n,则左子树中节点数为n-1(左子树均小于根节点),右子树节点数为i-n;从1~i分别当根节点;
        // 3. 初始化dp数组;
        dp[0] = 1;
        dp[1] = 1;
        // 4. 确定递归顺序,从2开始,一直递增;
        for (int i = 2; i <= n; i++) {
            for (int j = 0; j <= i - 1; j++) {
                dp[i] += dp[j] * dp[i - j - 1];
            }
        }
        return dp[n];
    }
};

给定一个整数 n,求以 1 ... n 为节点组成的二叉搜索树有多少种?

96.不同的二叉搜索树1

dp[i]代表有i个节点的搜索二叉树的棵树;

分析走到dp[i]的途径,将i个节点分为3组:根节点,左子树节点,右子树节点;

  • 一旦选中根节点j,左子树节点就有j个节点,右子树就有i - j - 1个节点;
  • 而左子树和右子树又同时是一颗二叉搜索树,所以可以分别求出左子树的二叉搜索树个数dp[j],右子树的二叉搜索树个数dp[i - j - 1]
  • 二叉搜索树的个数for (int j = 0; j < i; j++) dp[i] += dp[j] * dp[i - j - 1];;即状态转移方程求出;
  • 注意状态转移方程不是一个单纯的方程,而是一个累加过程;

二叉树的递推关系,一般由于二叉树有左子树和右子树,左子树和右子树又都是二叉树,所以可以由二叉树的左右子树性质推出二叉树的性质;


分割等和子集

力扣题目链接

给你一个只包含正整数的非空数组nums。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

最直观的方法就是排列组合,寻找一个和为数组之和一半的子数组;用回溯实现选中小于nums.size() / 2个不重复的数字,然后判断和是否为sum / 2;时间复杂度和空间复杂度都很高;

class Solution {
public:
    bool canPartition(vector<int>& nums) {
   
        // 先从二维数组考虑(比较简单,优化为一维数组);

        // 动态规划五部曲:
        // 1. 确定dp数组及其含义:
        int sum = 0;
        for (int i = 0; i < nums.size(); i++) sum += nums[i];

        if (sum / 2 + sum / 2 != sum) return false; // 奇数不可能平分;
        vector<vector<int>> dp(nums.size(), vector<int>(sum / 2 + 1)); // 想象一个表格;
        // dp[i][j]:从nums[0~i]中选,累加和小于j,选中元素的值的总和;(如果总和为sum/2,则说明余下未选中的元素总和也是sum/2,说明可以找到两个均分的数组)
        // 注意是sum/2 + 1大小,很容易错写为sum/2;

        // 2. 确定递推公式:dp[i][j] = max(dp[i-1][j], dp[i-1][j-nums[i]] + nums[i]); 
        // 注意weight数组和value数组都是nums;(01背包的递推公式都是一样的)

        // 3. 初始化dp数组(初始化第一行和第一列)
        for (int i = 1; i < sum / 2; i++) {
            if (i >= nums[0]) dp[0][i] = nums[0]; // 只有容量大于物品重量才能装入;
        }

        // 4. 确定遍历顺序,从左上到右下;(先遍历物品,再遍历背包容量)
        for (int i = 1; i < nums.size(); i++) {
            for (int j = 1; j <= sum / 2; j++) {
                if (j - nums[i] >= 0) { // 看看够不够分;加入物品i;
                    dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - nums[i]] + nums[i]);
                } else { // 不加入物品i;
                     dp[i][j] = dp[i - 1][j];
                }
            }
        }
        // 5. 模拟;

        // 判断是否有dp[nums.size() - 1][sum/2 - 1] == sum/2,如果有,说明可以;
        if (dp[nums.size() - 1][sum / 2] == sum / 2) return true;
        return false;
    }
};

遇到背包问题,首先应该知道哪一个代表背包容量,哪一个代表物品,物品的重量和质量又各是什么;

  • 背包容量:子数组元素之和;
    • 背包容量用j表示;
    • 要求的结果:dp[nums.size()][sum / 2]
  • 物品重量、物品价值:子数组元素的值;(物品重量和价值相等)
    • 即都是nums[i]表示;
  • 物品:子数组中的元素;
    • 即用i表示;

然后根据动态规划五部曲:

  1. 确定dp数组及其含义:dp[i][j]:从nums[0~i]中选,累加和小于j,选中元素的值的总和;(如果总和为sum/2,则说明余下未选中的元素总和也是sum/2,说明可以找到两个均分的数组)
  2. 确定递推公式:dp[i][j] = max(dp[i-1][j], dp[i-1][j-nums[i]] + nums[i]);(01背包问题通用)
  3. 初始化数组(注意背包容量要大于物品价值时,初始化才能塞进去)
  4. 确定递归顺序:从左向右,从上到下(左上部分推出右下部分)
  5. 模拟;

注意dp[][]数组初始化时,分配的空间大小为dp[nums.size()][sum / 2 + 1],因为背包容量从0开始到达sum / 2,并且sum / 2可以取到,一共sum / 2 + 1种容量;(别写成dp[nums.size()][sum / 2]


最后一块石头的重量II

力扣题目链接

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        /*
        任意两块石头,所以有很多种可能。要求石头最终的最小可能重量,即求当剩下最后一块石头时,重量最小可以是多少。转换为碰撞的石头尽可能接近总重量的一半时如何分配。
        方法选择:暴力回溯可以,但是复杂度太高。贪心算法明显不一定找到,所以使用动态规划;
        不要从两个石头碰撞模拟看,而是从两堆石头碰撞看;(找整体)
        使用动态规划,动态规划5部曲;
        背包问题:找到背包、价值、重量对应的实际问题;0-1背包
            1. 背包:一堆石头的总重量;
            2. 物品价值:单个石头的重量;stones[i]
            3. 物品重量:单个石头的重量;stones[i]
        结果选择:sum - dp[target] - dp[target];
        dp[j]代表:容量为j的背包最多可以背的最大重量是dp[j];
        0-1背包固定递推公式:
            dp[i][j] = max(dp[i-1][j], dp[i-1][j-stones[i]] + stones[i]); 二维情况下;
            dp[j] = max(dp[j], dp[j-stones[i]] + stones[i]); 滚动数组情况;
        初始化dp数组:
            1. dp数组容量:总石头重量的一半(为什么是一半?总重量也可以,但是空间浪费,不要将问题看成两个石头碰撞,看成两堆重量近似相同的石头,然后两堆石头碰撞,两堆石头碰撞,碰撞一次背包容量就会缩小一半,所以选择总石头重量的一半;)
            2. dp[j]全部初始化为0;
        确定遍历顺序:
            注意使用滚动数组,需要严格按照左上部分元素推出本位元素,所以先遍历物品,从上到下,从0到stones.size()-1;然后遍历背包,从背包容量大到小;(可以从二维状态转移方程看,dp[i][j]变成了dp[j],但是本质还是dp[i][j],而dp[i][j]依赖于i-1时的值,所以先要确定i-1时的dp,即先遍历物品,然后遍历背包容量)
        */
        int sum = 0;
        for (int i = 0; i < stones.size(); i++) sum += stones[i];
        // dp[]数组的容量为sum / 2;
        int target = sum / 2;
        vector<int> dp(target + 1);
        for (int i = 0; i < stones.size(); i++) { // 先物品后背包;
            for (int j = target; j >= stones[i]; j--) { // 背包容量从大到小;如果j小于stones[i],则不会覆盖即dp[j] = dp[j],无需滚动更新,同时保证状态转移方程中的dp[j-stones[i]]不会越界;(注意理解此处边界)
                dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]); // 状态转移方程;
            }
        }
        return sum - dp[target] - dp[target]; // 两堆石头碰撞,第一堆石头为dp[target],第二堆石头为sum - dp[target],碰撞后应该是大的堆减小的堆;
    }
};

很容易陷入一共思维误区:dp[sum / 2]不是说要找到sum / 2重量的石头堆,而是在容量为sum / 2的情况下,最大可以构建多大的石头堆;

如果可以找到sum / 2的石头堆当然好,一碰撞一块石头都不剩;但是如果不可能恰好分开呢?只能尽量接近sum / 2,即容量为sum / 2时,最大能堆多重的石头堆;


目标和

力扣题目链接

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int sum = 0;
        for (int i = 0; i < nums.size(); i++) sum += nums[i];
        if ((sum + target) % 2 == 1) return 0;
        if ((abs(target) > sum)) return 0; // 提前结束;
        vector<int> dp((sum + target) / 2 + 1, 0);
        dp[0] = 1;
        for (int i = 0; i < nums.size(); i++) {
            for (int j = (sum + target) / 2; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[(sum + target) / 2];
    }
};

给定一个非负整数数组[a1, a2, ..., an], 和一个目标数S。现在你有两个符号+-。对于数组中的任意一个整数,你都可以从+-中选择一个符号添加在前面。

暴力回溯(组合):选中k数字,使得该k个数字之和以及余下n-k个数字之和能够通过相减得到目标和,k的取值从0n

动态规划:

必须要先进行转换,因为有正有负根本没办法处理;总和为sum,如果加法总和为x,则减法总和为sum - x,目标和为target,则target = x - (sum - x),转换得x = (target + sum) / 2

01背包问题(因为数组中的元素只使用1次,所以01背包)

  • 背包容量:x,即取值从0(target + sum) / 2
  • 物品:数组中的元素;
  • 物品价值和重量:数组中元素的值;

没有答案的情况:

  • 如果sum + target是奇数,则没办法找到答案;
  • 如果target的绝对值大于sum,则没办法找到答案;
if ((sum + target) % 2 == 1) return 0;
if ((abs(target) > sum)) return 0; // 提前结束;

滚动数组实现的状态转移方程:

  • 一般的状态转移方程:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
  • 本题的状态转移方程:dp[j] += dp[j - nums[i]]
    • 先从二维看:dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]]
      • 一定要想清楚为什么是加,而不是取max
        • 如果dp[i][j]的含义是最大价值,则应该是max
        • 如果dp[i][j]的含义是方法数,则是累加;
        • 所以动态规划五部曲第一步要明确dp数组含义;
      • 为什么不是dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]] + nums[i]
        • 因为不使用物品i, 有dp[i - 1][j]种方法;使用物品i,有dp[i - 1][j - nums[i]]种方法而不是dp[i - 1][j - nums[i]] + nums[i]种方法;
    • 然后改进为滚动数组:dp[j] = dp[j] + dp[j - nums[i]],写成连加形式,即dp[j] += dp[j - nums[i]]

如果仅仅是求个数的话,就可以用dp动态规划,但要求的是把所有组合列出来,还是要使用回溯法暴力搜索;


一和零

力扣题目链接

class Solution {
public:
    int findMaxForm(vector<string>& strs, int m, int n) {
        // 正常应该选择越短越好;
        /*
            转换题目问题,列出等式:m,n,strs;
                最多有m个0和n个1,所以可以小于m或者n,但不能大于;
            动态规划五部曲:
                1. dp[i][j]数组:代表在元素个数为j个的条件下,从strs[0]到strs[i]中选择字符串加入子集中,得到子集中0的个数;
                2. 确定状态转移表达式:
                   dp[i][j] = dp[i - 1][j]; // strs[i]没有加入到子集中;
                   dp[i][j] = dp[i - 1][j - 1] + strs[i]中0的个数; // strs[i]加入到子集中;
                   选择小于等于m的dp[i][j],并且总长度-m小于等于n,选择更接近m,n的;
                   要判断0的个数和1的个数,是否要设置两个值?即dp[i][j]上是一个(m, n)两个数组成;
                3. 初始化dp数组:设置为0;
                4. 遍历顺序(滚动数组);
                5. 模拟;
        */
        // vector<int> dp(strs.size() + 1, 0); // 只有对字符0个数的存储,不合理;
        // 概念上有问题,记住目标是dp[j]而不应该是j,所以dp[i][j]应该是我们要的结果或者与结果有关,而不是j是结果;所以dp[i][j]代表i个0和j个1的最长子数组长度;
        vector<vector<int>> dp(m + 1, vector<int> (n + 1, 0)); // 默认初始化0
        for (string str : strs) { // 遍历物品
            int oneNum = 0, zeroNum = 0;
            for (char c : str) { // 统计新加入的字符串中0、1的个数;
                if (c == '0') zeroNum++;
                else oneNum++;
            }
            for (int i = m; i >= zeroNum; i--) { // 遍历背包容量且从后向前遍历!
                for (int j = n; j >= oneNum; j--) {
                    dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1); //注意此时是字符串数组里字符串个数,所以是加一;
                }
            }
        }
        return dp[m][n];
    }
};

物品是字符串数组中的字符串,只能取1次,所以是0-1背包问题;

状态转移表达式:dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1),这次状态转移表达式和之前的完全不同了;


零钱兑换II

力扣题目链接

本题和纯完全背包不一样,纯完全背包是凑成背包最大价值是多少,而本题是要求凑成总金额的物品组合个数!

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        // 方法数,累加,同时完全背包,可取多次
        vector<int> dp(amount + 1);
        for (int j = 0; j < amount + 1; ++j) {
            if (j % coins[0] == 0) dp[j] = 1; // 注意是方法数
        }
        dp[0] = 1;
        // 双重循环
        for (int i = 1; i < coins.size(); ++i) {
            for (int j = coins[i]; j < amount + 1; j++) {
                // 状态转移方程,组合数,累加
                dp[j] = dp[j] + dp[j - coins[i]];
                // dp[j] += dp[j - coins[i]]; // 更加简化
            }
        }
        return dp[amount];
    }
};
/*
测试数据:5, [1,2,5]
    0 1 2 3 4 5
  1 1 1 1 1 1 1
  2 1 1 2 2 3 3
  5 1 1 2 2 3 4 
*/

使用滚动数组比二维数组在思路上要简单的多;如果使用二维数组,在初始化时,就不能只初始化一排和一列,而是每一行都初始化为第一行的值;

例如:如果使用二维数组,只初始化第一行和第一列
    0 1 2 3 4 5
  1 1 1 1 1 1 1
  2 1 0 0 0 0 0
  5 1 0 0 0 0 0
然后再二重循环遍历中,j的初值为coins[i],如果i = 1,则从j = 2开始自增j,那么dp[1][1]的位置的值还是0,而又不会遍历到dp[1][1],所以该位置就被忽略了,后面依靠该位置的状态的位置都会出错;
所以初始化应该将第一行复制到后面的行,即:
    0 1 2 3 4 5
  1 1 1 1 1 1 1
  2 1 1 1 1 1 1
  5 1 1 1 1 1 1
而使用滚动数组,只需要初始化一行;

状态转移方程:由于是组合数,即个数、方法,状态转移方程一般是累加;要到达dp[i][j]有两个途径,一个是通过dp[i - 1][j],不添加coins[i],一个是添加coins[i],通过dp[i - 1][j - coins[i]],所以最终方程为:dp[j] = dp[j] + dp[j - coins[i]] * 1,乘以1可以忽略(乘以1代表选择coins[i]加入只有一种方法);

为什么dp[0]要单独初始化为1?其实这里我也不太清楚,只是模拟状态转移过程发现明明有1种方法,但实际处理为0种方法,即当dp[j - coins[i]] = dp[0]时,明显有一种方法,就是选择一次coins[i],可是如果dp[0]为0,此时就会出错;

所以写好状态转移方程之后一定要模拟,确保没有错误;

for (int i = 1; i < coins.size(); ++i) {
    for (int j = coins[i]; j < amount + 1; j++) {
        // 状态转移方程,组合数,累加
        dp[j] = dp[j] + dp[j - coins[i]];
    }
}

i的遍历范围就是coins[]的遍历,而j的遍历范围可以直接看状态转移方程,由于j - coins[i]所以j一定大于等于coins[i],所以jcoins[i]开始遍历,到cmount + 1结束(即dp[]数组末尾);

细节——组合与排列

// 组合,{1, 5}和{5, 1}算一个,只累加一次数量
for (int i = 0; i < coins.size(); i++) { // 遍历物品
    for (int j = coins[i]; j <= amount; j++) { // 遍历背包容量
        dp[j] += dp[j - coins[i]];
    }
}

// 排列,{1, 5}和{5, 1}算两组,累加两次数量
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]]; // 一列累加
    }
}

只要交换内外层循环顺序,再加上一个条件判断防止溢出,组合就变成了排列!(why?)

每个背包容量下都考虑了所有物品的选择,因此导致了组合变成排列。这是因为在每个背包容量的循环中,我们将所有物品都看作是可选的,而不仅仅是当前物品及之前的物品。

即滚动数组内层循环滚动一圈后,确定了在j背包容量的前提下,所有物品都可以选择的情况下,有多少种方法;


组合总数IV

力扣题目链接

本题和上题进行对比;上题是求组合,本题是求排列,即[1, 5][5, 1]是两种结果;

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        vector<int> dp(target + 1);
        dp[0] = 1;
        for (int j = 0; j < target + 1; ++j) {
            for (int i = 0; i < nums.size(); ++i) {
                // 由于有两个相加大于INT_MAX的数据,所以要判断
                if (j - nums[i] >= 0  && dp[j] < INT_MAX - dp[j - nums[i]]) dp[j] += dp[j - nums[i]];
            }
        }
        return dp[target];
    }
};

动态规划五部曲:

  • dp[j]: 凑成目标正整数为j的排列个数为dp[j]
  • 状态转移方程:求装满背包的方法数一般是:dp[j] += dp[j - nums[i]]
  • 初始化:dp[0] = 1
    • 因为递推公式dp[j] += dp[j - nums[i]]的缘故,dp[0]要初始化为1,这样递归其他dp[j]的时候才会有数值基础。
    • 但是dp[0] = 1其实没有意义;仅仅是为了推导公式(所以要模拟,有时候初始化未必遵循某些意义)
  • 确定遍历顺序:完全背包问题,从小到大遍历容量(如果01背包要从大到小遍历容量);
    • 如果求组合数就是外层for循环遍历物品i,内层for遍历背包容量j
    • 如果求排列数就是外层for遍历背包容量j,内层for循环遍历物品i
  • 举例推导dp数组;
377.组合总和Ⅳ

C++测试用例有两个数相加超过INT_MAX的数据,所以需要在if里加上dp[j] < INT_MAX - dp[j - nums[i]]。超过时,不再累加;

求装满背包有几种方法,递归公式都是一样的,没有什么差别,但关键在于遍历顺序!先物品后背包就是组合,先背包后物品就是排列。


爬楼梯(进阶版)

卡码网题目链接

#include <vector>
#include <iostream>
#include <climits>
using namespace std;


int main () {
    int n, m;
    cin >> n >> m;
    // dp数组,dp[j]代表j层楼最多的方法数
    vector<int> dp(n + 1);
    // 初始化dp数组
    dp[0] = 1; 
    // 确定遍历顺序:排列,所以先背包,完全背包,所以背包容量从小到大
    for (int j = 0; j < n + 1; ++j) { 
        for (int i = 1; i <= m; ++i) { // 从1开始而不是0
            if (j - i >= 0 && dp[j] < INT_MAX - dp[j - i]) { // 遇到加法,就要考虑防止加法溢出
                dp[j] += dp[j - i];
            }
        }
    }
    cout << dp[n] << endl;
    return 0;
}

爬1楼,爬2楼,…,爬m楼,这就是m个物品(注意从1开始而不是从0开始)

爬到的楼梯层数就是背包容量;

求排列数,一般状态转移方程是dp[j] += dp[j - 1],同时要dp[0] = 1

如果不用动态规划,求组合可以使用回溯,从1~m种选择至多m个数字,可以重复选择,要让选择的数字之和刚好等于n,当找到一种情况时,计数器累加;


零钱兑换

力扣题目链接

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        auto am = *min_element(coins.begin(), coins.end());
        cout << am << endl;
        vector<int> dp(amount + 1, amount / am + 1);
        // for (int i = 1; i < amount + 1; i++) dp[i] = 1;
        dp[0] = 0;
        for (int i = 0; i < coins.size(); ++i) {
            for (int j = coins[i]; j < amount + 1; ++j) {
                dp[j] = min(dp[j], dp[j - coins[i]] + 1);
            }
            cout << dp[amount] << endl;
        }
        return dp[amount] ==amount / am + 1 ? -1 : dp[amount];
    }
};

做动态规划最大的感受就是不知道怎么回事就错了,不知道怎么回事又对了。

初始值的选择是本题的重难点:

  • dp[0] = 0,因为递推公式中有dp[j - coins[i]] + 1为使用coins[i]物品时的情况,使用coins[i]物品并且j == coins[i]时,应该只有一种方法,即只用一个i硬币,所以dp[0] = 0;(是我们模拟尝试出来的,至于意义不做讨论)
  • 如果初值一行全设置为0,则min之后还是0,最终滚动多少轮结果都是0;
  • 如果初值一行处理dp[0],都设置为amount + 1,这样min之后一定会覆盖初始值;但是在测试时,发现会溢出,原因在于amount + 1可能很大,而dp[j - coins[i]] + 1可能溢出;
  • 将除了dp[0]之外的数,应该初始化为最多的硬币数量,即使用最小面额的硬币要多少个才能达到amount金额,即auto am = *min_element(coins.begin(), coins.end())am就是最小金额硬币,然后amount / am + 1就是最多方法数加1;
  • 其实还有溢出的风险,万一am = 1,则又回到了amount + 1dp[j - coins[i]] + 1依旧有可能溢出,但是测试案例通过了;最好在循环内部加if (dp[j - coins[i]] < INT_MAX - 1)防止溢出;

注意求数组中最大值和最小值:

*min_element(vec.begin(), vec.end())*max_element(vec.begin(), vec.end());引入头文件为#include <algorithm>

对于递推公式为min的,应该初始化一个大的数,让覆盖初始值;对于递推公式为max的,应该初始化一个小的数,让覆盖初始值;


完全平方数

力扣题目链接

给你一个整数n,返回和为n的完全平方数的最少数量 。完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。比如n = 12 = 4 + 4 + 4所以输出数的个数为3;

思路:本题要求我们自定设定背包和物品;背包容量自然就是所求的目标,即和为n的完全平方数的最少数量;但是背包要求我们自己生成,即生成一个完全平方数数组;然后使用完全背包、min方法数来实现;

class Solution {
public:
    int numSquares(int n) {
        // 初始化完全平方背包
        vector<int> bags;
        int ai = 1;
        while (ai * ai <= n) bags.push_back(ai * ai++);
        vector<int> dp(n + 1, n + 1);
        dp[0] = 0;
        for (int i = 0; i < bags.size(); ++i) {
            for (int j = bags[i]; j < n + 1; ++j) {
                if (dp[j - bags[i]] < INT_MAX - 1) dp[j] = min(dp[j], dp[j - bags[i]] + 1);
            }
        }
        return dp[n];
    }
};

本题难度在于看出是背包问题,然后自设背包;如果没有提前告知是背包问题,可能一时间想不到;


单词拆分

力扣题目链接

输入: s = "applepenapple", wordDict = ["apple", "pen"],输出: true,解释: 返回 true因为 "applepenapple" 可以由 "apple" "pen" "apple" 拼接成。注意,你可以重复使用字典中的单词。

如果使用回溯法:枚举分割所有字符串,判断是否在字典里出现过。时间复杂度很高;横向切割以startindex为起点的字符串,纵向递增startindex

class Solution {
private:
    bool backtracking (const string& s,
            const unordered_set<string>& wordSet,
            vector<bool>& memory,
            int startIndex) {
        if (startIndex >= s.size()) {
            return true;
        }
        // 如果memory[startIndex]不是初始值了,直接使用memory[startIndex]的结果
        if (!memory[startIndex]) return memory[startIndex];
        for (int i = startIndex; i < s.size(); i++) {
            string word = s.substr(startIndex, i - startIndex + 1); // 切割不同长度的子串
            if (wordSet.find(word) != wordSet.end() && backtracking(s, wordSet, memory, i + 1)) { // 在字符串集合中找到了结果后直接继续递归(注意是短路且)
                return true;
            }
        }
        memory[startIndex] = false; // 记录以startIndex开始的子串是不可以被拆分的
        return false;
    }
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        vector<bool> memory(s.size(), 1); // -1 表示初始化状态
        return backtracking(s, wordSet, memory, 0);
    }
};

单词就是物品,字符串s就是背包,单词能否组成字符串s,就是问物品能不能把背包装满。

动规五部曲:

  • 确定dp数组以及下标的含义:dp[j] : 字符串长度为j的话,dp[j]true,表示可以拆分为一个或多个在字典中出现的单词
  • 确定递推公式:
    • 如果加入物品(单词wordDict[i])之后是dp[j],则要看加入之前的情况,即看dp[j - wordDict[i].size()]是否是true,如果它是true,则再看[j - wordDict[i].size(), j)区间内字符串是否和新加入的单词相等;
    • 所以递推公式:dp[j] = dp[j - wordDict[i].size()] && (s.substr(j - wordDict[i].size(), wordDict[i].size()) == wordDict[i]);
  • 初始化dp数组:dp[i] 的状态依靠 dp[j]是否为true,那么dp[0]就是递推的根基,dp[0]一定要为true,否则递推下去后面都都是false了。
  • 选择遍历顺序(小心!):如果单词是applepenapplepenpenapple是两个字符串,所以是排列,先背包后物品;
139.单词拆分
class Solution {
    public:
    bool wordBreak(string s, vector<string>& wordDict) {
        unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
        vector<bool> dp(s.size() + 1, false);
        dp[0] = true;
        for (int i = 1; i <= s.size(); i++) {   // 遍历背包
            for (int j = 0; j < i; j++) {       // 遍历物品
                string word = s.substr(j, i - j); //substr(起始位置,截取的个数)
                if (wordSet.find(word) != wordSet.end() && dp[j]) {
                    dp[i] = true;
                }
            }
        }
        return dp[s.size()];
    }
};

背包:单词,并不用平常使用的数组来,而是使用集合!不要拘泥于特定的数据结构,不同的算法要选择不同的数据结构;

状态转移方程:dp[i] = true;为什么?因为状态只有两种,一种是true,一种是flase,状态转移方程自然简单;但这就是状态转移方程的全部吗?当然不是。必须加上条件判断:如果当前子串可以在集合中找到,即当前子串是一个单词,并且该子串之前的字符串判断结果为true

比如判断leetcodeleet,我们切割出了子串code,发现子串在单词组中,然后还要判断code之前的字符串leet,发现leet之前的判断结果为true,此时我们可以放心将leetcode设置为true

难点在于遍历顺序,是先定一个单词,然后切割出不同的子串,看子串是否是这个单词;还是一个先切割不同子串,然后再所有单词中找,看是否能找到一致的;明显是后者;

举一个例子s = "applepenapple",单词数组为["apple", "pen"]

  • 如果是组合,则先定apple物品遍历,在第二个apple时,由于pen还没有加入过,所以第二个apple的起始位置并不会设置为true;(第二个apple时,除了检查是否在字典中之外,还要看前面的字符串是否为true,前面字符串为applepen由于pen还未判断,所以applepen对应的是false,所以第二个applefalse);而之后并不会回来遍历apple,所以第二个以后的都是false,结果也是false
  • 如果是排列则不存在上述问题;
  • 如下图片,排列dp[8] = 1,如果是组合dp[8] = 0
img
模拟:s = "applepenapple",wordDict = ["apple", "pen"]
a: 判断a是否在字典中
ap: 判断ap,p是否在字典中
appl: 判断appl,ppl,pl,l是否在字典中
apple: 判断apple在字典中,然后判断apple之前的字符串是否验证为true(即dp[0]),如果都成立,则为true
applep: 判断以末尾字符p为结束的子串是否在字典中
applepe: 判断以末尾字符e结尾子串
applepen: 判断pen子串在字典中,判断pen之前的字符串为apple(即dp[5]),apple已经验证为true
applepena: 判断以末尾字符a结尾子串
applepenap: 判断以末尾字符p结尾子串
applepenapp: 判断以末尾字符p结尾子串
applepenappl: 判断以末尾字符l结尾子串
applepenapple: 判断apple在字典中,apple之前的为applepen也为true(即dp[8]),所以为true

时间复杂度比较:

  • 对于回溯:时间复杂度为 O ( 2 n ) O(2^n) O(2n)
  • 对于动态规划:时间复杂度为 O ( n 3 ) O(n^3) O(n3)​;

本题是一道很好的反模板题目,设置背包、状态转移方程、物品,这些都和一般的完全背包问题不同。


总结

img

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

OutlierLi

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值