目录
[2. 分隔等和子集](#2. 分隔等和子集)
[3. 目标和](#3. 目标和)
[4. 最后一块石头的重量Ⅱ](#4. 最后一块石头的重量Ⅱ)
一、什么是背包问题?
给一个情景:
假如你现在有一个背包,地上有一堆物品,你可以挑选一些放入你的背包中。但你背包的空间大小是有限的,而不同的物品又有各自的不同体积和价值。
问:背包在有限的空间内选择的物品的最大价值是多少?
这种类型的问题就是背包问题,而背包问题又可以分为几类
根据物品存在的数量又可以分为3类:
-
01背包问题,即每个物品只有一件,你可以选择拿(1)或不拿(0)
-
完全背包问题,即每个物品有无数件,你可以重复拿同一物品
-
多重背包问题,即每个物品有n件,每个物品的n不相同
另外,对背包的限制也能分为两类,一类是物品体积之和不超过背包的空间即可,还有一类是要求物品体积之和正好等于背包空间
今天我们要学习的就是01背包问题
二、例题
1.【模板】01背包问题

先看第一问:
状态表示:
首先根据题目要求和经验,我们初步可以定义dp[ i ]表示从前 i 个物品中选,所有选法中能选出来的最大价值。但当我们去推导状态时发现,从这个状态表示我们不知道背包此时的剩余空间状况,所以定义一维的dp表是不够的。
所以我们定义dp[ i ][ j ]表示,从前 i 个物品中挑选,总体积不超过 j 时,所有选法中能选出来的最大价值。
状态转移方程:
推导状态转移方程就涉及到两种情况,一种是没有选择第 i 个物品,此时直接等于dp[ i - 1][ j ]即可。另一种是选择了第 i 个物品,此时需要加上 i 物品的价值,并找到dp[ i - 1 ][ j - v[ i ] ],另外,还要考虑 j - v[ i ]是否小于0,小于0会越界访问。
综上,状态转移方程为:
如果 j >= v[i],则dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i]);
否则就 dp[i][j] = dp[i-1][j];
初始化:
开辟空间时可以多出一行一列,第1个物品对应下标1,方便操作,初始化都初始化成0即可。
第二问:
第二问与第一问的区间就是要恰好装满背包,所以状态表示为:
定义dp[ i ][ j ]表示,从前 i 个物品中挑选,总体积正好等于 j 时,所有选法中能选出来的最大价值
状态转移方程变为:
如果 j >= v[i] && dp[i-1][j-v[i]] != -1**,则dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i]);**
否则就 dp[i][j] = dp[i-1][j];
初始化时,由于要求正好装满,所以第一列除了第一个外都要初始化为-1(约定如果不存在刚好装满的情况就赋值-1)

示例代码:
cpp
#include <iostream>
using namespace std;
//n的最大规模是1000,所以直接定义一个最大值,便于操作
const int N = 1001;
int n, V;// 物品数量与容量
int v[N], w[N];// 物品的体积与价值
int dp[N][N];
int main() {
cin>>n>>V;
// 浪费一个空间来达成条件对称,第1个物品对应下标1
for(int i = 1; i <= n; i++){
cin>>v[i];
cin>>w[i];
}
// 第一问
// 默认都是0,无需初始化
for(int i = 1; i <= n; i++){
for(int j = 1; j <= V; j++){
if(j >= v[i]){
dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i]);
}
else{
dp[i][j] = dp[i-1][j];
}
}
}
cout<<dp[n][V]<<endl;
// 第二问,复用一下第一问的dp表
// 初始化,第一行除了第一个都初始化为-1
for(int j = 1; j <= V; j++){
dp[0][j] = -1;
}
for(int i = 1; i <= n; i++){
for(int j = 1; j <= V; j++){
if(j >= v[i] && dp[i-1][j-v[i]] != -1){
dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i]);
}
else{
dp[i][j] = dp[i-1][j];
}
}
}
cout<<(dp[n][V] != -1 ? dp[n][V] : 0)<<endl;
return 0;
}
代码题为了方面,我们直接建1001大小的数组
第二问输出时要处理结构为-1的情况,使其输出0
利用滚动数组进行优化
由上题的状态转移方程可知,求dp[ i ][ j ]时只用到了dp[ i - 1 ]这一列,所以之前的数据都可以舍弃掉。又当 j >= v[i]时,dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i]),发现dp[i][j]的值和dp[i-1][j-v[i]]的值有关,是将前面的值赋值给后面,所以我们改变一下策略,只用一个数组,并改为从后往前遍历,这样就能刚好能用到i-1行的值。
可以拿出状态转移遍历的一段代码,看一下前后代码的区别:
修改前
cppfor(int i = 1; i <= n; i++){ for(int j = 1; j <= V; j++){ if(j >= v[i]){ dp[i][j] = max(dp[i-1][j], dp[i-1][j-v[i]] + w[i]); } else{ dp[i][j] = dp[i-1][j]; } } }修改后
cppfor(int i = 1; i <= n; i++){ for(int j = V; j >= v[i]; j--){ dp[j] = max(dp[j], dp[j-v[i]] + w[i]); } }
优化后完整代码:
cpp
#include <iostream>
using namespace std;
//n的最大规模是1000,所以直接定义一个最大值,便于操作
const int N = 1001;
int n, V;// 物品数量与容量
int v[N], w[N];// 物品的体积与价值
int dp[N];
int main() {
cin>>n>>V;
// 浪费一个空间来达成条件对称,第1个物品对应下标1
for(int i = 1; i <= n; i++){
cin>>v[i];
cin>>w[i];
}
// 第一问
// 默认都是0,无需初始化
for(int i = 1; i <= n; i++){
for(int j = V; j >= v[i]; j--){
dp[j] = max(dp[j], dp[j-v[i]] + w[i]);
}
}
cout<<dp[V]<<endl;
// 第二问,复用一下第一问的dp表
// 初始化,第一行除了第一个都初始化为-1
for(int j = 1; j <= V; j++){
dp[j] = -1;
}
for(int i = 1; i <= n; i++){
for(int j = V; j >= v[i]; j--){
if(dp[j-v[i]] != -1){
dp[j] = max(dp[j], dp[j-v[i]] + w[i]);
}
}
}
cout<<(dp[V] != -1 ? dp[V] : 0)<<endl;
return 0;
}
2. 分隔等和子集

这道题初看感觉和背包问题没什么关系,但不急,我们分析看看。题目要求将数组分隔成两个子集,并且两个子集的元素和相等,那也就相当于是在数组中选出若干个元素,使得选出的元素和等于总元素和的一半。这就类似于背包问题中选择若干个物品,使其体积正好等于sum/2,并且还比上一道模板背包问题少了价值最大这一条件。
所以,状态表示可以为,dp[i][j]表示,在前 i 个元素中,是否存在一种选法,使其元素和等于 j ,存在则值为1,不存在则值为0
状态转移方程要分两种情况,一种是没选第 i 个元素,此时与前一个状态有关,dp[i][j] = dp[i-1][j], 第二种是选择了第 i 个元素,此时要加上当前元素的值,相当于去找元素和等于sum/2 - 当前元素值的情况存不存在,所以dp[i][j] = dp[i-1][j-nums[i-1]](因为初始化多开了一行一列,所以与nums数组对应时要-1)
综上,状态转移方程为:**dp[i][j] = (dp[i-1][j] || dp[i-1][j-nums[i-1]]),**两种情况取或||
初始化可以多创建一行一列,第一列是表示元素和等于0,只要不选就行,所以初始化为true
示例代码:
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for(auto e : nums){
sum += e;
}
// 如果元素和不能等分就直接返回false
if(sum%2 == 1){
return false;
}
int target = sum/2;
vector<vector<bool>> dp(n+1, vector<bool>(target+1));
//初始化
for(int i = 0; i <= n; i++){
dp[i][0] = true;
}
for(int i = 1; i <= n; i++){
for(int j = 1; j <= target; j++){
if(j >= nums[i-1]){
dp[i][j] = (dp[i-1][j] || dp[i-1][j-nums[i-1]]);
}
else{
dp[i][j] = dp[i-1][j];
}
}
}
return dp[n][target];
}
};
使用滚动数组优化方案:
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
int sum = 0;
for(auto e : nums){
sum += e;
}
// 如果元素和不能等分就直接返回false
if(sum%2 == 1){
return false;
}
int target = sum/2;
vector<bool> dp(target+1);
//初始化
dp[0] = true;
for(int i = 1; i <= n; i++){
for(int j = target; j >= nums[i-1]; j--){
dp[j] = (dp[j] || dp[j-nums[i-1]]);
}
}
return dp[target];
}
};
3. 目标和

这道题也是,需要分析一下才能转化为01背包问题,将数组中的数据添加正负,然后串联后得到target,也就是说数组中一部分数据为正,一部分数据为负,假定设为正的数据和为a,设为负的数据和为b,所以a - b = target,又a + b = sum,所以可以推出a = (target + sum)/2。所以题目转化为在数组中选出若干数据,使其相加等于(target + sum)/2
状态表示:dp[i][j]表示为前 i 个数据选择数据,数据之和恰好为 j 的选法数目
状态转移方程:
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]](有选/不选第i个元素两种情况,选法数相加)
示例代码:
cpp
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
int sum = 0;
for(auto e : nums){
sum += e;
}
int aim = (sum + target)/2;
if(aim < 0 || (sum + target)%2){
return 0;
}
vector<vector<int>> dp(n+1, vector<int>(aim+1));
for(int i = 0; i <= n; i++){
dp[i][0] = 1;
}
for(int i = 1; i <= n; i++){
for(int j = 0; j <= aim; 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[n][aim];
}
};
滚动数组优化后代码:
cpp
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
int sum = 0;
for(auto e : nums){
sum += e;
}
int aim = (sum + target)/2;
if(aim < 0 || (sum + target)%2){
return 0;
}
vector<int> dp(aim+1);
dp[0] = 1;
for(int i = 1; i <= n; i++){
for(int j = aim; j >= nums[i-1]; j--){
dp[j] += dp[j-nums[i-1]];
}
}
return dp[aim];
}
};
4. 最后一块石头的重量Ⅱ

分析:
要将石头粉碎,最后得到一块最小的石头,本质还是一部分设为正数,一部分设为负数,并且让正数和负数的绝对值尽可能地接近,此时就能得到最小值。那么我们可以先求出整个数组的和sum,然后从数组中选出部分数据,使数据和尽可能接近sum/2
状态表示:dp[i][j]表示从前 i 个数据中选,数据和不超过 j 时,数据和的最大值
状态转移方程要分选/不选第 i 个数据,两种情况取最大值
dp[i][j] = max(dp[i-1][j],dp[i-1][j-stones[i]]+stones[i-1] )(前提是j>stones[i],否则就直接等于dp[i-1][j])
初始化默认都是0,无需初始化
示例代码:
cpp
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for(auto e : stones){
sum += e;
}
int target = sum/2;
vector<vector<int>> dp(n+1, vector<int>(target+1));
for(int i = 1; i <= n; i++){
for(int j = 1; j <= target; j++){
dp[i][j] = dp[i-1][j];
if(j >= stones[i-1] ){
dp[i][j] = max(dp[i-1][j], dp[i-1][j-stones[i-1]]+stones[i-1]);
}
}
}
return sum - 2*dp[n][target];
}
};
注意返回值有所不同
滚动数组优化后代码:
cpp
class Solution {
public:
int lastStoneWeightII(vector<int>& stones) {
int n = stones.size();
int sum = 0;
for(auto e : stones){
sum += e;
}
int target = sum/2;
vector<int> dp(target+1);
for(int i = 1; i <= n; i++){
for(int j = target; j >= stones[i-1]; j--){
dp[j] = max(dp[j], dp[j-stones[i-1]]+stones[i-1]);
}
}
return sum - 2*dp[target];
}
};
总结:
本文介绍了背包问题并重点学习了01背包问题。第一道例题是模板题,通过这道题我们可以了解01背包问题的解决问题的通用方法。
后续几道题都是衍生出来的例题,需要先进行一定的转化才能变为01背包问题。关于如何转化,我们只需抓住01背包问题最核心的点,即在一组数据中选or不选第 i 个数据,然后能得到我们想要的成果
以上便是本文的所有内容了,如果觉得有帮助的话可以点赞收藏加关注支持一下!
