小掰笔记之:动态规划中的背包问题

目录

前言

一、01背包问题

1.什么是01背包?

2. 如何解决01背包问题?

二维dp数组01背包

一维dp数组01背包(滚动数组)

3. 01背包的例题与解析

 1.携带研究材料(纯01背包问题)

2.分割等和子集 

3.洗衣房问题 

 二.完全背包

1.什么是完全背包?

2.如何解决完全背包问题?

 3.完全背包的例题与解析

1.零钱兑换II

2.组合总和IV

文章总结

个人总结


前言

背包问题,可能有同学在《背包九讲》了解过,但是我认为对于初学者而言,《背包九讲》的难度较大,而且以伪代码形式展现不利于理解吸收,并且在基础或提高算法题目中主要的形式是01背包完全背包,偶尔会出现多重背包问题,至于《背包九讲》里面提到的其余背包问题几乎都是竞赛难度的。

而本文是结合了网站《代码随想录》以及作者个人课上所做的题目做出的一篇汇总文章,萌新同学们看完本文是可以很快入门并上手背包问题的!


一、01背包问题

所以背包问题的理论基础重中之重是01背包,一定要理解透!

leetcode上没有纯01背包的问题,都是01背包应用方面的题目,也就是需要转化为01背包问题。所以我先通过纯01背包问题,把01背包原理讲清楚,后续再讲解leetcode题目的时候,重点就是讲解如何转化为01背包问题了

1.什么是01背包?

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

有同学可能会根据上一篇文章的回溯算法来做,因为每个物品都有拿或者不拿的状态,然后我们通过回溯法把每个状态都列举出来,这不就完事了吗?

但是不要忘记回溯算法就是暴力枚举+剪枝优化,这样时间复杂度就是o(2^n),这里的n表示物品数量,剪枝函数编写难度大,大部分时候都会使题目超时,这个时候我们就需要动态规划进行优化

2. 如何解决01背包问题?

二维dp数组01背包

  我们还是使用动态规划基本模版构建dp数组

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

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

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

只看这个二维数组的定义,大家一定会有点懵,看下面这个图:

 

要时刻记着这个dp数组的含义,下面的一些步骤都围绕这dp数组的含义进行的,如果哪里看懵了,就来回顾一下i代表什么,j又代表什么。

2. 确定递推公式

再回顾一下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:由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数组的定义吻合,否则到递推公式的时候就会越来越乱

首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。如图:

 

再看其他情况。

状态转移方程 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的物品的时候,各个容量的背包所能存放的最大价值。

那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。

当j >= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。

其他部分我们可以将其初始化为任一不影响最终结果的值,这里我们统一赋值为0

因此初始化结果应该是:

4.确定遍历顺序

在图中我们可以看出,有两个遍历的维度:物品与背包重量

那么到底是先遍历物品再遍历背包重量呢还是先遍历背包重量再遍历物品呢?

其实都可以!! 但是先遍历物品更好理解,所以这里我们先只讨论先遍历物品的情况

但是在背包问题里,两个for循环的先后循序是非常有讲究的,理解遍历顺序其实比理解推导公式难多了

理解遍历顺序我们得先理解递推的本质和递推的方向。

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数组

最后来看一下对应的dp数组的数值,如图:

最终结果就是dp[2][4]。

只要是动态规划的问题,在做题的时候一定要记住 自己设置的 dp数组的定义,因为dp数组是整个推导过程的核心所在,忘记dp数组的定义是绝对做不出题的!!

建议大家此时自己在纸上推导一遍,看看dp数组里每一个数值是不是这样的。

最好的过程就是自己在纸上举一个例子把对应的dp数组的数值推导一下,然后在动手写代码!

c++代码如下:

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

    // 二维数组
    vector<vector<int>> dp(weight.size(), 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() {
    wei_bag_problem1();
}

一维dp数组01背包(滚动数组)

背包问题是可以压缩的

!!!!!!!!注意!!!!!!!!

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

大家可以发现我们对于dp[i]层的推导完全是基于dp[i - 1]层的数据。

相当于把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数组中的dp[i-1][j],即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品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就可以了。

这样才能让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]就只会放入一个物品,即:背包里只放入了一个物品。

倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。

5. 举例推导dp数组

最后得到的结果就是dp[bagweight];

c++代码如下:

void 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++) { // 遍历物品
        //注意第二层遍历是以bagweight为开头
        //j >= weight[i]防止越界,也相当于j <= weight[i]时就只能选择不拿,dp[j] = dp[j];
        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() {
    wei_bag_problem();
}

 因为使用一维数组更为便利,所以之后的例题都会使用一维dp数组解题。


3. 01背包的例题与解析

 1.携带研究材料(纯01背包问题)

~~传送门在这里~~

这题就是一道与上文案例毫无差别的题目,很适合作为新手练习的第一个背包题。

我们还是按照动规五部走:

1. 确定dp数组的定义

关于这题我们的dp[j]表示行李空间为 j 时,小明可以携带的研究材料的最大价值。

2.确定递推公式

本题的递推公式和上文一致

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

3.初始化dp数组 

dp[0]表示行李空间为0时携带的研究材料的最大价值,很显然为0;

剩余的部分就直接初始化为0即可,这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了

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

4.确定遍历顺序 

外层循环遍历每个类型的研究材料(遍历物品
内层循环从 N 空间逐渐减少到当前研究材料所占空间(遍历背包容量) 

5.推导dp数组

最后得到的结果就是dp[bagsize];

c++代码如下:

#include<bits/stdc++.h>
using namespace std;


int main() {
    //关闭同步流
	ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
		
	int n,bagweight;
	
	cin >> n >> bagweight;
	
	// 创建一个动态规划数组dp,初始值为0
	vector<int> dp(bagweight + 1,0);
	vector<int> weight(n);
	vector<int> value(n);
	
	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++) {
	    //再遍历容量
		for(int j = bagweight; j >= weight[i]; j--) {
		    //递推,在拿和不拿之间选择最优
			dp[j]= max(dp[j], dp[j - weight[i]] + value[i]);			
		}
	}

	cout << dp[bagweight] << '\n';
	return 0;
	
}

2.分割等和子集 

~~传送门在这里~~

 解析:

本题乍一看好像要用回溯法将子集分开分别求解,但是回溯法最后会超时,所以我们不考虑了,那么我们如何把这个问题转换成一个简单的01背包的问题呢?

首先我们看,本题是要将数组分割成两个子集,要求两个子集的元素和相等,那么就等价于我们要找出一个子集,它的元素的和等于nums数组里面所有元素和的一半。

令nums所有元素和为sum,那么target = sum / 2, 这样问题就变成了一个容量为 target 的背包,nums里面的元素是物品,我们要返回 “当背包容量为target的时候,物品的和是不是刚好等于 target ”,这就是一个纯01背包问题。

那么我们就开始五步走吧:

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

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

本题中每一个元素的数值既是重量,也是价值。

套到本题,dp[j]表示 背包总容量(所能装的总重量)是j,放进物品后,背的最大重量为dp[j]

那么如果背包容量为target, dp[target]就是装满 背包之后的重量,所以 当 dp[target] == target 的时候,背包就装满了。

那还有装不满的时候吗?当然。

拿输入数组 [1, 5, 11, 5],举例, dp[7] 只能等于 6,因为 只能放进 1 和 5。

而dp[6] 就可以等于6了,放进1 和 5,那么dp[6] == 6,说明背包装满了。

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数组如何初始化

在01背包,一维dp如何初始化,已经讲过,

从dp[j]的定义来看,首先dp[0]一定是0。

如果题目给的价值都是正整数那么非0下标都初始化为0就可以了,如果题目给的价值有负数,那么非0下标就要初始化为负无穷。

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

本题题目中 只包含正整数的非空数组,所以非0下标的元素初始化为0就可以了。

4. 确定遍历顺序

上文讲过,如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!

5. 举例推导dp数组

dp[j]的数值一定是小于等于j的。

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

c++代码如下:

class Solution {
public:
    bool canPartition(vector<int>& nums) {
        //创建dp数组
        // 题目中说:每个数组中的元素不会超过 100,数组的大小不会超过 200
        // 总和不会大于20000,背包最大只需要其中一半,所以10001大小就可以了
        vector<int> dp(10001,0);
        int sum = 0;

        //对nums元素求和
        // 也可以使用库函数一步求和
        // int sum = accumulate(nums.begin(), nums.end(), 0);
        for(int i = 0; i < nums.size(); i++) sum += nums[i];

        //如果nums是奇数,那么就肯定不能分成两个相等的部分
        if(sum % 2 == 1) return false;

        int target = sum / 2;
        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]);
            }
        }
        //如果最后能取到target就返回true
        return dp[target] == target;
    } 
};

3.洗衣房问题 

这题是我们学校oj上面的题目,但是很有参考价值,解法多样,私聊我可以给你发word文档

 

解析: 

这题咱们定睛一看,诶,有点眼熟,不确定再看一眼,oi! 

这不就和上题差不多吗?

题目中我们要将学生分为两组,这不就是将每个元素分为两部分,然后找出两个子集的和相差最小的情况,再判断两个小组中最大的那个是否小于洗衣房开放时间m; 转换成01背包问题,就是一个容量为m的背包,然后令target = sum / 2,找出当背包容量为target的时候可以容纳的最多物品,然后使用 dp[target] 和 sum - dp[target]最大的那个与m比较。

分析完成我们直接上五步走:

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

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

在本题中,dp[j]表示洗衣房开放时间为j时,学生使用其中一个洗衣机的最大时间。

2. 确定递推公式

和上题一样,我们的递推公式为  dp[j] = max(dp[j], dp[j - weight[i]] + weight[i]);

其中weight[i]表示第i个学生需要的洗衣时间,那么weight[j - weight[i]] + weight[j]就是第i个学生洗衣的情况,更新dp[j]。

3. dp数组如何初始化

从dp数组的定义看,dp[0]肯定是0;然后题目中各项数据都是正整数,且我们dp[j]取最大值,那么dp数组里面的其他位置也都可以初始化为0.

4. 确定遍历顺序

上文讲过,如果使用一维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒序遍历!

5. 举例推导dp数组

dp[j]的数值一定是小于等于j的。

如果dp[j] == j 说明,集合中的子集总和正好可以凑成总和j。这个时候两个洗衣机使用的时间是相等的。

c++代码如下:

#include<bits/stdc++.h>
using namespace std;

void make() {
    //输入数据
	int n,m;
	cin >> n >> m;

    //weight表示每个学生洗衣的用时,全部初始化为0
    //初始化的大小是我选取的一个较大的数,因为学校不提供测试数据
	int weight[10005] = {0};
		
	int sum = 0;
	for(int i = 1; i <= n; i++) cin >> weight[i];
    //将每个学生洗衣时间加起来
	for(int i = 1; i <= n; i++) sum += weight[i];

	//为了找到两个分组差值最小的情况
	int target = sum / 2;
    //如果连sum / 2都比洗衣房最大开放时间m大的话,就直接输出0
	if(target > m) {
		cout << 0 << '\n';
		return;
	}
	
	int dp[10005] = {0};
	for(int i = 1; i <= n; i++) {
		for(int j = target; j >= weight[i]; j--) {
            //dp数组更新
			dp[j] = max(dp[j], dp[j - weight[i]] + weight[i]);
		}
	}
		

    //选择dp[target], sum - dp[target]与m比大小
    //如果比m大就说明在m时间内不能完成洗衣
    //否则输出1表示可以
	if(max(dp[target], sum - dp[target]) > m){
		cout << 0 << '\n';
	}
	else cout << 1 << '\n';

}

int main() {
    //关闭同步流
	ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
	//t表示测试用例的数量
	int t;
	cin >> t;
	while(t--) {
        //调用函数
		make();
	}
	
	return 0;
	
}

 


 二.完全背包

1.什么是完全背包?

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

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件

2.如何解决完全背包问题?

同样,我用下面这个例子来讲解一下:

 

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

    }
}

dp状态图如下:

 

 但是请同学们思考,为什么两个for循环,遍历物品在外,遍历背包在内呢?能不能换位置呢?

这里我就直接放代码随想录原文,可以说是讲得非常详细且到位:

这个问题很多题解关于这里都是轻描淡写就略过了,大家都默认 遍历物品在外层,遍历背包容量在内层,好像本应该如此一样,那么为什么呢?

难道就不能遍历背包容量在外层,遍历物品在内层?

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

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

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

遍历物品在外层循环,遍历背包容量在内层循环,状态如图:

遍历背包容量在外层循环,遍历物品在内层循环,状态如图:

 

看了这两个图,大家就会理解,完全背包中,两个for循环的先后循序,都不影响计算dp[j]所需要的值(这个值就是下标j之前所对应的dp[j])。

全文我说的都是对于纯完全背包问题,其for循环的先后循环是可以颠倒的!

但如果题目稍稍有点变化,就会体现在遍历顺序上。

如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了,而leetcode上的题目都是这种稍有变化的类型。

这个区别,我将在后面讲解具体leetcode题目中给大家介绍,因为这块如果不结合具题目,单纯的介绍原理估计很多同学会越看越懵!

 3.完全背包的例题与解析

1.零钱兑换II

~~传送门在这里~~

解析:

这是一道典型的背包问题,一看到钱币数量不限,就知道这是一个完全背包。 

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

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

例如示例一:

5 = 2 + 2 + 1

5 = 2 + 1 + 2

这是一种组合,都是 2 2 1。

如果问的是排列数,那么上面就是两种排列了。

组合不强调元素之间的顺序,排列强调元素之间的顺序。 其实这一点我们在讲解回溯算法专题的时候就讲过了。

回归本题,动规五步走来分析如下:

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

dp[j]:凑成总金额j的货币组合数为dp[j]

2. 确定递推公式

dp[j] 就是所有的dp[j - coins[i]](考虑coins[i]的情况)相加。

所以递推公式:dp[j] += dp[j - coins[i]];这个递推公式大家应该不陌生了,我在讲解01背包题目的时候就讲解了,求装满背包有几种方法,公式都是:dp[j] += dp[j - nums[i]];

3.dp数组如何初始化

首先dp[0]一定要为1,dp[0] = 1是 递归公式的基础。如果dp[0] = 0 的话,后面所有推导出来的值都是0了。

那么 dp[0] = 1 有没有含义,其实既可以说 凑成总金额0的货币组合数为1,也可以说 凑成总金额0的货币组合数为0,好像都没有毛病。

但题目描述中,也没明确说 amount = 0 的情况,结果应该是多少。

这里我认为题目描述还是要说明一下,因为后台测试数据是默认,amount = 0 的情况,组合数为1的。

下标非0的dp[j]初始化为0,这样累计加dp[j - coins[i]]的时候才不会影响真正的dp[j]

dp[0]=1还说明了一种情况:如果正好选了coins[i]后,也就是j-coins[i] == 0的情况表示这个硬币刚好能选,此时dp[0]为1表示只选coins[i]存在这样的一种选法。

4.确定遍历顺序

本题中我们是外层for循环遍历物品(钱币),内层for遍历背包(金钱总额),还是外层for遍历背包(金钱总额),内层for循环遍历物品(钱币)呢?

完全背包的两个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]里算出来的就是排列数!

可能这里很多同学还不是很理解,那我这里给大家展示一下:

这是外层是背包内层是硬币的:

可以看到 j = 3 的时候dp数组的数量就多了一个,为什么?

这就是因为先遍历背包再遍历硬币求的是排列数,那么dp[3]就会把 {1,  2} 和 {2, 1}都算作一次

这个部分很抽象需要同学们自己在草稿本上手动模拟打印dp数组(如果你有更好的解释办法欢迎跟我交流意见)

5.举例推导dp数组

输入: amount = 5, coins = [1, 2, 5] ,dp状态图如下:

以上分析完毕,C++代码如下:

class Solution {
public:
    int change(int amount, vector<int>& coins) {
        vector<int> dp(amount + 1, 0);
        dp[0] = 1;

        for(int i = 0; i < coins.size(); i++){
            for(int j = coins[i]; j <= amount; j++) {
                dp[j] += dp[j - coins[i]];
            }
        }
        return dp[amount];
    }
};

 

2.组合总和IV

~~传送门在这里~~

解析: 

本题题目描述说是求组合,但又说是可以元素相同顺序不同的组合算两个组合,其实就是求排列!

弄清什么是组合,什么是排列很重要。

组合不强调顺序,(1,5)和(5,1)是同一个组合。

排列强调顺序,(1,5)和(5,1)是两个不同的排列。

大家在学习回溯算法专题的时候,应该做过这两道题目组合总和组合总和II,会感觉这两题和本题很像!

但其本质是本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。

如果本题要把排列都列出来的话,只能使用回溯算法爆搜

动规五部曲分析如下:

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

dp[i]: 凑成目标正整数为i的排列个数为dp[i]

2. 确定递推公式

dp[i](考虑nums[j])可以由 dp[i - nums[j]](不考虑nums[j]) 推导出来。

因为只要得到nums[j],排列个数dp[i - nums[j]],就是dp[i]的一部分。

求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];

3. dp数组如何初始化

因为递推公式dp[i] += dp[i - nums[j]]的缘故,dp[0]要初始化为1,这样递归其他dp[i]的时候才会有数值基础

至于dp[0] = 1 有没有意义呢?

其实没有意义,所以我也不去强行解释它的意义了,因为题目中也说了:给定目标值是正整数! 所以dp[0] = 1是没有意义的,仅仅是为了推导递推公式。

至于非0下标的dp[i]应该初始为多少呢?

初始化为0,这样才不会影响dp[i]累加所有的dp[i - nums[j]]。

4.确定遍历顺序

个数可以不限使用,说明这是一个完全背包。

得到的集合是排列,说明需要考虑元素之间的顺序。

本题要求的是排列,那么这个for循环嵌套的顺序可以有说法了。

在上题中就已经讲过了。

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

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

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

所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历

5. 举例来推导dp数组

c++代码如下:

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

 


文章总结

本文详细地讲解了动态规划背包问题中的两类——01背包和完全背包

因为是动态规划题目,所以我们动态规划五步走始终贯彻所有题目,其中第一点确定dp数组以及下标的含义最为重要,因为只有搞明白题目的本质,将其类比转换为我们熟悉的背包,才能往下继续解题

01背包和完全背包的两个重点就是递推公式和遍历顺序;解决递推公式的办法就是理解dp[j]的本质,找出项与项之间的关系,而便利顺序记住两点:

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

然后就是多重背包问题,这个问题出难题也难,出简单题也简单,并且也不是面试考察重点,所以我们在这里就不多赘述了。

关于这几种常见的背包,其关系如下:

 然后就是卡尔哥给大伙准备的题目:(全体起立!)

个人总结

 终于写完第二篇文章了,动态规划真是耗费头发。

关于我为什么把语言又换成了c++是因为照顾到萌新同学,如果你学习了Java那你也应该学习过c++

也是因为最近都在期中期末备考大学各种麻烦的事情一大堆,所以小掰的更新有点慢,但是小掰绝对会保证文章质量和题目质量,所以希望各位大佬给小掰点点关注,点点赞,让小掰更有动力!!

最后感谢卡尔哥的代码随想录,感谢各位对小掰的支持,我们下篇见!

  • 12
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小掰是苣蒻

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

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

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

打赏作者

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

抵扣说明:

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

余额充值