完全背包理论基础-二维DP数组
有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。
完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。
1、确定dp数组以及下标的含义
dp[i][j] 表示从下标为[0-i]的物品,每个物品可以取无限次,放进容量为j的背包,价值总和最大是多少。
2、确定递推公式
这里再把基本信息给出来:

抽象化如下:
① 不放物品i: 背包容量为j,里面不放物品i的最大价值是dp[i - 1][j]。
② 放物品i: 背包空出物品i的容量后,背包容量为j - weight[i],dp[i][j - weight[i]] 为背包容量为j - weight[i]且不放物品i的最大价值,那么dp[i][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值(区别就在这里 ,放物品i则前面也可以取到物品i)。
递推公式: dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
01背包:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3、dp数组如何初始化
关于初始化,一定要和dp数组的定义吻合,否则到递推公式的时候就会越来越乱。
先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。
再看其他情况。
状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]); 可以看出有一个方向 i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。
dp[0][j],即:存放编号0的物品的时候,各个容量的背包所能存放的最大价值。
当 j < weight[0]的时候,dp[0][j] 应该是0,因为背包容量比编号0的物品重量还小。
当j >= weight[0]时,dp[0][j] 如果能放下weight[0]的话,就一直装,每一种物品有无限个。(不同处)
代码初始化如下:
cpp
for (int i = 1; i < weight.size(); i++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略,但很多同学应该没有想清楚这一点。
dp[i][0] = 0;
}
// 正序遍历,如果能放下就一直装物品0
for (int j = weight[0]; j <= bagWeight; j++)
dp[0][j] = dp[0][j - weight[0]] + value[0];
一开始就统一把dp数组统一初始为0,更方便一些。
最后初始化代码如下:
cpp
// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
4、确定遍历顺序
一般先遍历物品再遍历背包:
cpp
for (int i = 1; i < n; i++) { // 遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
}
}
5、举例推导dp数组
完全背包-一维数组
2、递推公式
压缩成一维DP数组,也就是将上一层拷贝到当前层:dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
4、遍历顺序
在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!且无需倒序 ,因为可以重复取物品!
先遍历物品再遍历背包:
cpp
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = weight[i]; j <= bagWeight; j++) { // 遍历背包容量,从weight[i]开始
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
细心的同学可能发现,全文我说的都是对于纯完全背包问题,其for循环的先后循环是可以颠倒的!
但如果题目稍稍有点变化,就会体现在遍历顺序上。如果问装满背包有几种方式的话? 那么两个for循环的先后顺序就有很大区别了,而leetcode上的题目都是这种稍有变化的类型。这个区别,将在后面讲解具体leetcode题目中介绍。
322. 零钱兑换

主要思路与方法
题目中说每种硬币的数量是无限的,可以看出是典型的完全背包问题。
动规五部曲分析如下:
1、确定dp数组以及下标的含义
dp[j]:凑足总额为j所需钱币的最少个数为dp[j]
2、确定递推公式
凑足总额为j - coins[i]的最少个数为dp[j - coins[i]],那么只需要加上一个钱币coins[i]即dp[j - coins[i]] + 1就是dp[j](考虑coins[i])
所以dp[j] 要取所有 dp[j - coins[i]] + 1 中最小的。
递推公式:dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
3、dp数组如何初始化
首先凑足总金额为0所需钱币的个数一定是0,那么dp[0] = 0;
其他下标对应的数值呢?
考虑到递推公式的特性,dp[j]必须初始化为一个最大的数 ,否则就会在min(dp[j - coins[i]] + 1, dp[j])比较的过程中被初始值覆盖。
所以下标非0的元素都是应该是最大值。
代码如下:
cpp
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
4、确定遍历顺序
本题求钱币最小个数,那么钱币有顺序和没有顺序都可以,都不影响钱币的最小个数 。
所以本题并不强调集合是组合还是排列。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。(由于物品的固定顺序,不会出现同一组合的不同排列)
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
下面代码采用coins放在外循环,target在内循环的方式,都为正序。
核心代码:
cpp
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 0; i < coins.size(); i++) { // 遍历物品
for (int j = coins[i]; j <= amount; j++) { // 遍历背包
if (dp[j - coins[i]] != INT_MAX) { // 如果dp[j - coins[i]]是初始值则跳过
dp[j] = min(dp[j - coins[i]] + 1, dp[j]);
}
}
}
if (dp[amount] == INT_MAX) return -1;
return dp[amount];
}
【注】
1、 if (dp[j - coins[i]] != INT_MAX) {
如果 dp[j - coins[i]] 仍然是 INT_MAX,说明无法凑出 j - coins[i],因此不需要进行转移,直接跳过。
不做判断,当 dp[j - coins[i]] 为 INT_MAX 时,INT_MAX + 1 会导致整数溢出!
2、if (dp[amount] == INT_MAX) return -1;
没找到结果单独写出来!!
279. 完全平方数

思路与解法
动规五部曲分析如下:
1、确定dp数组以及下标的含义
dp[j]:和为j的完全平方数的最少数量为dp[j]
2、确定递推公式
dp[j] 可以由dp[j - i * i]推出, dp[j - i * i] + 1 便可以凑成dp[j]。
此时我们要选择最小的dp[j],所以递推公式:
cpp
dp[j] = min(dp[j - i * i] + 1, dp[j]);
3、dp数组如何初始化
dp[0]表示和为0的完全平方数的最小数量,那么dp[0]一定是0。
非0下标的dp[j]应该是多少呢?
从递归公式dp[j] = min(dp[j - i * i] + 1, dp[j]);中可以看出每次dp[j]都要选最小的,所以非0下标的dp[j]一定要初始为最大值,这样dp[j]在递推的时候才不会被初始值覆盖。
4、确定遍历顺序
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
本题外层for遍历背包,内层for遍历物品,还是外层for遍历物品,内层for遍历背包,都是可以的!
给出外层for循环遍历物品,内层for遍历背包的代码:
cpp
for (int i = 1; i * i <= n; i++) { // 遍历物品
for (int j = i * i; j <= n; j++) { // 遍历背包
dp[j] = min(dp[j - i * i] + 1, dp[j]);
}
}
疑问:为何这里不需要判断 dp[j - i * i] !=INT_MAX;
在零钱兑换问题中,硬币面值不一定包含1,因此某些金额可能无法由给定的硬币组合而成,此时对应的dp[j]仍为初始值INT_MAX。若在状态转移时不加判断,直接计算 dp[j - coins[i]] + 1 会导致整数溢出(INT_MAX + 1 变为负数),从而得到错误结果。因此需要显式检查 dp[j - coins[i]] 是否为 INT_MAX,只有可达状态才参与转移。
而在完全平方数问题中,平方数序列必然包含1(因为 1^2 = 1),所以任何正整数 n 都可以由若干个1组成。第一次遍历平方数1时,所有 j 对应的 dp[j] 都会被更新为 j(即全用1组成),此后所有 dp[j] 均不再是 INT_MAX。因此后续遍历其他平方数时,dp[j - i*i] 一定已经可达,无需再判断溢出。
cpp
class Solution {
public:
int numSquares(int n) {
vector<int> dp(n+1,INT_MAX);
dp[0]=0;
for(int i=1;i*i<=n;i++){
for(int j=i*i;j<=n;j++){
dp[j]=min(dp[j-i*i]+1,dp[j]);
}
}
return dp[n];
}
};
139. 单词拆分

动规五部曲分析如下:
1、确定dp数组以及下标的含义
dp[i] : 字符串长度为i的话,dp[i]为true,表示可以拆分为一个或多个在字典中出现的单词。
2、确定递推公式
如果确定dp[j]是true,且 [j, i] 这个区间的子串出现在字典里,那么dp[i]一定是true。(j < i )。
所以递推公式是:
if([j, i] 这个区间的子串出现在字典里 && dp[j]是true) 那么dp[i] = true。
3、dp数组如何初始化
从递推公式中可以看出,dp[i]的状态依靠dp[j]是否为true,那么dp[0]就是递推的根基,dp[0]一定要为true,否则递推下去后面都是false了。
那么dp[0]有没有意义呢?
dp[0]表示如果字符串为空的话,说明出现在字典里。
但题目中说了 "给定一个非空字符串 s" 所以测试数据中不会出现i为0的情况,那么dp[0]初始为true完全就是为了推导公式。
下标非0的dp[i]初始化为false,只要没有被覆盖说明都是不可拆分为一个或多个在字典中出现的单词。
4、确定遍历顺序
题目中说是拆分为一个或多个在字典中出现的单词,所以这是完全背包。
还要讨论两层for循环的前后顺序。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
本题其实我们求的是排列数,为什么呢?
拿 s = "applepenapple", wordDict = ["apple", "pen"] 举例。
"apple", "pen" 是物品,那么我们要求物品的组合一定是 "apple" + "pen" + "apple" 才能组成 "applepenapple"
"apple" + "apple" + "pen" 或者 "pen" + "apple" + "apple" 是不可以的,那么我们就是强调物品之间顺序。所以说,本题一定是先遍历背包,再遍历物品。
5、举例推导dp[i]
以输入: s = "leetcode", wordDict = ["leet", "code"]为例,dp状态如图:

dp[s.size()] 就是最终结果,因为dp[1]对应第一个元素。
核心代码:
unordered_set 适合在以下场景使用:
需要频繁判断一个元素是否存在于集合中,且不关心元素的顺序。例如:
1、单词拆分中检查子串是否在字典里
2、去重:将数据插入 unordered_set 后,集合中的元素自动去重
3、缓存:记录已处理过的对象,避免重复计算
哈希集合不是不能重复么,本题不同单词之间有重复字母?
unordered_set<string> 中不允许存在两个完全相同的字符串。如果字典中有重复的单词(例如 ["leet", "leet"]),放入集合后只会保留一个。
背包: 字符串 s 的前缀长度(即 dp 数组的下标 i)。dp[i]表示前i个字符能否被字典中的单词拼出,相当于背包容量为 i 时能否被恰好装满。
物品: 字典中的单词。每个单词可以重复使用(因为拆分时单词可以多次出现),对应完全背包中物品无限供应的特性。
核心代码:
cpp
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordset(wordDict.begin(),wordDict.end());
vector<bool> dp(s.size()+1,false);
dp[0]=true;
for(int i=1;i<=s.size();i++){ //i表示当前要填充的字符串前缀的长度
for(int j=0;j<i;j++){ //j表示当前单词的起始索引
string word = s.substr(j,i-j);//substr(起始位置,截取的个数)
if(wordset.find(word)!=wordset.end()&&dp[j]){
dp[i]=true;
}
}
}
return dp[s.size()];
}
};
对于到底是j还是j+1,i-j还是i-j+1有疑问?
核心是先把 i 和 j 各自代表什么定清楚。
dp[i]:表示前 i 个字符 s[0...i-1] 能不能被拆分,注意是字符长度为i!!
别把dp[i]和s[i]弄混,dp[i]表示前 i 个字符,即s[0...i-1]。
j:表示最后一个单词的起始位置
那么最后这个单词就是:s[j...i-1],长度是i-1-j+1=i-j!!!