416. 分割等和子集
给你一个 只包含正整数 的 非空 数组 nums 。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11] 。
示例 2:
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个元素和相等的子集。
解题思路
这道题可以换一种表述:给定一个只包含正整数的非空数组 nums[0],判断是否可以从数组中选出一些数字,使得这些数字的和等于数组元素和的一半。因此这道问题可以转换成「0-1 背包问题」。这道题与传统的「0-1 背包问题」的区别在于,传统的「0-1 背包问题」要求选取的物品的重量之和不能超过背包的总容量,这道题则要求选取的数组中的数字的和恰好等于背包的总容量的一半。类似于传统的「0-1 背包问题」,可以使用动态规划来解决。
在使用动态规划之前,首先需要进行以下判断。
-
根据数组的长度,判断数组是否可以被划分。如果 n < 2 n < 2 n<2,则不可能将数组分割成元素和相等的两个子集,因此直接返回 false。
-
计算数组中的元素和以及最大元素 maxNum。如果 sum 是奇数,则不可能将数组分割成元素和相等的两个子集,因此直接返回 false。如果 sum 是偶数,则令 t a r g e t = s u m 2 target = \frac{sum}{2} target=2sum,需要判断是否可以从数组中选出一些数字,使得这些数字的和等于 target。如果 maxNum > target,则除去 maxNum 以外的所有元素之和一定小于 target,因此不可能将数组分割成元素和相等的两个子集,直接返回 false。
创建二维数组 dp,包含 n n n 行 t a r g e t + 1 target+1 target+1 列,其中 dp[i][j] 表示从数组的 [0, i] 下标范围内选取若干个正整数(可以是 0 个),是否存在一种选取方案使得这些正整数的和等于 j。初始化时,dp 中的全部元素都是 false。
在定义状态之后,需要考虑边界情况。以下两种情况都属于边界情况。
-
如果不选取任何正整数,则被选取的正整数之和等于 0。因此对于所有 0 ≤ i < n,都有 dp[i][0] = true。
-
当 i = 0 时,只有一个正整数 nums[0] 可以被选取,因此 dp[0][nums[0]] = true。
对于 i > 0 且 j > 0 的情况,如何确定 dp[i][j] 的值?需要分别考虑以下两种情况。
-
如果 j ≥ nums[i],则对于当前的数字 nums[i],可以选取也可以不选取,两种情况只要有一种为 true,就有 dp[i][j] = true。
-
如果不选取 nums[i],则 dp[i][j] = dp[i−1][j];
-
如果选取 nums[i],则 dp[i][j] = dp[i−1][j−nums[i]]。
-
-
如果 j < nums[i],则在选取的数字的和等于 j 的情况下无法选取当前的数字 nums[i],因此有 dp[i][j] = dp[i−1][j]。
状态转移方程如下:
d p [ i ] [ j ] = { d p [ i − 1 ] [ j ] or d p [ i − 1 ] [ j − n u m s [ i ] ] , j ≥ n u m s [ i ] d p [ i − 1 ] [ j ] , j < n u m s [ i ] dp[i][j] = \begin{cases} dp[i-1][j] \, \text{or} \, dp[i-1][j - nums[i]], & j \geq nums[i] \\ dp[i-1][j], & j < nums[i] \end{cases} dp[i][j]={dp[i−1][j]ordp[i−1][j−nums[i]],dp[i−1][j],j≥nums[i]j<nums[i]
最终得到 d p [ n − 1 ] [ t a r g e t ] dp[n-1][target] dp[n−1][target] 即为答案。
可以发现在计算 dp 的过程中,每一行的 dp 值都只与上一行的 dp 值有关,因此只需要一个一维数组即可将空间复杂度降到 O ( t a r g e t ) O(target) O(target)。此时的转移方程为:
d p [ j ] = d p [ j ] or d p [ j − n u m s [ i ] ] dp[j] = dp[j] \, \text{or} \, dp[j - nums[i]] dp[j]=dp[j]ordp[j−nums[i]]
且需要注意的是第二层的循环我们需要从大到小计算,因为如果我们从小到大更新 dp 值,那么在计算 dp[j] 值的时候,dp[j - nums[i]] 已经是被更新过的状态,不再是上一行的 dp 值。
def canPartition(nums):
n = len(nums)
if n < 2:
return False
total = sum(nums)
maxNum = max(nums)
# 判断nums和是否是偶数,以及nums中的最大值是否大于target
if total & 1:
return False
target = total // 2
if maxNum > target:
return False
dp = [[False] * (target + 1) for _ in range(n)]
for i in range(n):
dp[i][0] = True
dp[0][nums[0]] = True
for i in range(1, n):
num = nums[i]
for j in range(1, target + 1):
if j >= num:
dp[i][j] = dp[i - 1][j] | dp[i - 1][j - num]
else:
dp[i][j] = dp[i - 1][j]
return dp[n - 1][target]
def canPartition2(nums: List[int]) -> bool:
if not nums or len(nums) < 2:
return False
total = sum(nums)
if total % 2 != 0:
return False
target = total // 2
dp = [False] * (target + 1)
dp[0] = True
for num in nums:
for i in range(target, num - 1, -1):
dp[i] = dp[i] or dp[i - num]
return dp[target]