【算法】——动态规划之01背包问题

目录

一、什么是背包问题?

二、例题

1.【模板】01背包问题

[2. 分隔等和子集](#2. 分隔等和子集)

[3. 目标和](#3. 目标和)

[4. 最后一块石头的重量Ⅱ](#4. 最后一块石头的重量Ⅱ)

总结:


一、什么是背包问题?

给一个情景:

假如你现在有一个背包,地上有一堆物品,你可以挑选一些放入你的背包中。但你背包的空间大小是有限的,而不同的物品又有各自的不同体积和价值。

问:背包在有限的空间内选择的物品的最大价值是多少?

这种类型的问题就是背包问题,而背包问题又可以分为几类

根据物品存在的数量又可以分为3类:

  1. 01背包问题,即每个物品只有一件,你可以选择拿(1)或不拿(0)

  2. 完全背包问题,即每个物品有无数件,你可以重复拿同一物品

  3. 多重背包问题,即每个物品有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;
}
  1. 代码题为了方面,我们直接建1001大小的数组

  2. 第二问输出时要处理结构为-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行的值。

可以拿出状态转移遍历的一段代码,看一下前后代码的区别:

修改前

cpp 复制代码
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];
            }
        }
    }

修改后

cpp 复制代码
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]);
        }
    }

优化后完整代码:

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 个数据,然后能得到我们想要的成果

以上便是本文的所有内容了,如果觉得有帮助的话可以点赞收藏加关注支持一下!

相关推荐
im_AMBER4 小时前
Leetcode 41
笔记·学习·算法·leetcode
jinmo_C++4 小时前
数据结构_深入理解堆(大根堆 小根堆)与优先队列:从理论到手撕实现
java·数据结构·算法
迷失的walker4 小时前
【Qt C++ QSerialPort】QSerialPort fQSerialPortInfo::availablePorts() 执行报错问题解决方案
数据库·c++·qt
IT19955 小时前
OpenSSL3.5.2实现SM3数据摘要生成
算法·哈希算法·散列表
Excuse_lighttime5 小时前
排序数组(快速排序算法)
java·数据结构·算法·leetcode·eclipse·排序算法
潘小安5 小时前
『译』迄今为止最强的 RAG 技术?Anthropic 的上下文检索与混合搜索
算法·llm·claude
kessy15 小时前
安全与续航兼备的“国密芯”——LKT6810U
算法
leo__5205 小时前
基于经验模态分解的去趋势波动分析(EMD-DFA)方法
人工智能·算法·机器学习
lzptouch6 小时前
AdaBoost(Adaptive Boosting)算法
算法·集成学习·boosting