0805算法 - 动态规划03

01背包问题基础 | 416. 分割等和子集

Posted by ZA on August 5, 2024

背包问题基础

背包问题用于求解有限背包容量下,装入物品的最大价值,并根据每件物品的数量划分为01背包、完全背包、多重背包等多种问题,其框架如下:

01背包问题

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。 每件物品只能用一次 ,求解将哪些物品装入背包里物品价值总和最大。

暴力解法:每件物品本质上只有 装 与 不装 两种选择,因此完整遍历n个物品以搜索出所有情况,表现为一个满二叉树,时间复杂度为O(2^n)

因此需要动态规划方法来避免这种指数级别的时间复杂度:

1. dp数组及其下标含义

为了同时考虑 物品价值 和 背包容量 两个维度,需要使用二维数组。此时,dp[i][j]表示任取下标为[0-i]的物品,放进容量为j的背包,最大的价值总和:

以一组物品为例

编号 重量 价值
物品0 1 15
物品1 3 20
物品2 4 30

首先考虑仅将物品0放入背包,即dp[0][j]

容量为0时放不下,自容量1开始,仅放物品0的价值均为15

接下来考虑包含物品0和物品1的情况,即dp[1][j]

容量为0时放不下,自容量1开始放入物品0,当容量3时可以放入价值更大的物品1,并在重量4全部放入

更好理解得,dp[1][4]表示从物品0、1中任取放入容量为4的背包,最大价值为35

2. 递推公式

思考dp[i][j]可以由哪些情况推出? A.不放物品 i: 此时dp[i-1][j]仅选取物品0~物品i-1,即不放物品 i 的最大价值

dp[1][4]的一种情况是不放物品1,此时其可由dp[0][4]推导得到

B.放入物品 i : 那么就必须预留出物品 i 的容量,找出背包能支持放入的最大价值dp[i][j-weight(i)],再加上放入物品 i 的价值value(i)

dp[1][4]的另一种情况是放入物品1,此时dp[1][4-3]=dp[1][1]首次预留出了足够容量,再向其中放入物品1计算新的价值。

因此在本例子中可得递推公式:

dp[1][4] = max(dp[0][4], dp[0][1] + 物品1 的重量)

总结可得01背包问题的总体递推公式为:

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3. 数组初始化

在初始状态,二维数组中部分值是确定的:

  • 背包容量为0:此时无论如何也不能放进物品,因此dp[i][0]=0
  • 仅物品0时放不下:即j < weight[0],此时dp[0][j]=0
  • 仅物品0时能放下:即j >= weight[0],此时dp[0][j]=value[0]

4. 遍历顺序

由递推公式可得,dp[i][j]完全由dp[i-1][j]推导而来,因此必须先计算出小编号物品的dp行,才能计算更大编号的物品。因此先遍历物品,然后遍历背包重量。

01背包问题场景题

小明需要携带一些材料,它们各自占据不同的空间,并且具有不同的价值。若小明的行李空间为 N,问小明应该如何抉择,才能携带最大价值的材料?规定每种材料只能选择一次,并且只有选与不选两种选择,不能进行切割。

输入描述

第一行包含两个正整数,第一个整数 M 代表研究材料的种类,第二个正整数 N,代表小明的行李空间。

第二行包含 M 个正整数,代表每种研究材料的所占空间。

第三行包含 M 个正整数,代表每种研究材料的价值。

输出描述

输出一个整数,代表小明能够携带的研究材料的最大价值。

输入示例
6 1
2 2 3 1 5 2
2 3 1 5 4 3
输出示例
5
代码实现
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, bagweight; // bagweight代表行李箱空间

    cin >> n >> bagweight;

    vector<int> weight(n, 0); // 存储每件物品所占空间
    vector<int> value(n, 0);  // 存储每件物品价值

    for(int i = 0; i < n; ++i) {
        cin >> weight[i];
    }
    for(int j = 0; j < n; ++j) {
        cin >> value[j];
    }
    // dp数组, dp[i][j]代表行李箱空间为j的情况下,从下标为[0, i]的物品里面任意取,能达到的最大价值
    vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));

    // 初始化, 因为需要用到dp[i - 1]的值
    // j < weight[0]已在上方被初始化为0
    // j >= weight[0]的值就初始化为value[0]
    for (int j = weight[0]; j <= bagweight; j++) {
        dp[0][j] = value[0];
    }

    for(int i = 1; i < weight.size(); i++) { // 遍历科研物品
        for(int j = 0; j <= bagweight; j++) { // 遍历行李箱容量
            if (j < weight[i]) dp[i][j] = dp[i - 1][j]; // 如果装不下这个物品,那么就继承dp[i - 1][j]的值
            else {
                dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
            }
        }
    }
    cout << dp[n - 1][bagweight] << endl;

    return 0;
}

01背包问题的一维解法

我们使用二维数组求解01背包问题时:

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);

分析该递推公式,其中dp[i]的设计完全是为了标识第i-1层的值,即然第i层还未填充,如果能够把第i-1层的值直接复制到第i层上,那此时递推公式就变为:

dp[i][j] = max(dp[i][j], dp[i][j - weight[i]] + value[i]);

此时dp[i]已无特殊意义,可直接移除变为一维:

dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

此时再进行动态规划解题分析:

  1. dp数组及其下标含义:容量为j的背包,所背物品的最大价值为dp[j]
  2. 递推公式dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
  3. 数组初始化:易得dp[0]=0
  4. 遍历顺序:与二维不同的是,对于背包容量j的遍历要从大到小,这是因为用第i行保存第i-1行时,要想在本行左侧取到正确的dp[j - weight[i]] ,它就不能先于右侧被计算填充!否则正确值被覆盖,每j++都会有一个物品i被重复放入
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
代码实现
// 一维dp数组实现
#include <iostream>
#include <vector>
using namespace std;

int main() {
    // 读取 M 和 N
    int M, N;
    cin >> M >> N;

    vector<int> costs(M);
    vector<int> values(M);

    for (int i = 0; i < M; i++) {
        cin >> costs[i];
    }
    for (int j = 0; j < M; j++) {
        cin >> values[j];
    }

    // 创建一个动态规划数组dp,初始值为0
    vector<int> dp(N + 1, 0);

    // 外层循环遍历每个类型的研究材料
    for (int i = 0; i < M; ++i) {
        // 内层循环从 N 空间逐渐减少到当前研究材料所占空间
        for (int j = N; j >= costs[i]; --j) {
            // 考虑当前研究材料选择和不选择的情况,选择最大值
            dp[j] = max(dp[j], dp[j - costs[i]] + values[i]);
        }
    }

    // 输出dp[N],即在给定 N 行李空间可以携带的研究材料最大价值
    cout << dp[N] << endl;

    return 0;
}

416. 分割等和子集

给你一个 **只包含正整数 **的 **非空 **数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

示例 1:

输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。

提示:

  • 1 <= nums.length <= 200
  • 1 <= nums[i] <= 100
思路

两个子集的元素和相等为nums/2,可以视为一个容量为nums/2的,目标价值也为nums/2的背包,每个数字的重量和价值等价于其数值本身,如果正好装满,则说明本题数组子集可分。

因此有特殊的nums[i] = weight[i] = value[i]

  1. dp数组及其下标含义:背包用容量j装了价值d[j]的物品,因此本题判正的条件可以定义为dp[target]=target
  2. 递推公式:将上述特殊等式代入可得 dp[j] = max(dp[j], dp[ j-num[i] ]+num[i])
  3. 数组初始化:易得dp[0]=0,又可知数组至多200个不超过100的元素,因此所需元素和=背包总重量=200*100/2=10000 vector<int> dp(10001, 0);
  4. 遍历顺序:根据一维01背包问题逻辑,依旧是从小到大遍历物品,从大到小遍历背包容量
代码实现
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        vector<int> dp(10001, 0);
        int sum = 0;
        for(int i=0; i<nums.size(); i++){
            sum += nums[i];
        }
        int target = sum/2;

        if (sum % 2 == 1) return false;

        for(int i=0; i<nums.size(); i++){
            for(int j=target; j>=nums[i]; j--){
                dp[j] = max(dp[j], dp[j-nums[i]]+nums[i]);
            }
        }

        if (dp[target] == target) return true;
        return false;
    }
};

后记

动态规划的核心在于递推公式的设计,它是循环体的主要部分;

此外,数组初始化决定了循环前dp数组的初值和长度、dp数组及其下标含义+遍历顺序共同决定了循环条件的设计。

参考