动态规划算法:完全背包类问题

前言

现在我们考虑下面的问题:

(1)小明有一个背包,背包容积为v,有m种物品,其中第i种物品的价值为val[i],体积为t[i]每样物品有无限个,请问背包内物品总价值最大为多少?

(2)小明有若干面值的硬币nums,小明需要买一个物品需要m元,小明想知道自己的硬币能否刚好凑够m元,如果可以,那么需要的最少硬币数量是多少?假设每种面值的硬币数量不做限制

分析这些问题我们发现,后两个问题仅需要一个结果,一个数字,因此可以使用动态规划来解决。而这两个问题与上一篇文章中讲的问题的区别就在于,每种物品可以放入无限次。这种问题一般叫做完全背包问题,暨每个物品可以选择无数次。

还记得之前我在回溯算法的子集问题文章中描述过这样一个问题:

给你一个无重复元素的整数数组candidates和一个目标整数target,找出candidates中可以使数字和为目标数target的所有不同组合 ,并以列表形式返回。你可以按任意顺序返回这些组合。candidates中的同一个数字可以无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。(文章链接:回溯算法(1):子集问题

可以看到这些子集问题具有高度相似性,每个回溯的题型都对应了一个动态规划的案例,对于使用回溯方法解决这个问题我已经讲过了,今天我们要解决的是使用动态规划来解决这些问题。注意,使用动态规划方法解决就会损失具体信息,选择方案时一定要看一下题目要求

没看过我上一篇文章的请移步:动态规划算法:01背包问题(子集问题)

问题分析

这里我们分析问题(1):

小明有一个背包,背包容积为v,有m种物品,其中第i种物品的价值为val[i],体积为t[i]每样物品有无限个,请问背包内物品总价值最大为多少?

dp数组的定义

首先确定dp数组的定义,与上一篇文章一致,
dp[i][j]:在0i种物品中选取若干物品放入容积为j的背包可能获得的最大价值。

dp数组的状态转移

不同之处主要在dp[i][j]的状态转移过程中,还是两种情况:

(1)当j<t[i]时,背包无法放入第i种物品,因此能取得的最大价值与前i-1种能取得的最大价值相同,暨dp[i][j] = dp[i-1][j]

(2)当j≥t[i]时,可以放入第i种物品,当我们不放入第i种物品,则dp[i][j] = dp[i-1][j]。当我们放入一个i种物品时,(注意,差别就在这里!!! ),背包剩余容量为j-t[i],我们能获得的价值是:dp[i][j] = dp[i][j-t[i]] + val[i]

迟钝的同学还没发现问题,这里用伪代码我们横向对比一下:
01背包(上一节案例):

cpp 复制代码
if(j >= t[i]) dp[i][j] = max(dp[i-1][j], val[i] + dp[i-1][j-t[i]]);

完全背包(本节案例)

cpp 复制代码
if(j >= t[i]) dp[i][j] = max(dp[i-1][j], val[i] + dp[i][j-t[i]]);

差别体现在容积缩小后的处理,由于在题目中我们得知,我们可以放入任意数量的同种物品,因此背包容积缩小后,内部仍然可能有第i种物品。默念一下定义:
dp[i-1][j-t[i]]:在0i-1种物品中选取若干物品放入容积为j-t[i]的背包可能获得的最大价值。

因此,如果我们使用dp[i-1][j-t[i]]是不正确的,因为这相当于我们背包中第i种物品只有一个。

我们应该使用的是:dp[i][j] = max(dp[i-1][j], val[i] + dp[i][j-t[i]])

dp数组的初始化

初始化的过程是最能体现出01背包与完全背包差异性的一个步骤,直接上代码展示:

c 复制代码
for(int i = 0; i<=v; i++) {
	dp[0][i] = ((int)(i/t[0]))*val[0];
}

与之前不同之处在于,现在只要背包还放得下,我么就要将第0个物品放进去,没有次数限制。

完整代码:

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

int main() {
	int v, m;
	cin >> m >> v;
	vector<int> t(m, 0);
	vector<int> val(m, 0);
	for(int i = 0; i<m; i++) cin >> t[i] >> val[i]; 

	vector<vector<int>> dp(m, vector<int>(v+1, 0));
	for(int j = 0; j <= v; i++) {
		dp[0][j] = (j/t[0])*val[0];
	}
	
	for(int i = 1; i < m; i++) {
		for(int j = 0; j <= v; j++) {
			if( j < t[i] ) dp[i][j] = dp[i-1][j];
			else
				dp[i][j] = max(dp[i][j-t[i]] + val[i], dp[i-1][j]);
		}
	}
	
	cout << dp.back().back() << endl;
	return 0;
}

维度压缩

同理我们也可以用一个维度来代替两个维度,与上一篇文章类似,但是有一点点细微的差别,我们先上代码,再来看差别。

完整代码(维度压缩后)

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

int main() {
	int v, m;
	cin >> m >> v;
	vector<int> t(m, 0);
	vector<int> val(m, 0);
	for(int i = 0; i < m; i++) cin >> t[i] >> val[i];

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

	for(int i = 0; i < m; i++) {
		for(int j = 0; j <= v; j++) {
			if(j >= t[i]) dp[j] = max(dp[j], dp[j-t[i]] + val[i]); 
		}
	}
	
	cout << dp.back() <<endl;
	return 0;
}

维度压缩的思想是一样的,把第一个维度消掉,但是在for循环内部遍历是顺序与之前发生了改变,我们来对比一下:
01背包(上一节案例)

c 复制代码
	for(int i = 0; i < m; i++) {
		for(int j = v; j >= 0; j--) {
			if(j < t[i]) dp[j] = dp[j];		// 这一行其实没有用,为了让大家更加清晰
			else dp[j] = max(dp[j], dp[j-t[i]] + val[i]);
		}
	}

完全背包(本节案例)

c 复制代码
	for(int i = 0; i < m; i++) {
		for(int j = 0; j <= v; j++) {
			if(j >= t[i]) dp[j] = max(dp[j], dp[j-t[i]] + val[i]); 
		}
	}

在上一节中我们使用了从后向前遍历避免数据污染,而这节课我们使用了从前向后遍历,实现了题目的要求,为什么会这样呢?,我们从状态转移图来看:

对于01背包,推导dp[j]的源数据应该是来自上一行的,不会被改变的数据。而如果从小到大遍历,在维度压缩后,数据一定会被改变,因为可以存储数据的维度消失了,所有的数据都在当前维度上。

对于完全背包,可以看到,在未压缩维度时,我的数据就是来自于同一行的,已经被修改过的数据,因此压缩维度后不会参数任何影响。推导方向决定了我必须先更新数据再进行计算。

补充

对于前言中提到的问题(2),如果大家认真看了上一篇文章以及这一篇文章,相信已经没有任何难度了,子要改变一下dp[i][j]的定义以及dp[i][j]的取值方法即可。因此我在这里也不做过多讲述。

小结

在这两篇文章里我们详细讲述了如何使用动态规划方法来解决子集类的问题。在阉割掉最终结果包含信息量的前提下可以快速计算出想要的答案。

这里问题基于子集的不同性质,可能演化出不同的变种,但是实质上都是一样的,无非是求(1)子集内元素的和、(2)子集内元素的数量、(3)满足条件的子集个数。大家遇到问题不要慌,可能做一个脑筋急转弯,分清是哪一种情况,之后代码怎么写自然就清楚了。

举个例子力扣上的例子:

目标和-力扣

给你一个非负整数数组 nums 和一个整数 target 。向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1"

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

看到这道题,题目要求返回的是数目,不是具体表达式,因此条件反射的要使用动态规划。那么这道题和我们的子集问题有联系吗?乍一看没有,实际上我们分析一下。

表达式中前面是加号的数字实际上就是正数,前面是减号的数字实际上就是负数。假设数组元素总和为sum,前面为加号的元素的和为t,那剩余的就是前面是减号的数字,他们的总和就是(sum-t)

要求表达式的和为target,暨t-(sum-t) = target,因此t = (target + sum)/2

换句话说,这道题描述的就是我刚刚说的(3)求满足条件的子集个数。就是让你在给定的元素中寻找一些子集,另这些子集的元素的和为 (target + sum)/2,返回符合条件的子集个数罢了。

这就是我想表达的,要从看似毫无关联的题干中找到问题的本质。这种能力需要锻炼,希望大家早日具有这样的能力!

相关推荐
好易学数据结构8 分钟前
可视化图解算法:按之字形顺序打印二叉树( Z字形、锯齿形遍历)
数据结构·算法·leetcode·面试·二叉树·力扣·笔试·遍历·二叉树遍历·牛客网·层序遍历·z·z字形遍历·锯齿形遍历
慕容青峰15 分钟前
【第十六届 蓝桥杯 省 C/Python A/Java C 登山】题解
c语言·c++·python·算法·蓝桥杯·sublime text
enyp8015 分钟前
C++抽象基类定义与使用
开发语言·c++
superior tigre21 分钟前
C++学习:六个月从基础到就业——C++学习之旅:STL容器详解
c++·学习
硬匠的博客1 小时前
C++IO流
c++
时光の尘1 小时前
FreeRTOS菜鸟入门(六)·移植FreeRTOS到STM32
c语言·数据结构·stm32·单片机·嵌入式硬件·嵌入式
大学生亨亨1 小时前
蓝桥杯之递归二
java·数据结构·笔记·算法
天天扭码1 小时前
一分钟解决 | 高频面试算法题——滑动窗口最大值(单调队列)
前端·算法·面试
tan77º2 小时前
【算法】BFS-解决FloodFill问题
算法·leetcode·宽度优先
知识烤冷面2 小时前
【力扣刷题实战】找到字符串中所有字母异位词
数据结构·算法·leetcode