文章目录
- 1、动规思路简介
- 2、完全背包【模板】
- 3、零钱兑换
- 4、零钱兑换Ⅱ
- 5、完全平方数
背包问题需要读者先明白动态规划是什么,理解动规的思路,并不能给刚接触动规的人学习。所以最好是看了之前的动规博客,以及01背包博客,才能看完全背包博客,或者你本人就已经懂得动规了。
1、动规思路简介
动规的思路有五个步骤,且最好画图来理解细节,不要怕麻烦。当你开始画图,仔细阅读题时,学习中的沉浸感就体验到了。
状态表示
状态转移方程
初始化
填表顺序
返回值
动规一般会先创建一个数组,名字为dp,这个数组也叫dp表。通过一些操作,把dp表填满,其中一个值就是答案。dp数组的每一个元素都表明一种状态,我们的第一步就是先确定状态。
状态的确定可能通过题目要求来得知,可能通过经验 + 题目要求来得知,可能在分析过程中,发现的重复子问题来确定状态。还有别的方法来确定状态,但都大同小异,明白了动规,这些思路也会随之产生。状态的确定就是打算让dp[i]表示什么,这是最重要的一步。状态表示通常用某个位置为结尾或者起点来确定。
状态转移方程,就是dp[i]等于什么,状态转移方程就是什么。像斐波那契数列,dp[i] = dp[i - 1] + dp[i - 2]。这是最难的一步。一开始,可能状态表示不正确,但不要紧,大胆制定状态,如果没法推出转移方程,没法得到结果,那这个状态表示就是错误的。所以状态表示和状态转移方程是相辅相成的,可以帮你检查自己的思路。
要确定方程,就从最近的一步来划分问题。
初始化,就是要填表,保证其不越界。像第一段所说,动规就是要填表。比如斐波那契数列,如果要填dp[1],那么我们可能需要dp[0]和dp[-1],这就出现越界了,所以为了防止越界,一开始就固定好前两个值,那么第三个值就是前两个值之和,也不会出现越界。初始化的方式不止这一点,有些问题,假使一个位置是由前面2个位置得到的,我们初始化最一开始两个位置,然后写代码,会发现不够高效,这时候就需要设置一个虚拟节点,一维数组的话就是在数组0位置处左边再填一个位置,整个dp数组的元素个数也+1,让原先的dp[0]变为现在的dp[1],二维数组则是要填一列和一行,设置好这一行一列的所有值,原先数组的第一列第一行就可以通过新填的来初始化,这个初始化方法在下面的题解中慢慢领会。
第二种初始化方法的注意事项就是如何初始化虚拟节点的数值来保证填表的结果是正确的,以及新表和旧表的映射关系的维护,也就是下标的变化。
填表顺序。填当前状态的时候,所需要的状态应当已经计算过了。还是斐波那契数列,填dp[4]的时候,dp[3]和dp[2]应当都已经计算好了,那么dp[4]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。
返回值,要看题目要求。
背包问题有很多种分类,此篇是关于完全背包问题的,优化方法写在模板题中,此后都直接写在代码上。
2、完全背包【模板】
DP42 【模板】完全背包
和01背包不同的是,完全背包一个数可以选择多次。dp[i][j]表示从前i个物品中选,总体积不超过j,所有选法中最大的价值。
最后一个位置i,如果不选,那就看dp[i - 1][j],如果选1个,那就看dp[i - 1][j - v[i]] + w[i],如果选2个,那就看dp[i - 1][j - 2v[i]] + 2w[i],依次类推,这里只有j和后面的w变了,那么再按照这个式子写出dp[i][j - v[i]]的值,最后通过数学计算能得到dp[i][j] = max(dp[i - 1][j], dp[i][j - v[i]] + w[i])。
初始化时,新增的一行一列全部为0。从上到下,从左到右填写,返回值是dp[n][V]。
接着第二问,再上面基础上做调整。 dp[i][j]表示体积必须等于j。按照之前的思路,dp[i][j] = -1来表示这个情况不存在,做不到要求。初始化时dp[0][0] = 0,第一行其余位置都是-1,第一列还是0。
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1010;
int n, V, v[N], w[N];
int dp[N][N];
int main()
{
cin >> n >> V;
for(int i = 1; i <= n; i++)
{
cin >> v[i] >> w[i];
}
for(int i = 1; i <= n; i++)
{
for(int j = 0; j <= V; j++)
{
dp[i][j] = dp[i - 1][j];
if(j >= v[i]) dp[i][j] = max(dp[i][j], dp[i][j - v[i]] + w[i]);
}
}
cout << dp[n][V] << endl;
memset(dp, 0, sizeof(dp));
for(int j = 1; j <= V; j++) dp[0][j] = -1;
for(int i = 1; i <= n; i++)
{
for(int j = 0; j <= V; j++)
{
dp[i][j] = dp[i - 1][j];
if(j >= v[i] && dp[i][j - v[i]] != -1)
dp[i][j] = max(dp[i][j], dp[i][j - v[i]] + w[i]);
}
}
cout << (dp[n][V] == -1 ? 0 : dp[n][V]) << endl;
return 0;
}
利用滚动数组做优化。和01背包不一样,它不需要从右到左来循环,它的一个位置需要同行的左边的一个位置和上方一个位置,同行这个位置得是更新后的。
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1010;
int n, V, v[N], w[N];
int dp[N];
int main()
{
cin >> n >> V;
for(int i = 1; i <= n; i++)
{
cin >> v[i] >> w[i];
}
for(int i = 1; i <= n; i++)
{
for(int j = v[i]; j <= V; j++)
{
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << dp[V] << endl;
memset(dp, 0, sizeof(dp));
for(int j = 1; j <= V; j++) dp[j] = -1;
for(int i = 1; i <= n; i++)
{
for(int j = v[i]; j <= V; j++)
{
if(dp[j - v[i]] != -1)
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << (dp[V] == -1 ? 0 : dp[V]) << endl;
return 0;
}
还有一个优化
memset(dp, 0, sizeof(dp));
for(int j = 1; j <= V; j++) dp[j] = -1;
for(int i = 1; i <= n; i++)
{
for(int j = v[i]; j <= V; j++)
{
if(dp[j - v[i]] != -1)
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << (dp[V] == -1 ? 0 : dp[V]) << endl;
这里有一个if判断,是为了能让这个dp值可用,再去让他+w[i]和dp[j]比较,我们可以让那个dp表中的每一个值足够小,这样即使dp[j - v[i]]参与了比较,也不会用它。
memset(dp, 0, sizeof(dp));
for(int j = 1; j <= V; j++) dp[j] = -0x3f3f3f3f;//INT_MIN的一半,也足够小。
for(int i = 1; i <= n; i++)
{
for(int j = v[i]; j <= V; j++)
{
dp[j] = max(dp[j], dp[j - v[i]] + w[i]);
}
}
cout << (dp[V] < 0 ? 0 : dp[V]) << endl;
3、零钱兑换
322. 零钱兑换
dp[i][j]表示从前i个硬币中选,总金额正好等于j,所有的选法中,最少的硬币个数,j就是amount。
最后一个位置i,不选的话就看dp[i - 1][j]。如果选i,选1个,那么这个硬币价值就是coins[i],那为了能正好达到j而不超过,那就得看dp[i - 1][j - coins[i]],然后+1,因为存的是个数,如果选2个,那么就是dp[i - 1][j - 2coins[i]] + 2,根据之前的数学计算,选i的情况就是dp[i][j - coins[i]] + 1,然后和dp[i - 1][j]取小就行。
初始化时多加一行一列,dp[0][0]是0,第一行其它位置是无效的,因为没有硬币的话就不可能凑成金额,按照之前的优化,本来设置成-1然后判断,这里就初始化成足够大的数字就行,因为这道题就min,初始化成0x3f3f3f3f。
返回最后一个位置的值,但有可能到最后也凑不成,所以最后要判断一下。以及滚动数组优化。
int coinChange(vector<int>& coins, int amount) {
const int INF = 0x3f3f3f3f;
int n = coins.size();
vector<int> dp(amount + 1, INF);
dp[0] = 0;
for(int i = 1; i <= n; i++)
{
for(int j = coins[i - 1]; j <= amount; j++)
{
dp[j] = min(dp[j], dp[j - coins[i - 1]] + 1);
}
}
return dp[amount] >= INF ? -1 : dp[amount];
}
4、零钱兑换Ⅱ
518. 零钱兑换 II
看了上一个题的思路,这题很快就出来答案了。
上一个题代码
int coinChange(vector<int>& coins, int amount) {
const int INF = 0x3f3f3f3f;
int n = coins.size();
vector<int> dp(amount + 1, INF);
dp[0] = 0;
for(int i = 1; i <= n; i++)
{
for(int j = coins[i - 1]; j <= amount; j++)
{
dp[j] = min(dp[j], dp[j - coins[i - 1]] + 1);
}
}
return dp[amount] >= INF ? -1 : dp[amount];
}
现在要求组合数,所以选i的情况中就不需要+1了。不用求min,而是所有可能的数值加起来,按照优化后的代码,dp[j] = dp[j] + dp[j - coins[i]],返回时不需要判断,直接返回最后一个位置的值即可。
int change(int amount, vector<int>& coins) {
int n = coins.size();
vector<int> dp(amount + 1);
dp[0] = 1;
for(int i = 1; i <= n; i++)
{
for(int j = coins[i - 1]; j <= amount; j++)
{
dp[j] += dp[j - coins[i - 1]];
}
}
return dp[amount];
}
5、完全平方数
279. 完全平方数
也是一个完全背包问题,dp[i][j]表示从前i个完全平方数中挑选,总和正好等于j,所有选法中,最小的数量。
从1到i方的区间来分析,不选i方,那就看dp[i - 1][j],选一个i方,那就看dp[i - 1][j - i^2] + 1,选两个i方,和之前的一样,最后就是dp[i][j - i^2] + 1,然后两个数取min。
初始化,dp[0][0]是0,第一行其余位置都是不存在的,所以也弄成特殊值0x3f3f3f3f,第一列不用管,默认为0就行。
返回值是dp[根号n][n]。
int numSquares(int n) {
int m =sqrt(n);
vector<int> dp(n + 1, 0x3f3f3f3f);
dp[0] = 0;
for(int i = 1; i <= m; i++)
{
for(int j = i * i; j <= n; j++)
{
dp[j] = min(dp[j], dp[j - i * i] + 1);
}
}
return dp[n];
}
结束。