leetcode 416. 分割等和子集

Posted 大忽悠爱忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了leetcode 416. 分割等和子集相关的知识,希望对你有一定的参考价值。

在这里插入图片描述



DFS

思路

题意就是:给你一个非空数组,和为sum,你能否找到一个子序列,和为sum/2。

  • 如果sum为奇数,肯定找不到,因为sum/2为小数,而数组只包含正整数。
  • 如果sum为偶数,有可能找到。

对于每个元素,都有 选或不选它 去组成子序列。我们可以 DFS 回溯去穷举所有的情况。
在这里插入图片描述
每次考察一个元素,用索引i描述,还有一个状态:当前累加的curSum。
递归函数:基于已选的元素(和为curSum),从i开始继续选,能否选出和为sum/2的子集。
每次递归,都有两个选择:

  • 选nums[i]。基于选它,往下继续选(递归):dfs(curSum + nums[i], i + 1)
  • 不选nums[i]。基于不选它,往下继续选(递归):dfs(curSum, i + 1)

递归的终止条件有三种情况:

  • curSum > target,已经爆了,不用继续选数字了,终止递归,返回false。
  • curSum == target,满足条件,不用继续选数字了,终止递归,返回true。
  • 指针越界,考察完所有元素,能走到这步说明始终没有返回false。

代码:

class Solution {
public:
	bool canPartition(vector<int>& nums) 
	{
		//数组元素个数小于两个,肯定无法分成两个子序列
		if (nums.size() < 2) return false;
		//数组和为奇数,也不满足条件
		int Sum = accumulate(nums.begin(), nums.end(), 0);
		if (Sum%2!=0) return false;
		return dfs(0,0,Sum/2,nums);
	}
	//cursum:当前累加和  i:当前索引  target:目标和
	bool dfs(int cursum,int i,int target, vector<int>& nums)
	{
		if (cursum == target) return true;//找到目标和
		//超和,越界
		if (cursum > target || i >= nums.size()) return false;
			//选择当前数字
			bool sel = dfs(cursum + nums[i], i+1, target, nums);
		//不选择当前数字
			bool unsel = dfs(cursum, i + 1, target, nums);
			return sel || unsel;
	}
};

在这里插入图片描述


记忆化搜索

上面递归超时了怎么办,遇事不决,记忆力学
这里用unordered_map容器保存计算出来的结果,防止重复计算

代码:

class Solution {
	unordered_map<string,bool> cache;//缓存器---保存当前索引值对应累加和下的真假
public:
	bool canPartition(vector<int>& nums) 
	{
		//数组元素个数小于两个,肯定无法分成两个子序列
		if (nums.size() < 2) return false;
		//数组和为奇数,也不满足条件
		int Sum = accumulate(nums.begin(), nums.end(), 0);
		if (Sum%2!=0) return false;
		return dfs(0,0,Sum/2,nums);
	}
	//cursum:当前累加和  i:当前索引  target:目标和
	bool dfs(int cursum,int i,int target, vector<int>& nums)
	{
		string key = to_string(i) + '&' + to_string(cursum);
		//当前索引值对应累加和下的真假
		if (cache.find(key) != cache.end()) return cache[key];
		if (cursum == target) return true;//找到目标和
		//超和,越界
		if (cursum > target || i >= nums.size()) return false;
		bool ret=dfs(cursum + nums[i], i+1, target, nums)|| dfs(cursum, i + 1, target, nums);
		return cache[key]= ret;
	}
};

在这里插入图片描述


注意:这里用map加pair会超时

class Solution {
	map<pair<int,int>,bool> cache;//缓存器---保存当前索引值对应累加和下的真假
public:
	bool canPartition(vector<int>& nums) 
	{
		//数组元素个数小于两个,肯定无法分成两个子序列
		if (nums.size() < 2) return false;
		//数组和为奇数,也不满足条件
		int Sum = accumulate(nums.begin(), nums.end(), 0);
		if (Sum%2!=0) return false;
		return dfs(0,0,Sum/2,nums);
	}
	//cursum:当前累加和  i:当前索引  target:目标和
	bool dfs(int cursum,int i,int target, vector<int>& nums)
	{
		//当前索引值对应累加和下的真假
		if (cache.find({ i,cursum }) != cache.end()) return cache[{i, cursum}];
		if (cursum == target) return true;//找到目标和
		//超和,越界
		if (cursum > target || i >= nums.size()) return false;
	    //选择当前数字
		bool sel = dfs(cursum + nums[i], i+1, target, nums);
		//不选择当前数字
		bool unsel = dfs(cursum, i + 1, target, nums);
		return cache[{i,cursum}]= (sel || unsel);
	}
};

在这里插入图片描述


记忆化搜索的另一种写法

这里再对之前重复计算问题用图片配文字解释一下

输入 [1, 1, 1, 4, 5],总和sum为12,取半half为6;
针对第一个元素,减去得5,不减得6,依次产生完全二叉树;
出现负数直接返回否,等于0直接返回是。

在这里插入图片描述
里面有大量重复元素。思考发现,在二叉树的同一层出发,如果剩下的数字remain一样大,它后续的分支是完全相同的。

“只选第一个1”和“只选第二个1”的结果是一样的;
同一层的两个remain如果相同,它们的子树就完全相同。

在这里插入图片描述
针对这种情况我们引入记忆化搜索。

每次递归,我们检查这个remain是否在这一层出现过。如果是,就跳过这个结点。

代码:

class Solution {
	vector<unordered_set<int>> dp;
public:
	bool canPartition(vector<int>& nums) 
	{
		if (nums.size() < 2) return false;
		int Sum = accumulate(nums.begin(), nums.end(), 0);
		if (Sum%2!=0) return false;
		dp.resize(nums.size());
		return dfs(Sum/2,0,nums);
	}
	bool dfs(int remain,int i,vector<int>& nums)
	{
		if (remain==0) return true;
		if (remain<0|| i == nums.size()|| dp[i].find(remain) != dp[i].end()) return false;
		dp[i].insert(remain);
		return dfs(remain, i + 1, nums)|| dfs(remain - nums[i], i + 1,nums);
	}
};

在这里插入图片描述
在这里插入图片描述
可以看到,现在每一层同一个remain数字只出现一次。


动态规划

基本分析

  • 通常「背包问题」相关的题,都是在考察我们的「建模」能力,也就是将问题转换为「背包问题」的能力。
  • 由于本题是问我们能否将一个数组分成两个「等和」子集。
  • 问题等效于能否从数组中挑选若干个元素,使得元素总和等于所有元素总和的一半。
  • 这道题如果抽象成「背包问题」的话,应该是:
  • 我们背包容量为 target=sum/2,每个数组元素的「价值」与「成本」都是其数值大小,求我们能否装满背包。

转换为 01 背包
没了解过01背包问题的建议先看这篇文章

  • 由于每个数字(数组元素)只能被选一次,而且每个数字选择与否对应了「价值」和「成本」,求解的问题也与「最大价值」相关。
  • 可以使用「01 背包」的模型来做。
  • 当我们确定一个问题可以转化为「01 背包」之后,就可以直接套用「01 背包」的状态定义进行求解了。
  • 注意,我们积累 DP 模型的意义,就是在于我们可以快速得到可靠的「状态定义」。
  • 在 路径问题 中我教过你通用的 DP 技巧解法,但那是基于我们完全没见过那样的题型才去用的,而对于一些我们见过题型的 DP题目,我们应该直接套用(或微调)该模型「状态定义」来做。
  • 我们直接套用「01 背包」的状态定义:
  • f[i][j] 代表考虑前 i个数值,其选择数字总和不超过 j 的最大价值。
  • 当有了「状态定义」之后,结合我们的「最后一步分析法」,每个数字都有「选」和「不选」两种选择。
  • 因此不难得出状态转移方程:
    在这里插入图片描述
    大白话:把数组总和一半看做背包容量和我们需要找的价值,数组元素同时表示物品大小和物品价值,并且每个物品只能选择一次,现在让你在不超过背包容量的情况下,尽量往背包里面塞入物品,并且价值要尽可能大,最终如果最大价值与要找的价值吻合说明存在解,小于说明不存在解,不存在大于的情况,因为这里物品大小就是物品的价值,背包最大容量就是背包塞入物品的最大价值

代码:

class Solution {
public:
	bool canPartition(vector<int>& nums) 
	{
		if (nums.size() < 2) return false;
		int Sum = accumulate(nums.begin(), nums.end(), 0);
		if (Sum&1) return false;
		//行标:对应每个物品  列表:对应容量  dp[i][j]:当前物品对应当前容量状态下的最大价值
		vector<vector<int>> dp(nums.size(),vector<int>(Sum/2+1));
		//初始化第一行---将第一件物品所有状态进行初始
		for (int i = 0; i <= Sum / 2; i++)//前提是当前对应容量能够塞下第一个物品
			dp[0][i] = i >= nums[0] ? nums[0] : 0;
		//计算其他行
		for (int i = 1; i <nums.size(); i++)
		{
			for (int j = 0; j <= Sum / 2; j++)
			{
				//不选当前物品放入背包
				int unsel = dp[i - 1][j];
				//选当前物品放入背包---先看当前对应容量下能不能塞的下
				int sel = j >= nums[i] ? dp[i - 1][j - nums[i]] + nums[i] :0;

				dp[i][j] = max(sel, unsel);
			}
		}
		// 如果最大价值等于 target,说明可以拆分成两个「等和子集」
		return dp[nums.size()-1][Sum/2] == Sum/2;
	}
};

在这里插入图片描述


「滚动数组」解法

在上一讲我们讲到过「01 背包」具有两种空间优化方式。

其中一种优化方式的编码实现十分固定,只需要固定的修改「物品维度」即可。

代码:

class Solution {
public:
	bool canPartition(vector<int>& nums) 
	{
		if (nums.size() < 2) return false;
		int Sum = accumulate(nums.begin(), nums.end(), 0);
		if (Sum&1) return false;
		//行标:对应每个物品  列表:对应容量  dp[i][j]:当前物品对应当前容量状态下的最大价值
		vector<vector<int>> dp(2,vector<int>(Sum/2+1));
		//初始化第一行---将第一件物品所有状态进行初始
		for (int i = 0; i <= Sum / 2; i++)//前提是当前对应容量能够塞下第一个物品
			dp[0][i] = i >= nums[0] ? nums[0] : 0;
		//计算其他行
		for (int i = 1; i <nums.size(); i++)
		{
			for (int j = 0; j <= Sum / 2; j++)
			{
				//不选当前物品放入背包
				int unsel = dp[(i - 1)&1][j];
				//选当前物品放入背包---先看当前对应容量下能不能塞的下
				int sel = j >= nums[i] ? dp[(i - 1)&1][j - nums[i]] + nums[i] :0;

				dp[i&1][j] = max(sel, unsel);
			}
		}
		// 如果最大价值等于 target,说明可以拆分成两个「等和子集」
		return dp[(nums.size()-1)&1][Sum/2] == Sum/2;
	}
};

在这里插入图片描述


「一维空间优化」解法

事实上,我们还能继续进行空间优化:只保留代表「剩余容量」的维度,同时将容量遍历方向修改为「从大到小」。
遍历方向从大到小是防止数据覆盖操作,详情可以去看上面给出链接的01背包问题的文章

代码:

class Solution {
public:
	bool canPartition(vector<int>& nums) 
	{
		if (nums.size() < 2) return false;
		int Sum = accumulate(nums.begin(), nums.end(), 0);
		if (Sum&1) return false;
		vector<int> dp(Sum / 2 + 1); // 将「物品维度」取消

		for (int i = 0; i < nums.size(); i++)
		{
			int t = nums[i];//临时记录当前物品的大小
			//从尾往前进行覆盖操作,是因为在计算下一行时会用到前面的数据和对应自身的原始数据,尾端不存在会利用到的数据
			//如果当前容量小于当前要塞入物品大小,那么下面就不用看了,等于当前物品不放入背包,数据等于上一行数据
			for (int j = Sum / 2; j >= nums[i]; j--)
			{
				//不选当前物品
				int us = dp[j];
				//选当前物品
				int s = j >= t ? t + dp[j - t] : 0;

				dp[j] = max(us, s);
			}
		}
		return dp[Sum / 2]==Sum/2;
	}
};

在这里插入图片描述

以上是关于leetcode 416. 分割等和子集的主要内容,如果未能解决你的问题,请参考以下文章

leetcode 416. 分割等和子集

leetcode 416. 分割等和子集---直接解法

LeetCode 416. 分割等和子集

Leetcode刷题Python416. 分割等和子集

(Java) LeetCode 416. Partition Equal Subset Sum —— 分割等和子集

416-分割等和子集(01背包)