算法分析与设计第十三次作业( leetcode 中 Partition Equal Subset Sum 和 Partition to K Equal Sum Subsets 题解 )

心得体会

这次的题目 Partition Equal Subset Sum 和 Partition to K Equal Sum Subsets 之所以被选中,是有一定原因的:
看起来这两个题目一个是基础版一个是进阶版吧,所以我先做了第一题,使用动态规划,思路有一些巧,但是难度不大,就是一个0-1背包问题。然而开始做第二个题目,看起来应该是第一题的加强版,但是仔细思考之后使用动态规划并不是一个很好的选择。反而应该使用普通的dfs,因为随着几何中一些组合被挑选,这些组合中的数字也不能再被使用,需要动态的去掉这些数字,如果非要使用动态规划,那么需要使用k次动态规划(k是“Partition to K Equal Sum Subsets”中的K),并且每次的计算都需要重新开始算,因为有些之前的数字不能再被使用了。所以使用动态规划反而不如直接dfs更好,而且根据题目的描述,复杂度 N ∗ 2 N N*2^N N2N中的n是一个不超过16的数字,所以使用dfs实际并不会带来很高的代价,相反使用动态规划将带来很高昂的代价,更详细的原因会在正文中分析。
这就告诉我们解决问题要灵活变通,根据实际情况选择最合适的方法。

题解正文

题目描述
  • Partition Equal Subset Sum
    在这里插入图片描述
  • Partition to K Equal Sum Subsets
    在这里插入图片描述
问题分析

第一个题目是给出一个集合,然后需要判断是否存在划分方法使得集合被分为两个和相等的集合,
第二个题目非常类似,也给出一个集合,然后需要判断是否存在划分方法使得集合被分为k个和相等的集合。
不同之处就是前者划分为两个和相同集合,后者则需要划分为多个和相同集合

解题思路
  • 第一题
    两个集合和相等那么自然等于全部数字总和的一半,如果数字总和是奇数,那可以直接判断不存在这样的划分方法。因为总和的一半是一个小数,整数相加不可能是小数。如果和是偶数,那么记sum为全部数字总和,问题变为:是否存在一种选择方法,从集合中选出若干个数使得它们的和为sum/2。
    这就变得有点像背包问题:给出一个容量为sum/2大小的背包,现在有nums.size()个物品,每个物品的重量是nums[i],判断背包是否能够放满,也就是最终放入背包的物品质量和恰好是sum/2,如果能放满,说明划分存在(背包内一组,背包外物品一组),否则划分不存在。
    然后这就变成了一个最简单的0-1背包问题,写出如下状态转移方程:
    记向容量为j的背包放入前i个物品,背包所能够装的最大重量为f[i][j],则f[i][j]满足:
    f[i][j] =
    0,若i=0;
    f[i-1][j],若容量j<nums[i],即容量j的背包放不下第i个物品,f[i][j]等于向其中放入前i-1件物品背包所能装下的最大重量;
    max{f[i-1][j],f[i-1][j-nums[i]]+nums[i]},若容量j的背包能放下第i个物品,那么可以选择放入第i件物品和不放入第i件物品,这两种操作所能取得的背包最大容量分别是f[i-1][j]、f[i-1][j-nums[i]]+nums[i],取其中更大的即可;
    求解出f[nums.size()+1][sum/2+1]数组后,判断f[nums.size()][sum/2]是否为sum/2即可,也就是容量为sum/2的背包的最多装入质量是否为sum/2

  • 第二题
    这个题看起来也可以用上面的做法,即求出sum/k,然后使用动态规划算出f[nums.size()+1][sum/k+1],但是这样做只能够求出一个和为sum/k的子集,我们还需要求出k-2个和为sum/2的子集才能判定存在题述划分。这里若k大于3,那么我们就需要继续找和为sum/k的子集。
    但是问题是我们继续寻找这样的子集之前还有工作需要做:首先需要再nums数组中去掉前面所找到的子集中的元素,因为这些元素已经归属于其它划分,不能再划分到接下来的分组中了。这个前提工作或许不难,只需要记住f[i][j]中最后一个物品,记其下标为g[i][j],然后再往前一个物品下标是g[g[i][j]-1][j-nums[g[i][j]]],不断如此直到背包装满。但是问题在于我们去掉这些元素之后,需要重新计算f[nums.size()+1][sum/k+1],这时候nums.size()大小变了,并且有些之前的元素不能再使用,所以这个数组需要完全重新计算,这导致工作量变大了。
    一共计算k次大小为 s i z e ( n u m s ) ∗ s u m / k size(nums)*sum/k size(nums)sum/k的数组,记n = nums.size(),总共的复杂度约为 O ( n ∗ s u m ) O(n*sum) O(nsum),这个复杂度和上一个题一样,但是需要注意的是第二题的n非常大,最大有10000,这导致sum非常大。粗略估计一下,n在1到16之间, s u m = ∑ i = 1 n n u m s [ i ] sum=\sum_{i=1}^{n}nums[i] sum=i=1nnums[i],所以sum在1到160000之间, n ∗ s u m n*sum nsum在1到2560000之间,可能需要次简单计算,这是非常恐怖的。
    再对比简单粗暴的dfs方法,我们仍然需要遍历k次,每次求出一个和为sum/k的分组,每次求出一个分组需要遍历nums数组的全部组合方式,一共是 2 n 2^n 2n种,n是nums数组元素个数,所以复杂度是 O ( k ∗ 2 n ) O(k*2^n) O(k2n),k和n度都在1到16之间,所以最多需要1048576次简单计算。从时间复杂度上看,这仅仅比动态规划方法好几倍而已,但是从空间复杂度上看,dfs仅仅需要O(n)空间复杂度,而动态规划需要O(n*sum)的复杂度(一个二维数组)。空间复杂度上要好很多。并且这种方法更简单粗暴,所以当然选择使用dfs方法。
    而且在n为个位数的时候 k ∗ 2 n k*2^n k2n n ∗ s u m n*sum nsum小得多,这也是优势。
    虽然dfs方法非常简单,但是下面还是简要说一下:使用方法match(nums,isUsed,start,sum)来使用从start开始的nums.size()-start件物品,匹配大小为sum的背包。相关的物品质量信息在nums数组中,isUsed数组表示哪些元素已经用过了不能再用。在该函数中,如果sum,即背包容量大于当前物品重量,就可以将其放入并记录isUsed,然后递归调用match(nums,isUsed,start+1,sum-nums[start]),如果背包容量放不下当前物品则调用match(nums,isUsed,start+1,sum),即不改变背包大小,直到sum为0表示匹配成功,否则若一直无法匹配,start将超过nums.size(),说明匹配失败。如果匹配失败则回溯到最近一次选择物品i的操作,将isUsed重新设置为没有使用过,然后递归调用match(nums,isUsed,start+1,sum)表示不选择物品i进行匹配。如此做法最终将知道当前是否存在这种划分使得划分的子集和为sum/k。
    最后我们需要遍历k,找寻k-1次和为sum/k的子集,每次都能找到则说明划分方法存在,否则不存在划分方法将原集合划分为k个和相等的子集。

算法步骤
  • 第一题
    遍历 nums 数组,求和计算 sum 值;
    申请&初始化二维数组 biggestWeight[nums.size()+1][sum/2+1] ;
    遍历 nums.size() 个待放入背包的元素,i从1到nums.size();
    	遍历 sum/2 种可能的背包容量,j从1到sum/2
    		如果背包容量 j 放不下第 i 个元素,即 j<nums[i], biggestWeight[i][j] = biggestWeight[i-1][j];
    		如果背包能够放下第i个元素,biggestWeight[i][j] = max{biggestWeight[i-1][j-nums[i]]+nums[i],biggestWeight[i-1][j]},即从放入/不放入第i个元素的方法种选取更好的那种;
    如果biggestWeight[nums.size()][sum/2]为sum/2,返回true,划分存在,背包内划分一组,背包外元素划分一组,否则返回false;
    
  • 第二题
    match(nums, isUsed, start, sum)函数,使用从start开始的nums.size()-start件物品,匹配大小为sum的背包
    	如果背包容量sum为0,返回true,表示已经找到一个和为sum/k的子集;
    	如果start下标越界,大于nums.size(),则返回false,表示当前这种方法不能找到和为sum/k的子集,需要回溯,替换划分方案来找到和为sum/k的子集;
    	如果下标为start的元素之前未被使用过,并且当前背包容量sum足以装下该元素,则:
    		如果 match(nums, isUsed, start+1, sum) 为true,返回true;
    		否则
    			标记isUsed[start]为true,表示该元素被使用
    			如果 match(nums, isUsed, start+1, sum-nums[start])为真
    				返回true;
    			否则
    				标记isUsed[start]为false,因为这种选择方案不可用,不能向背包中放入第start物品(因为这样无法形成和为sum/k的子集),把他从背包中剔除,即标记isUsed[start]为false;
    				返回false;
    	否则一律返回 match(nums, isUsed, start+1, sum) ;
    	
    排序nums数组;
    遍历nums数组,求和sum,初始化isUsed数组;
    遍历k,寻找k-1次和为sum/k的子集
    	如果 match(nums, isUsed, 0, sum) 返回false,表示不存在这样的划分方法,直接返回false;
    返回 true,表示存在这种划分方法;
    
复杂度分析

在思路分析中已经详细说明了两个题目的时间空间复杂度。以下简要分析:

  • 第一题
    时间复杂度:根据算法流程,遍历一次求解sum为 O ( n ) O(n) O(n);初始化以及计算biggestWeight[nums.size()+1][sum/2+1]数组中全部元素值需要 O ( n ∗ n ∗ x ˉ ) O(n*n*\bar x) O(nnxˉ)次操作,其中 x ˉ \bar x xˉ是nums数组中元素平均值, n ∗ x ˉ n*\bar x nxˉ也就是sum的大小;以及常数次数的比较操作。
    所以最后复杂度为 O ( n ∗ n ∗ x ˉ ) O(n*n*\bar x) O(nnxˉ)

    空间复杂度:使用了二维数组biggestWeight[nums.size()+1][sum/2+1]和常数个临时变量,所以空间复杂度是 O ( n ∗ n ∗ x ˉ ) O(n*n*\bar x) O(nnxˉ)

  • 第二题
    时间复杂度:根据算法流程,首先遍历一次求解sum、初始化isUsed数组,需要O(n)次操作;然后遍历k次,每次求出一个和为sum/k的分组,每次求出一个分组需要遍历nums数组的全部组合方式,一共是 2 n 2^n 2n种,n是nums数组元素个数(即nums.size()),所以一共需要 O ( k ∗ 2 n ) O(k*2^n) O(k2n)次操作;
    所以最后总的时间复杂度为 O ( k ∗ 2 n + n ) O(k*2^n+n) O(k2n+n),也就是 O ( k ∗ 2 n ) O(k*2^n) O(k2n),O(n)被略去。

    空间复杂度:使用了nums数组、isUsed数组和其它常数个变量,所以空间复杂度是O(n)。
    这比使用动态规划方法要好上一个量级,所以在这个题中,尽管动态规划和dfs方法的时间复杂度差别不大,但是空间复杂度好很多,所以选择dfs方法。

代码实现 & 结果分析
  • Partition Equal Subset Sum
    代码实现

    class Solution {
    public:
    	int ** biggestWeight = NULL;
        bool canPartition(vector<int>& nums) {
        	int sum = 0;
            for (int i = 0; i < nums.size(); ++i) {
            	sum += nums[i];
            }
            if (sum%2 == 1) return false;
            sum = sum/2;
    
            biggestWeight = new int* [nums.size()+1];
            for (int i = 0; i < nums.size()+1; ++i) {
            	biggestWeight[i] = new int[sum+1];
            	for (int j = 0; j < sum+1; ++j) {
            		biggestWeight[i][j] = 0;
            	} 
            }
    
            for (int i = 1; i < nums.size()+1; ++i) {
            	for (int j = 0; j < sum+1; ++j) {
            		if (j >= nums[i-1]) {
            			int temp = biggestWeight[i-1][j-nums[i-1]] + nums[i-1];
            			biggestWeight[i][j] = temp > biggestWeight[i-1][j] ? temp : biggestWeight[i-1][j];
            		} else {
            			biggestWeight[i][j] = biggestWeight[i-1][j];
            		}
            	}
            }
            
            if (biggestWeight[nums.size()][sum] == sum) return true;
            return false;
        }
    };
    

    结果分析:
    在这里插入图片描述

  • Partition to K Equal Sum Subsets
    代码实现

    class Solution {
    public:
       bool match(vector<int>& nums, vector<bool>& isUsed, int start, int sum) {
       	if (sum == 0) {
       		return true;
       	} else if (start >= nums.size()) {
       		return false;
       	}
       	if (isUsed[start] == true) {
       		return match(nums,isUsed,start+1,sum);
       	} else {
       		if (nums[start] <= sum) {
       			if (match(nums,isUsed,start+1,sum)) {
       				return true;
       			} else {
       				isUsed[start] = true;
       				if (match(nums,isUsed,start+1,sum-nums[start])) {
       					// cout << nums[start] << ' ';
       					return true;
       				} else {
       					isUsed[start] = false;
       					return false;
       				}
       			}
       		} else {
       			return match(nums,isUsed,start+1,sum);
       		}
       	}
       }
    
       bool canPartitionKSubsets(vector<int>& nums, int k) {
       	if (k == 0) {
       		return false;
       	}
       	sort(nums.begin(),nums.end());
       	int sum = 0;
           vector<bool> isUsed(nums.size());
           for (int i = 0; i < nums.size(); ++i) {
           	isUsed[i] = false;
           	sum += nums[i];
           }
           if (sum % k != 0) {
           	return false;
           }
           sum = sum/k;
           for (int i = 0; i < k-1; ++i) {
           	// cout << endl;
           	if (match(nums, isUsed, 0, sum) == false) {
           		return false;
           	}
           }
           return true;
       }
    };
    

    结果分析:4ms,beats 95%,由此可见在这个题目中dfs的确可行,并且还很高效。所以我们在解题的时候需要仔细思考,选择合适的算法解题,优化时空复杂度,而不是生搬硬套某些模板方法。比如这一题就不适合使用动态规划求解,它将导致很高的时空复杂度。
    (1)虽然说大多数情况下动态规划能够避免大量重复计算,但是在这一题中每次动态规划只能避免较少量重复计算,却要付出大量的空间来存储记忆化的信息;
    (2)并且k次的动态规划各不相同,每次计算完一次,下一次又要重新计算,这带来很大的计算量,重新计算带来的代价超过了节省的重复计算,导致时间复杂度甚至要超过dfs方法。
    综合上面两点,我们更应该选择dfs,虽然它需要2^n次计算(变量所有选择方案),但是以一来这一题的n比较小(小于16),二来还可以节省空间复杂度,所以dfs方法自然更好。
    在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值