背包问题需要读者先明白动态规划是什么,理解动规的思路,并不能给刚接触动规的人学习。所以最好是看了之前的动规博客,以及两个背包博客,或者你本人就已经懂得动规了。
动规的思路有五个步骤,且最好画图来理解细节,不要怕麻烦。当你开始画图,仔细阅读题时,学习中的沉浸感就体验到了。
状态表示
状态转移方程
初始化
填表顺序
返回值
动规一般会先创建一个数组,名字为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]也就出来了,此时的顺序就是从左到右。还有别的顺序,要依据前面的分析来决定。
返回值,要看题目要求。
背包问题有很多种分类,此篇是关于二维费用背包问题的,优化代码的方法在之前的两篇背包博客的模板题中,此篇就不写了。
474. 一和零
二维费用的背包问题就是原先的背包问题再加一个考虑因素,比如要考虑体积和重量。二维也有01和完全背包,这道题是二维01背包问题。
01背包中,dp[i][j]表示从前i个物品中挑选,总体积不超过j,最大价值的选法。这道题就要再加一维,变成dp[i][j][k],从前i个字符串中挑选,字符0的个数不超过j,字符1的个数不超过k,最大长度的选法。
状态转移方程。其实还是一样的分析。最后一个位置的字符串,如果不选i,那么就看dp[i - 1][j][k];如果选i,i这个字符串有a个0 1以及b个1,所以就看dp[i - 1][j - a][k - b],然后 + 1,并且要求j - a >= 0,k - b >= 0;两个值取max。
初始化时,i为0,则dp[0][j][k]全为0。返回值,因为要从整个字符串数组中挑选,而不是其中某一个最大值,所以返回值是最后一个值。
int findMaxForm(vector<string>& strs, int m, int n) {
int len = strs.size();
vector<vector<vector<int>>> dp(len + 1, vector<vector<int>>(m + 1, vector<int>(n + 1)));
for(int i = 1; i <= len; i++)
{
int a = 0, b = 0;
for(auto ch : strs[i - 1])
{
if(ch == '0') a++;
else b++;
}
for(int j = 0; j <= m; j++)
{
for(int k = 0; k <= n; k++)
{
dp[i][j][k] = dp[i - 1][j][k];
if(j >= a && k >= b)
dp[i][j][k] = max(dp[i][j][k], dp[i - 1][j - a][k - b] + 1);
}
}
}
return dp[len][m][n];
}
但肯定不能这样写,做优化。去掉i这一维,j和k从大到小循环。
int findMaxForm(vector<string>& strs, int m, int n) {
int len = strs.size();
vector<vector<int>> dp(m + 1, vector<int>(n + 1));
for(int i = 1; i <= len; i++)
{
int a = 0, b = 0;
for(auto ch : strs[i - 1])
{
if(ch == '0') a++;
else b++;
}
for(int j = m; j >= a; j--)
{
for(int k = n; k >= b; k--)
{
dp[j][k] = max(dp[j][k], dp[j - a][k - b] + 1);
}
}
}
return dp[m][n];
}
879. 盈利计划
给了n和minProFit,group和profit数组,挑选几个人做某一份工作,人数不能超过n,并且利润,也就是profit数组里的被挑选的数,要>= minProFit,选group中第几个元素,利润就是profit数组中第几个元素。每个工作只能选一个,所以就是01背包问题。
让dp[i][j][k]表示从前i个工作中挑选,总人数不超过j,总利润至少为k,总共的选法。
状态转移方程。我们当然还是以最后一个位置i来分析。选择第i个工作,那就看dp[i - 1][j][k];如果选i,那么按照上一个题就是dp[i - 1][j - g[i]],k部分,由于是至少,思路就不一样,如果p[i]小于,那么就正常地看[k - p[i]]位置,如果p[i]大于k,就不能选择k - p[i]的位置了,因为数组下标不能为负数,所以这样写max(0, k - p[i]),如果p[i]更大,那么保证前面至少为0就行。然后这两个数相加。
初始化时dp[0][j][0] = 1。填表顺序要保证i从小到大即可。返回值是最后一个值。
int profitableSchemes(int n, int m, vector<int>& g, vector<int>& p) {
const int MOD = 1e9 + 7;
int len = g.size();
vector<vector<int>> dp(n + 1, vector<int>(m + 1));
for(int j = 0; j <= n; j++) dp[j][0] = 1;
for(int i = 1; i <= len; i++)
{
for(int j = n; j >= g[i - 1]; j--)
{
for(int k = m; k >= 0; k--)
{
dp[j][k] += dp[j - g[i - 1]][max(0, k - p[i - 1])];
dp[j][k] %= MOD;
}
}
}
return dp[n][m];
}
结束。