心得体会
这次的题目 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
N∗2N中的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(n∗sum),这个复杂度和上一个题一样,但是需要注意的是第二题的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 n∗sum在1到2560000之间,可能需要次简单计算,这是非常恐怖的。
再对比简单粗暴的dfs方法,我们仍然需要遍历k次,每次求出一个和为sum/k的分组,每次求出一个分组需要遍历nums数组的全部组合方式,一共是 2 n 2^n 2n种,n是nums数组元素个数,所以复杂度是 O ( k ∗ 2 n ) O(k*2^n) O(k∗2n),k和n度都在1到16之间,所以最多需要1048576次简单计算。从时间复杂度上看,这仅仅比动态规划方法好几倍而已,但是从空间复杂度上看,dfs仅仅需要O(n)空间复杂度,而动态规划需要O(n*sum)的复杂度(一个二维数组)。空间复杂度上要好很多。并且这种方法更简单粗暴,所以当然选择使用dfs方法。
而且在n为个位数的时候 k ∗ 2 n k*2^n k∗2n比 n ∗ s u m n*sum n∗sum小得多,这也是优势。
虽然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(n∗n∗xˉ)次操作,其中 x ˉ \bar x xˉ是nums数组中元素平均值, n ∗ x ˉ n*\bar x n∗xˉ也就是sum的大小;以及常数次数的比较操作。
所以最后复杂度为 O ( n ∗ n ∗ x ˉ ) O(n*n*\bar x) O(n∗n∗xˉ)。空间复杂度:使用了二维数组biggestWeight[nums.size()+1][sum/2+1]和常数个临时变量,所以空间复杂度是 O ( n ∗ n ∗ x ˉ ) O(n*n*\bar x) O(n∗n∗xˉ)
-
第二题
时间复杂度:根据算法流程,首先遍历一次求解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(k∗2n)次操作;
所以最后总的时间复杂度为 O ( k ∗ 2 n + n ) O(k*2^n+n) O(k∗2n+n),也就是 O ( k ∗ 2 n ) O(k*2^n) O(k∗2n),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方法自然更好。