动态规划-背包01问题推理与实践

动态规划-背包01问题推理与实践

背包01问题描述:

有storage大小的背包和weights.size()数量的物品,每个物品i对应的物品大小为sizes[i],价值为values[i],在不超过storage大小的情况下,如何装载物品使背包中的values和最大.


物品大小: vector<int> sizes;

物品价值: vector<int> values;

背包容量: <int> storage;


理解(状态转移公式的推理):

构建二维数组dp[i][j],定义式为 std::vector<std::vector<int>> dp(sizes.size(),std::vector<int>(storage + 1,0));默认值为0;

dp[i][j] 的意义是在前 i 个物品中选择任意少于等于 i 个物品,其总大小不超过 j 的最大价值和.

则同理 dp[i - 1][j] 为在前 i - 1 物品中选择任意少于等于 i - 1 个物品,其总大小不超过 j - 1的最大价值和,此即为子状态.

假设求取dp[i][j],则考虑两种情况:

①第i个物品取的可能

②第i个物品不取的可能

针对上述情况进行分类讨论:

①已知dp[i-1][j],此时背包已满,则应腾出某物品以装在第 i 物品,则装载前必须知道dp[i - 1][j - sizes[i]]的值,即背包扣除第i个物品的大小后的最大价值装载方式[1],则可推导出dp[i][j] = dp[i - 1][j - sizes[i]] + values[i];

②已知dp[i - 1][j],此时第 i 个物品的价值过低或大小过大,不适合替换背包中的物品,则推理出dp[i][j] = dp[i - 1][j];

因为无法知道上述两种情况何时会发生,应该取两者的较大值:

dp[i][j] = std::max(dp[i - 1][j - sizes[i]] + values[i],dp[i - 1][j]);

此即为背包01问题的状态转移公式.

代码实现dp数组如下[2]:

//初始化第一个物品放入背包的子状态,即dp[0][j],此处的j代表的是>=第一个物品的大小的位置处,都填充values[0],注意子数组的额存放索引为增长的storage.
for(int j = sizes[0]; j <= storage; j++) {
    dp[0][j] = values[0];
}

//构建dp数组
for(int i = 1; i < sizes.size(); i++) {
    for(int j = 0; j <= storage; j++) {
        //该装载的物品过大,即使只装它一个也装不下,直接返回不装载的情况即可
        if(j < sizes[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = std::max(
        			dp[i - 1][j - sizes[i]] + values[i],
            		dp[i - 1][j]
            );
    }
}

完整代码(对应2. 01背包问题 - AcWing题库):

#include <iostream>
#include <vector>
#include <algorithm>

int main(int argc,char** argv) {
    std::vector<int> sizes;
    std::vector<int> values;
    int storage;
    int size;

    std::cin >> size >> storage;
    for(int i = 0; i < size; i++) {
        int t_size;
        int t_value;
        std::cin >> t_size >> t_value;
        sizes.push_back(t_size);
        values.push_back(t_value);
    }

    std::vector<std::vector<int>> dp(sizes.size(),std::vector<int>(storage + 1,0));

    for(int j = sizes[0]; j <= storage; j++) {
        dp[0][j] = values[0];
    }

    for(int i = 1; i < sizes.size(); i++) {
        for(int j = 0; j <= storage; j++) {
            if(j < sizes[i]) dp[i][j] = dp[i - 1][j];
            else dp[i][j] = std::max(
                        dp[i - 1][j - sizes[i]] + values[i],
                        dp[i - 1][j]
                );
        }
    }

    //Debug查看部分
    // for(int i = 0; i < size; i++) {
    //     for(int j = 0; j <= storage; j++) {
    //         std::cout << "[" << i << "]" << "[" << j << "]" << " = " << dp[i][j] << std::endl;
    //     }
    // }

    std::cout << dp[size - 1][storage];

    return 0;
}

一维数组简化理解:

构建dp[j]一维数组,定义式为 std::vector<std::vector<int>> dp(storage + 1,0);

dp[j]数组意义为背包容量为j时,背包的最大价值为dp[j];

一维数组的遍历代码:

for(int i = 0; i < sizes.size(); i++) {
    for(int j = storage; j >= sizes[i]; j--) {
        dp[j] = std::max(
        	dp[j - sizes[i]] + values[i],
            dp[j]
        );
    }
}

这里复用dp[j]的推理是,外层循环可以理解为依次遍历当次放入的物品 i 与放入该物品时容量为 j 的最大价值计算,即dp[j]为复用上一层循环 i = i - 1, j = j 时的最大价值,所变化的是考虑了新加入的 i 物品;内层循环反向遍历的理由因此可以推理得出,若正向进行遍历时,外层的i物品会被内层多次放入背包中(多次被遍历到),而反向遍历则不会重复遍历到,即举例以下情况:

// i:(in) 1 2 3...sizes.size() - 1 当 i = 2时
// j:(in) size[i] size[i] + 1 size[i] + 2...storage 当 j = x - 1(实际值在此例中不重要,合理即可)时
dp[j] = dp[j - sizes[i]] + values[i] == dp[x - 1] = dp[x - 1 - sizes[2]] + values[2];
//当 i = 2时
//当 j = x时
dp[j] = dp[j - sizes[i]] + values[i] == dp[x] = dp[x - sizes[2]] + values[2]; 
//观察可得: 在j:容量增长的情况下重复考虑了 i = 2 的情况,即重复加入了背包
//此时我们反向考虑
//i:(in) 与上文相同, 当i = 2时
//j:(in) storage... size[i] 当 j = x + 1(同上)时
dp[j] == dp[x + 1] = dp[x + 1 - size[2]] + values[2];
//当 i = 2时
//当 j = x时
dp[j] == dp[x] = dp[x - sizes[2]] + values[2];
//观察可得: 在j:容量递减的情况下,dp[x + 1] 依赖于上一次外层循环的dp[x + 1 - sizes[2]]值,而与此次循环的dp[x]值无关,故不会出现重复加入的情况

一维数组的初始化方式:

一维数组dp[j]不需要像二维数组dp[i][j]那样繁琐的初始化

仅仅只需要设置整体默认值(一般为0)即可,可参考定义式[3]

完整代码(对应416. 分割等和子集(LeetCode)):

class Solution {
    public:
    bool canPartition(std::vector<int>& nums) {
        int sum{0};
        for(int elem : nums) {
            sum += elem;
        }
        
        if(sum % 2 != 0) return false;
        
        int target = sum / 2;
        
        std::vector<int> dp(target + 1,0);
        
        for(int i = 0; i < nums.size(); i++) {
            for(int j = target; j >= nums[i]; j--) {
                dp[j] = std::max(
                	dp[j],
                    dp[j - nums[i]] + nums[i]
                );
            }
        }
        
        if(dp[target] == target) return true;
        else return false;
        
    }
}

例题理解(对应494. 目标和(LeetCode)):

给你一个非负整数数组 nums 和一个整数 target

向数组中的每个整数前添加 '+''-' ,然后串联起所有整数,可以构造一个 表达式

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1"

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

输入:nums = [1], target = 1
输出:1

提示:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

解题思路:

将nums数组的选择抽取划分为题干对应的两种状态,"+" 和 "-";我们称 "+" 的成员集成为left数组,"-" 的成员集成为right数组;

显然可见:

① int sum{0}; for: sum += nums.elems; sum = sum(left) + sum(right); sum => const	//对nums数组成员求和,其值为定值,且可转换为left和 right的成员和
② sum(left) - sum(right) = target; target => const	//满足题目要求的left和right,其相减值为定值

①和②可推理出

target + sum = sum(left) * 2 => sum(left) = (target + sum) / 2; //依此可得,sum(left)也为定值

推导出,当我们满足sum(left)为 (target + sum) / 2时,便可满足题干要求,抽象为当我们装满容量为sum(left)的背包时,dp[left]所存储的值即为解

确定背包定义式: std::vector<int> dp (storage + 1,0); storage = (sum + target) / 2;

确定背包状态转移公式: dp[j] = dp[j] + dp[j - nums[i]] + nums[i] == dp[j] += dp[j - nums[i]] + nums[i];解释为在背包容量为j时,当前dp[j]的值为装入选择该i物品加入left数组和不加入left数组后的满足次数;

该题目与许多其他背包问题的思维大致相同,都是将题目问题转换为可理解的背包01问题.

完整代码:

class Solution {
public:
    int findTargetSumWays(std::vector<int>& nums, int target) {
        int sum{0};
        for(int elem : nums) {
            sum += elem;
        }

        if((target + sum) % 2 != 0 || abs(target) > sum) return 0;

        int storage = (sum + target) / 2;



        std::vector<int> dp (storage + 1,0);
        dp[0] = 1;

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

        return dp[storage];
    }
};

文章参考于: 代码随想录,CSDN,CNBlog等各优秀编辑者,开发者的优质文章作品.

                                                                                                --2024.11.10 Neko 总结

  1. 这里是在推理在storage为 j - sizes[i] 的情况下的最大价值(dp[i - 1][j - sizes[i]),从而求得装载第i个物品后总sizes <= storage的最大价值,因为dp[i - 1][ j -sizes[i]]为子状态,其已经定义且为已知结果,故能推导出选择第i个物品时的最大价值 ↩︎

  2. 这里的dp[i][j] 初始化代码为 (sizes.size(),std::vector<int>(storage + 1,0)); 此处为兼容语言数组特性,将对其自然数字记录方式;第i个物品的大小在sizes[i - 1]处存放,而重量则符合自然数字规律(0-storage) ↩︎

  3. C++的此定义式本身也初始化了所有元素(为0),语法请自行查略 ↩︎

posted @ 2024-11-10 15:22  Neko_Code  阅读(17)  评论(0编辑  收藏  举报