0-1 背包问题
背包问题的细致分析
第一步要明确两点,**状态 ** 和 选择 。
先说状态,如何才能描述一个问题局面?只要给几个物品和一个背包容量,就形成了背包问题。所以说状态有两个,一个是 可选择的物品 ,另一个是 背包的容量。
再说选择,这个也很容易想到,每一个物品的选择是什么呢,无非是 选择这个物品(装进背包) 和 不选择这个物品(不装进背包)
- 初步框架
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 择优(选择1,选择2,....)
第二步明确 dp
数组定义,首先看看刚刚找到的状态,状态有两个,也就是说dp
数组是二维的。
dp[i][w]
的定义如下: 对于前i个物品,当前背包的容量为w,这种情况下可装的最大价值为 dp[i][w]
。比如说dp[3][5]
的值为6,表示对于前3个物品,背包的容量为5,可装下的最大价值为6。
根据这个定义,最终我们要的结果就是dp[N][W]
,
base case: 当没有物品或者背包没有空间时,可装价值为0, dp[0][...] = dp[...][0] = 0
。
- 细化上面的框架
//需要注意dp数组大小
int dp[N+1][W+1];
dp[0][...] = 0;
dp[...][0] = 0;
for i in [1...N]:
for w in [1....W]:
dp[i][w] = max(
第i个物品放入背包,
第i个物品不放入背包
)
第三步,根据 选择,思考 状态转移 的逻辑
简单来说,就是第i个物品放入背包和第i个物品不放入背包,怎么体现出来呢?
由dp
数组定义可轻松想到,如果第i个物品没有放入背包,则dp[i][w] = dp[i-1][w]
,如果第i个物品放入了背包,则dp[i][w] = val[i-1] + dp[i-1][w - W[i-1]]
。
- 状态方程得出,进一步细化代码
for i in [1...N]
for w in [1...W]:
dp[i][w] = max(
dp[i-1][w],
val[i-1] + dp[i-1][w-W[i-1]]
)
最后一步,将伪码翻译成代码,再处理一些边界情况
之前我们只是简单考虑第i个物品放与不放入背包中,忽略了 在前 i-1 个物品分析好了的情况下,第 i 个物品背包装不装的下的情况。
这里我们利用 wt[] 数组记录考虑前 i 个物品时,背包此刻已经装了多少容量的物品。
- 最终代码
#include <iostream>
const int SIZE = 1010; //比测试点要求的最大空间再大一些即可
int N,V; //记录物品个数和背包最大容量
int v[SIZE+1],w[SIZE+1]; //记录每个物品体积和价值
int main(){
std::cin >> N >> V;
for(int i = 0;i<N;i++){
std::cin >> v[i] >> w[i]; //空格,Tab,换行 都可中断cin输入
}
//dp数组定义
int dp[N+1][V+1]; //dp[i][j]为前i个物品,背包容量为j,能装下的最大价值
//base case
for(int j=1;j<=V;j++){
dp[0][j] = 0;
}
for(int i=0;i<=N;i++){
dp[i][0] = 0;
}
//状态转移
for(int i=1;i<=N;i++){
for(int j=1;j<=V;j++){
if(j < v[i-1]){
dp[i][j] = dp[i-1][j];
}
else{
dp[i][j] = std::max(dp[i-1][j], dp[i-1][j-v[i-1]] + w[i-1]);
}
}
}
std::cout << dp[N][V];
return 0;
}
其他与背包问题有关的例题
LeetCode1235
规划兼职工作
你打算利用空闲时间来做兼职工作赚些零花钱。
这里有 n
份兼职工作,每份工作预计从 startTime[i]
开始到 endTime[i]
结束,报酬为 profit[i]
。
给你一份兼职工作表,包含开始时间 startTime
,结束时间 endTime
和预计报酬 profit
三个数组,请你计算并返回可以获得的最大报酬。
注意,时间上出现重叠的 2 份工作不能同时进行。如果你选择的工作在时间 X
结束,那么你可以立刻进行在时间 X
开始的下一份工作。
int jobScheduling(vector<int> &startTime, vector<int> &endTime, vector<int> &profit) {
//转换成0-1背包解决问题
//先对endTime,进行排序,保证这些事件的开始时间按升序排序
int n = startTime.size();
for (int i = 0; i < n - 1; i++) {
int min = endTime[i];
int min_index = i;
for (int j = i + 1; j < n; j++) {
if (endTime[j] < min) {
min = endTime[j];
min_index = j;
}
}
if (min_index != i) {
int t1 = startTime[i];
int t2 = endTime[i];
int t3 = profit[i];
startTime[i] = startTime[min_index];
endTime[i] = endTime[min_index];
profit[i] = profit[min_index];
startTime[min_index] = t1;
endTime[min_index] = t2;
profit[min_index] = t3;
}
}
//找出最晚结束时间与最早结束时间
int lateTime = endTime[0];
int Time1 = endTime[0];
for (int i = 1; i < n; i++) {
if (endTime[i] > lateTime) {
lateTime = endTime[i];
}
if (endTime[i] < Time1) {
Time1 = endTime[i];
}
}
//dp数组定义: dp[i][j]表示考虑前i个事件且时间最长为j的情况下所能获取的最大报酬
int dp[n + 1][lateTime + 1];
//base case: 不考虑任何事件,报酬自然为0
for (int j = 0; j <= lateTime; j++) {
dp[0][j] = 0;
}
//没有时间,则报酬为0
for (int j = Time1 - 1; j >= 0; j--) {
for (int i = 1; i <= n; i++) {
dp[i][j] = 0;
}
}
//状态转移:
for (int i = 1; i <= n; i++) {
for (int j = Time1; j <= lateTime; j++) {
if (endTime[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = std::max(dp[i - 1][j],
dp[i - 1][startTime[i - 1]] + profit[i - 1]);
}
}
}
return dp[n][lateTime];
}
这道题可以用0-1背包解决,但是其时间复杂度和空间复杂度都过高,不是一种高效的解法。读者可自行利用动态规划和二分查找写出简易解法,稍后我会在评论区给出简易高效解法。
子集背包问题
**子集背包问题 **沿用 **0-1背包问题 **的分析思路即可,我们通过下面一个题目来研究子集背包问题。
LeetCode416
分割等和子集
输入一个只包含正整数的非空数组 nums
,请你写一个算法,判断这个数组是否可以被分割成两个子集,使得两个子集的元素和相等。
比如说输入 nums = [1,5,11,5]
,算法返回 true,因为 nums
可以分割成 [1,5,5]
和 [11]
这两个子集。
如果说输入 nums = [1,3,2,5]
,算法返回 false,因为 nums
无论如何都不能分割成两个和相等的子集。
我们先回忆一下0-1背包问题的大致描述:
给你一个可装载重量为 W
的背包和 N
个物品,每个物品有重量和价值两个属性。其中第 i
个物品的重量为 wt[i]
,价值为 val[i]
,现在让你用这个背包装物品,最多能装的价值是多少?
那么对于这个问题,我们可以先对nums
求和,得出sum
,再转化为背包问题:
给一个可装载重量为 sum/2
的背包和 N
个物品,每个物品的重量为 nums[i]
。
分析到现在,解决方案已经豁然明了,唯一要注意的是dp
数组的base case
。按照背包问题的套路,可以给出如下定义:
dp[i][j] = x
表示,对于前 i
个物品(i
从 1 开始计数),当前背包的容量为 j
时,若 x
为 true
,则说明可以恰好将背包装满,若 x
为 false
,则说明不能恰好将背包装满。
比如说,如果 dp[4][9] = true
,其含义为:对于容量为 9 的背包,若只是在前 4 个物品中进行选择,可以有一种方法把背包恰好装满。
或者说对于本题,含义是对于给定的集合中,若只在前 4 个数字中进行选择,存在一个子集的和可以恰好凑出 9。
根据这个定义,我们想求的最终答案就是 dp[N][sum/2]
,base case 就是 dp[..][0] = true
和 dp[0][..] = false
,因为背包没有空间的时候,就相当于装满了,而当没有物品可选择的时候,肯定没办法装满背包。
bool canPartition(vector<int> &nums) {
int sum = 0;
for (int i = 0; i < nums.size(); i++) {
sum += nums[i];
}
if (sum % 2 == 1) return false;
//dp数组定义 dp[i][j]为考虑前i个数能否凑出j来,能为True,否为false
int size = nums.size();
int dp[size + 1][sum / 2 + 1];
//base case 背包没有空间时,相当于装满了
for (int i = 1; i <= size; i++) {
dp[i][0] = true;
}
//没有物品可供选择,自然不能装满
for (int j = 0; j <= sum / 2; j++) {
dp[0][j] = false;
}
//状态转移
for (int i = 1; i <= size; i++) {
for (int j = 1; j <= sum / 2; j++) {
if (j < nums[i - 1]) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i - 1]];
}
}
}
return dp[size][sum / 2];
}
利用空间压缩进一步优化
这里我不再过多赘述,参考之前的文章即可得出空间压缩后的代码。
完全背包问题
回顾一下0-1背包问题和子集背包问题:
- 0-1 背包问题讲的是,有N个物品,每个物品体积为
vi
,价值为wi
,有个大背包,体积为V,用这些物品去填充这个大背包,问大背包最多能装多少价值? - 子集背包问题就是0-1背包问题,只是这个大背包要变一变,这个大背包的V可能不是问题一开始就给出的V,而是它的一个子集。比如前面的分割等和子集的例子,我们设定大背包的V就是数组总和
Sum
的一半Sum/2
。
而完全背包问题其实讲的就是,每个物品有无数个,不必像之前0-1背包讲的那样,取走之后就没有了。
[!TIP]
背包问题的核心思想就是:
在有限的容纳空间里,怎么去获取最大资源?这个容纳空间可以是 时间,体积空间,个人所有财产(与金额有关的题)等等有限资源。
完全背包的 选择与状态 , dp
数组定义 与 简单0-1背包问题完全一致,也就是状态转移方程有些区别。
- 简单0-1背包问题的状态转移方程:
dp[i][j] = std::max(dp[i-1][j],dp[i-1][j-w[i-1]] + Value[i-1]);
dp[i-1][j]
表示不选择第i个物品,则dp[i[][j]
直接为在容量为j
的背包中装前i-1
个物品的最大价值。
dp[i-1][j-w[i-1]] + Value[i-1]
表示选择第i个物品,则dp[i][j]
为在容量为j-w[i-1]
的背包中选择前i-1
个物品的最大值加上第i
个物品的价值即可。
分析到这,我们发现,要想改写成完全背包问题(即物品有无数个),那就要体现出选择了第i个物品之后,还能再次选择第i个物品,所以选择第i个物品的状态转移方程我们改写为dp[i][j-w[i-1]] + Value[i-1]
。
我们通过LeetCode518
零钱兑换 这道题来详细分析一下完全背包问题
给你一个整数数组 coins
表示不同面额的硬币,另给一个整数 amount
表示总金额。请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0
。假设每一种面额的硬币有无限个。题目数据保证结果符合 32 位带符号整数
我们将这个题目转换成完全背包问题的描述形式:
有一个背包,其容量为amount
,有一系列物品 coins[]
,每个物品的重量为 coins[i]
,每个物品的数量无限,请问有多少种方法可以把背包填满。
解题思路:
- 状态与选择
很简单,状态 有两个,背包的容量 与 物品的数量 ,选择 就是 物品装进背包 与 物品不装进背包 。
dp
数组定义
状态有两个,所以我们考虑二维dp
数组,并定义dp[i][j]
为背包容量为j且只考虑前i个物品时的填充方法数。根据定义,我们可以得出base case
:
当背包容量为0时,什么都不做,不往背包里填东西就是唯一一种填法,即dp[...][0] = 1
;
当选择物品为0时,则一个物品都没有,这个时候都没东西能够用来填充背包,填法自然为0,即dp[0][1,...]
= 0 ;
- 状态转移方程
我们知道,选择只有两种,物品装进背包 与 物品不装进背包 。现在对dp[i][j]
进行分析,它是哪些子状态转移过来的呢?
如果第i个物品不装进背包,则dp[i][j]
的方法数和 dp[i-1][j]
的方法数一致,如果第i个物品装进背包,那么背包里的剩余空间为 j - coins[i-1]
,dp[i][j]
的方法数与dp[i][j - coins[i-1]]
一致。再考虑一下边界情况,状态转移方程就能写出来了:
for(int i = 1; i <= n ;i++){
for(int j = 1;j<=amount;j++){
if(coins[i-1] > j){
dp[i][j] = dp[i-1][j];
}else{
dp[i][j] = dp[i-1][j] + dp[i][j-coins[i-1]];
}
}
}
最终代码:
//转换为完全背包问题,利用动态规划求解
int change(int amount, vector<int> &coins) {
// dp数组定义
int n = coins.size();
int dp[n + 1][amount + 1];
//base case
for (int i = 0; i <= n; i++) {
dp[i][0] = 1;
}
for (int j = 1; j <= amount; j++) {
dp[0][j] = 0;
}
//开始状态转移
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= amount; j++) {
if (coins[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]];
}
}
}
return dp[n][amount];
}