【动态规划】01背包问题

📝前言说明:

  • 本专栏主要记录本人的动态规划算法学习以及LeetCode刷题记录,按专题划分
  • 每题主要记录:(1)本人解法 + 本人屎山代码;(2)优质解法 + 优质代码;(3)精益求精,更好的解法和独特的思想(如果有的话)
  • 文章中的理解仅为个人理解。如有错误,感谢纠错

🎬个人简介:努力学习ing

📋本专栏:C++刷题专栏

📋其他专栏:C语言入门基础python入门基础C++学习笔记Linux

🎀CSDN主页 愚润泽

你可以点击下方链接,进行不同专题的动态规划的学习

点击链接 开始学习
斐波那契数列模型 路径问题
简单多状态(一) 简单多状态(二)
子数组系列(一) 子数组系列(二)
子序列问题(一) 子序列问题(二)
回文串(一) 回文串(二)
两个数组dp问题(一) 两个数组的dp问题(二)
01背包问题 完全背包
二维的背包问题 其他

题单汇总链接:点击 → 题单汇总

题目


背包问题开篇导论(重点)

NP完全问题

背包问题简介 :在有限容量约束下,通过合理选择物品使得总价值最大化。(我们需要关注:物品属性和背包属性)

  • 当每个物品只能选一次:01背包,能选多次完全背包,
  • 背包又分为必须装满 / 不必装满
  • 01背包问题是其他背包问题的基础!

DP41 【模板】01背包(开篇引导题)

题目链接 → 题目链接


第一问(背包不必选满)

思路

我们把物品从 1 开始编号,我们的dp表也天然的多一行和一列。对于每一个物品,我们考虑选与不选,从第一个物品开始,则 01背包问题 变成 线性dp问题

状态表示

  • dp[i][j]:从前i个物品中挑选,总体积不超过j,所有选法中,能选出来的最大价值

状态转移方程

线性 dp 状态转移方程分析方式,⼀般都是根据「最后一步」的状况,来分情况讨论:

  • 不选第i个位置:dp[i][j] = dp[i - 1][j]
  • 选第i个位置:if(j - v[i] >= 0) dp[i][j] = dp[i - 1][j - v[i]] + w[i](要确保选了物品不会超过体积)

初始化

对于dp表

  • 当容量为 0时,无法选择物品,所以价值为 0 → 第一列全为 0
  • 当从前0个物品选择,没有物品能选,所以价值也都为0 → 第一行全为0

填表顺序

  • 看状态转移方程依赖哪些位置的值,经过分析 → 从上往下

返回值

  • 返回dp[n][V]

第二问(背包必须满)

思路:
状态表示(因为必须要装满)

  • dp[i][j]:从前i个物品中挑选,总体积正好等于j,所有选法中,能挑选出来的最大价值

状态转移方程

  • 和第一问一样
  • 但是:dp[i][j]不一定存在,因为总体积可能凑不到j
    • 所以我们用dp[i][j] == -1来表示凑不到j(因为0被初始化占用了)
    • 相比于第一问的区别:第一问不要求正好装到j,也就是第一问的dp[i][j] 可能根本没装东西0 也 < j
  • 因此,我们要注意我们使用的dp是否是-1
    • 对于不选的情况,不需要要判断,因为是直接赋值(把dp[i - 1][j]-1赋值给dp[i][j]是ok的,都代表不存在)
    • 对于选的情况(因为是取max-1 + w[i] > -1被选到,但是其实前面的dp[i-1][j-v[i]]是不存在的),所以需要判断dp[i-1][j-v[i]]是否存在

初始化

因为多了一种表示不存在的状态

  • 当物品为0(第一行),容量要满足j
    • j0:这种情况存在:0物品,0容量,所以dp[0][0] = 0
    • j > 0 :选择0物品,但是还要有容量,所以不存在这种情况,dp[0][j] = -1
  • 当容量为0(第一列),从前i个物品选
    • 对于每个物品都不选,则可以容量为0,所以第一列全为0

填表顺序和返回值

  • 和第一问一样

代码(未优化写法):

cpp 复制代码
int main() {

    // 读取数据
    int n, V;
    cin >> n >> V;
    // 从 1 开始编号
    vector<int> v(n + 1);
    vector<int> w(n + 1);
    for (int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];

    // dp 填表(第一问)
    vector<vector<int>> dp1(n + 1, vector<int>(V + 1, 0));
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= V; j++) {
            dp1[i][j] = dp1[i - 1][j];
            if (j - v[i] >= 0)
                dp1[i][j] = max(dp1[i - 1][j], dp1[i - 1][j - v[i]] + w[i]);
        }
    }

    // dp 填表第二问
    vector<vector<int>> dp2(n + 1, vector<int>(V + 1, 0));
    for(int j = 1; j <= V; j++) dp2[0][j] = -1; // 初始化

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= V; j++) {
            dp2[i][j] = dp2[i - 1][j];
            // j - v[i]: 确保物体的体积不会超过容量
            // dp中的 j - v[i]: 确保 + 上物体以后,总容量 == j
            if(j - v[i] >= 0 && dp2[i - 1][j - v[i]] != -1)
                dp2[i][j] = max(dp2[i - 1][j], dp2[i - 1][j - v[i]] + w[i]);
        }
    }
    cout << dp1[n][V] << endl;
    cout << (dp2[n][V] == -1 ? 0 : dp2[n][V]) << endl;
}

时间复杂度: O ( n V ) O(nV) O(nV)
空间复杂度: O ( n V ) O(nV) O(nV)

优化

  • 利用滚动数组做空间上的优化(可直接在原始代码上修改)
  • i行的状态只依赖第i - 1行。因此我们可以只用两个数组来填写,当第i行填完以后,把第i - 1行"变成"第i + 1行(即:无用变有用)然后继续填写。

更进一步

只用一行数组,我们在第i - 1行上直接填数据,变成第i行:

  • 我们要观察我们到底依赖的是第i - 1行的哪些值
  • 然后注意覆盖问题:选择填改行数组的方向
  • 本题我们可以选择从右往左填:用完以后再覆盖

改代码:

  1. 删除横坐标
  2. 修改j的遍历顺序

代码:

cpp 复制代码
int main() {

    // 读取数据
    int n, V;
    cin >> n >> V;
    // 从 1 开始编号
    vector<int> v(n + 1);
    vector<int> w(n + 1);
    for (int i = 1; i <= n; i++)
        cin >> v[i] >> w[i];

    // dp 填表(第一问)
    vector<int> dp1(V + 1, 0);
    for (int i = 1; i <= n; i++) {
        for (int j = V; j >= v[i]; j--)
            dp1[j] = max(dp1[j], dp1[j - v[i]] + w[i]);
    }

    // dp 填表第二问
    vector<int> dp2(V + 1, 0);
    for(int j = 1; j <= V; j++) dp2[j] = -1; // 初始化
    for (int i = 1; i <= n; i++) {
        for (int j = V; j >= v[i]; j--) {
            if(dp2[j - v[i]] != -1)
                dp2[j] = max(dp2[j], dp2[j - v[i]] + w[i]);
        }
    }
    cout << dp1[V] << endl;
    cout << (dp2[V] == -1 ? 0 : dp2[V]) << endl;
}

时间复杂度: O ( n V ) O(nV) O(nV),但是是有降低的
空间复杂度: O ( V ) O(V) O(V)

注意:不要强行解释优化后的状态表示以及状态转移方程(我们只是借助了变化的特点来优化罢了)


416. 分割等和子集

题目链接:https://leetcode.cn/problems/partition-equal-subset-sum/description/


优质解

思路:

  • 我们带着把数组划分成两部分的思想就很难解题
  • 因为最后要求把数组分割成两个元素和相等的子集,这意味着每个子集的元素和 = sum / 2
  • 所以我们只需要看能不能在数组中挑选出一定的元素,使元素和= sum / 2
  • 于是问题就变成了01背包问题

代码:

cpp 复制代码
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int n = nums.size();
        int sum = 0;
        for(auto x:nums)
            sum += x;
        if(sum % 2 != 0) return false;
        vector<vector<bool>> dp(n + 1, vector<bool>(sum / 2 + 1, 0));
        for(int i = 0; i <= n; i++) dp[i][0] = true; // 第一列初始化全为 0 
        for(int i = 1; i <= n; i++)
        {
            for(int j = 1; j <= sum / 2; j++)
            {
                dp[i][j] = dp[i - 1][j];  // 不选第 i 个数
                if(j >= nums[i - 1] && dp[i - 1][j - nums[i - 1]]) // 选择第 i 个数且前面的和也存在
                    dp[i][j] = true;
            }
        }
        return dp[n][sum / 2];
    }
};

时间复杂度: O ( n ∗ s u m ) O(n*sum) O(n∗sum)
空间复杂度: O ( n ∗ s u m ) O(n*sum) O(n∗sum)

优化

cpp 复制代码
class Solution {
public:
    bool canPartition(vector<int>& nums) {
        int n = nums.size();
        int sum = 0;
        for(auto x:nums)
            sum += x;
        if(sum % 2 != 0) return false;
        vector<bool> dp(sum / 2 + 1, false);
        dp[0] = true; 
        for(int i = 1; i <= n; i++)
        {
            for(int j = sum / 2; j >= nums[i - 1]; j--)
            {
                if(dp[j - nums[i - 1]]) // 选择第 i 个数且前面的和也存在
                    dp[j] = true;
            }
        }
        return dp[sum / 2];
    }
};

时间复杂度: O ( n ∗ s u m ) O(n*sum) O(n∗sum) ,但是是有变快的
空间复杂度: O ( s u m ) O(sum) O(sum)


494. 目标和

题目链接:https://leetcode.cn/problems/target-sum/description/


优质解

思路:

  • 转换成上一题,但是这个初始化和第一列的填写特别恶心

代码:

cpp 复制代码
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) 
    {
        // 从数组中选数,和 == (sum + target) / 2;
        int sum = 0, n = nums.size();
        for(int i = 0; i < n; i++) sum += nums[i];
        if(sum < abs(target) || (sum + target) % 2 != 0) return 0;
        int m = (sum + target) / 2;
        // dp[i][j] : 从前 i 个数里面选,能选出的`和 == j`的选法总数
        // nums[i]位置:不选 + 选(两种情况的方法总和)
        // dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
        vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
        // 因为状态转移方程要依赖于上一行,所以我们先初始化第一行
        // 当不选任何数字时, 和只能是 0
        dp[0][0] = 1;
        for(int i = 1; i <= n; i++)
        {
            for(int j = 0; j <= m; j++) // 和为 0, 因为数组内元素可能有多个 0 ,所以从 前 i 个数里面选也不知道有几种选法
            {
                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][m];
    }
};

时间复杂度: O ( n ∗ m ) O(n*m) O(n∗m)
空间复杂度: O ( n ∗ m ) O(n*m) O(n∗m)

优化:

cpp 复制代码
class Solution {
public:
    int findTargetSumWays(vector<int>& nums, int target) 
    {
        int sum = 0, n = nums.size();
        for(int i = 0; i < n; i++) sum += nums[i];
        if(sum < abs(target) || (sum + target) % 2 != 0) return 0;
        int m = (sum + target) / 2;
        vector<int> dp(m + 1, 0);
        dp[0] = 1;
        for(int i = 1; i <= n; i++)
        {
            for(int j = m; j >= nums[i - 1]; j--)
            {
                dp[j] += dp[j - nums[i - 1]];
            }
        }
        return dp[m];
    }
};

时间复杂度: O ( n ∗ m ) O(n*m) O(n∗m)
空间复杂度: O ( m ) O(m) O(m)


1049. 最后一块石头的重量 II

题目链接:https://leetcode.cn/problems/last-stone-weight-ii/description/

个人解

思路:

  • 把前面的题目写好了,这道题就不难
  • 选数,数的和 <= sum / 2(最大体积) -> 变成 01 背包问题(只不过石头价值和体积相同)
  • 右边的数一定能把左边的数"粉碎"到只剩最小的吗?
    • 一定的, 左边和右边先各自内部粉碎,剩下的数左边 <= 右边 再粉碎

屎山代码:

cpp 复制代码
class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) 
    {

        int sum = 0;
        for(auto x: stones) sum += x;
        int n = stones.size(), m = sum / 2;
        vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
        for(int i = 1; i <= n; i ++)
        {
            for(int j = 1; j <= m; j++)
            {
                dp[i][j] = dp[i - 1][j];
                if(j >= stones[i - 1])
                    dp[i][j] = max(dp[i][j], dp[i - 1][j - stones[i - 1]] + stones[i - 1]);
            }
        }
        return sum - 2 * dp[n][m];
    }
};

时间复杂度: O ( n ∗ m ) O(n*m) O(n∗m)
空间复杂度: O ( n ∗ m ) O(n*m) O(n∗m)

优化:

cpp 复制代码
class Solution {
public:
    int lastStoneWeightII(vector<int>& stones) 
    {
        int sum = 0;
        for(auto x: stones) sum += x;
        int n = stones.size(), m = sum / 2;
        vector<int> dp(m + 1, 0);
        for(int i = 1; i <= n; i ++)
        {
            for(int j = m; j >= stones[i - 1]; j--)
                dp[j] = max(dp[j], dp[j - stones[i - 1]] + stones[i - 1]);
        }
        return sum - 2 * dp[m];
    }
};

时间复杂度: O ( n ∗ m ) O(n*m) O(n∗m)
空间复杂度: O ( m ) O(m) O(m)


🌈我的分享也就到此结束啦🌈

要是我的分享也能对你的学习起到帮助,那简直是太酷啦!

若有不足,还请大家多多指正,我们一起学习交流!

📢公主,王子:点赞👍→收藏⭐→关注🔍

感谢大家的观看和支持!祝大家都能得偿所愿,天天开心!!!

相关推荐
m0_5350646017 分钟前
C++模版编程:类模版与继承
java·jvm·c++
今天背单词了吗98043 分钟前
算法学习笔记:19.牛顿迭代法——从原理到实战,涵盖 LeetCode 与考研 408 例题
笔记·学习·算法·牛顿迭代法
jdlxx_dongfangxing1 小时前
进制转换算法详解及应用
算法
Tanecious.2 小时前
C++--红黑树封装实现set和map
网络·c++
why技术2 小时前
也是出息了,业务代码里面也用上算法了。
java·后端·算法
2501_922895583 小时前
字符函数和字符串函数(下)- 暴力匹配算法
算法
IT信息技术学习圈4 小时前
算法核心知识复习:排序算法对比 + 递归与递推深度解析(根据GESP四级题目总结)
算法·排序算法
会唱歌的小黄李4 小时前
【算法】贪心算法入门
算法·贪心算法
源代码•宸5 小时前
C++高频知识点(十三)
开发语言·c++·经验分享·面经