问题描述
给定一个只包含正整数的非空数组 nums
。请你判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
1 | 输入:nums = [1,5,11,5] |
示例 2:
1 | 输入:nums = [1,2,3,5] |
约束条件:
1 <= nums.length <= 200
1 <= nums[i] <= 100
解题思路
这个问题可以转化为一个经典的组合优化问题:0/1 背包问题。
首先,计算整个数组的总和 sum
。如果 sum
是奇数,那么不可能将数组分割成两个元素和相等的子集,因为两个子集的和必须都是 sum / 2
,而 sum / 2
必须是整数。因此,如果 sum
为奇数,直接返回 false
。
如果 sum
是偶数,设目标和 target = sum / 2
。问题就转化为:能否从数组 nums
中选取若干个元素,使得这些元素的和恰好等于 target
?
这就可以看作是一个 0/1 背包问题:
- 背包的容量 (W):
target
- 物品的重量 (weight[i]):数组中的每个元素
nums[i]
- 物品的价值 (value[i]):同样是数组中的每个元素
nums[i]
(因为我们关心的是和,所以重量和价值是相同的) - 问题:能否恰好装满容量为
target
的背包?
我们定义一个动态规划数组 dp
,其中 dp[j]
表示是否存在一种选取方式,使得选取的元素之和等于 j
。dp[j]
是一个布尔值,true
表示存在,false
表示不存在。
状态定义:
dp[j]
:表示从数组 nums
中选取元素,是否存在一个子集的和恰好为 j
。
状态转移方程:
对于数组中的每个元素 num
(即 nums[i]
),我们需要更新 dp
数组。
遍历 dp
数组时,为了确保每个元素只被使用一次(0/1 背包的特性),我们需要从后往前遍历 j
(从 target
到 num
)。
对于当前的元素 num
和当前的和 j
:
- 如果不选取
num
,那么dp[j]
的值保持不变,即dp[j]
。 - 如果选取
num
(前提是j >= num
),那么我们需要看dp[j - num]
是否为true
。如果dp[j - num]
为true
,表示在不包含当前num
的情况下,已经可以凑成j - num
,那么加上num
之后就可以凑成j
。
所以,状态转移方程为:
dp[j] = dp[j] || dp[j - num]
(当 j >= num
时)
初始化:
dp[0] = true
,因为不选取任何元素时,和为 0,这是肯定可以达成的。
其余 dp[j]
(j > 0) 初始化为 false
。
遍历顺序:
- 外层循环遍历数组
nums
中的每个元素num
。 - 内层循环遍历背包容量
j
,从target
向下到num
(确保每个物品只用一次)。
最终结果:
遍历完所有元素后,dp[target]
的值就是最终答案。如果 dp[target]
为 true
,则可以分割成两个和相等的子集;否则,不可以。
推导过程
假设 nums = [1, 5, 11, 5]
- 计算总和
sum = 1 + 5 + 11 + 5 = 22
。 sum
是偶数,target = sum / 2 = 11
。- 初始化
dp
数组,长度为target + 1 = 12
。
dp = [true, false, false, false, false, false, false, false, false, false, false, false]
(dp[0] = true
)
遍历 nums
:
1. num = 1
:
内层循环 j
从 11
到 1
:
j = 11
:dp[11] = dp[11] || dp[11-1] = false || dp[10] = false
j = 10
:dp[10] = dp[10] || dp[10-1] = false || dp[9] = false
- …
j = 1
:dp[1] = dp[1] || dp[1-1] = false || dp[0] = false || true = true
dp
变为:[true, true, false, false, false, false, false, false, false, false, false, false]
2. num = 5
:
内层循环 j
从 11
到 5
:
j = 11
:dp[11] = dp[11] || dp[11-5] = false || dp[6]
(此时dp[6]
还是false
)= false
j = 10
:dp[10] = dp[10] || dp[10-5] = false || dp[5]
(此时dp[5]
还是false
)= false
- …
j = 6
:dp[6] = dp[6] || dp[6-5] = false || dp[1] = false || true = true
j = 5
:dp[5] = dp[5] || dp[5-5] = false || dp[0] = false || true = true
dp
变为:[true, true, false, false, false, true, true, false, false, false, false, false]
(dp[1], dp[5], dp[6] 为 true)
3. num = 11
:
内层循环 j
从 11
到 11
:
j = 11
:dp[11] = dp[11] || dp[11-11] = false || dp[0] = false || true = true
dp
变为:[true, true, false, false, false, true, true, false, false, false, false, true]
(dp[1], dp[5], dp[6], dp[11] 为 true)
4. num = 5
(第二个 5):
内层循环 j
从 11
到 5
:
j = 11
:dp[11]
已经是true
。dp[11] = dp[11] || dp[11-5] = true || dp[6] = true || true = true
j = 10
:dp[10] = dp[10] || dp[10-5] = false || dp[5] = false || true = true
j = 9
:dp[9] = dp[9] || dp[9-5] = false || dp[4]
(此时dp[4]
为false
)= false
j = 8
:dp[8] = dp[8] || dp[8-5] = false || dp[3]
(此时dp[3]
为false
)= false
j = 7
:dp[7] = dp[7] || dp[7-5] = false || dp[2]
(此时dp[2]
为false
)= false
j = 6
:dp[6]
已经是true
。dp[6] = dp[6] || dp[6-5] = true || dp[1] = true || true = true
j = 5
:dp[5]
已经是true
。dp[5] = dp[5] || dp[5-5] = true || dp[0] = true || true = true
After this iteration, dp
might look like:
dp[0]=true
(initial)
dp[1]=true
(from num=1)
dp[5]=true
(from first num=5)
dp[6]=true
(from first num=5 + num=1)
dp[10]=true
(from second num=5 + first num=5)
dp[11]=true
(from num=11 or from second num=5 + dp[6])
最终 dp[11]
为 true
。所以返回 true
。
代码实现
1 | // filepath: /Users/adrianwang/.leetcode/416.分割等和子集.go |
复杂度分析
- 时间复杂度: O(n * target),其中
n
是数组nums
的长度,target
是数组总和的一半。我们需要遍历每个数字,并对每个数字更新dp
数组中最多target
个状态。 - 空间复杂度: O(target),
dp
数组的大小为target + 1
。
关键收获
- 识别问题是否可以转化为背包问题是解决这类动态规划问题的关键。
- 0/1 背包问题的核心在于每个物品只能选择一次,这通常通过内层循环逆序遍历容量来实现。
- 动态规划的状态定义和状态转移方程是解题的核心。