动态规划全题型解析【基础、背包、股票、编辑距离、回文】

 

目录

 

基础题目

509.斐波那契数列

746.爬楼梯及其拓展

62.不同路径及拓展

343.整数拆分

96.不同的二叉搜索树

背包问题

01背包及基础

压缩空间(一维dp滚动数组)

416.分割等和子集

698.划分k个相等的子集

1049.最后一块石头的重量

494.目标和

474.一和零

完全背包

理论基础

518.零钱兑换 Ⅱ

377.组合总和 Ⅳ

70.爬楼梯(n阶,完全背包解法)

322.零钱兑换

279.完全平方数

139.单词拆分

背包问题总结篇

打家劫舍

198.打家劫舍

213.打家劫舍 Ⅱ

337.打家劫舍 Ⅲ

股票问题

子序列问题

674.最长连续递增序列

718.最长重复子数组

1143.最长公共子序列

1035.不相交的线

53.最大子序和

编辑距离

392.判断子序列

115.不同的子序列

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

72.编辑距离

回文子串

647.回文子串

516.最长回文子序列


基础题目

509.斐波那契数列

普通的代码:

将数组压缩,使用滚动的方式缩小空间:

而此处如果使用递归的话时间复杂度是O(2^n)(为什么?)

 而空间复杂度:O(n) 算上了编程语⾔中实现递归的系统栈所占空间

此处的时间复杂度的思考,参照通过一道面试题目,讲一讲递归算法的时间复杂度! (qq.com)

来看求一个数的n次方,看下面这几段代码的时间复杂度:

递归算法的时间复杂度本质上是要看: 「递归的次数 * 每次递归中的操作次数」

int function2(int x, int n) {
    if (n == 0) {
        return 1; // return 1 同样是因为0次方是等于1的
    }
    return function2(x, n - 1) * x;
}

function2是递归,递归了n次,时间复杂度为O(n)

int function3(int x, int n) {
    if (n == 0) {
        return 1;
    }
    if (n % 2 == 1) {
        return function3(x, n / 2) * function3(x, n / 2)*x;
    }
    return function3(x, n / 2) * function3(x, n / 2);
}

function3,我们来分析一下,首先看递归了多少次呢,可以把递归抽象出一颗满二叉树。刚刚同学写的这个算法,可以用一颗满二叉树来表示(为了方便表示,选择n为偶数16),如图:

总共n-1个结点时间复杂度为O(n)

int function4(int x, int n) {
    if (n == 0) {
        return 1;
    }
    int t = function4(x, n / 2);// 这里相对于function3,是把这个递归操作抽取出来
    if (n % 2 == 1) {
        return t * t * x;
    }
    return t * t;
}

此处将代码在3的基础上改改,每次只调用一次递归,则此时调用递归的次数为树的深度,即为logn,时间复杂度即为O(logn)

再来看看前面的求斐波那契数列的递归法时间复杂度为什么是O(2^n)?

数学解法:

递归算法的时间复杂度,记计算第n个数的所需时间为T(n),那么T(n) = T(n-1) + T(n-2) ,由T(n-1) > T(n-2) 可以推出T(n) < 2T(n-1) < 2^2 * T(n-2)<2^(n-1)T(1),可以推出T(n)的时间复杂度为O(2^n)。

746.爬楼梯及其拓展

根据dp[i]代表到第i层有几种方法,我们让i从1开始,舍弃dp[0],不考虑其意义。

代码很好写

重点是扩展:

这道题⽬还可以继续深化,就是⼀步⼀个台阶,两个台阶,三个台阶,直到 m个台阶,有多少种⽅法爬 到n阶楼顶。

这⼜有难度了,这其实是⼀个完全背包问题,但⼒扣上没有这种题⽬,所以后续我在讲解背包问题的时 候,今天这道题还会拿从背包问题的⻆度上来再讲⼀遍。

其实就是对于dp[8],那么假如可以一次爬1个、2个、3个、4个台阶的话。

那么dp[8]的值就等于dp[8-1]+dp[8-2]+dp[8-3]+dp[8-4]。

所以代码如下:

扩展2:力扣746:

746. 使用最小花费爬楼梯 - 力扣(LeetCode)

dp[i]代表的是到第i层所花费的最小代价,并且支付了费用,然后可以往上走一或两层。

也就是说最后想走到第n层,是不需要付第n层的费用的。也就是说最后的返回值是min(dp[n-1],dp[n-2])。

另外这里下标它说从0开始,那就从0开始写,当然可以从1开始。

转移方程:

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

 代码:

62.不同路径及拓展

62. 不同路径 - 力扣(LeetCode)

递归方法当然好写,不过其时间复杂度如何计算?

由于其只能往右或者下走,则走过的长度是确定的,即m+n-1,每一步都有两种选择,所以时间复杂度是O(2^(m+n-1)) 

代码如下,很好写:

当然,还有数学方法:

但是有一点需要注意,阶乘容易溢出!

所以正确代码如下:

 

63. 不同路径 II

与上文不同,此处多了一个障碍物。

 

 和上面那题大体相同,区别在于:

所以代码如下:

343.整数拆分

对于dp[i],其代表的意义是数字为i时,最大的乘积。

而dp[i],对于数字i,其可以由1和i-1,2和i-2,3和i-3……加过来,加到i-1和1。

对于拆分出来的i和j,这个数字所能得到的最大乘积,由于是从前往后遍历,所以可以直接永dp[i-j]和dp[j]得出。

所以dp[i]=max(dp[i],dp[i-j]*dp[j])

不过需要注意一下,此处的dp[i]值,对于i从1到3需要做一下特殊处理

因为例如dp[3],直接脑算,值应该是2+1=3,然后2x1=2,好像看似dp[3]的值是2。

但是我们在当i大的时候,计算dp[i]值时,需要用到的dp[i-j]和dp[j]时,假如此时i-j或者j为3,那么此时dp[3]不应该是2,而应该是3!

因为此时的3,是i-j或者j得到的结果,是已经拆分过的!所以它代表的乘积就是它本身!而前面计算出的dp[3]为2,是因为单纯计算dp[3]它是需要拆分的,

对于dp的i值为1~3时,其dp值代表的是已经拆分过的,也就是说不需要再拆分了。

换个角度理解,i为1~3时,不拆分才是最大值!对于i从4开始时,对于4及以后的数字来说,此时拆分才能得到最大值!

所以我们就对1~3特殊处理一下,i从4开始时使用递推公式。

class Solution {
public:
    int integerBreak(int n) {
        if(n==1||n==2)return 1;
        if(n==3)return 2;
        vector<int> dp(n+1);
        dp[1]=1,dp[2]=2,dp[3]=3;
        for(int i=4;i<=n;i++){
            for(int j=1;j<i;j++){
                dp[i]=max(dp[i],dp[j]*dp[i-j]);

            }
        }
        return dp[n];
    }
};


 

96.不同的二叉搜索树

链接:https://leetcode-cn.com/problems/unique-binary-search-trees/

 我们应该先举⼏个例⼦,画画图,看看有没有什么规律,如图:

n为1的时候有⼀棵树,n为2有两棵树,这个是很直观的

我们可以将n=3的情况分为3种情况,1为头结点时,2为头结点时,3为头结点时的三种情况。

当1为头结点的时候,其右⼦树有两个节点,看这两个节点的布局,是不是和 n 为2的时候两棵树的布局 是⼀样的啊!

而对于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]

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

1到i为节点组成的⼆叉搜索树的个数为dp[i]。

2. 确定递推公式 在上⾯的分析中,其实已经看出其递推关系,

dp[i] += dp[以j为头结点左⼦树节点数量] * dp[以j为头结 点右⼦树节点数量]

j相当于是头结点的元素,从1遍历到i为⽌。

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

j-1 为j为头结点左⼦树节点数量,i-j 为以j为头结点右⼦树节点数量

3. dp数组如何初始化 初始化,只需要初始化dp[0]就可以了,推导的基础,都是dp[0]

而dp[0]的初始化应该定义为1,如果为0则有问题,如果为1代表左边的节点数为0的话,乘1相当于没乘。

4. 确定遍历顺序

5. 举例推导dp数组

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

综上所述代码如下:

背包问题

 对于大厂面试题,只需要掌握01背包和完全背包问题即可。

01背包及基础

怎么取能使价值更大?

暴⼒的解法应该是怎么样的呢? 每⼀件物品其实只有两个状态,取或者不取,所以可以使⽤回溯法搜索出所有的情况,那么时间复杂度 就是O(2^n),这⾥的n表示物品数量。 所以暴⼒的解法是指数级别的时间复杂度。进⽽才需要动态规划的解法来进⾏优化!

依然动规五部曲分析⼀波。

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

对于背包问题,有⼀种写法, 是使⽤⼆维数组,即dp[i][j] 表示从下标为[0-i]的物品⾥任意取,放进容量 为j的背包,价值总和最⼤是多少。

 

所以我们需要初始化第一行第一列。

 

 

 

完整代码:

压缩空间(一维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 - 1]这⼀层拷⻉到dp[i]上,不如只⽤⼀个⼀维数组了,只⽤dp[j](⼀维数组,也可以理解是 ⼀个滚动数组)

这就是滚动数组的由来,需要满⾜的条件是上⼀层可以重复利⽤,直接拷⻉到当前层。

动规五步曲:

1. 确定dp数组的定义

回顾一下二维dp数组中i和j的含义:

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

那我们进行压缩后,核心思想是把第i行的数据拷贝到第i+1行上,那此时变成一维后,dp[j]代表的就是循环第i轮时,容量为j的背包最大价值为dp[j]。

2. ⼀维dp数组的递推公式

dp[j]可以通过dp[j - weight[j]]推导出来,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]);

3. ⼀维dp数组如何初始化

此处的初始化,dp[j]代表容量为j的物品的最大价值。

初始化时,我们可以放入第一个物品,然后循环的时候i从第二个物品开始循环,不过也可以不考虑第一个物品,都可以,这种情况全部初始化为0即可。(因为此时没有物品,价值自然为0)

因为如果是二维的话,代码是写的第i-1行,所以i的第0行需要初始化才能方便后面的填表。

不过如果是一维的情况,就没有第i-1行了,所以初始化考不考虑第一个物品都可以。

但是有一点需要注意!如果价值里面有负数!!那么⾮0下标就要初始化为负⽆穷。

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

4. ⼀维dp数组遍历顺序

先给出结论

我们可以发现,我们对于j,是从末尾往前循环,而不是正序,这是为什么呢?

如果是二维数组,第i行的值是由第i-1行决定的,也就是说第i行的第j列的dp值如何改变,都不会影响第i行后续其他的dp值。

但是如果是一维数组,此时我们是用当前行的值代替上一行来计算,如果我们在前面改变了第j列的值,那很可能会影响第j列后面的dp值,因为此时该dp值不再是由所谓的“上一行”来决定的了,而是会受到当前行的影响!

那为什么要从后往前遍历呢?因为第j列的值只会受到第j列和其前面的列数影响,不会受到后面的列数影响。换言之即使后面改变了也不会影响前面的!

5. 举例推导dp数组

代码如下:

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

416.分割等和子集

本题和下面这两题也很类似:

698. 划分为k个相等的子集

473. 火柴拼正方形 - 力扣(LeetCode)

你将得到一个整数数组 matchsticks ,其中 matchsticks[i] 是第 i 个火柴棒的长度。你要用 所有的火柴棍 拼成一个正方形。你 不能折断 任何一根火柴棒,但你可以把它们连在一起,而且每根火柴棒必须 使用一次 。

如果你能使这个正方形,则返回 true ,否则返回 false 。

先回到本题看看,本题可以直接用递归法解决,但是会超时。

解法如下:

class Solution {
public:
    vector<int> num;
    double allSum=0;
    bool findFlag=false;
    bool canPartition(vector<int>& nums) {
        for(auto val:nums)allSum+=val;
        num=nums;
        dfs(0,0);
        return findFlag;
    }
    void dfs(double nowSum,int index){
        if(index>=num.size())return;
        if(nowSum==allSum/2){
            findFlag=true;
            return;
        }
        else{
            dfs(nowSum+num[index],index+1);
            dfs(nowSum,index+1);
        }
    }
};

或者将bool变量作为返回值也是可以的,两种代码仅仅改了判断true和false的方式。

 

动规五部曲分析如下:

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

01背包中,dp[i] 表示: 容量为j的背包,所背的物品价值可以最⼤为dp[j]。

套到本题,dp[i]表示 背包总容量是i,最⼤可以凑成i的⼦集总和为dp[i]。

(使用压缩后的一维数组)

2. 确定递推公式

01背包的递推公式为:

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

本题,相当于背包⾥放⼊数值,那么物品i的重量是nums[i],其价值也是nums[i]。

所以递推公式:dp[j] = max(dp[j], dp[j - nums[i]] + nums[i])

3. dp数组如何初始化

首先dp[0]一定是0,如果题目价值给的都是正整数,那么初始化全为0即可。如果价值中出现负数,非0下标就要全部初始化为负无穷。

此处有一点初始化,已知题目的题意:每个数组中的元素不会超过 100,数组的⼤⼩不会超过 200。

而总和不会⼤于20000,背包最⼤只需要其中⼀半,所以10001⼤⼩就可以了。

vector dp(10001, 0);

4.确定遍历顺序

如果使⽤⼀维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒叙遍历!

(如果使用一维,必须先遍历物品后遍历背包容量,不可以反过来。因为如果反过来,就无法使用压缩数组的核心思想:《使用上一行作为结果》)

// 开始 01背包
for(int i = 0; i < nums.size(); i++) {
    for(int j = target; j >= nums[i]; j--) { 
    // 每⼀个元素⼀定是不可重复放⼊,所以从⼤到⼩遍历
    dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]);
    }
}

5. 举例推导dp数组 dp[i]的数值⼀定是⼩于等于i的。

如果dp[i] == i 说明,集合中的⼦集总和正好可以凑成总和i,理解这⼀点很重要。

这也是本题能使用背包算法的核心要点和难点! 

最终代码如下:

二维版:

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum=0;
        int maxNum=INT_MIN;
        for(int i=0;i<nums.size();i++){
            sum+=nums[i];
            maxNum=max(maxNum,nums[i]);
        }
        int len=nums.size();
        int target=sum/2;
        sum/=2;
        if(sum%2!=0)return false;
        if(maxNum>target)return false;
        vector<vector<int>> dp(len,vector<int> (target+1));

        for(int j=1;j<=target;j++){
            if(j-nums[0]>=0)dp[0][j]=nums[0];
        }
        
        for(int i=1;i<=len-1;i++){
            for(int j=1;j<=sum;j++){
                if(j-nums[i]>=0)dp[i][j]=max(dp[i-1][j],dp[i-1][j-nums[i]]+nums[i]);
                else dp[i][j]=dp[i-1][j];
            }
        }
        return dp[len-1][sum]==sum;

    }
};

一维版:

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int sum=0;
        int maxNum=INT_MIN;
        for(int i=0;i<nums.size();i++){
            sum+=nums[i];
            maxNum=max(maxNum,nums[i]);
        }
        int len=nums.size();
        int target=sum/2;
        if(sum%2!=0)return false;

        sum/=2;
        if(maxNum>target)return false;
        vector<int> dp(target+1) ;

        for(int j=1;j<=target;j++){
            if(j-nums[0]>=0)dp[j]=nums[0];
        }
        
        for(int i=1;i<=len-1;i++){
            for(int j=sum;j>=0;j--){
                if(j-nums[i]>=0)dp[j]=max(dp[j],dp[j-nums[i]]+nums[i]);
                else dp[j]=dp[j];
            }
        }
        return dp[sum]==sum;

    }
};

1049.最后一块石头的重量

​​​​​​1049. 最后一块石头的重量 II

这题怎么用动态规划,怎么用背包呢?

乍一看好像没什么思路,先来实际跟着例子手操试一下要怎么做。

stones = [2,7,4,1,8,1]

假如我们用最普通的顺序,我们依次让前两个石头碰一碰。

例如2和7碰,得到5,然后5和4碰,得到1,然后1和1碰,就变成0了,最后剩下8和1,那得到7。

很明显可以看到,这个留下的数字是比较大的,那我们要怎么做才能让最终留下的数字变小呢?

回顾上面刚才的过程中,在碰到一半的过程中,出现了

stones = [1,1,8,1]的过程

那我们要怎么做才可以使得最终结果最小呢?

我们不应该让前两个1自己碰,这属于是内耗,我们应该让三个弱小的1联手起来去攻打大的8!

换言之,我们想让最终得到的结果最小,我们应该将整个石头划分为势力接近均等的两个势力,如果出现一方势力强于另一方势力很多,那么最终赢的那方剩下的兵力就比较强。

而划分为接近均等的两股势力这就和之前的分割等和子集很像了!

所以此处才可以使用背包问题,核心思想就是尽可能凑到接近sum/2的水平。

那么分析和步骤和之前类似,容量为sum/2,

⽽我们要求的target其实只是最⼤重量的⼀半,所以dp数组开到15000⼤⼩就可以了。 当然也可以把⽯头遍历⼀遍,计算出⽯头总重量 然后除2,得到dp数组的⼤⼩。 我这⾥就直接⽤15000了。

vector dp(15001, 0);

 

最终代码如下:

二维版:

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int len=stones.size();
        int sum=0;
        for(auto val:stones){sum+=val;}
        int target=sum/2;
        vector< vector<int> > dp(len,vector<int>(target+1));
        for(int j=1;j<=target;j++){
            if(j>=stones[0])dp[0][j]=stones[0];
        }

        for(int i=1;i<len;i++){
            for(int j=1;j<=target;j++){
                if(j>=stones[i])dp[i][j]=max(dp[i-1][j],dp[i-1][j-stones[i]]+stones[i]);
                else dp[i][j]=dp[i-1][j];
            }
        }
        return sum-dp[len-1][target]-dp[len-1][target];
    }
};

一维版:

class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) {
        int len=stones.size();
        int sum=0;
        for(auto val:stones){sum+=val;}
        int target=sum/2;
        vector<int> dp(target+1);
        for(int j=1;j<=target;j++){
            if(j>=stones[0])dp[j]=stones[0];
        }

        for(int i=1;i<len;i++){
            for(int j=target;j>=0;j--){
                if(j>=stones[i])dp[j]=max(dp[j],dp[j-stones[i]]+stones[i]);
                else dp[j]=dp[j];
            }
        }
        return sum-dp[target]-dp[target];
    }
};

494.目标和

494. 目标和 - 力扣(LeetCode)

本题可以使用回溯法,后续再考虑。

  • 如何转化为01背包问题呢。

首先每个数字只能选一次,我们选择它是正还是负。

那么最终我们要解出哪些是正的哪些是负的,正的减去负的得到就是我们target。

假设加法项的总和为positive,那么减法项的绝对值对应的总和设为negative

于是有:

positive+negative=sum;

positive-negative=target;

则positive=(target+sum)/2

所以如果给出了target,我们就可以算出来positive。

那有了positive后,我们要做的是就算出我们哪些项可以凑出positive,于是又回归到了背包问题,只不过此时的目标和不是target,而是positive

不过有个地方需要注意,就是x=(target+sum)/2可能会发生溢出的问题,以及向下取整会不会产生问题。

首先看数据,可知本题不会溢出:

  • 再看什么时候会发生向下取整,如何应对?

假如sum为5,而target为2,那么此时会发生向下取整。

但是我们仔细思考可以发现,如果sum为5,无论是1和4,还是2和3,都无法凑出target为2的情况。

事实上分析后可以得知,当sum和target的和不为偶数时,怎么都凑不出来target。所以此种情况我们可以直接返回0,即凑不出答案。

  • 然后本题属于背包问题中的哪一种呢?

是01背包问题,为什么是01背包呢? 因为每个物品(题⽬中的1)只⽤⼀次

  • 至于这个递推公式要怎么计算呢?

这次和之前遇到的背包问题不⼀样了,之前都是求容量为j的背包,最多能装多少。 本题则是装满有⼏种⽅法。其实这就是⼀个组合问题了

动规五步曲

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

dp[j] 表示:填满j(包括j)这么⼤容积的包,有dp[i]种⽅法。

其实也可以使⽤⼆维dp数组来求解本题,dp[i][j]:使⽤ 下标为[0, i]的nums[i]能够凑满j(包括j)这么⼤ 容量的包,有dp[i][j]种⽅法。

下⾯统⼀使⽤⼀维数组进⾏讲解, ⼆维降为⼀维(滚动数组),其实就是上⼀层拷⻉下来。

2. 确定递推公式

回顾一下以前使用背包问题时的递推公式都是,

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

代表选择或者不选择当前物品所能得到的最大价值。

如果不选择当前物品,则直接由上面一行继承,如果选择该物品,则计算减去当前物品的体积后,剩余的体积所能获得的最大价值,再加上当前物品的价值。

对于本题,我们先来举例子判断一下。

其中sum为5,target为3,则目标的x为(5+3)/2=4。

然后我们的目标就是在上面的5个数字中,每个数字有选与不选两种选择,然后凑出4来。与01背包问题不同的在于我们要求出有多少种解法。

比如第0行(第0个物品),当j为0时,dp[i][j]为1,因为此时不选择即可使得和为1。

第0行,j为1时,dp[i][j]也为1,因为此时选择即可使得和为0。至于第一行后面的数都为0了,因为不可能凑出和为2、3、4的情况。故初始化如下:

 而对于第1个物品,和为0的情况,此时是没得选择的,所有的物品都应该不选择,所以只有一种解法。对于第2、3、4、5个物品是同理的。

所以最终初始化如下:

对于第一个物品,target为1的情况,此时就有两种选择,对于当前物品的选与不选:

如果不选择,则有dp[i-1][j]种选择

如果选择,则有dp[i-1][j-nums[i]]种选择。

两种情况加起来则有dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i]]种选择。此即为递推公式。

如果使用的是一维数组,那么此时dp[i][j]就等于dp[i-1][j]了,于是不需要再加,那么只需要再加上dp[i-1][j-nums[i]]即可。

于是一维的递推公式:

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

所以最终代码如下:

个人书写版:

以下代码有几个注意的点,与传统背包问题不同,本题是计算方法数。以前背包问题遍历的行数,就是物品的数量,比如物品有4个,则行数从是,0,1,2,3,

但是题是计算“能够凑满该容量的方法数,也就是说,也可以不选该物品,那么假如物品有4个,则行数就是0,1,2,3,4,有5行!

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int positive,negative;
        int sum=0;
        int len=nums.size();
        for(int i=0;i<len;i++){
            if(nums[i]<0)nums[i]=-nums[i];
            sum+=nums[i];
        }
        //if(abs(target)>sum)return 0;
        if(target<0)target*=-1;
        //positive+negative=sum;
        //positive-negative=target;
        if(target>sum)return 0;
        if((sum+target)%2!=0)return 0;
        positive=(sum+target)/2;
        vector< vector<int> > dp(len+1,vector<int>(15000));

        dp[0][0]=1;
        //dp[0][nums[0]]=1;
        /*for(int j=0;j<=positive;j++){
            if(j>=nums[0])dp[0][j]=nums[0];
        }由于是计算凑满的方法数,而不是计算该容量能存放多少东西,因此第一行只有一个数不为0,这与以往的初始化第一行的方法不同*/


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



        return dp[len][positive];

    }
};

另外还有一点,本题由于会出数组中有负值,或者target为负值,但是事实上无论正负不会影响方法数,而负数有时候不方便计算,所以可以全部转化为正数,如图中这样:

一维版如下:

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) {
        int positive,negative;
        int sum=0;
        int len=nums.size();
        for(int i=0;i<len;i++){
            if(nums[i]<0)nums[i]=-nums[i];
            sum+=nums[i];
        }
        //if(abs(target)>sum)return 0;
        if(target<0)target*=-1;
        //positive+negative=sum;
        //positive-negative=target;
        if(target>sum)return 0;
        if((sum+target)%2!=0)return 0;
        positive=(sum+target)/2;
        vector<int> dp(15000);

        dp[0]=1;
        //dp[0][nums[0]]=1;
        /*for(int j=0;j<=positive;j++){
            if(j>=nums[0])dp[0][j]=nums[0];
        }由于是计算凑满的方法数,而不是计算该容量能存放多少东西,因此第一行只有一个数不为0,这与以往的初始化第一行的方法不同*/


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



        return dp[positive];

    }
};

讲义版代码:

class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int S) {
        int sum = 0;
        for (int i = 0; i < nums.size(); i++) sum += nums[i];
        if (abs(S) > sum) return 0; // 此时没有方案
        if ((S + sum) % 2 == 1) return 0; // 此时没有方案
        int bagSize = (S + sum) / 2;
        vector<int> dp(bagSize + 1, 0);
        dp[0] = 1;
        for (int i = 0; i < nums.size(); i++) {
            for (int j = bagSize; j >= nums[i]; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[bagSize];
    }
};

 于是同理,力扣39:

39. 组合总和 - 力扣(LeetCode)

假如本题不是要我们求满足target的具体组合,而是要求有多少种,就可以使用动态规划。

474.一和零

这道题和经典的背包问题非常相似,但是和经典的背包问题只有一种容量不同,这道题有两种容量,即选取的字符串子集中的 0 和 1的数量上限。

而且本题要求的是在限制了0和1的数量的情况下(也是相当于限定容量),问子集的个数最多可以为多少个。

以往的背包问题是限定容量的情况,该容量的商品最多值多少钱。而本题相当于限定容量后,最多能装多少个物品,物品数量越多相当于价值越大。

经典的背包问题可以使用二维动态规划求解,两个维度分别是物品和容量。这道题有两种容量,因此需要使用三维动态规划求解,三个维度分别是字符串、0的容量和 1 的容量。

完整版:

class Solution {
public:

vector<int> getZeroOne(string &str){
    int len=str.size();
    vector<int> zeroOnes(2);
    for(int i=0;i<len;i++){
        zeroOnes[str[i]-'0']++;
    }
    return zeroOnes;
}
    int findMaxForm(vector<string>& strs, int m, int n) {
        int len=strs.size();
        vector<vector<vector<int>>> dp(len,vector<vector<int>> (m+1,vector<int>(n+1)));
        vector<int> zeroOnes=getZeroOne(strs[0]);
        for(int j=0;j<=m;j++){
            for(int k=0;k<=n;k++){
                if(j>=zeroOnes[0]&&k>=zeroOnes[1])
                    dp[0][j][k]=1;
            }
        }

        for(int i=1;i<len;i++){
            vector<int> zeroOnes=getZeroOne(strs[i]);
            int zeroNum=zeroOnes[0];
            int oneNum=zeroOnes[1];
            for(int j=0;j<=m;j++){
                for(int k=0;k<=n;k++){
                    if(j>=zeroNum&&k>=oneNum)
                        dp[i][j][k]=max(dp[i-1][j][k],dp[i-1][j-zeroNum][k-oneNum]+1);
                    else 
                        dp[i][j][k]=dp[i-1][j][k];
                }
            }
        }
        return dp[len-1][m][n];

    }
};

第0行是我自己多做了一步初始化的过程,

事实上,也可以多声明一行,然后从第一行开始遍历,然后就可以省去额外初始化的过程:

class Solution {
public:
    vector<int> getZeroOne(string &str){
        int len=str.size();
        vector<int> zeroOnes(2);
        for(int i=0;i<len;i++){
            zeroOnes[str[i]-'0']++;
        }
        return zeroOnes;
    }
    
    int findMaxForm(vector<string>& strs, int m, int n) {
        int len=strs.size();
        vector<vector<vector<int>>> dp(len+1,vector<vector<int>> (m+1,vector<int>(n+1)));
        for(int i=1;i<=len;i++){
            vector<int> zeroOnes=getZeroOne(strs[i-1]);
            int zeroNum=zeroOnes[0];
            int oneNum=zeroOnes[1];
            for(int j=0;j<=m;j++){
                for(int k=0;k<=n;k++){
                    if(j>=zeroNum&&k>=oneNum)
                        dp[i][j][k]=max(dp[i-1][j][k],dp[i-1][j-zeroNum][k-oneNum]+1);
                    else 
                        dp[i][j][k]=dp[i-1][j][k];
                }
            }
        }
        return dp[len][m][n];

    }
};

再简化一步,删去一个维度:

class Solution {
public:
    vector<int> getZeroOne(string &str){
        int len=str.size();
        vector<int> zeroOnes(2);
        for(int i=0;i<len;i++){
            zeroOnes[str[i]-'0']++;
        }
        return zeroOnes;
    }
    
    int findMaxForm(vector<string>& strs, int m, int n) {
        int len=strs.size();
        vector<vector<int>> dp(m+1,vector<int>(n+1));
        for(int i=1;i<=len;i++){
            vector<int> zeroOnes=getZeroOne(strs[i-1]);
            int zeroNum=zeroOnes[0];
            int oneNum=zeroOnes[1];
            for(int j=m;j>=0;j--){
                for(int k=n;k>=0;k--){
                    if(j>=zeroNum&&k>=oneNum)
                        dp[j][k]=max(dp[j][k],dp[j-zeroNum][k-oneNum]+1);
                    else 
                        dp[j][k]=dp[j][k];
                }
            }
        }
        return dp[m][n];

    }
};

完全背包

理论基础

对于这种题,一种简单的方法就是,比如对于背包来说最多装5件A物品,那么此时我们可以认为数组中有五件A物品,也就是说不是无限的。对每个物品都进行这样的操作,这样就可以将完全背包转化为01背包了。

接下来讲解通法,可以先从一般的例子入手,对比01背包的转移方程,我们可知,对于完全背包来说,其转移方程如下:

dp[i,j]=max(dp[i-1,j] , dp[i-1,j-weight[i]]+value[i] , dp[i-1,j-2*weight[i]]+2*value[i]  , dp[i-1,j-3*weight[i]]+3*value[i],.....
,dp[i-1,j-k*weight[i]]+k*value[i]

可以看到其有无穷多种。

那么这里怎么优化这个公式呢?

数学推导的方式可以见:完全背包 —— 打破思维定式 - 知乎 (zhihu.com)

对于完全背包来说,最终的公式是这个:

好像和01背包没什么差别,对吧?其实差别在这里!第二项取得是i而不是i-1。

如何理解呢?完全背包重点在于一件物品可以取多次。在原来的01背包中,第二项用的是dp[i-1][j-w[i]],代表的是用上一行,也就是上一个物品的数据。

而为什么这里用的是dp[i][j-w[i]]呢?为什么是第i行呢?首先对于动态规划的二维表格来说,第i行时代表的是第i个物品。而第i个物品我们希望可以使用多次,也就是说假如这一行前面使用过了第i件物品,而后面我们希望还可以使用,并且我们希望接下来是基于前面使用过了第i件物品的情况下的最大利益,后续可以继续添加第i件物品,所以此时我们就是以当前这行来计算后续的最大利益。

因而是第i行。

结论:完全背包相比动态规划只需将原来动规中的状态转移方程变更一处即可,如下所示。

而对于压缩过后的一维数组来说,首先在回顾⼀下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[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可 以了。 遍历物品在外层循环,遍历背包容量在内层循环,状态如图:

完全背包示例代码:

先遍历物品,再遍历背包容量

// 先遍历物品,在遍历背包
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();
}

看似内外循环顺序可以颠倒,但那是对于纯完全背包才这样。但如果是变种问题,则顺序会有影响!

常见的背包问题有1、组合问题。2、True、False问题。3、最大最小问题。
分为三类。

希望用一种规律搞定背包问题 - 组合总和 Ⅳ - 力扣(LeetCode)
1、组合问题:
377. 组合总和 Ⅳ
494. 目标和
518. 零钱兑换 II
2、True、False问题:
139. 单词拆分
416. 分割等和子集
3、最大最小问题:
474. 一和零
322. 零钱兑换

518.零钱兑换 Ⅱ

518. 零钱兑换 II - 力扣(LeetCode)

 但本题和纯完全背包不⼀样,纯完全背包是能否凑成总⾦额,⽽本题是要求凑成总⾦额的个数!

注意题⽬描述中是凑成总⾦额的硬币组合数,为什么强调是组合数呢?

 例如示例⼀: 5 = 2 + 2 + 1

5 = 2 + 1 + 2 这是⼀种组合,都是 2 2 1。

这是⼀种组合,都是 2 2 1。 如果问的是排列数,那么上⾯就是两种排列了。 组合不强调元素之间的顺序,排列强调元素之间的顺序。 

本题可以使用背包,我们要凑的金额数量就是容量,而不同价值的货币就是不同类型的商品。

而每个货币的币值,即代表重量,也代表价值。

本题与传统背包还有一个区别在于,要计算的是总方案数,而不是容量下的最大商品。因此动态规划方程如下:

回顾在非完全背包中,涉及到组合总数的dp计算代码是这样的 

而我们上面讨论过,由于是完全背包,所以只需要改动一个地方即可:

所以可以看到下面的代码中仅仅改动了一个地方

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        int len=coins.size();
        vector<vector<int> > dp(len+1,vector<int> (amount+1));
        dp[0][0]=1;
        for(int i=1;i<=len;i++){
            for(int j=0;j<=amount;j++){
                dp[i][j]=dp[i-1][j];
                if(j-coins[i-1]>=0)dp[i][j]+=dp[i][j-coins[i-1]];
            }
        }
        return dp[len][amount];
    }
};

 当然,本题可以再简化为一维数组:

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        int len=coins.size();
        vector<int> dp(amount+1);
        dp[0]=1;
        for(int i=1;i<=len;i++){
            for(int j=0;j<=amount;j++){
                if(j-coins[i-1]>=0)dp[j]+=dp[j-coins[i-1]];
            }
        }
        return dp[amount];
    }
};

当然本题也可以有其他更简单的思路做法,就是既然物品可以使用很多次,那我就使用一次,使用两次,使用三次,……就摁算就行了

 

 上面使用硬币的方式如下,比方说,5可以由5个1组成,此时为1种方式,假如有某个硬币币值为2,那么我们可以用2去替换其中的两个1,那么就变成2 1 1 1。于是方法就多了dp[3] 种,即:“组成2的方法加上组成3的方法。” 而组成2的方法就是一个货币,所以是一种。

所以即dp[5]+=dp[5-2];

而我们可以不止一次用2去替换两个1,可以替换两次,所以即2  2  1

所以答案即为:dp[5]+=dp[5-2*2];

也就是说,每有一种方法可以满足和a+b=5的话,就加上它。而方法数量就是dp[b]。(其中a为多次使用硬币,而b为5-a)

因而代码如下:

class Solution {
    public int change(int cnt, int[] cs) {
        int n = cs.length;
        int[][] f = new int[n + 1][cnt + 1];
        f[0][0] = 1;
        for (int i = 1; i <= n; i++) {
            int val = cs[i - 1];
            for (int j = 0; j <= cnt; j++) {
                f[i][j] = f[i - 1][j];
                初始时该行值为0,所以使用上一行来进行初始化,代表使用了上一轮的货币后,现在有多少种方法
                for (int k = 1; k * val <= j; k++) {
                    f[i][j] += f[i - 1][j - k * val];  
                }
            }
        }
        return f[n][cnt];
    }
}

补充:上面的做法会出现不同的排列吗?比如说1 1 2本质和1 2 1是相同的,这两种顺序应该只记录一种。 

答案是不会。

只会出现1 1 2 不可能出现 1 2 1。

原因如下:

当固定物品的时候(物品在外层循环),遍历背包容量的时候,dp 记录的是当前元素及以前的排列,不包含后面元素的排列,例如在固定元素为 1 时,遍历结束背包容量的时候,dp 记录的是只有元素 1 的时候,背包从空到满的排列方式。

当元素为 2 的时候,此时元素1在前一轮循环已经固定下来了。i=2时,准备固定的元素是2, ,i从2不断变大,没法变小,所以没办法查找 dp[i-元素1] 的情况,即 {元素2, 元素1} 这种排列方式漏掉了,只保留了{元素1,元素2}这种排列方式。

377.组合总和 Ⅳ

377. 组合总和 Ⅳ - 力扣(LeetCode)

 注意,本题与39题:组合总和几乎一样:

区别在于39题需要返回具体的组合,那么就只能使用回溯法。

本题是只需要求数量,虽然可以用回溯,但是回溯的速度慢。因为只需要求数量,所以使用动规即可。

根据本题所给的顺序中:如果顺序不同也视为不同的组合,这与上面找零那题就区别开来了。上面找零中不允许重复。

 

dp[0]=1的意思就是,不选任何数字凑出0的方法就是1种。

 当target(容量)放在外层循环时,而物品在内层循环时,对于每个dp[i][j]值,其会将所有物品再遍历一遍,那么这就可以出现将j值(比如等于4)分为【1+dp[3]】和【3+dp[1]】的情况

为什么在外层循环,就可以出现不同的组合呢?

定义dp[j]表示和为i的组合个数,那么这个dp[j]怎么求呢。举个例子,比如j是5,如果要找和为5的组合,

我们可以用和为1的组合加上一个4
或者用和为2的组合加上3
或者用和为3的组合加上2
或者用和为4的组合加上1
而和为1,2,3,4的组合个数分别是dp[1],dp[2],dp[3],dp[4]。

所以和为5的组合个数就是他们几个的和,也就是dp[5]=dp[1]+dp[2]+dp[3]+dp[4];

这个j就代表着容量,并且是一维数组,按照上面所想的,写出了下面的代码:

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        int len=nums.size();
        vector<vector<unsigned long long>> dp(len+1,vector<unsigned long long > (target+1));
        dp[0][0]=1;
        for(int j=0;j<=target;j++){
            for(int i=1;i<=len;i++){
                dp[i][j]=dp[i-1][j];
                if(j>=nums[i-1])dp[i][j]+=dp[i][j-nums[i-1]];
            }
        }
        return dp[len][target];
    }
};


但是发现不对,这是为什么呢?

回想下前面,当我们想使用

遍历顺序是一列一列从左往右的遍历,比如对于容量1,去遍历完0~4的物品,此时就知道了dp[1]的大小是什么,在二维数组中,这个dp[1]其实就是dp[len][1]

因此,本文真正的遍历二维的代码如下:

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        int len=nums.size();
        vector<vector<unsigned long long>> dp(len+1,vector<unsigned long long > (target+1));
        dp[0][0]=1;
        for(int j=0;j<=target;j++){
            for(int i=1;i<=len;i++){
                dp[i][j]=dp[i-1][j];
                if(j>=nums[i-1])dp[i][j]+=dp[len][j-nums[i-1]];
            }
        }
        return dp[len][target];
    }
};

对比上文仅改动了这样的一个地方:

 这样才能实现充分利用《前面对于每一个固定的容量j都去遍历了从0到i的所有物品后得到的该容量有多少种情况》的这个结果

转化到一维的情况下时,此时

因此最终代码如下:只需要改动几个地方即可

class Solution {
public:
    int combinationSum4(vector<int>& nums, int target) {
        int len=nums.size();
        vector<unsigned long long> dp(target+1);
        dp[0]=1;
        for(int j=0;j<=target;j++){
            for(int i=1;i<=len;i++){
                if(j>=nums[i-1])dp[j]+=dp[j-nums[i-1]];
            }
        }
        return dp[target];
    }
};

注意到本题中有很大的数据,加和后可能会导致溢出,所以使用unsigned long long型。

由于是一维的,所以行数不需要像二维的那样自己多弄出一个第一行来做特殊处理,所以代码可以再优化一下变成下面这种:

class Solution {

public:

    int combinationSum4(vector<int>& nums, int target) {

        int len=nums.size();

        vector<unsigned long long> dp(target+1);

        dp[0]=1;

        for(int j=0;j<=target;j++){

            for(int i=0;i<len;i++){

                if(j>=nums[i])dp[j]+=dp[j-nums[i]];

            }

        }

        return dp[target];

    }

};


 

70.爬楼梯(n阶,完全背包解法)

 

 

注意,此时的容量是n,即总共的台阶数,物品即为跳的步骤数目:

322.零钱兑换

 

518题是求方法数,而本题是求最小数,核心模型都是背包模型,区别在于本题使用的递归方程是求最大最小值类型的。本题和原始背包一样,原始背包问题求的是最大价值,而本题求的是最小数量。都是求一个最大值,只不过原始背包问题的价值是有一个独特的value数组来衡量的,而本题的最小个数,可以直接衡量。

 

 

初始化因为是要找能凑出的最小的数,所以那些凑不出的dp值应该设为一个很大的数

为了防止INT_MAX+1之后溢出,可以使用long型来存储

另一方面,假如当前格子的容量不足以装新东西,那么方案数至少是上一层的内容



class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        if(amount==0)return 0;
        int len=coins.size();
        vector<vector<long>> dp(len+1,vector<long> (amount+1,INT_MAX));
        dp[0][0]=0;

        for(int i=1;i<=len;i++){
            for(int j=0;j<=amount;j++){
                    if(j>=coins[i-1])dp[i][j]=min(dp[i-1][j],dp[i][j-coins[i-1]]+1);
                    else dp[i][j]=dp[i-1][j];
            }
        }
        if(dp[len][amount]>=INT_MAX)return -1;
        return dp[len][amount];
    }
};

再将其改造成一维的就变成了:

class Solution {
public:
    int coinChange(vector<int>& coins, int amount) {
        if(amount==0)return 0;
        int len=coins.size();
        vector<long> dp(amount+1,INT_MAX);
        dp[0]=0;

        for(int i=0;i<len;i++){
            for(int j=0;j<=amount;j++){
                    if(j>=coins[i])dp[j]=min(dp[j],dp[j-coins[i]]+1);
            }
        }
        if(dp[amount]>=INT_MAX)return -1;
        return dp[amount];
    }
};

279.完全平方数

 

 4.确定遍历顺序

本题的遍历顺序没有要求,因为此处不对组合数还是排列数做要求。

但是此处最好用背包容量在外,物品在内。

因为其实物品有无数种,但是要求是物品的²要小于容量,那么可以先确定容量,然后让j从1到根号容量,逐渐变大的循环。

class Solution {
public:
    int numSquares(int n) {
        vector<int> dp(n+1,INT_MAX);
        dp[0]=0;
        for(int i=1;i<=n;i++){
            for(int j=1;j*j<=i;j++){
                dp[i]=min(dp[i],dp[i-j*j]+1);
            }
        }
        return dp[n];
    }
};

当然物品数量在外也可以,只不过就要稍微注意一下条件了:

class Solution {
public:
    int numSquares(int n) {

        vector<long> dp(n+1,INT_MAX);
        dp[0]=0;
        int j=1;
        for(int i=1;i*i<=j;i++){
            for(j=1;j<=n;j++){
                if(j>=i*i)dp[j]=min(dp[j],dp[j-i*i]+1);
            }
        }


        return dp[n];
    }
};

139.单词拆分

这题我个人感觉和背包关系不大…

自己需要初始化第一个格子的位置,可以用dp0表示,意思是空字符串在字典中一定可以找到,即的                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       拼接一定为true,

 

 

背包问题总结篇

 

⼆维dp数组01背包先遍历物品还是先遍历背 包都是可以的,且第⼆层for循环是从⼩到⼤遍历。

⼀维dp数组01背包只能先遍 历物品再遍历背包容量,且第⼆层for循环是从⼤到⼩遍历。

打家劫舍

198.打家劫舍

 

 代码很简单:

class Solution {
public:
int rob(vector<int>& nums) {
    if (nums.size() == 0) return 0;
    if (nums.size() == 1) return nums[0];
    vector<int> dp(nums.size());
    dp[0] = nums[0];
    dp[1] = max(nums[0], nums[1]);
    for (int i = 2; i < nums.size(); i++) {
        dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
    }
    return dp[nums.size() - 1];
    }
};

213.打家劫舍 Ⅱ

337.打家劫舍 Ⅲ

 

 

股票问题

股票问题具体可以见我的另外一篇文章:

一个解法解决所有力扣买卖股票问题_晴夏。的博客-CSDN博客_力扣 股票问题

子序列问题

 

 

674.最长连续递增序列

 

718.最长重复子数组

 

假如对于A的数组,和B的数组,其拥有一个长度为3最大公共子数组。

那么A数组从第i位到第i+2位,和B数组的第j位到第j+2位的应该是相等。

这意味着,A数组的第i位等于B数组的第j位,然后A数组的i+1位等于B数组的j+1位。

这意味着什么呢?这意味着假如我们用dp[i][j]存储A数组的前i位,和B数组的前j位的最大公共子数组,假如第i位和第j位相同,那么此时dp[i][j]=dp[i-1][j-1]+1

意思就是说假如前面已经有两个位置的字符相同了,那么走到这里时,相同的字符就是前面的2加上当前的1了。

(此处的dp[i][j]可以是代表第i位和第j位结尾的字符串所得到的最大长度,也可以是i-1位和j-1位代表的最大长度。

如果是前者,那么二维矩阵中,第0行第0列会代表字符串的第0个字符,并且需要我们自己去初始化第一行第一列。(其实本题的二维矩阵就是下面那种写法的矩阵的右下方罢了:如图红框内容所示:

本题该种解法代码如下:

class Solution {
public:
    int findLength(vector<int>& nums1, vector<int>& nums2) {
        int len1=nums1.size();
        int len2=nums2.size();
        vector<vector<int>> dp(len1,vector<int>(len2));

        int maxLen=0;
        for(int i=0;i<len1;i++){
            if(nums2[0]==nums1[i]){maxLen=1;dp[i][0]=1;}
        }
        for(int j=0;j<len2;j++){
            if(nums1[0]==nums2[j]){maxLen=1;dp[0][j]=1;}
        }
        for(int i=1;i<len1;i++){
            for(int j=1;j<len2;j++){
                if(nums1[i]==nums2[j])dp[i][j]=dp[i-1][j-1]+1;
                else dp[i][j]=0;
                maxLen=max(maxLen,dp[i][j]);
            }
        }
        return maxLen;
    }
};

如果是后者,则二维矩阵的第0行第0列不代表字符串的第0个,第一行第一列才代表字符串的第一个。此时我们不需要自己去初始化第0行第0列。

对于该dp值的二维数组该如何初始化?注意到我们需要使用到斜上一格,所以第一行第一列需要空出来初始化为0。效果如下:

 

1143.最长公共子序列

 

 

 

注意,本题不要求连续,而前面那题子数组要求连续这意味着dp[i][j]的大小必须是以(i,j)结尾的,而本题不要求连续,这意味着dp[i][j]得到最大值时,不一定是以(i,j)结尾的

意味着dp[i][j]得到最大值时,不一定是以(i,j)结尾的,所以可以是

 

准确来说当str1[i]和str2[j]不相等时,dp的状态方程应为:

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

还应该包含第三个状态,但是左方和上方的数,一定大于等于左上方的数,所以可以忽略这个状态,

1035.不相交的线

 

 

53.最大子序和

 

 

 

编辑距离

392.判断子序列

双指针思路较为简单,此处不在双指针这里过多的赘述。

除此之外,本题与前面的1143.求最长公共子序列的题目十分相像,区别在于1143是求公共长度,本题是求其中一个是否为另外一个的子序列。

那其实很简单,只需要在1143题的代码最下方最终判断一下是否满足公共子序列长度是短的字符串那个的长度即可:

(为什么此处和上题的动规方程少了一个dp[i][j]=max(dp[i-1][j],dp[i][j-1])呢?

因为此处i结尾的字符串一定比以j结尾的字符串短,所以不需要考虑i-1结尾的字符串了。

接下来本题做法和1143非常类似。

 此处也可以使用dp[i][j]代表第i位,第j位,这样初始化的时候就需要做出改变,动规方程不变。

 

 

115.不同的子序列

 

 

 

(dp[i][j]也可以是代表以i结尾的子串和以j结尾的子串的最大方法数。)

假设不相等,则只能由s[i-2]去和t[j-1]去匹配,因此此时dp[i][j]=dp[i-1][j]

如果相等,那么此时可以由s[i-1]和t[j-1]去匹配,也可以用s[i-2]去和t[j-1]做匹配,也就是多了一种选择。因此此时dp[i][j]=dp[i-1][j]+dp[i-1][j-1]

例如: s:bagg 和 t:bag ,s[3] 和 t[2]是相同的,但是字符串s也可以不⽤s[3]来匹配,即⽤ s[0]s[1]s[2]组成的bag去和t去匹配。而本题是要计算有多少种情况,所以可以由这两种的总方法数加起来。

二次复习时新补充的更便于理解的思路:

比如字符串bag和bxagg

首先就是,我们让i为行,让i代表的是短的那个字符串,然后j代表长的字符串。

我们可以以遍历的顺序来去思考:即一行一行从左到右去遍历 意思就是比如固定短的字符串为:bag,而长的字符串就是从b、bx、bxa、bxag、bxagx、bxagxg去不断加长的过程

另一方面,由于是在的子串j中找i的串,所以j一定大于i。因此,对于二维数组dp值,当j小于i时,dp[i][j]一定为0。

对于两个字符串的第i位和第j位,当i和j不相等时,就说明此时新的那个j的字符是没有用的,例如此时j从4到5,也就是从bxag变成了bxagx,那么此时新增的j对应的字符是x没法匹配上是无用的。于是即使无视这个第j个字符,结果不会变。所以dp值就继承了前面的值,也就是dp[i][j-1]。

但当如果第j个字符相等,即比如此时是bxagxg,此时第j个字符(最后一个)是匹配的,那么,由于是计算的情况数,所以需要把不同的情况累加起来。

首先情况数肯定不会减小,只会增加,增加的数额如何计算呢?那就是,我们都去排除第i个字符和第j个字符,看0到i-1的的字符串和0到j-1的字符串的dp值是多大,加上这个值即可。因此此时

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

(为什么没有dp[i-1][j]呢?很简单,我觉得就是没有关系

(这也解释了为什么动规的转移方程时不需要考虑dp[i-1][j]

(下面这个解析的i是长字符,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]是 ⼀定要初始化的。

其实此处初始化比较好理解,只要判断第0列和第0行的情况,判断这种情况,t是否是s的子集即可。看下图即可知道怎么初始化:

于是最终代码如下:

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

 本题和动态规划:115.不同的⼦序列相⽐,其实就是两个字符串可以都可以删除了,情况虽说复杂⼀ 些,但整体思路是不变的。

动规的dp方程怎么想呢?题目要我们求什么,我们就设定dp值为什么!

要求最小步数,我们就设定dp值为最小步数。

上面的这个+1和+2是哪里来的?就[j]是删除字符得来的,删除第i-1个字符,这个操作就是1次,然后再加上之前的操作次数,即dp值[i-1]

 

 

72.编辑距离

 

 

其实删除元素还有一种,就是两个字符串各自删除一个当前字符,但是这种情况不如替换字符。因为删两个字符和替换一个字符,最终效果一样。

添加元素的本质上和删除元素是相同的。

 

 

 

 

 

回文子串

回文子串的dp值的表达式,遍历顺序,dp推导公式都与其他上面的那些题不是一个类型,因此多开一个板块。

647.回文子串

 

 

注意,此处的i是从大到小,而j是从小到大。

并且j是要从i出发,因为需要计算j-i的值。

这种循环的实际意义思路就是说:对于字符串abcdefedc来说,先从最后一个结点开始,然后往两边扩散,判断是否是回文,再从倒数第二个结点往中间扩散。对于每个节点往中间扩散

完整代码如下:

动态规划法复杂度较高,此处看看双指针法。

516.最长回文子序列

  子序列不要求连续!!!

(因为是子序列,并不要求连续,所以可以单独延伸一边,得到一个最大值) 

 

顺序:从下到上,从左到右

回溯法

 698.划分k个相等的子集

本题的回溯法可以参考这篇文章:

经典回溯算法:集合划分问题「重要更新 🔥🔥🔥」 - 划分为k个相等的子集 - 力扣(LeetCode)

代码:

以前回溯时,我们只需要一个sum即可,比如上面那题分为两个等和子集,实际上只需要考虑一个即可。但是本题有k个,所以我们可以设置k个桶,然后每次dfs时使用一个球,将其放到1~k个桶里面。

对于本题不能使用全局变量findFlag的形式,因为我们一旦找到,即可停止搜索:

此外这里还有三个剪枝的方法,具体可以看上面那篇文章讲的很详细。

class Solution {
public:
    int sum=0;
    int target;
    int len;
    vector<int> num;
    vector<int> buckets;

    bool canPartitionKSubsets(vector<int>& nums, int k) {
        num=nums;
        len=nums.size();
        for(auto val:nums)sum+=val;
        if(sum%k!=0)return false;
        buckets.resize(k);
        target=sum/k;
        
        return dfs(0);
    }
    bool dfs(int index){
        if(index==len){
            for(int i=0;i<buckets.size();i++){
                if(buckets[i]!=target)return false;
            }
            return true;
        }

        for(int i=0;i<buckets.size();i++){
            if(buckets[i]+num[index]>target)continue;
            buckets[i]+=num[index];

            if(dfs(index+1))return true;
            buckets[i]-=num[index];
        }
        return false;
    }

};

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值