1. 题目描述
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意:
每个数组中的元素不会超过 100
数组的大小不会超过 200
示例 1:
输入: [1, 5, 11, 5]
输出: true
解释: 数组可以分割成 [1, 5, 5] 和 [11].
示例 2:
输入: [1, 2, 3, 5]
输出: false
解释: 数组不能分割成两个元素和相等的子集.
2. 分析
典型的背包问题,在n个物品中选出一定物品,填满sum/2的背包 F(n, C )考虑将n个物品填满容量为C的背包
F(i,c) = F(i-1 ,c) II F(i-1 ,C- w(i) )
时间复杂度: O(n* sum/2)= O(n* sum )
题目中给出:(1)最多有200个数字(2)每个数字最大为100。——可以得出所有数字和为20000;背包最大为10000;时间复杂度为: n * sum/2 = 100* 10000 = 100万。这个现在的计算机是可以处理的,所以算法设计合适。
题目中给出的数据规模可以帮助我们判断我们设计的算法是否是有效的合理的,每个数字的最大值是多少会对算法有影响,比如这题:最大值合在一起将决定背包的容量,进而决定算法的时间复杂度。
3. 第一种方法——记忆化搜索
- 第一步首先计算这些数字之和是多少:sum(nums),求出sum之后,首先必须确保sum%2=0,也就是说这个和是可以被平分的,若不能平分,我们就无法在nums中取出一部分让它等于另外一部分,因为这个和本身无法平分。 在这种情况直接return False。
- 否则就是真正解决算法的逻辑,依旧是先使用递归+记忆化搜索:设置一个递归函数tryPartition,传入的参数包括这些数字 nums 以及这些数字的下标 len(nums)-1 以及设计的背包大小 sum//2(注意:Python3这个地方应该写为 sum//2,用地板除取整数。若写为 sum/2,当sum为奇数时会得到一个浮点类型的数,会报错)如下图:
tryPartition(nums, index, sum)
该函数返回的是一个bool类型的值,表示使用nums[0…index],是否可以完全填充一个容量为sum的背包。 - 首先若发现sum=0,就说明这个背包里已经没有任何空间了,换句话说我们已经填充好了这个背包,此时应该return True。
- 否则的话,若sum<0或index<0(要么就是之前填充的内容多了背包装不下;要么就是没有物品可选了),此时直接return False。
- 之后进行具体的逻辑,有两种策略:
(1)是依旧对nums这个数组使用[0…index-1]这些数字尝试对大小为sum的背包进行填充:tryPartition(nums, index-1, sum)
,看它能否成功填充,若能填充,说明我们使用[0…index]这些数字也能填充,只不过nums[index]不使用就好;
(2)对于nums[index]确实要使用,tryPartition(nums, index-1, sum-nums[index])
直接返回这两个结果的or即可。
接下来看,index和sum这个数据对可能产生重叠子问题,所以使用记忆化搜索
- 初始化一个二维的数组(一共有len(nums)行,有sum//2+1列),
memo = [[-1 for j in range(sum//2 + 1)]for i in range(len(nums))]
,memo[i][c]表示使用索引为[0…i]的这些元素,是否可以完全填充一个容量为c的背包,-1表示为未计算,0表示计算过了但是不可以填充,1表示计算过了并且可以填充。 - 递归函数中在递归终止条件之后,看当前传入的数据对index和sum对应的memo[index][sum]有没有被计算过,若其不等于-1,return memo[index][sum] == 1(表示看当前memo[index][sum]记录的值是否为1,是的话就是True了,否则便为False)
- 后面就对逻辑运算的结果进行记录,若结果为True,则将memo[index][sum]置为1,否则置为0。最后直接返回memo[index][sum] == 1即可。
class Solution:
def canPartition(self, nums: List[int]) -> bool:
n = len(nums)
s = sum(nums)
if n == 0:
return True
if s % 2:
return False
self.memo = [[-1 for j in range(s // 2 + 1)] for i in range(n)]
return self.tryPartition(nums, n - 1, s // 2)
def tryPartition(self, nums, index, sum):
if sum == 0:
return True
if sum < 0 or index < 0:
return False
if self.memo[index][sum] != -1:
return self.memo[index][sum] == 1
self.memo[index][sum] = 1 if self.tryPartition(nums, index-1, sum) or self.tryPartition(nums, index - 1, sum - nums[index]) else 0
return self.memo[index][sum] == 1
4. 第二种方法——动态规划
自底向上的,前面部分跟记忆化搜索一样
- 首先记录一下n,n=len(nums);另外设置一个C,也就是背包的容量 C=sum//2;紧接着设置一个memo,有两点要注意:第一点是 memo 为一维数组,使用0-1背包问题中最优化的方式,第二点这个memo是bool类型,因为递推的过程中肯定是从小问题到大问题,所以不存在访问到某一个值出现没有被计算过的情况:
memo=[False for i in range(C+1)]
- 下面进行memo的初始化 :
for i in range(0, C+1)
,现在看只考虑第一个nums的情况,看能不能把memo对应的背包填满:看nums[0]是否等于这个背包i,若相等,就直接把nums[0]扔进去就填满了这个背包,否则这个背包均是无法填满的:memo[i] = (nums[0]==i)
。 - 下面要做的就是双重循环
for i in range(1, n)
,每一次多考虑一个数,多考虑一个数时,for j in range(C, nums[i]-1, -1)
,当j<nums[i]时,直接不用计算了,肯定不用考虑nums[i]的值,此时memo[j]的值就是上一次考虑 i-1 个物品时所得到的结果是否能够填充这个背包。每一次要做的事就是计算这个memo[j],此时的memo[j]就为看之前的memo[j]是否为True(如果之前memo[j]就为True的话说明不使用我们新添加的第i个物品,直接使用前面的第i-1个物品就可以填满这个背包了)或者我们使用这个新的第i个物品,现在就来看memo[j-nums[i]]这个背包能否被填满,若可以填满,配上新添加的第i个物品,也可以填满这整个背包:memo[j] = memo[j] or memo[j - nums[i]]
- 最后return memo[C]就好。
class Solution:
def canPartition(self, nums: List[int]) -> bool:
n = len(nums)
C = sum(nums) // 2
if n == 0:
return True
if sum(nums) % 2:
return False
memo = [False for i in range(C + 1)]
for i in range(0, C + 1):
memo[i] = (nums[0] == i)
for i in range(1, n):
for j in range(C, nums[i] - 1, -1):
memo[j] = memo[j] or memo[j - nums[i]]
return memo[C]