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

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


动态规划二维处理

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

举个 🌰,在「完全背包」问题中,凑成总价值为 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]]

即转移方程为:
在这里插入图片描述

代码:

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

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

例如目标值为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]
  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,所有的溢出会被补偿回来:

{
    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即可。

评论 42
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值