1 完全背包问题
完全背包问题也是背包问题的基础之一,与01背包的区别仅在于物体的数目无限。
完全背包问题:
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
在01背包问题中,使用一维dp数组时,我们从后向前遍历,从而保证了每个物品只能选一次,而在完全背包问题中,我们必须从前向后遍历,利用覆盖的特性,从而允许物品得到无限次的选取。
在遍历顺序上:
1 先物品后背包:这种遍历顺序意味着组合,即顺序不重要,因此,每次都在只添加一类物品的情况下保证优化所有的背包容量。因为每次都只考虑一类物品,因此,每个容量下的物品必然是无序的,即{1,2,3}={3,2,1}。
2 先背包后物体:这意味着排列,顺序不同是有区别的。每一次都考虑在固定的背包容量下,从物品0-物品i的遍历,因此再考虑更高容量的背包时,依然会从头开始考虑物品0,这样的排序必然是有序的,即{1,2,3}!={3,1,2}。
代码如下:
int bagProblem(vector<int>& weight, vector<int>& value, int bagWeight)
{
vector<int> dp(bagWeight + 1, 0);
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0)
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
return dp[bagWeight];
}
2 零钱兑换II
LeetCode:零钱兑换II
这是一道完全背包的题目,背包的容量为amount,物体重量与价值一致,为coins[i],最终所求的是装满amount的方案有多少种。
由于找零结果是无序的,因此采用组合,先遍历物品(硬币),再遍历背包(金额)。
class Solution {
public:
int change(int amount, vector<int>& coins) {
//一个完全背包问题,背包的容积为amount
//物品的重量和价值一致,都为coins[i]
//最终求的是装满amount大小的背包的方案有多少种
vector<int> dp(amount+1,0);
//先硬币种类,再遍历背包容积
//递推公式为dp[j]=dp[j-num[0]]+dp[j-num[1]]+...+dp[j-num[?]]
//初始化dp[0]=1
dp[0]=1;
for(int i=0;i<coins.size();++i)
{
for(int j=coins[i];j<=amount;++j)
{
dp[j]=dp[j]+dp[j-coins[i]];
}
}
return dp[amount];
}
};
3 组合总和Ⅳ
LeetCode:组合总和Ⅳ
和上一题唯一的区别在于,这一题所求的是排列,先遍历背包,再遍历物品。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
//完全背包问题,容积为target的背包,装满target大小的背包的可能性有多少
//递推公式为dp[j]=dp[j]+dp[j-nums[i]],且因为target>=1,所以dp[0]=1是可以放心初始化的
//因为顺序不同的组合被认为是不同的组合,实际上就是一个排列问题
//因此,最内层的循环理应每一次都从nums的最开头开始取数,防止遗漏
//因此,先遍历容积,在遍历物品
vector<int> dp(target+1,0);
dp[0]=1;
//由于每一次i都是从0开始遍历
//所以{j,i}={1,3}和{j,i}={3,1}会被视为不同的组合被记录
for(int j=1;j<=target;++j)
{
for(int i=0;i<nums.size();++i)
{
if(j>=nums[i] && dp[j]<INT_MAX-dp[j-nums[i]])
dp[j]+=dp[j-nums[i]];
}
}
return dp[target];
}
};
4 爬楼梯
LeetCode:爬楼梯
曾经作为斐波那契数列的动态规划题目出现,如果每次能上1-m阶,那么就可以用完全背包问题解决,即背包=要上的阶数,物品=一次能上的阶数(1-m),因为爬楼梯必然是有序的,使用排列:先背包后物品。
class Solution {
public:
int climbStairs(int n) {
int m=2;
//使用背包问题去思考,相当于一个容积为n的背包
//对重量和价值为1,2,...,m的物品求完全背包问题的解法个数
//爬楼梯有先后顺序,是排列问题,需要最内层从0开始遍历物品
//故先遍历背包
vector<int> dp(n+1,0);
dp[0]=1;
for(int i=1;i<=n;i++)
{
for(int j=1;j<=m;j++)
{
if(i>=j)
dp[i]+=dp[i-j];
}
}
return dp[n];
}
};
5 零钱兑换
LeetCode:零钱兑换
目标是用最少数量的物品装满amount大小的背包,遍历顺序无所谓,因为所求的是最小找零的个数。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
//不需要找零的情况排除
if(amount==0)
return 0;
//这是一个完全背包问题,在amount大小的背包里
//用最少的物品装满
vector<int> dp(amount+1,amount+1);
//递推公式为dp[i]=min(1+dp[i-coin[0]],1+dp[i-coin[1]],...,)
//所以dp[0]=0
dp[0]=0;
//先遍历背包
for(int i=1;i<=amount;++i)
{
for(int j=0;j<coins.size();++j)
{
if(coins[j]<=i)
dp[i]=min(dp[i],1+dp[i-coins[j]]);
}
}
if(dp[amount]==amount+1)return -1;
else return dp[amount];
}
};
6 完全平方数
LeetCode:完全平方数
做了上道题和这道题,才发现以前好多自然而然解决的动态规划题,本质都是背包问题。
class Solution {
public:
int numSquares(int n) {
//完全背包问题,用1,4,9,...的物品去装容积为n的背包
vector<int> dp(n+1,n+1);
//递推公式dp[i]=min(dp[i],1+dp[i-num[j]*num[j]])
dp[0]=0;
for(int i=1;i<=n;++i)
{
for(int j=1;j*j<=i;++j)
{
dp[i]=min(dp[i],1+dp[i-j*j]);
}
}
return dp[n];
}
};
7 单词拆分
LeetCode:单词拆分
因为可以重复使用,所以是完全背包问题,因为单词的拼写是有序的,applepen!=penapple,所以必须使用排列:先遍历背包再遍历物品。
class Solution {
public:
bool suffixSame(string& s,int s_length,string& word)
{
for(int i=0;i<word.size();++i)
{
if(word[word.size()-1-i]!=s[s_length-1-i])
return false;
}
return true;
}
bool wordBreak(string s, vector<string>& wordDict) {
//一个完全背包问题,背包容量为s,物品为wordDict
vector<bool> dp(s.size()+1,false);
dp[0]=true;
//必须先遍历背包,再遍历物品,这是因为本题讲究的是排列
//applepen=apple+pen!=pen+apple
for(int i=1;i<=s.size();++i)
{
for(int j=0;j<wordDict.size();++j)
{
string word=wordDict[j];
if(i<word.size())
continue;
else
{
//背包大小为i,word大小为w.size,前置单词数为i-w.size,起点为i-w.size
if(suffixSame(s,i,word) && dp[i]==false)
dp[i]=dp[i-word.size()];
}
}
}
return dp[s.size()];
}
};
8 总结
完全背包问题的精髓在于,遍历顺序的选择,无论是从前到后的允许多次,还是先背包或先物品导致的排列与组合,关键在于对问题进行抽象。
——2023.3.4