我觉得学习算法很重要的一点是举一反三,题目是刷不完的(这个指的是我自己,我听说过有刷完一千多道leetcode的大佬…),背包问题是我笔试面试过程中遇到非常多的一个类型,而且被坑了好几次,因此我打算好好总结一下,而这个组和问题(你没有看错,是组和,这个是我自己取的名字,英文题目叫Combination Sum)会和背包问题由极大的相似之处,我在一次笔试的过程中将一组和问题用背包问题的解法去做而浪费的不少时间,背包问题应该是可以求出答案的,但是不如组和问题的专有解法来的干脆,因此我把这两个问题放在一起进行一个总结。
背包问题建议看看《背包九讲》,本文也主要是参考的《背包九讲》中的内容,我这里主要只总结一下比较基础的三种——01背包、完全背包、多重背包,而混合背包和分组背包我目前还没怎么接触过(还是做题太少…),就先不做总结了。
(1)01背包
有 N N N 件物品和一个容量为 V V V 的背包。放入第 i i i 件物品耗费的费用是 C i 1 C_i^1 Ci1,得到的价值是 W i W_i Wi。求解将哪些物品装入背包可使价值总和最大
状态变量定义的是 F [ i , v ] F[i, v] F[i,v] 表示将前 i i i件物品恰放入一个容量为 v v v 的背包可以获得的最大价值。则其状态转移方程便是: F [ i , v ] = max { F [ i − 1 , v ] , F [ i − 1 , v − C i ] + W i } F[i, v]=\max \left\{F[i-1, v], F\left[i-1, v-C_{i}\right]+W_{i}\right\} F[i,v]=max{F[i−1,v],F[i−1,v−Ci]+Wi}其伪代码为: F [ 0 , 0.. V ] ← 0 for i ← 1 to N for v ← C i to V F [ i , v ] ← max { F [ i − 1 , v ] , F [ i − 1 , v − C i ] + W i } \begin{array}{l}{F[0,0 . . V] \leftarrow 0} \\ {\text { for } i \leftarrow 1 \text { to } N} \\ {\qquad \begin{aligned} \text { for } v & \leftarrow C_{i} \text { to } V \\ & F[i, v] \leftarrow \max \left\{F[i-1, v], F\left[i-1, v-C_{i}\right]+W_{i}\right\} \end{aligned}}\end{array} F[0,0..V]←0 for i←1 to N for v←Ci to VF[i,v]←max{F[i−1,v],F[i−1,v−Ci]+Wi}而对其进行空间优化之后伪代码为: F [ 0.. V ] ← 0 for i ← 1 to N for v ← V to C i F [ v ] ← max { F [ v ] , F [ v − C i ] + W i } \begin{array}{l}{F[0 . . V] \leftarrow 0} \\ {\text { for } i \leftarrow 1 \text { to } N} \\ {\qquad \begin{aligned} \text { for } v \leftarrow & V \text { to } C_{i} \\ & F[v] \leftarrow \max \left\{F[v], F\left[v-C_{i}\right]+W_{i}\right\} \end{aligned}}\end{array} F[0..V]←0 for i←1 to N for v←V to CiF[v]←max{F[v],F[v−Ci]+Wi}所谓空间优化就是就是将原本的二维DP数组优化为一维DP数组,能这样做的理由是,从未优化的伪代码中可以看出,每次循环二维DP数组中更新的位置只和其正上方 F [ i − 1 , v ] F[i-1, v] F[i−1,v]和左上方 F [ i − 1 , v − C i ] F\left[i-1, v-C_{i}\right] F[i−1,v−Ci]有关,因此可以用一维DP数组直接代替(这其实也叫做滚动数组),那么空间优化后的代码如下:
int backPack(int V, vector &C, vector &W){
int n = C.size();
vector F(V+1, 0);
for(int i = 1; i=C[i]; j--)
{
F[j] = max(F[j],F[j-C[i]]+W[i]);
}
}
return F[V];
}
(2)完全背包
有 N N N 种物品和一个容量为 V V V 的背包,每种物品都有无限件可用。放入第 i i i 种物品的费用是 C i C_i Ci,价值是 W i W_i Wi。求解:将哪些物品装入背包,可使这些物品的耗费的费用总和不超过背包容量,且价值总和最大。
这里的状态变量的定义和01背包是一样的,但是状态转移方程稍有不同,如下: F [ i , v ] = max { F [ i − 1 , v − k C i ] + k W i ∣ 0 ≤ k C i ≤ v } F[i, v]=\max \left\{F\left[i-1, v-k C_{i}\right]+k W_{i} | 0 \leq k C_{i} \leq v\right\} F[i,v]=max{F[i−1,v−kCi]+kWi∣0≤kCi≤v}其伪代码为 F [ 0 , 0.. V ] ← 0 for i ← 1 to N for v ← C i to V for k ← 0 to j / C i F [ i , v ] ← max { F [ i − 1 , v ] , F [ i − 1 , v − k C i ] + k W i } \begin{array}{l}{F[0,0 . . V] \leftarrow 0} \\ {\text { for } i \leftarrow 1 \text { to } N} \\ {\qquad \begin{aligned} \text { for } v & \leftarrow C_{i} \text { to } V \\ & \text{ for } k \leftarrow 0 \text{ to } j/C_i\\&F[i, v] \leftarrow \max \left\{F[i-1, v], F\left[i-1, v-kC_{i}\right]+kW_{i}\right\} \end{aligned}}\end{array} F[0,0..V]←0 for i←1 to N for v←Ci to V for k←0 to j/CiF[i,v]←max{F[i−1,v],F[i−1,v−kCi]+kWi}完全背包由两种优化方法,第一种是利用二进制的思想将其转化为01背包,第二种是优化为一维数组,其时间复杂度为 O ( V N ) O(VN) O(VN)。用得更多的是第二种方法,因为它确实很方便,第一种方法的思想主要用在多重背包问题里,那么第二种方法优化的伪代码如下: F [ 0.. V ] ← 0 for i ← 1 to N for v ← C i to V F [ v ] ← max ( F [ v ] , F [ v − C i ] + W i ) \begin{array}{l}{F[0 . . V] \leftarrow 0} \\ {\text { for } i \leftarrow 1 \text { to } N} \\ {\qquad \begin{aligned} \text { for } v & \leftarrow C_{i} \text { to } V \\ & F[v] \leftarrow \max \left(F[v], F\left[v-C_{i}\right]+W_{i}\right) \end{aligned}}\end{array} F[0..V]←0 for i←1 to N for v←Ci to VF[v]←max(F[v],F[v−Ci]+Wi)这里可以看出来,如果你对01背包熟悉的话,记住完全背包也就是一句话的事情,他们之间唯一不同的是第二个for循环,完全背包是从 C i C_i Ci到 V V V,01背包是从 V V V到 C i C_i Ci,这个规则为什么会成立呢?《背包九讲》中是这么说的:
为什么这个算法就可行呢?首先想想为什么 01 背包中要按照 v v v 递减的次序来循环。让 v v v 递减是为了保证第 i i i 次循环中的状态 F [ i ; v ] F[i; v] F[i;v] 是由状态 F [ i − 1 ; v − C i ] F[i − 1; v − C_i] F[i−1;v−Ci]递推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入第 i i i 件物品”这件策略时,依据的是一个绝无已经选入第 i i i 件物品的子结果 F [ i − 1 ; v − C i ] F[i − 1; v − C_i] F[i−1;v−Ci]。而现在完全背包的特点恰是每种物品可选无限件,所以在考虑“加选一件第 i i i 种物品”这种策略时,却正需要一个可能已选入第 i 种物品的子结果 F [ i ; v − C i ] F[i; v − C_i] F[i;v−Ci],所以就可以并且必须采用 v v v 递增的顺序循环。这就是这个简单的程序为何成立的道理。
这里给出按照第二种优化后的代码(其实真的就是把上面的代码趴下来改一句话就行了)
int backPack(int V, vector &C, vector &W){
int n = C.size();
vector F(V+1, 0);
for(int i = 1; i
(3)多重背包
有 N N N 种物品和一个容量为 V V V 的背包。第 i i i 种物品最多有 M i M_i Mi 件可用,每件耗费的空间是 C i C_i Ci,价值是 W i W_i Wi。求解将哪些物品装入背包可使这些物品的耗费的空间总和不超过背包容量,且价值总和最大。
同样,多重背包的状态变量定义和前两者是相同的,不同的还是状态转移方程,如下: F [ i , v ] = max { F [ i − 1 , v − k ∗ C i ] + k ∗ W i ∣ 0 ≤ k ≤ M i } F[i, v]=\max \left\{F\left[i-1, v-k * C_{i}\right]+k * W_{i} | 0 \leq k \leq M_{i}\right\} F[i,v]=max{F[i−1,v−k∗Ci]+k∗Wi∣0≤k≤Mi}多重背包通常采用的优化方式是通过二进制思想转化为01背包进行求解,所谓二进制思想按照我的个人理解就是,通过给数量为 M i M_i Mi个的物体 i i i 带上二进制标签 2 k 2^k 2k 之后相当于变成了多个数量为 1 的物体,然后通过对这些数量为 1 的物体采用取或者不取的方式最后组合成取具体数量的物体 i i i 。这里直接给出一段多重背包的代码,具体怎么进行二进制的可以通过代码进行理解
int main()
{
int n, w;
cin>>n>>w;
vector weights;//物体重量
vector values;//物体价值
vector nums;//物体数量
for(int i = 0; i>weight>>value>>num;
int temp = 1;
while(num)//在输入的时候进行二进制化
{
if(num>=temp)
{
weights.push_back(temp * weight);
values.push_back(temp * value);
num -= temp;
}else{
weights.push_back(num * weight);
values.push_back(num * weight);
num = 0;
}
temp *= 2;
}
}
//下面就是直接调用01背包的程序就可以了
vector dp(w+1, 0);
for(int i = 0; i=weights[i]; j--)
{
dp[j] = max(dp[j], dp[j-weights[i]]+values[i]);
}
}
cout<
上面的程序是在输入的时候就直接根据物体的数量对物体的价值和重量进行二进制化,temp就是用来进行二进制化的变量,在循环中temp分别是 1 , 2 , 2 2 … 2 k − 1 , M i − 2 k + 1 1,2,2^{2} \ldots 2^{k-1}, M_{i}-2^{k}+1 1,2,22…2k−1,Mi−2k+1,通过将物体的价值和重量乘以这些系数实现二进制化,二进制化物体直接采用01背包的策略就可以直接求得多重背包最终的结果。
好了,真正的重点来了,通过背包问题的变形我们将引出来组和问题,注意到,上面的背包问题都是最典型形式,问的都是给定一个背包容量 V V V,怎么取放物体使得装下的价值 W W W最大 ,这类问题的状态变量的意义是价值,以01背包为例,其状态转移方程为 F [ i , v ] = max { F [ i − 1 , v ] , F [ i − 1 , v − C i ] + W i } F[i, v]=\max \left\{F[i-1, v], F\left[i-1, v-C_{i}\right]+W_{i}\right\} F[i,v]=max{F[i−1,v],F[i−1,v−Ci]+Wi}那么背包问题有一种很典型的变形形式是给定一个背包容量 V V V,怎样取放物体可以放满背包,一共有几种取放方式,对于这个问题,我们就需要将状态变量的意义定义为满足方面背包条件的情况种类,以01背包为例,其状态转移方程就变为: F [ i , v ] = F [ i − 1 , v ] + F [ i − 1 , v − C i ] F[i, v]= F[i-1, v]+ F\left[i-1, v-C_{i}\right] F[i,v]=F[i−1,v]+F[i−1,v−Ci]对应的代码也给出来
int combinationSum(int V, vector &C){
int n = C.size();
vector F(V+1, 0);
V[0] = 1;
for(int i = 1; i=C[i]; j--)
{
F[j] = F[j]+F[j-C[i]];
}
}
return F[V];
}
注意这里,除了状态转移方程有点不太一样之外,还有就是初始话的时候需要将V[0]初始话为1,这里注意一下就好了,这类问题又称找零钱问题,leetcode中还有一个找零钱问题的变形(332题),如下:
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
这个题目看上去像是完全背包问题,但是题目所求的既不是最大价值,也不是方案数,而是最少的硬币个数,这个怎么做呢?方法也是使用动态规划,但是状态转移方程的思路和上述讨论的方式不太相同,具体如下,我们维护一个一维动态数组 dp,其中 dp[i] 表示钱数为i时的最小硬币数的找零,注意由于数组是从0开始的,所以要多申请一位,数组大小为 amount+1,这样最终结果就可以保存在 dp[amount] 中了。初始化 dp[0] = 0,因为目标值若为0时,就不需要硬币了。其他值可以初始化是 amount+1,为啥呢?因为最小的硬币是1,所以 amount 最多需要 amount 个硬币,amount+1 也就相当于当前的最大值了,注意这里不能用整型最大值来初始化,因为在后面的状态转移方程有加1的操作,有可能会溢出,除非你先减个1,这样还不如直接用 amount+1 舒服呢。好,接下来就是要找状态转移方程了,没思路?不要紧!回归例子1,假设我取了一个值为5的硬币,那么由于目标值是 11,所以是不是假如我们知道 dp[6],那么就知道了组成 11 的 dp 值了?所以更新 dp[i] 的方法就是遍历每个硬币,如果遍历到的硬币值小于i值(比如不能用值为5的硬币去更新 dp[3])时,用 dp[i - coins[j]] + 1 来更新 dp[i],所以状态转移方程为(分析来源[LeetCode] 322. Coin Change 硬币找零): d p [ i ] = m i n ( d p [ i ] , d p [ i − c o i n s [ j ] ] + 1 ) dp[i] = min(dp[i], dp[i - coins[j]] + 1) dp[i]=min(dp[i],dp[i−coins[j]]+1)代码如下:
int coinChange(vector& coins, int amount) {
if(coins.empty())
return 0;
vector dp(amount+1,amount+1);
dp[0] = 0;
for(int i = 1; i<=amount; i++)
{
for(int j = 0; j
这里需要读者细细去理解这几种变形中的不同,然后我们继续变形,现在仅仅是要求输出有多少种情况,如果题目要求将所有的情况都打印出来呢?用背包能做吗?答案是可以的,但是比较麻烦,一种更加简单的思路是采用回溯加剪枝,也就是图论的方式去解决,也就是接下来要讨论的组和问题。
组和问题在leetcode中一共由四道,是一个系列,分别是
39. 组合总和
40. 组合总和 II
216. 组合总和 III
377. 组合总和 Ⅳ
其实这个系列并不难,简单分析下:
(1)组和问题
给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。 candidates 中的数字可以无限制重复被选取。
这个其实就是相当于背包问题中的完全背包问题,完成组和问题需要实现一个深度搜索的函数,具体代码如下:
void dfs(vector& candidates, int target, int start, vector& out, vector>& res)
{
if(target<0)
return;
if(target == 0)
{
res.push_back(out);
return;
}
for(int i = start; i> combinationSum(vector& candidates, int target) {
vector> res;
vector out;
dfs(candidates, target, 0, out, res);
return res;
}
(2)组和问题II
给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。
这个相当于是01背包问题,和上面稍有不同的处理方式是,这里因为每个数字只能使用一次,因此需要先对数组进行一次排序,然后递归取数的时候控制不要重复取就好了,代码如下:
void dfs(vector& candidates, int target, int start, vector& out, vector>& res)
{
if(target<0)
return;
if(target == 0)
{
res.push_back(out);
return;
}
for(int i = start; istart && candidates[i] == candidates[i-1]) continue;//判断相邻两个数据是否相同,避免重复
out.push_back(candidates[i]);
dfs(candidates, target-candidates[i], i+1, out, res);//将i改成i+1,避免重复
out.pop_back();
}
}
vector> combinationSum(vector& candidates, int target) {
vector> res;
vector out;
sort(candidates.begin(), candidates.end());
dfs(candidates, target, 0, out, res);
return res;
}
(3)组和问题III
找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。
还是很类似的,这里只是规定了取的个数,有点类似与多重背包,但不完全是,代码如下:
void dfs(int k, int n, int start, vector& out, vector>& res)
{
if(n<0)
return;
if(n == 0 && out.size() == k)//这里限制了取的数量
{
res.push_back(out);
return;
}
for(int i = start; i<=9; i++)
{
out.push_back(i);
dfs(k, n-i, i+1, out, res);//i+1保证了不会重复取
out.pop_back();
}
}
vector> combinationSum3(int k, int n) {
vector> res;
vector out;
dfs(k, n, 1, out, res);
return res;
}
(4)组和问题IV
给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。
这是何方神圣?这不就是01背包吗!是的,但是leetcode上这道题和01背包有些不同的是,组合的顺序不同属于不同的组合,因此其和01背包的解题思路会稍有差别,先看代码
int combinationSum4(vector& nums, int target) {
int n = nums.size();
vector dp(target+1);
dp[0] = 1;
for(int i = 1; i<=target; i++)
{
for(int j = 0; j=nums[j])
dp[i] = dp[i] + dp[i-nums[j]];
}
}
return dp[target];
}
观察发现和01背包不同的是,如果要求组合的顺序不同属于不同的组合,就内外循环的顺序换一下就好了,这个解法的思路和爬梯子的思路就更加相似的,比如说对于 [1,2,3] 4,这个例子,当我们在计算 dp[3] 的时候,3可以拆分为 1+x,而x即为 dp[2],3也可以拆分为 2+x,此时x为 dp[1],3同样可以拆为 3+x,此时x为 dp[0],我们把所有的情况加起来就是组成3的所有情况了。
通过上面的分析可以看出来,如果是求方案数的话通常是用动态规划的方法,如果是要求输出方案的话通常是用回溯加剪枝的方法,从本质上说,这两者是等价的,最终都是搜索了一颗隐式树,回溯加剪枝是深度优先搜索的过程,而动态规划更像是层序遍历的过程,真正体会这两者之间的关系我还需要刷更多的题。