动态规划二维处理
本题与「完全背包求方案数」问题的差别在于:选择方案中的不同的物品顺序代表不同方案。
举个 🌰,在「完全背包」问题中,凑成总价值为 6 的方案 [1,2,3] 算是 11 种方案,但在本题算是 3 * 2 * 1 = 63∗2∗1=6 种方案([1,2,3],[2,1,3],[3,1,2] … )。
因此我们不能直接代入「完全背包」的思路(状态定义)来求解。
这时候可以从「构成答案的组合」入手:利用 1 <= nums[i] <= 1000 和 1 <= target <= 1000 条件可以确定,组合长度必然在 [1, 1000]。
定义 f[i][j] 为组合长度为 i,凑成总和为 j 的方案数是多少。
由于对组合方案的长度没有限制,因此我们最终答案为所有的 f[x][target]的总和。
同时有显而易见的初始条件(有效值):f[0][0] = 1。即当我们考虑0个数字时,并且当前目标值也为0时,算一种最小子问题,方案数为1
那么对任意的 f[len][target] 而言,组合中的最后一个数字可以选择 nums 中的任意数值,因此 f[len][target]应该为以下所有方案总和:
- 最后一个数选择 nums[0],方案数为 f[len - 1][target - nums[0]]
- 最后一个数选择 nums[1],方案数为 f[len - 1][target - nums[1]]
- 最后一个数选择 nums[2],方案数为 f[len - 1][target - nums[2]]
- …
即转移方程为:
代码:
class Solution {
public:
int combinationSum4(vector<int>& nums, int target)
{
int size = target;//最大的组合长度就是都选1,凑出target
//防止数据溢出,这里要设置为unsigned long或unsigned long long
vector<vector<unsigned long>> dp(size + 1, vector<unsigned long>(target + 1, 0));
//当考虑的数字为0个时,并且目标值为0时,找到一个方案数
//当我们考虑的数字为0个时,目标值从1---target,显然都凑不出来,方案数为0,这是在初始化时就已经做好的工作
dp[0][0] = 1;
int ans = 0;//记录最终计算的结果
//考虑其他组合长度
for (int i = 1; i <=size; i++)
{
for (int j = 0; j <= target; j++)
{
for (auto num : nums)
{
//选择当前数字的前提是,目标值大于等于当前所选数字
if (j >= num)
dp[i][j] += dp[i - 1][j - num];
}
}
//累加所有能凑出目标值的组合方案数量
ans += dp[i][target];
}
return ans;
}
};
动态规划(降维优化)
我们知道「完全背包」可以通过取消物品维度来实现降维优化。
本题也可以使用相同手段:定义 f[i]为凑成总和为 i 的方案数是多少。
由于 nums 的数都是正整数,因此我们有显然的初始化条件 f[0] = 1(代表什么都不选,凑成总和为 0 的方案数为 1),同时最终答案为 f[target]。
不失一般性的考虑 f[i] 该如何转移,由于每个数值可以被选择无限次,因此在计算任意总和时,我们保证 nums 中的每一位都会被考虑到即可(即确保对组合总和 target 的遍历在外,对数组 nums 的遍历在内
)。
即转移方程为:
代码:
class Solution {
public:
int combinationSum4(vector<int>& nums, int target)
{
vector<int> dp(target + 1,0);// or vector<unsigned long long> f(target + 1,0); 就不用做取模的操作了
dp[0] = 1;
for (int i = 0; i <=target; i++)
{
for (auto num : nums)
{
//c++计算的中间结果会溢出,但因为最终结果是int
//因此每次计算完都对INT_MAX取模,0LL是将计算结果提升到long long类型
if (i >= num)
dp[i] = (0LL + dp[i] + dp[i - num]) % INT_MAX;
}
}
return dp[target];
}
};
动态规划—完全背包的一维套路模板双重for循环变式
思路:
本题题目描述说是求组合,但又说是可以元素相同顺序不同的组合算两个组合,其实就是求排列!
弄清什么是组合,什么是排列很重要。
组合不强调顺序,(1,5)和(5,1)是同一个组合。
排列强调顺序,(1,5)和(5,1)是两个不同的排列。
本题求的是排列总和,而且仅仅是求排列总和的个数,并不是把所有的排列都列出来。
如果本题要把排列都列出来的话,只能使用回溯算法爆搜。
动态规划五部曲
1.确定dp数组以及下标的含义
- dp[i]: 凑成目标正整数为i的排列个数为dp[i]
2.确定递推公式
- dp[i](考虑nums[j])可以由 dp[i - nums[j]](不考虑nums[j]) 推导出来。
- 因为只要得到nums[j],排列个数dp[i - nums[j]],就是dp[i]的一部分。
- 求装满背包有几种方法,递推公式一般都是dp[i] += dp[i - nums[j]];
- 本题也一样。
3.dp数组如何初始化
-
因为递推公式dp[i] += dp[i - nums[j]]的缘故,dp[0]要初始化为1,这样递归其他dp[i]的时候才会有数值基础。
-
至于dp[0] = 1 有没有意义呢?
-
意义: 当什么数字都不考虑时候,并且当前目标和为0,是最小子结构
-
至于非0下标的dp[i]应该初始为多少呢?
-
初始化为0,意义:靠目标和为0时,考虑其他任何数字都不符合条件,方案数为0
4.确定遍历顺序
个数可以不限使用,说明这是一个完全背包。
得到的集合是排列,说明需要考虑元素之间的顺序。
本题要求的是排列,那么这个for循环嵌套的顺序可以有说法了。
在动态规划:518.零钱兑换II 中就已经讲过了。
如果求组合数就是外层for循环遍历物品,内层for遍历背包。
如果求排列数就是外层for遍历背包,内层for循环遍历物品。
如果把遍历nums(物品)放在外循环,遍历target的作为内循环的话,举一个例子:计算dp[4]的时候,结果集只有 {1,3} 这样的集合,不会有{3,1}这样的集合,因为nums遍历放在外层,3只能出现在1后面!
所以本题遍历顺序最终遍历顺序:target(背包)放在外循环,将nums(物品)放在内循环,内循环从前到后遍历。
举例来推导dp数组
代码:
class Solution {
public:
int combinationSum4(vector<int>& nums, int target)
{
int size = nums.size();//可选数字的个数
vector<int> dp(target+1,0);
dp[0] = 1;
for (int j = 0; j <= target; j++)
{
for (int i = 0; i < size; i++)
{
if (j >= nums[i] && dp[j] < INT_MAX - dp[j - nums[i]])
dp[j] += dp[j - nums[i]];
}
}
return dp[target];
}
};
C++测试用例有超过两个树相加超过int的数据,所以需要在if里加上dp[i] < INT_MAX - dp[i - num]。
对上述动态规划的一个小总结
求装满背包有几种方法,递归公式都是一样的,没有什么差别,但关键在于遍历顺序!
本题与动态规划:518.零钱兑换II 就是一个鲜明的对比,一个是求排列,一个是求组合,遍历顺序完全不同。
如果对遍历顺序没有深度理解的话,做这种完全背包的题目会很懵逼,即使题目刷过了可能也不太清楚具体是怎么过的。
此时大家应该对动态规划中的遍历顺序又有更深的理解了。
记忆化搜索
把问题转化为对一颗多叉树的遍历过程
green:代表递归越界
red:代表找到了一个解
递归三部曲:
- 结束条件:越界或找到一个解
- 返回值:当前找到的可行方案数
- 本级递归做什么:依次选取数组中每个数字,并累计求其返回的方案数之和
如果大家仔细看图,不难发现在递归过程中出现了很多重复计算的结果:
例如目标值为1的状态就重复求解了四次,目标值为2的状态重复求解了两次
很显然这里需要用哈希表保存已经计算出来的结果,下次用到的时候直接返回即可
代码:
class Solution {
unordered_map<int, int> cache;//保存当前目标值状态下对应的求解方法数
public:
int combinationSum4(vector<int>& nums, int target)
{
if (cache.find(target) != cache.end()) return cache[target];
if (target < 0) return 0;
if (target == 0) return 1;
int waysSum = 0;
for (int i = 0; i < nums.size(); i++)
{
waysSum += combinationSum4(nums, target - nums[i]);
}
return cache[target] = waysSum;
}
};
进阶
- 如果给定的数组中含有负数会发生什么?问题会产生何种变化?如果允许负数出现,需要向题目中添加哪些限制条件?
如果存在负权值,答案可能会有无穷个。因为本身数值能够被选无限次,一旦存在负权,我们可以构造多个总和为 0 的方案,然后在此方案上构造出 target。
举个 🌰,考虑如下数据:nums = [-1,1,2] , target = 2,我们构造出无数个总和为 0 的方案:
- [-1,1]
- [-1,1,-1,1]
- [-1,1,-1,1,-1,1]
- …
然后在这些方案的基础上,构造出 target。
因此,如果允许出现负权,需要增加选择数量的限制。
可以考虑在二维解决方案的基础上去做,因为本质是一个「图论的有限步数到达具体节点」的问题,当我们期望从状态 f[0][0]到达 f[m][n],但是中间存在总权值为 0 的环,那么我们可以通过进入无数次这样的环,来构成无限种方案。因此直接限制进环次数,或者增加总步数限制,就能从无限集合中解脱出来。
关于溢出说明
首先 Java 不需要考虑溢出,CPP 需要考虑溢出,绝不是因为测试数据不同,而是两者对于「溢出」处理不同导致。
由于题目最终答案是 int,因此 Java 不需要用额外操作。
当 Java 发生溢出时,会直接当成负数来处理。这就导致了只要答案最终是 int,所有的溢出会被补偿回来:
{
System.out.println(Integer.MIN_VALUE); // -2147483648
int a = Integer.MAX_VALUE;
System.out.println(a); // 2147483647
a += 1;
System.out.println(a); // -2147483648
a -= 1;
System.out.println(a); //2147483647
}
这意味着,如果我们在运算过程中如果只涉及「纯加减运算」,而不涉及「乘除」、「取最大值/最小值」和「数值大小判断」的话,Java 是不需要使用 Long 来确保正确性的,因为最终溢出会被转化回来。
按道理,CPP 本身对于 int 溢出的转化处理也是一样的。
但在 LC 上的 CPP 发生溢出时,不会直接当做负数来处理,而是直接抛出异常。因此同样的代码在 LC 上是无法被正常执行的:
{
cout << INT_MIN << endl; //-2147483648
int a = INT_MAX;
cout << a << endl; // 2147483647
a += 1; // 溢出报错
cout << a << endl;
a -= 1;
cout << a << endl;
}
这是一般性的,对于 LC 上的同一道题,Java 不需要处理溢出,CPP 需要处理的原因。
但本题还有另外一个原因:由于状态值是被累加的,最终答案又是 int,所以其实那些溢出值是不会被用到的(不会与我们的目标状态值相关),CPP 使用 ULL 其实只是单纯为了解决溢出报错罢了。
cpp溢出解决方法
c++计算的中间结果存在溢出的情况,第一种解决方案是每次计算完都对INT_MAX取模,因为最终答案保证在int范围内。 第二种是将int换为unsigned long long即可。