背包问题详细分析—康Sir的学习笔记

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 时,若 xtrue,则说明可以恰好将背包装满,若 xfalse,则说明不能恰好将背包装满。

比如说,如果 dp[4][9] = true,其含义为:对于容量为 9 的背包,若只是在前 4 个物品中进行选择,可以有一种方法把背包恰好装满。

或者说对于本题,含义是对于给定的集合中,若只在前 4 个数字中进行选择,存在一个子集的和可以恰好凑出 9。

根据这个定义,我们想求的最终答案就是 dp[N][sum/2],base case 就是 dp[..][0] = truedp[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];
}
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

东秦小熊

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

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

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

打赏作者

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

抵扣说明:

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

余额充值