动态规划 - 背包问题
背包问题
背包问题概述
背包问题(Knapsack problem)是一种组合优化的NP 完全问题。其核心是:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,如何选择物品,才能使得物品的总价格最高。
按物品个数分类
根据物品的可选取个数,背包问题分为以下几类:
- 01 背包问题:每个物品只有一个,即对于每个物品,只有 “选” 或 “不选” 两种决策。
- 完全背包问题:每个物品有无限多个,可多次选取同一物品。
- 多重背包问题:每件物品最多有
个,
为第 i 种物品的数量上限),选取次数受限于该物品的数量。
- 混合背包问题:每个物品可能属于上述 01、完全、多重背包中的任意一种情况,是多种背包类型的混合。
- 分组背包问题:物品被分为 n 组,每组里有若干个物品,且每组里最多选一个物品。
按背包是否装满分类
在上述 “按物品个数” 的分类基础上,还可根据背包是否必须装满,分为两类:
- 不一定装满背包:目标是在重量限制内,总价值最大,不要求背包完全装满。
- 背包一定装满:不仅要总价值最大,还要求背包恰好被装满(若无法装满,则问题无解或需特殊处理)。
优化方案
为提升背包问题的求解效率(时间或空间),有多种优化思路:
- 空间优化 - 滚动数组:利用 “01 背包” 等问题的状态转移特性,将二维 DP 数组压缩为一维,减少空间复杂度(如 01 背包的 “逆序遍历” 优化)。
- 单调队列优化:针对 “多重背包”“完全背包” 等问题,用单调队列维护状态转移的最优值,将时间复杂度从 O(NV) 优化到 O(N)(N 为物品数,V 为背包容量)。
- 贪心优化:在某些特殊场景下(如物品 “单位重量价值” 差异显著),用贪心策略(优先选单位重量价值高的物品)快速得到近似最优解(或在特定条件下的精确解)。
按限定条件个数分类
根据限制条件的数量,分为两类:
- 限定条件只有一个:如仅限制 “体积”,这是最基础的背包问题(普通背包问题)。
- 限定条件有两个:如同时限制 “体积 + 重量”,属于二维费用背包问题,需同时满足多个限制条件下的价值最大化。
按不同问法分类
根据问题的最终要求(“问法”),还可细分为多类:
- 输出方案:不仅要总价值,还要输出具体选了哪些物品。
- 求方案总数:不关注总价值,而是统计有多少种选法能满足重量限制(或同时满足价值要求等)。
- 最优方案:最典型的问法,即求重量限制内的最大总价值。
- 方案可行性:判断是否存在一种选法,能满足重量限制(或其他条件,如总价值不低于某个值)。
总结
背包问题种类繁多、题型丰富且难度灵活,但所有变种都是从 “01 背包问题” 演化而来。因此,掌握 “01 背包问题” 的解法(如动态规划思路),是学习其他背包问题的基础。
题目练习
【模板】01背包_牛客题霸_牛客网
解法(动态规划):
算法思路:
我们先解决第一问。
1. 状态表示:
dp[i][j]
表示:从 i
个物品中挑选,总体积不超过 j
,所有的选法中,能挑选出来的最大值。
2. 状态转移方程:
线性 dp
状态转移方程一般都是根据 **「最后一步的状态」** 来分析:
-
不选第
i
个物品:相当于就是前i - 1
个物品中挑选,总体积不超过j
,此时dp[i][j] = dp[i - 1][j]
; -
选第
i
个物品:那么就只能从前面i - 1
个物品中挑选,并且总体积不超过j - v[i]
,此时dp[i][j] = dp[i - 1][j - v[i]] + w[i]
。
但这种情况下需要保证 j >= v[i]
。
因此,状态转移方程为:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i])
(当 j >= v[i]
时)。
3. 初始化:
我们先多加一行一列来处理边界情况:
-
第一个格子为
0
,因为正好能装体积为0
的背包; -
但是第一行后面的格子都是
-1
,因为没有物品,无法满足体积大于0
的情况。
4. 填表顺序:
根据 **「状态转移」,我们需要「从上往下」填每一行,每一行「从左往右」**。
5. 返回值:
根据 **「状态表示」**,返回 dp[n][V]
。
接下来解决第二问,可只做第一问过程的五步即可。
因为有可能存在一个 j
不优化的物品,因此我们不合法的状态设置为 -1
。
#include <iostream>
#include <string.h>
using namespace std;const int N = 1010;
int n, V, v[N], w[N];
int dp[N][N];
int main() {cin >> n >> V;for(int i = 1; i <= n; ++i)cin >> v[i] >> w[i];for(int i = 1; i <= n; ++i){for(int j = 1; j <= V; ++j){dp[i][j] = dp[i - 1][j];if(j >= v[i]) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);}}cout << dp[n][V] <<endl;memset(dp, 0, sizeof dp);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){dp[i][j] = dp[i - 1][j];if(j >= v[i] && dp[i - 1][j - v[i]] != -1) dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);}}cout << (dp[n][V] == -1 ? 0 : dp[n][V]) <<endl;
}
// 64 位输出请用 printf("%lld")
空间优化:
背包问题基本上都是利用 「滚动数组」 来做空间上的优化:
利用「滚动数组」优化;
直接在「原始代码」上修改;
在 01
背包问题中,优化的结果为:
- 删除所有的横坐标;
- 修改一下
j
的遍历顺序。
我们甚至可以优化到一维数组:但是要注意填表顺序,防止错误的覆盖问题(我们选取从右往左的遍历顺序)
#include <iostream>
#include <string.h>
using namespace std;const int N = 1010;
int n, V, v[N], w[N];
int dp[N];
int main() {cin >> n >> V;for(int i = 1; i <= n; ++i)cin >> v[i] >> w[i];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;memset(dp, 0, sizeof dp);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 ? 0 : dp[V]) <<endl;
}
// 64 位输出请用 printf("%lld")
416. 分割等和子集 - 力扣(LeetCode)
解法(动态规划):
算法思路:
先将问题转化成我们 「熟悉」 的题型。
如果数组能够被分成两个相同元素之和相同的子集,那么原数组必须有下面几个性质:
-
ⅰ. 所有元素之和应该是一个偶数;
-
ⅱ. 数组中最大的元素应该小于所有元素总和的一半;
-
ⅲ. 挑选一些数,这些数的总和应该等于数组总和的一半。
根据前两个性质,我们可以提前判断数组能够被划分。根据最后一个性质,我们发现问题就转化成了 「01 背包」 的模型:
-
ⅰ. 数组中的元素只能选择一次;
-
ⅱ. 每个元素面临被选择或者不被选择的处境;
-
ⅲ. 选出来的元素总和要等于所有元素总和的一半。
其中,数组内的元素就是物品,总和就是背包。
那么我们就可以用背包模型的分析方式,来处理这道题。
请大家注意,「不要背」 状态转移方程,因为题型变化之后,状态转移方程就会跟着变化。我们要记住的是分析问题的模式。用这种分析问题的模式来解决问题。
1. 状态表示:
dp[i][j]
表示在前 i
个元素中选择,所有的选法中,能否凑成总和为 j
这个数。
2. 状态转移方程:
老规矩,根据 「最后一个位置」 的元素,结合题目的要求,分情况讨论:
-
ⅰ. 不选择
nums[i]
:那么我们是否能够凑成总和为j
,就要看在前i - 1
个元素中选,能否凑成总和为j
。根据状态表示,此时dp[i][j] = dp[i - 1][j]
; -
ⅱ. 选择
nums[i]
:这种情况下是有前提条件的,此时的nums[i]
应该是小于等于j
。因为如果这个元素都比要凑成的总和大,选择它就没有意义呀。那么我们是否能够凑成总和为j
,就要看在前i - 1
个元素中选,能否凑成总和为j - nums[i]
。根据状态表示,此时dp[i][j] = dp[i - 1][j - nums[i]]
。
综上所述,两种情况下只要有一种能够凑成总和为 j
,那么这个状态就是 true
。因此,状态转移方程为:
dp[i][j] = dp[i - 1][j];
if (nums[i - 1] <= j) dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i]];
3. 初始化:
由于需要用到上一行的数据,因此我们可以先把第一行初始化。
第一行表示不选择任何元素,要凑成目标和 j
。只有当目标和为 0
的时候才能做到,因此第一行仅需初始化第一个元素 dp[0][0] = true
。
4. 填表顺序:
根据 **「状态转移方程」,我们需要「从上往下」填写每一行,每一行的顺序是「无所谓的」**。
5. 返回值:
根据 「状态表示」,返回 dp[n][aim]
的值。
其中 n
表示数组的大小,aim
表示要凑的目标和。
class Solution {
public:bool canPartition(vector<int>& nums) {int n = nums.size(), sum = 0, aim = 0;for(auto x : nums) sum += x;if(sum % 2) return false;aim = sum / 2;vector<vector<bool>> dp(n + 1, vector<bool>(aim + 1));for(int i = 0; i <= n; ++i) dp[i][0] = true;for(int i = 1; i <= n; ++i){for(int j = 1; j <= aim; ++j){dp[i][j] = dp[i - 1][j] || (j >= nums[i - 1] && dp[i - 1][j - nums[i - 1]]);}}return dp[n][aim];}
};
6. 空间优化:
所有的 「背包问题」,都可以进行空间上的优化。
对于 01
背包类型的,我们的优化策略是:
- ⅰ. 删掉第一维;
- ⅱ. 修改第二层循环的遍历顺序即可。
class Solution {
public:bool canPartition(vector<int>& nums) {int n = nums.size(), sum = 0, aim = 0;for(auto x : nums) sum += x;if(sum % 2) return false;aim = sum / 2;vector<bool> dp(aim + 1);dp[0] = true;for(int i = 1; i <= n; ++i){for(int j = aim; j >= nums[i - 1]; j--){dp[j] = dp[j] || dp[j - nums[i - 1]];}}return dp[aim];}
};
494. 目标和 - 力扣(LeetCode)
解法(动态规划):
算法思路:
本题可以直接用 「暴搜」的方法解决。但是稍微用数学知识分析一下,就能转化成我们常见的「背包模型」 的问题。
设我们最终选取的结果中,前面加 +
号的数字之和为 a
,前面加 -
号的数字之和为 b
,整个数组的总和为 sum
,于是我们有:
上面两个式子消去 b 之后,可以得到 。
也就是说,我们仅需在 nums
数组中选择一些数,将它们凑成和为 即可。
问题就变成了 「416. 分割等和子集」 这道题。
我们可以用相同的分析模式,来处理这道题。
1. 状态表示:
dp[i][j]
表示:在前 i
个数中选,总和正好等于 j
,一共有多少种选法。
2. 状态转移方程:
老规矩,根据 「最后一个位置」的元素,结合题目的要求,我们有「选择」最后一个元素或者「不选择」 最后一个元素两种策略:
-
ⅰ. 不选
nums[i]
:那么我们凑成总和j
的总方案,就要看在前i - 1
个元素中选,凑成总和为j
的方案数。根据状态表示,此时dp[i][j] = dp[i - 1][j]
; -
ⅱ. 选择
nums[i]
:这种情况下是有前提条件的,此时的nums[i]
应该是小于等于j
。因为如果这个元素都比要凑成的总和大,选择它就没有意义呀。那么我们能够凑成总和为j
的方案数,就要看在前i - 1
个元素中选,能否凑成总和为j - nums[i]
。根据状态表示,此时dp[i][j] = dp[i - 1][j - nums[i]]
。
综上所述,两种情况如果存在的话,应该要累加在一起。因此,状态转移方程为:
dp[i][j] = dp[i - 1][j];
if (nums[i - 1] <= j) dp[i][j] = dp[i][j] += dp[i - 1][j - nums[i - 1]];
3. 初始化:
由于需要用到 「上一行」 的数据,因此我们可以先把第一行初始化。
第一行表示不选择任何元素,要凑成目标和 j
。只有当目标和为 0
的时候才能做到,因此第一行仅需初始化第一个元素 dp[0][0] = 1
。
4. 填表顺序:
根据 「状态转移方程」,我们需要「从上往下」填写每一行,每一行的顺序是「无所谓的」。
5. 返回值:
根据 「状态表示」,返回 dp[n][aim]
的值。
其中 n
表示数组的大小,aim
表示要凑的目标和。
class Solution {
public:int findTargetSumWays(vector<int>& nums, int target) {int n = nums.size(), sum = 0;for(auto x : nums) sum += x;int aim = (target + sum) / 2;if(aim < 0 || (target + sum) % 2) return 0;vector<vector<int>> dp(n + 1, vector<int>(aim + 1));dp[0][0] = 1;for(int i = 1; i <= n; ++i){for(int j = 0; j <= aim ; ++j){dp[i][j] = dp[i - 1][j] + (j >= nums[i - 1] ? dp[i - 1][j - nums[i - 1]] : 0);}}return dp[n][aim];}
};
6. 空间优化:
所有的 「背包问题」,都可以进行空间上的优化。
对于 01
背包类型的,我们的优化策略是:
- ⅰ. 删掉第一维;
- ⅱ. 修改第二层循环的遍历顺序即可。
class Solution {
public:int findTargetSumWays(vector<int>& nums, int target) {int n = nums.size(), sum = 0;for(auto x : nums) sum += x;int aim = (target + sum) / 2;if(aim < 0 || (target + sum) % 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];}
};
1049. 最后一块石头的重量 II - 力扣(LeetCode)
解法(动态规划):
算法思路:
先将问题 「转化」 成我们熟悉的题型。
-
任意两块石头在一起粉碎,重量相同的部分会被丢掉,重量有差异的部分会被留下来。那就相当于在原始的数据的前面,加上 「加号」或者「减号」,是最终的结果最小即可。也就是说把原始的石头分成两部分,两部分的和越接近越好。
-
又因为当所有元素的和固定时,分成的两部分越接近数组 「总和的一半」,两者的差越小。
因此问题就变成了:在数组中选择一些数,让这些数的和尽量接近 sum / 2
,如果把数看成物品,每个数的值看成体积和价值,问题就变成了 「01 背包问题」。
1. 状态表示:
dp[i][j]
表示在前 i
个元素中选择,总和不超过 j
,此时所有元素的 「最大和」。
2. 状态转移方程:
老规矩,根据 「最后一个位置」 的元素,结合题目的要求,分情况讨论:
-
ⅰ. 不选
stones[i]
:那么我们是否能够凑成总和为j
,就要看在前i - 1
个元素中选,能否凑成总和为j
。根据状态表示,此时dp[i][j] = dp[i - 1][j]
; -
ⅱ. 选择
stones[i]
:这种情况下是有前提条件的,此时的stones[i]
应该是小于等于j
。因为如果这个元素都比要凑成的总和大,选择它就没有意义呀。那么我们是否能够凑成总和为j
,就要看在前i - 1
个元素中选,能否凑成总和为j - stones[i]
。根据状态表示,此时dp[i][j] = dp[i - 1][j - stones[i]] + stones[i]
。
综上所述,我们要的是最大价值。因此,状态转移方程为:
dp[i][j] = dp[i - 1][j];
if (j >= stones[i]) dp[i][j] = dp[i][j] + dp[i - 1][j - stones[i]] + stones[i];
3. 初始化:
由于需要用到上一行的数据,因此我们可以先把第一行初始化。
第一行表示 「没有石子」。因此想凑成目标和 j
,最大和都是 0
。
4. 填表顺序:
根据 「状态转移方程」,我们需要「从上往下」填写每一行,每一行的顺序是「无所谓的」。
5. 返回值:
-
a. 根据 「状态表示」,先找到最接近
sum / 2
的最大和dp[n][sum / 2]
; -
b. 因为我们要的是两堆石子的差,因此返回
sum - 2 * dp[n][sum / 2]
。
class Solution {
public:int lastStoneWeightII(vector<int>& stones) {int n = stones.size(), sum = 0;for(auto x : stones) sum += x;int aim = sum / 2;vector<vector<int>> dp(n + 1, vector<int>(aim + 1));for(int i = 1; i <= n; ++i){for(int j = 0; j <= aim; ++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][aim];}
};
6. 空间优化:
所有的背包问题,都可以进行 「空间」 上的优化。
对于 01
背包类型的,我们的优化策略是:
-
ⅰ. 删掉第一维;
-
ⅱ. 修改第二层循环的 「遍历顺序」 即可。
class Solution {
public:int lastStoneWeightII(vector<int>& stones) {int n = stones.size(), sum = 0;for(auto x : stones) sum += x;int aim = sum / 2;vector<int> dp(aim + 1);for(int i = 1; i <= n; ++i){for(int j = aim; j >= stones[i - 1]; j--){dp[j] = max(dp[j], dp[j - stones[i - 1]] + stones[i -1]);}}return sum - 2 * dp[aim];}
};