文章目录
经典背包问题——最优方案的值
以下代码的含义见《2013年王道论坛计算机考研机试指南》的《动态规划》那章的《背包》部分。
价值和体积的定义
如果题目没有明确给出价值和体积的定义,首先需要想清楚什么是价值,什么是体积。一般是根据限制条件(满足的等式)来设体积,根据目标值(最大化或最小化的量)来设价值。例如 leetcode 的 Coin Change,这一题中,限制总面额恰好等于amount,所以可以将硬币的面额视为体积,而目标是硬币的总数量最小,所以可以将每种硬币的价值都设为1(每取一个,总数量+1)。
假设物品的数量为 n,体积数组int w[n],价值数组int v[n],背包总体积 s.
DP数组的定义和初始化
dp维度为总体积加一,要多一位是因为需要用dp[0]来表示取前0个且总体积≤(或=)s时的最大(或最小)价值。
vector<int> dp(s+1, 0);
dp[0]初始化为0,对于dp[1]~dp[s]:
if (题目要求恰好装入背包):{
if (求的是最大总价值){
初始化为INT_MIN
}else if(求的是最小总价值){ //例如Coin Change
初始化为INT_MAX
}
else if(不要求恰好装满背包){
初始化为0
}
状态转移
遍历DP数组 n 次,时间复杂度 O( n*s )。
如果是0-1背包问题,即每个物品最多取一次,则状态转移代码:
for (int i = 0; i < n; i++) {
for (int j = s; j >= w[i]; j--) { // 逆序遍历dp数组(与完全背包的差异之处在这里)
// 如果要求恰好装入,需要if判断
if (dp[j - w[i]] != INT_MAX或INT_MIN) { //根据目标为最小/最大来选INT_MAX/INT_MIN
dp[j] = min或max(dp[j], dp[j - w[i]] + v[i]); //根据目标为最小/最大来选min/max
}
}
}
如果是完全背包问题,即每个物品可以取无限次,则状态转移代码:
for (int i = 0; i < n; i++) {
for (int j = w[i]; j <= s; j++) { // 顺序遍历dp数组(与0-1背包的差异之处在这里)
// 如果要求恰好装入,需要if判断
if (dp[j - w[i]] != INT_MAX或INT_MIN) { //根据目标为最小/最大来选INT_MAX/INT_MIN
dp[j] = min或max(dp[j], dp[j - w[i]] + v[i]); //根据目标为最小/最大来选min/max
}
}
}
完全背包只需修改遍历顺序的原因(重点)
在0/1背包中,根据用还是不用第 i 个物品,dp[i][j] 有两个状态来源:
- 如果不使用第
i个物品,则dp[i][j]=dp[i-1][j] - 如果使用第
i个物品,则dp[i][j]=dp[i-1][j-w[i]]+v[i]
因此在简化dp数组为一维后,我们逆序遍历dp数组,遍历到 j 时,dp[j]等价于二维情况中的 dp[i-1][j],dp[j-w[i]] 等价于二维情况中的 dp[i-1][j-w[i]].
完全背包情况下,未简化空间的二维dp数组dp[i][j]仍然定义为:使用前 i 个物品且总体积 ≤(或=) j 的最大/小价值。
在完全背包中,还是根据用还是不用第 i 个物品,dp[i][j] 有两个状态来源:
- 如果不使用第
i个物品,则dp[i][j]=dp[i-1][j],(这里和0/1背包一样) - 如果使用第
i个物品,则dp[i][j]=dp[i][j-w[i]]+v[i]
第二个转移方程是因为,在使用第 i 个物品时,至少需要使用 1 次,而至多使用 k=s/w[i] 次。至多使用 k 次 = 至多使用 k-1 次 + 使用 1 次. 而且, “至多使用第 i 个物品k-1 次” 正好就是dp[i][j-w[i]].
将dp数组简化到一维后,由于我们是顺序更新dp数组的,所以在更新 dp[j] 时,dp[j]等价于二维情况中的 dp[i-1][j],dp[j-w[i]] 等价于二维情况中的 dp[i][j-w[i]]. 因此才有了完全背包情况下的转移方程.
返回最小值/最大值
如果题目不要求恰好装入,直接返回 dp[s];
如果题目要去恰好装入,判断dp[s]是不是INT_MAX或INT_MIN,是的话表示无解,按照规定来返回(例如返回-1),否则表示有解,直接返回dp[s].
例题:LeetCode - Coin Change
本题 n 为硬币数量,即coins数组长度,s为amount。因为面额总和有限制,所以将每个物品(硬币)的体积定义为它的面额。因为目标为硬币数量最小,所以将每个物品(硬币)的价值定义为1。由于不限制每种硬币的数量,所以这是完全背包问题。按照模板得到代码如下(前面多了一两行代码用于处理特殊情况):
int coinChange(vector<int>& coins, int amount) {
int n = coins.size();
if (n == 0 || amount == 0)return 0;
//定义和初始化DP数组
vector<int> dp(amount + 1, 0);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
dp[i] = INT_MAX;
}
//状态转移
for (int i = 0; i < n; i++) {
for (int j = coins[i]; j <= amount; j++) {
if (dp[j - coins[i]] != INT_MAX) {
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
}
}
}
//返回最小值
return dp[amount] == INT_MAX ? -1 : dp[amount];
}
变种背包问题——有无解/解的个数
这类变种既可以基于0/1背包,也可以基于完全背包,这里先给出基于0/1背包的解释,每个物品只可能取或不取。基于完全背包的情况其实和前面所述一样,只需要修改dp数组从左到右遍历。
这类问题的目标不是求最值,而是求解的个数 / 方案的个数,而且,问题只能找到“体积”的定义而找不到“价值”的定义,换言之,价值可以是任意数,我们关心的是在体积限制下的解的个数。
例如 Partition Equal Subset Sum 问题可以理解为“给定一个数组,求其和为 s u m 2 \frac {sum}2 2sum的子集个数,其中sum为数组的总和”。问题中的体积就是每个数字的大小;每个数字(物品)可以最多选一次,选出的数字子集的体积限制是恰好等于 s u m 2 \frac {sum}2 2sum;然而,我们没办法定义“价值”。
DP数组的定义和初始化
dp维度为总体积加一,要多一位是因为需要用dp[0]来表示取前0个且总体积≤(或=)s时的解的个数。dp[0]初始化为1,表示前0个物品的“体积和为0的解”数量为1(即空集)。其余初始化为0,表示前0个物品的“体积和为j(j≠0)的解”数量为0。
若是问有无解:
vector<int> dp(s+1, false);
dp[0]=true;
若是问解的个数:
vector<int> dp(s+1, 0);
dp[0]=1;
状态转移
for (int i = 0; i < n; i++) {
for (int j = s; j >= w[i]; j--) { // 逆序遍历dp数组(基于0/1背包)
dp[j] = dp[j] + dp[j - w[i]]; // 问有无解则将加号 + 改为或操作 ||
}
}
返回有无解/解的个数
return dp[s];
例题一:Partition Equal Subset Sum
参考答案:
bool canPartition(vector<int>& nums) {
int sum=0;
for(int i=0;i<nums.size();i++){
sum+=nums[i];
}
if(sum%2==1)return false;
int s=sum/2;
// 求是否存在总体积为s的子集,即有无解
vector<bool> dp(s+1,false);
dp[0]=true;
for(int i=0;i<nums.size();i++)
for(int j=s;j>=nums[i];j--)
dp[j]=dp[j] || dp[j-nums[i]]; //本题是问有无解,故使用或操作
return dp[s];
}
例题二:Target Sum
本题可以转化为求解个数(子集个数)的问题,分析过程如下:
本题对集合中所有元素取正号或负号,将得到两个子集: P P P内所有元素都取正号, N N N内所有元素都取负号。
根据题意有:
P + N = t a r g e t ⟺ P + P = t a r g e t − N + P ⟺ 2 ∗ P = t a r g e t + s u m ⟺ P = ( t a r g e t + s u m ) / 2 \begin{aligned} P+N=target &\iff P+P=target-N+P\\ &\iff 2*P=target+sum\\ &\iff P=(target+sum)/2 \end{aligned} P+N=target⟺P+P=target−N+P⟺2∗P=target+sum⟺P=(target+sum)/2
其中, s u m sum sum为数组的元素和。即问题转化为,问和为 ( t a r g e t + s u m ) / 2 (target+sum)/2 (target+sum)/2的子集有多少个,即解的个数的问题。
参考答案:
int findTargetSumWays(vector<int>& nums, int target) {
int sum=0;
for(int i=0;i<nums.size();i++)sum+=nums[i];
if(target>sum||(target+sum)&1==1)return 0;
int s=(target+sum)>>1;
//求和(“总体积”)为s的子集个数,即解的个数问题
vector<int> dp(s+1,0);
dp[0] = 1;
for (int i=0;i<nums.size();i++)
for (int j = s; j >= nums[i]; j--)
dp[j] = dp[j] + dp[j-nums[i]];
return dp[s];
}
例题三:拼凑硬币(基于完全背包的例子)
题目:

参考答案:
#include <iostream>
#include <vector>
using namespace std;
//注意dp数组的定义以及结果的返回值都必须用long long防止溢出导致部分样例无法通过
long long combination(int n) {
vector<int> w = { 1,5,10,20,50,100 };
vector<long long> dp(n + 1, 0);
dp[0] = 1;
for (int i = 0; i < w.size(); i++)
for (int j = w[i]; j <= n; j++) //基于完全背包时仅需要修改遍历顺序
dp[j] = dp[j - w[i]] + dp[j];
return dp[n];
}
int main() {
int n;
while (cin >> n) {
cout << combination(n) << endl;
}
return 0;
}


455

被折叠的 条评论
为什么被折叠?



