前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >leetcode 377. 组合总和 Ⅳ----动态规划之双重for循环变式----求排列数

leetcode 377. 组合总和 Ⅳ----动态规划之双重for循环变式----求排列数

作者头像
大忽悠爱学习
发布2021-11-15 10:59:03
5400
发布2021-11-15 10:59:03
举报
文章被收录于专栏:c++与qt学习
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

组合总和 Ⅳ题解集合


动态规划二维处理

本题与「完全背包求方案数」问题的差别在于:选择方案中的不同的物品顺序代表不同方案。

举个 🌰,在「完全背包」问题中,凑成总价值为 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]应该为以下所有方案总和:

  1. 最后一个数选择 nums[0],方案数为 f[len - 1][target - nums[0]]
  2. 最后一个数选择 nums[1],方案数为 f[len - 1][target - nums[1]]
  3. 最后一个数选择 nums[2],方案数为 f[len - 1][target - nums[2]]

即转移方程为:

在这里插入图片描述
在这里插入图片描述

代码:

代码语言:javascript
复制
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 的遍历在内)。

即转移方程为:

在这里插入图片描述
在这里插入图片描述

代码:

代码语言:javascript
复制
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数组

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

代码:

代码语言:javascript
复制
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. 返回值:当前找到的可行方案数
  3. 本级递归做什么:依次选取数组中每个数字,并累计求其返回的方案数之和

如果大家仔细看图,不难发现在递归过程中出现了很多重复计算的结果:

在这里插入图片描述
在这里插入图片描述

例如目标值为1的状态就重复求解了四次,目标值为2的状态重复求解了两次

很显然这里需要用哈希表保存已经计算出来的结果,下次用到的时候直接返回即可

代码:

代码语言:javascript
复制
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]
  2. [-1,1,-1,1]
  3. [-1,1,-1,1,-1,1]

然后在这些方案的基础上,构造出 target。

因此,如果允许出现负权,需要增加选择数量的限制。

可以考虑在二维解决方案的基础上去做,因为本质是一个「图论的有限步数到达具体节点」的问题,当我们期望从状态 f[0][0]到达 f[m][n],但是中间存在总权值为 0 的环,那么我们可以通过进入无数次这样的环,来构成无限种方案。因此直接限制进环次数,或者增加总步数限制,就能从无限集合中解脱出来。


关于溢出说明

首先 Java 不需要考虑溢出,CPP 需要考虑溢出,绝不是因为测试数据不同,而是两者对于「溢出」处理不同导致。

由于题目最终答案是 int,因此 Java 不需要用额外操作。

当 Java 发生溢出时,会直接当成负数来处理。这就导致了只要答案最终是 int,所有的溢出会被补偿回来:

代码语言:javascript
复制
{
    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 上是无法被正常执行的:

代码语言:javascript
复制
{
    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即可。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2021/05/24 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 组合总和 Ⅳ题解集合
  • 动态规划二维处理
  • 动态规划(降维优化)
  • 动态规划—完全背包的一维套路模板双重for循环变式
  • 对上述动态规划的一个小总结
  • 记忆化搜索
  • 进阶
  • 关于溢出说明
  • cpp溢出解决方法
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档