0-1背包与完全背包模板

经典背包问题——最优方案的值

以下代码的含义见《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=targetP+P=targetN+P2P=target+sumP=(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];
}

例题三:拼凑硬币(基于完全背包的例子)

本题为美团点评2017秋招笔试题目,试卷链接题目链接

题目:

在这里插入图片描述

参考答案:

#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;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值