☰
LeetCode 416 分割等和子集:从0/1背包到动态规划
2026/10/10 6:34:17 网站建设 项目流程

1. 拿到题先别动手:分割等和子集的题目本质拆解

1.1 这道题到底在说什么

题目是LeetCode上的“分割等和子集”,题号416,隶属于“热门100题”阵营,也是一个动不动就被大厂拿来当面试题的老面孔。题目描述非常简单:给一个只包含正整数的非空数组 nums,判断能否把这个数组分割成两个子集,使得两个子集的元素和相等。

举个例子,nums = [1, 5, 11, 5],因为可以分成 [1, 5, 5] 和 [11],两个子集和都是11,所以返回 true。而 nums = [1, 2, 3, 5],你无论怎么拆,都做不到两边相等,返回 false。

很多人第一次看到这个问题时,会觉得这不就是个“组合枚举”吗?把所有划分方式都试一遍不完了?但如果数组长度是几百,子集的划分方式是指数级的 2^n,几轮测试用例下去直接超时。这道题真正考的不是你能不能写出暴力解,而是你能不能看穿它的本质——它不是一个“集合划分”问题,而是一个“背包问题”。

1.2 核心数学抽象:子集和为总和的一半

先做一个很关键的推导。如果两个子集的和相等,假设都是 S,那么整个数组的总和就是 2S,也就是说数组总和必须是偶数。一旦总和是奇数,直接返回 false,连算都不用算。

接下来,问题变成:能不能从 nums 里挑出一些元素,让它们的和正好等于 S(即总和的一半)?只要找到了这样的一个子集,剩下的元素天然就是另一个和也等于 S 的子集。这里要特别注意,我们不需要关心“哪一半”,只需要回答“存在还是不存在”。

从“分成两个和相等的子集”到“选出一个和为 target 的子集”,这一步转换是整个题目的题眼。这一步想通了,后面就可以用非常成熟的0/1背包模型来解。为什么叫0/1背包?因为数组里的每个数字只有两种状态:选进这个子集,或者不选。每个物品重量等于它的值,背包容量等于 target,问题就变成了:能否用一些物品恰好装满容量为 target 的背包。

1.3 前置剪枝:不写代码就能排除的情况

很多初次接触这道题的人会忽略前置判断,直接数据结构和动态规划轮番上阵。其实在具体推导之前,有三个“白给的判断”能省掉大量无用功:

数组总和为奇数时,直接 false。这个前面已经说了,因为两个相等整数之和必然是偶数。

数组里的最大值超过 target 时,直接 false。因为任何一个元素如果已经大于 target,你不可能把它放进任何一个和为 target 的子集里,矛盾。

数组为空或长度为 1 时,直接 false。一个数字没法拆成两个非空子集。

把这三个判断写完,你其实已经把简单的边界问题全部挡在门外。我在面试中见过不少候选人,拿到题直接开始写状态转移方程,结果样例有奇数总和,一跑就错了。先把简单情况排除掉,是职业习惯,也是一种编码素养。

2. 方案选型:为什么回溯会超时而动态规划能过关

2.1 回溯的思路与代价

有人可能会问,我就是想用回溯,为什么要拦着我?我们用回溯的思路走一遍:写一个递归函数,从数组下标 0 开始,对每个数字做两个分支:选它或者不选它。当累加和等于 target 时返回 true,当累加和大于 target 时剪枝返回 false。

这个思路在数组长度小的时候完全没问题,比如只有十几个元素,2^10 也就是 1024 种情况,眨眼跑完。但 LeetCode 的测试用例会把数组长度拉到 200 左右,全是正数,2^200 是天文数字,剪枝也救不回来。虽然由于提前排序和搜索顺序优化,能砍掉不少分支,但最坏情况下依然是指数级。刷题时追求的是稳定过题,不赌数据强度,所以回溯只能作为思路扩展,不能作为主解。

2.2 把问题映射到0/1背包

动态规划的好处在于把“重复计算”彻底抹掉了。假设数组 nums = [1, 5, 11, 5],总和 22,target = 11。我们从头到尾决定每个数字“选”或“不选”,那么到某一步时,你只关心“当前已经凑出的和是多少”,而不关心“具体是哪几个元素凑出来的”。

这是一个非常重要的思维切换:只要两个不同的选择路径走到了同一个和值,它们在后续的决策中就完全等价。所以不需要记录具体的组合方案,只需要开一个布尔数组,记录“每个和值是否可达”。

这就像在背包里装东西:背包容量固定为 target,每件物品只能用一次,问能不能恰好装满。0/1背包的动态规划复杂度是 O(n * target),对 n=200、target=1000 级别的数据来说完全轻松。

2.3 状态设计:dp[j] 到底表示什么

我第一次系统学习动态规划时,最头疼的就是dp数组下标到底代表什么。在这里,我建议用一个最直观的定义:

dp[j] 表示“从数组已经处理过的元素里,能否选出若干元素,使得它们的和恰好等于 j”。

这个定义是布尔值,true 或 false。初始化时,dp[0] = true,因为不选任何元素,和天然为 0。其余 dp[j] 初始化为 false。

状态转移时,每处理一个新的数字 num,我们更新 dp 数组。对于每个 j,有两种可能:不拿当前这个数,那 dp[j] 保持原样;拿当前这个数,那前提是 dp[j - num] 为 true,说明之前能凑出 j - num,加上 num 就凑出了 j。因此转移方程为:

dp[j] = dp[j] || dp[j - num]

用语言描述就是:当前能凑出 j,要么之前就能,要么之前能凑出 j-num 然后加上 num。动态规划的所有核心,都可以浓缩在这一个“或”运算里。

3. 核心细节:一维dp的滚动优化与倒序遍历真相

3.1 为什么可以用一维数组

最朴素的思路是用二维布尔数组 dp[i][j],表示前 i 个元素能否凑出和 j。这个解法是对的,时间 O(n * target),空间也是 O(n * target)。但观察状态转移可以发现,第 i 行的状态只依赖第 i-1 行的状态,跟更早的行没有关系。

所以我们可以只用一行数组,每次处理一个新数字时,在当前这一行上进行“原地更新”。这就是滚动数组,空间从 O(n * target) 降到 O(target)。很多题解把这一步叫做“空间优化”,但我觉得更准确的说法是:状态依赖关系决定了我们只需要保留最近一层。

3.2 内层循环倒序是这道题真正的考点

一维数组的写法里有一个很致命的问题:如果正序更新 dp[j],会发生什么?我们来看 nums = [1, 5, 11, 5],target = 11。假设当前数字是 5,正序更新:

j 从 1 遍历到 11,j=5 时发现 dp[0] = true,于是把 dp[5] 设为 true。接着 j 继续走到 10 时,发现 dp[5] 已经是 true 了,于是把 dp[10] 也设为 true。但这里的 dp[5] = true 其实就是刚由当前这个数字 5 更新的,再用它去更新 dp[10],就相当于同一个数字 5 被用了两次,得到了“5+5=10”的错误结论。

这就是完全背包和0/1背包的核心区别:完全背包允许无限次使用同一个物品,所以内层循环正序;0/1背包每个物品只能用一次,所以内层循环必须倒序,保证更新 dp[j] 时使用的 dp[j-num] 是还没被当前物品污染过的旧值。

这个点我见过太多人在面试中翻车。经常是代码背熟了,能写出来,但一问“为什么倒序”,立刻卡壳。真正理解倒序的本质是:在倒序遍历时,j-num 一定小于 j,所以它会在本轮更新中排在 j 之后被访问,也就是依然保留着上一轮的状态。换句话说,你永远拿不到“已经在当前轮次被更新过”的 dp[j-num],自然就不可能重复使用同一个元素。

3.3 初始化细节与循环边界

dp 数组要开 target + 1 个位置,因为和值的范围是从 0 到 target。循环 j 从 target 开始,一直减到 num 为止。

这里注意一个细节:j 的下限是 num,而不是 1。因为当 j < num 时,j - num 是负数,没有任何意义。这个边界条件写错,要么数组越界,要么白白跑很多无效循环。

再看初始化,dp[0] = true 是必须的,它是整个状态转移链的地基。如果你把 dp[0] 写成 false,后面所有“从 0 加一个 num 得到 num”的转移都会断掉。我见过有些新手尝试把 dp[num] 直接设为 true,然后在循环里从 0 开始推,这样做虽然能过一部分用例,但语义就乱了,遇到复杂用例很容易出问题。

4. 全流程实操:三种解法从思路到AC代码

4.1 常规一维动态规划解法

我直接给出一份可运行的 C++ 代码,这也是 LeetCode 上最常见的题解写法:

class Solution { public: bool canPartition(vector<int>& nums) { int sum = accumulate(nums.begin(), nums.end(), 0); if (sum % 2 == 1) return false; int target = sum / 2; vector<bool> dp(target + 1, false); dp[0] = true; for (int num : nums) { if (num > target) return false; for (int j = target; j >= num; --j) { if (dp[j - num]) { dp[j] = true; } } if (dp[target]) return true; // 提前退出 } return dp[target]; } };

有几个小细节值得展开说明。第一,sum 用 accumulate 算的时候要注意整数溢出,这道题因为数字和 target 的范围不大,一般没事,但养成用 long long 的习惯更好。第二,每个 num 处理完后可以立刻检查一次 dp[target],如果已经为 true,就没必要继续处理剩余元素了,直接返回。第三,提前判断 num > target 可以在最外层直接返回 false,不需要进入动态规划流程。

除了 C++,Python 版也几乎长一样。官方题库里有一堆语言可以切换,但核心逻辑完全一致,语言差异不影响理解:

class Solution: def canPartition(self, nums: List[int]) -> bool: total = sum(nums) if total % 2: return False target = total // 2 dp = [False] * (target + 1) dp[0] = True for num in nums: for j in range(target, num - 1, -1): if dp[j - num]: dp[j] = True if dp[target]: return True return False

4.2 回溯 + 记忆化的等价实现

如果就是想用递归来表达,也不是不行,加上“记忆化搜索”以后时间复杂度和动态规划是一样的。这里的 memo 记录的是“在某个下标位置,当前累计和能否到达 target”,避免重复计算相同状态。

from functools import lru_cache class Solution: def canPartition(self, nums: List[int]) -> bool: total = sum(nums) if total % 2: return False target = total // 2 @lru_cache(None) def dfs(i, cur): if cur == target: return True if i == len(nums) or cur > target: return False return dfs(i + 1, cur + nums[i]) or dfs(i + 1, cur) return dfs(0, 0)

这段代码比动态规划好理解,但实际跑起来会慢不少,因为 Python 的函数调用开销和缓存机制比单纯数组更新要重。而且如果 target 很大,递归深度可能触及 Python 的递归限制。所以这个版本我一般只用来做“语义验证”,也就是在本地写测试时确认题目理解没跑偏,真正提交答案我还是用一维动态规划。

4.3 位运算版本的“彩蛋”解法

说到后面想提升一下格调,可以用 bitset 来做。C++ 的 bitset 可以直接按位或来更新所有可达状态,一句话解决:

class Solution { public: bool canPartition(vector<int>& nums) { int sum = accumulate(nums.begin(), nums.end(), 0); if (sum & 1) return false; bitset<10001> dp; dp[0] = 1; for (int num : nums) { dp |= dp << num; } return dp[sum >> 1]; } };

这里的关键在于 dp 是一个布尔集合,dp |= dp << num 表示把当前所有可达的和值整体加上 num,生成新的可达状态。这个写法的效率非常高,因为底层是用整块整块的位操作来完成的,一次位移就能处理一大批状态。但要注意:bitset 的大小必须预先开够,这道题 target 最大也就是 10000 左右,开 10001 就够了。如果在面试现场能写出这种解法,并且解释清楚“位移就是加法,或运算就是状态合并”,那基本属于加分项。

5. 踩坑实录:常见错误与面试追问

5.1 高频错误一:把状态设计成 int 还是 bool

很多初学动态规划的人总想用 dp[j] 存“最大和”,比如 dp[j] 表示容量为 j 时能凑出的最大和,最后判断 dp[target] == target。这种写法本身没错,能过不少题目,但它有一个隐患:它隐含了“尽可能填满背包”的贪心思路,而这道题要求的是“恰好填满”。

如果某些元素组合后得到的是“最接近 target 的值”,但你恰好错过了唯一一种刚好等于 target 的组合,用最大值判断就会误判。所以这道题推荐用布尔数组,语义就是“可达/不可达”。从面试角度说,布尔数组的含义也更清晰,不容易被追问卡住。

5.2 高频错误二:忘记提前处理奇数总和

如果总和是奇数,却忘了判断,那 target 就成了一个带小数的值。在 C++ 里整数除法会直接截断,比如 sum=15,sum/2=7,于是你傻乎乎地去凑 7,最终可能返回 true,但实际题目要求的是两半都等于 7.5,那是根本不可能的事。

所以我在很多题解底下看到评论提问“为什么样例过了但提交全错”,一半是这个问题。处理方式很简单,总和模 2 判断一下,奇数直接 return false,提前退出。

5.3 高频错误三:内层循环写成正序

这个问题前面详细展开过,这里再补充一个实际调试中看到的例子。有朋友在 LeetCode 讨论区贴过一份“错误答案”,他用了正序循环,结果 nums = [1, 2, 5] 这个用例是对的,但 nums = [2, 2, 2, 3, 3] 这种用例就会错。原因就是 2 可以被重复使用,凑出了根本不应该存在的和值。

如果你在本地自己调代码,最容易察觉这个 bug 的方法就是打印 dp 数组观察变化。比如 nums=[1,5,11,5],正序更新到数字 5 时,你会看到 dp[10] 变成 true,然后思考一下“10 到底是怎么凑出来的”,就会意识到是同一个 5 被用了两次。

5.4 面试追问:空间还能继续优化吗

有时候面试官会追问:target 很大,但 n 很小,一维数组还能不能压缩?答案是可以换用哈希表来存所有可达和值。因为随着处理元素增加,可达状态的数量最多也就 n 个,但哈希表在元素数量远小于 target 时会省很多空间。比如 nums = [100, 200, 300],target = 3000,用布尔数组要开 3001 个位置,但哈希表只有 8 个状态左右。

不过哈希表版本的时间复杂度不稳定,而且代码更啰嗦,在 LeetCode 的常规数据下反而没有数组快。面试中你可以提这个思路,但没必要真写,点到为止。

5.5 扩展:这题还能怎么变

分割等和子集这道题,一变就是“目标和”,再一变就是“最后一块石头的重量 II”,它们在本质上都是同一个0/1背包模型。如果能把 [分割等和子集] 的动态规划彻底吃透,再去做后面这几道题,基本就是套模板。

我个人刷题时,习惯把这类“能否凑出某个和”的布尔 DP 归纳成一个固定框架:先算总量、确定目标、初始化 dp[0]、再按物品倒序更新。之后遇到类似问题先套框架,再根据题意调整细节,效率会高出很多。这也是为什么这道题值得反复刷的原因——它不是一个孤立知识点,而是一整类背包问题的地基。

最后分享一个小技巧:如果哪天你手边没有编译器,又想快速验证思路是否正确,可以直接在纸上画一个 target 行的表格,把每个数字的更新过程一行一行填进去。画完三个数字的更新,你基本就能直观感受到“倒序”的意义。我在给人讲这道题时,一画表格,对方就再也不会写错内层循环的方向了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询