一文搞懂背包问题

目录

一、背包定义

二、背包分类

三、背包解题模板

1、分类解题模板

2、背包分类模板

3、问题分类模板

四、背包相关例题

1、经典0-1背包

2、分割等和子集

3、零钱兑换 II

4、零钱兑换

5、最后一块石头的重量 II

6、目标和

7、完全平方数

8、组合总和 Ⅳ

9、掷骰子的N种方法


一、背包定义

背包定义:给定一个背包重量target,再给定一个数组nums(物品),按照一定方式选取nums中物品,得到target。

备注:

  • nums中物品可能是数,也可能是字符串
  • target可能显示给出,也可能是由于题意推理出来的(例如nums物品总重量的一半)
  • 选取方式:每个元素只取一次/每个元素可取多次/每个元素选取排列组合

二、背包分类

根据选取方式不同,如下分类:

  • 0-1背包:单个物品只可以选取一次
  • 子集背包:0-1背包的子集问题
  • 完全背包:单个物品可以选取多次
  • 组合背包:物品选取考虑顺序
  • 分组背包:多个背包,需要进行背包遍历

问题分类:

  • 最值问题:最大值/最小值
  • 存在问题:是否存在……满足……
  • 组合问题:满足……的排列组合

三、背包解题模板

1、分类解题模板

背包问题大体为两层循环,分别遍历物品nums和背包容量target。根据背包的分类确定物品和容量遍历的先后顺序,根据问题分类确定转移方程。

2、背包分类模板

  • 0-1背包:外循环nums,内循环target,target倒序,target>=nums[i]
  • 完全背包:外循环nums,内循环target,target正序,target>=nums[i]
  • 组合背包:外循环nums,内循环target,target正序,target>=nums[i]
  • 分组背包:外循环背包数bags,内循环两层根据题目转换成以上三种背包类型

3、问题分类模板

  • 最值问题:dp[i] = max/min(dp[i],dp[i - nums[i]] + 1)或dp[i] = max/min(dp[i],dp[i - nums[i]] + nums[i]);
  • 存在问题:dp[i] = dp[i] || dp[i-nums[i]]
  • 组合问题:dp[i] += dp[i-nums[i]]

四、背包相关例题

1、经典0-1背包

题目:给定一个容量为W的背包和N个物品,每个物品有重量和价值两个属性。其中第i个物品重量为w[i],价值为val[i]。现在让你用这个背包装物品,最多能装的价值为多少

src=http___1yke.cn_img.php_gis4g.pku.edu.cn_wp-content_uploads_2015_04_375px-Knapsack.svg_.png&refer=http___1yke.jfif

 输入如下:

N=3,W=4

w = [2,1,3]

val = [4,2,3]

输出:6

分析:

  • 物品不可分割,只有装入背包和不装背包两种选择(0-1由来);
  • dp数组定义:对于前i个物品,当前背包容量为w,这种情况下可以装的最大价值是dp[i][w];
  • 答案结果就为dp[N][W],base case是dp[0][…] = dp[…][0] = 0,因为没有物品或者背包没有空间,能装的价值都为0;
  • 对于第i个物品分情况讨论:
  1. 不放入背包:则dp[i][w] =dp[i-1][w]; 不装就等于之前的结果
  2. 放入背包:则dp[i][w] = dp[i-1][w-w[i-1]] + val[i-1],由于i是从1开始,所以val和w取值为i-1;dp[i-1][w-w[i-1]] + val[i],在w-w[i-1]限制下的最大价值,加上当前第i个物品的价值val[i - 1]就是装入第i个物品的最大价值

示例代码:

int knapsack(int W, int N, vector<int> wt, vector<int> val) {
    // vector 全填入 0,base case 已初始化
    vector<vector<int>> dp(N + 1, vector<int>(W + 1, 0));
    for (int i = 1; i <= N; i++) 
    {
        for (int w = 1; w <= W; w++) 
        {
            if (w - wt[i-1] < 0)
            {
                // 当前背包容量装不下,只能选择不装入背包
                dp[i][w] = dp[i - 1][w];
            }
            else 
            {
                // 装入或者不装入背包,择优
                dp[i][w] = max(dp[i - 1][w - wt[i-1]] + val[i-1],
                        dp[i - 1][w]);
            }
        }
    }

    return dp[N][W];
}

可以看到dp[i][w]都是通过dp[i-1][…]得到的,可以进行压缩成一维数组,示例代码如下:

int knapsack(int W, int N, vector<int> wt, vector<int> val) {
    vector<int> dp(vector<int>(W + 1, 0));
    for (int i = 1; i <= N; i++) 
    {
        for (int w = W; w >= wt[i-1]; w--) 
        {
            dp[w] = max(dp[w], dp[w - wt[i-1]] + val[i-1]);
        }
    }
    return dp[W];
}

2、分割等和子集

题目:给定一个非空的正整数数组 nums ,请判断能否将这些数字分成元素和相等的两部分。

示例 1:

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

输入:nums = [1,2,3,5]
输出:false
解释:nums 不可以分为和相等的两部分

分析:

  • 设定nums总和为sum,那么可以看成装在重量为sum/2的背包和N个物品,每个物品的重量为nums[i],是否存在一种装法,将背包装满;
  • dp数组定义:对于前i个物品,当前背包容量为w时,如果恰好装满,dp[i][w] = true,否则为false;
  • base case:dp[…][0] = true和dp[0][…] = false,背包没空间认为装满,没有物品则没办法装满背包
  • 对第i个物品分情况讨论
  1. 不放入第i个物品,则dp[i][w] = dp[i-1][w],等于上次结果
  2. 放入第i个物品,则dp[i][w] = dp[i-1][w-nums[i-1]] ,因为第i个物品重量为nums[i-1],那么放入第i个物品恰好装满,取决于w-num[i-1]是否装满

示例代码:

bool canPartition(vector<int>& nums) {
    int sum = 0;
    for (int num : nums) sum += num;
    // 和为奇数时,不可能划分成两个和相等的集合
    if (sum % 2 != 0) return false;
    int n = nums.size();
    sum = sum / 2;
    vector<vector<bool>> dp(n + 1, vector<bool>(sum + 1, false));
    // base case
    for (int i = 0; i <= n; i++)
    {
        dp[i][0] = true;
    }

    for (int i = 1; i <= n; i++) 
    {
        for (int w = 1; w <= sum; w++) 
        {
            if (w - nums[i - 1] < 0) 
            {
               // 背包容量不足,不能装入第 i 个物品
                dp[i][w] = dp[i - 1][w]; 
            } 
            else 
            {
                // 装入或不装入背包
                dp[i][w] = dp[i - 1][w] | dp[i - 1][w-nums[i-1]];
            }
        }
    }
    return dp[n][sum];
}

根据模板,0-1背包存在性问题:是否存在一个子集,其和为target=sum/2,外循环nums,内循环target倒序,应用状态方程dp[i] = dp[i] || dp[i-nums[i]],压缩成一维数组,示例代码:

bool canPartition(vector<int>& nums) {
    int sum = 0, n = nums.size();
    for (int num : nums) sum += num;
    if (sum % 2 != 0) return false;
    sum = sum / 2;
    vector<bool> dp(sum + 1, false);
    // base case
    dp[0] = true;

    for (int i = 0; i < n; i++)
    {
        for (int j = sum; j >= nums[i]; j--)
        {
            dp[j] = dp[j] || dp[j - nums[i]];
        }
    }

    return dp[sum];
}

3、零钱兑换 II

题目:给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0 。

假设每一种面额的硬币有无限个。 

题目数据保证结果符合 32 位带符号整数。

示例 1:

输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
示例 2:

输入:amount = 3, coins = [2]
输出:0
解释:只用面额 2 的硬币不能凑成总金额 3 。
示例 3:

输入:amount = 10, coins = [10] 
输出:1

分析:

  • dp数组定义:前i个硬币,凑成金额为w,有dp[i][w]种方法
  • base case:dp[0][…] = 0和dp[…][0] = 1,因为没有任何硬币,无法凑成任何金额;凑成金额为0,则凑法唯一
  • 对于第i个硬币分情况讨论:
  1. 不选取第i个硬币,则dp[i][w] = dp[i-1][w],等于上次结果
  2. 选取第i个硬币,则dp[i][w] = dp[i][w-nums[i-1]],使用第i个硬币,取决于凑出选取第i个硬币之前,凑成w-nums[i-1]金额方法总数

示例代码:

int change(int amount, vector<int> coins) {
    int n = coins.size();
    vector<vector<int>> dp(n + 1,vector<int>(amount + 1 ));
    // base case
    for (int i = 0; i <= n; i++)
    {
        dp[i][0] = 1;
    }
        
    for (int i = 1; i <= n; i++) 
    {
        for (int w = 1; w <= amount; w++)
        {
            if (w - coins[i-1] >= 0)
            {
                dp[i][w] = dp[i - 1][w]
                         + dp[i][w - coins[i-1]];
            }
            else
            {
                dp[i][w] = dp[i - 1][w];
            }
        }
    }
    return dp[n][amount];
}

根据模板,完全背包不考虑顺序的组合问题:外循环coins,内循环target正序,应用转移方程:

dp[i] += dp[i-nums[i]],示例代码:

int change(int amount, vector<int> &coins)
{
    vector<int> dp(amount + 1);
    dp[0] = 1;
    for (int coin : coins)
    {
        for (int i = 1; i <= amount; i++)
        {
            if (i >= coin)
            {
                dp[i] += dp[i - coin];
            }
        }
    }
    return dp[amount];
}

4、零钱兑换

题目:给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。

你可以认为每种硬币的数量是无限的。

示例 1:

输入:coins = [1, 2, 5], amount = 11
输出:3 
解释:11 = 5 + 5 + 1
示例 2:

输入:coins = [2], amount = 3
输出:-1
示例 3:

输入:coins = [1], amount = 0
输出:0
示例 4:

输入:coins = [1], amount = 1
输出:1
示例 5:

输入:coins = [1], amount = 2
输出:2

根据模板,完全背包最值问题:外循环coins,内循环amount正序,应用状态方程:

dp[i] = max/min(dp[i],dp[i - nums[i]] + 1),示例代码:

int coinChange(vector<int> &coins, int amount)
{
    vector<long long> dp(amount + 1, INT_MAX); 
    dp[0] = 0;  
    for (int coin : coins)
    {
        for (int i = 0; i <= amount; i++)
        {
            if (coin <= i)
            {
                dp[i] = min(dp[i], dp[i - coin] + 1);
            }
        }
    }
    return dp[amount] == INT_MAX ? -1 : dp[amount];
}

5、最后一块石头的重量 II

题目:有一堆石头,用整数数组 stones 表示。其中 stones[i] 表示第 i 块石头的重量。

每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:

如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块 石头。返回此石头 最小的可能重量 。如果没有石头剩下,就返回 0。

示例 1:

输入:stones = [2,7,4,1,8,1]
输出:1
解释:
组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1],
组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1],
组合 2 和 1,得到 1,所以数组转化为 [1,1,1],
组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。
示例 2:

输入:stones = [31,26,33,21,40]
输出:5
示例 3:

输入:stones = [1,2]
输出:1

分析:

  • 把一堆石头分成两堆,求两堆石头重量差最小值
  • 要让差值小,两堆石头的重量都要接近sum/2;我们假设两堆分别为A,B,A<sum/2,B>sum/2,若A更接近sum/2,B也相应更接近sum/2
  • 将一堆stone放进最大容量为sum/2的背包,求放进去的石头的最大重量MaxWeight,最终答案即为sum-2*MaxWeight;

根据模板,0-1背包最值问题:外循环stones,内循环target=sum/2倒序,应用转移方程:

dp[i] = max/min(dp[i],dp[i - nums[i]] + nums[i]),示例代码:

int lastStoneWeightII(vector<int> &stones)
{
    int sum = accumulate(stones.begin(), stones.end(), 0);
    int target = sum / 2;
    vector<int> dp(target + 1);
    for (auto & stone : stones)
    {
        for (int i = target; i >= stone; i--)
        {
            dp[i] = max(dp[i], dp[i - stone] + stone);
        }
    }
    return sum - 2 * dp[target];
}

6、目标和

题目:给你一个整数数组 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

分析:

  • 数组和sum,目标和s, 正数和x,负数和y,则x+y=sum,x-y=s,那么x=(s+sum)/2=target

根据模板,0-1背包不考虑元素顺序的组合问题:选nums里的数得到target的种数,外循环nums,内循环target倒序,应用状态方程:dp[i] += dp[i-nums[i]],示例代码:

int findTargetSumWays(vector<int> &nums, int s)
{
    int sum = accumulate(nums.begin(), nums.end(), 0);
    if ((sum + s) % 2 != 0 || sum < s)
    {
        return 0;
    }
    int target = (sum + s) / 2;
    vector<int> dp(target + 1);
    dp[0] = 1;
    for (auto & num : nums)
    {
        for (int i = target; i >= num; i--)
        {
            dp[i] += dp[i - num];
        }
    }
    return dp[target];
}

7、完全平方数

题目:给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。

给你一个整数 n ,返回和为 n 的完全平方数的 最少数量 。

完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。

示例 1:

输入:n = 12
输出:3 
解释:12 = 4 + 4 + 4
示例 2:

输入:n = 13
输出:2

分析:

  • 完全平方数最小为1,最大为sqrt(n),故题目转换为在nums=[1,2.....sqrt(n)]中选任意数平方和为target=n

根据模板,外循环nums,内循环target正序,应用转移方程:dp[i] = max/min(dp[i], dp[i-nums]+1),示例代码:

int numSquares(int n)
{
    vector<int> dp(n + 1, INT_MAX); //dp[i]:和为i的完全平方数的最小数量
    dp[0] = 0;
    for (int num = 1; num <= sqrt(n); num++)
    {
        for (int i = 0; i <= n; i++)
        {
            if (i >= num * num)
            {
                dp[i] = min(dp[i], dp[i - num * num] + 1);
            }
        }
    }
    return dp[n];
}

8、组合总和 Ⅳ

题目:给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素组合的个数。

题目数据保证答案符合 32 位整数范围。

示例 1:

输入:nums = [1,2,3], target = 4
输出:7
解释:
所有可能的组合为:
(1, 1, 1, 1)
(1, 1, 2)
(1, 2, 1)
(1, 3)
(2, 1, 1)
(2, 2)
(3, 1)
请注意,顺序不同的序列被视作不同的组合。
示例 2:

输入:nums = [9], target = 3
输出:0

根据模板,考虑顺序的组合问题:外循环target,内循环nums,应用状态方程:

dp[i] += dp[i-nums[i]],示例代码:

int combinationSum4(vector<int> &nums, int target)
{
    vector<int> dp(target + 1);
    dp[0] = 1;
    for (int i = 1; i <= target; i++)
    {
        for (int num : nums)
        {
            if (num <= i)
            {
                dp[i] += dp[i - num];
            }
        }
    }
    return dp[target];
}

9、掷骰子的N种方法

题目:这里有 d 个一样的骰子,每个骰子上都有 f 个面,分别标号为 1, 2, ..., f。

我们约定:掷骰子的得到总点数为各骰子面朝上的数字的总和。

如果需要掷出的总点数为 target,请你计算出有多少种不同的组合情况(所有的组合情况总共有 f^d 种),模 10^9 + 7 后返回。

示例 1:

输入:d = 1, f = 6, target = 3
输出:1
示例 2:

输入:d = 2, f = 6, target = 7
输出:6
示例 3:

输入:d = 2, f = 5, target = 10
输出:1
示例 4:

输入:d = 1, f = 2, target = 3
输出:0
示例 5:

输入:d = 30, f = 30, target = 500
输出:222616187

根据模板,分组0-1背包的组合问题:dp[i][j]表示投掷i个骰子点数和为j的方法数;三层循环:最外层为背包d,然后先遍历target后遍历点数f
应用二维拓展的转移方程3:dp[i][j]+=dp[i-1][j-f],示例代码:

int numRollsToTarget(int d, int f, int target)
{
    vector<vector<int>> dp(d + 1, vector<int>(target + 1, 0));
    dp[0][0] = 1;
    for (int i = 1; i <= d; i++)
    {
        for (int j = 1; j <= target; j++)
        {
            for (int k = 1; k <= f && j >= k; k++)
            {
                dp[i][j] += dp[i - 1][j - k];
            }
        }
    }
    return dp[d][target];
}
  • 1
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值