动态规划一直是我自从接触编程以及算法以来比较畏惧的一个算法,不管是用他暴力打表还是用他优化代码,dp的思路一直是十分高深莫测的,第一个发明dp的人简直是天才!
完全背包问题是背包问题的一种,另一种与之媲美的是01背包,区别就在于物品的是否可重复选择性,本文主要对于完全背包(物品可以重复选择)进行总结;
01背包:
如果是 01 背包,即数组中的元素不可重复使用,外循环遍历 arrs,内循环遍历 target,且内循环倒序
完全背包:
第一种:如果组合问题需考虑元素之间的顺序,需将 target 放在外循环,将 arrs 放在内循环,且内循环正序。
第二种:如果是完全背包,即数组中的元素可重复使用并且不考虑元素之间顺序,arrs 放在外循环(保证 arrs 按顺序),target在内循环。且内循环正序。
当前状态可以通过多个之前状态得到,所以需要每次都更新最优解,外层循环正向和逆向都可以,主要看怎样选择能够构造出递推关系(先将初始状态更新完之后再更新下一个状态)
做题的主要思路就是:用子问题递推出母问题,再找出递推时的规律【状态转移方程】。
首先题目意思就是通过字典中的单词(物品)来看看是否能组成目标(装进背包),这很明显就是一个完全背包类的问题了:
要找是否能从字典中的单词拼接为连续的目标字符串,而且目标字符串是有顺序的,所以外层循环要遍历目标字符串,内层循环要遍历字典,当前的问题就变成了在遍历字符串时,当前位置是否能被字典中的单词所组成,所以要设置一个初始状态dp[0]=0,开始遍历时,外层循环的i表示字符串的前i个所组成的子字符串,(外层循环正向进行,因为正向进行可以先把字典中的字符串给标记出来(初始化初始状态))每次都遍历字典,看看是否有能用的(能够拼接在某个dp[i-len]之后),能够拼接的条件就是拼接处之前的子字符串能够通过字典中的单词拼接得到,所以最后只需要看dp[s.size()]的状态即可。
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
vector<bool> dp(s.size()+1,false);
dp[0] = true;
//dp[i]等于true了说明i之前的能拼接成(i后面可以拼接)
//到i为止之前长度为i的子串能否被匹配到
for(int i=1;i<=s.size();++i)
{
for(auto it : wordDict)
{
int len = it.size();
if(i-len>=0 && s.substr(i-len,len)==it)
// dp[i] = dp[i] || dp[i-len];
if(dp[i-len]) dp[i]=true;
}
}
return dp[s.size()];
}
};
这种第一种完全背包问题,由于需要保证目标的元素顺序,所以需要把目标放在外层循环!
显然不需要考虑顺序,所以为第二种完全背包问题,目标放在内循环(且正序)。
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n+1,INT_MAX);
dp[0] = 0;
//对物品顺序有要求 -> 先遍历物品再遍历目标
//每次遍历内都遍历目标是因为找出选这个物品时的最优解(选与不选)
for(int i=1;i<=sqrt(n);i++)
{
//dp[j]表示的是和为j的时候所需要的最小数量
for(int j=1;j<=n;j++)
{
//+1是因为选择了当前的这个i
if(j>=i*i) dp[j] = min(dp[j],dp[j-i*i]+1);
}
}
return dp[n];
}
};
其实完全背包问题外层循环的正序还是倒序不影响结果,只需要维护好数组索引不越界以及当前索引表示的数组的值有效即可,因为每一次循环都有一次内层循环,而内层循环才是更新最优解,所以不管外层循环的值是多少,动态规划至于上一个状态有关,上一个状态又是上上一个状态的最优解,所以当前状态就是最优解了,与外层循环无关,只不过选好遍历顺序有时候好理解一点。
完全背包:物品循环(外层)的顺序不会对结果产生影响,但内层的重量循环必须是正序。
01 背包:物品循环(外层)的顺序同样不影响结果,然而内层的重量循环必须是倒序。
和上面这道题一样:
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1,INT_MAX);
dp[0] = 0;
// sort(coins.begin(),coins.end(),greater<>());
for(int j=0;j<coins.size();j++)
{
int t = coins[j];
for(int i=1;i<=amount;i++)
//对于每一个dp[i]都是由dp[i-t]递推过来的
if(i>=t && dp[i-t]!=INT_MAX) dp[i] = min(dp[i],dp[i-t]+1);
}
return (dp[amount]==INT_MAX ? -1 : dp[amount]);
}
};
由于target与元素顺序有关,所以target放在外层循环!遍历方向都可以,因为内层循环会每次都遍历一遍最优解。
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
vector<long long> dp(target + 1);
dp[0] = 1;
//target放在外层 对于每一个target都遍历每一种顺序 用于求“排列问题”
for (int i = 1; i <= target; i++) {
for (int& num : nums) {
if (num <= i && dp[i - num] <= INT_MAX - dp[i]) {
dp[i] += dp[i - num];
}
}
}
return dp[target];
}
};
与顺序无关:外层循环遍历物品,遍历过后就不考虑这个物品了
class Solution {
public:
int change(int amount, vector<int>& coins) {
vector<unsigned long long> dp(amount+1);
dp[0] = 1;//种类数初始化为1 什么都不选
for(auto it : coins)
{
for(int i=1;i<=amount;++i)//因为与顺序无关所以内部循环target
{
//每个物品只循环一次(虽然可以多次取,但是只循环一次保证了顺序性即只会考虑到这一种“组合”而不是“排列”)
if(i>=it) dp[i] += dp[i-it];
}
}
return dp[amount];
}
};
所以,完全背包就是:
目标target与元素顺序有关 就把target放在外层循环,否则就放在内层循环,而内层循环都要正向遍历,因为要以最小的为基础递推更大的情况!
完全背包问题的核心是:状态转移允许同一物品多次选取,通过正序遍历目标值实现。