如果你刷题刷到 LeetCode 416 这道分割等和子集,我猜你多半是在动态规划专题里遇见它的。这道题我刷过很多遍,每次给人讲背包问题的入门,我都拿它当第一个案例,因为它把 0/1 背包最核心的“选与不选”藏在一个看起来只是算术的题干里。整道题用一句话概括就是:给一个正整数数组,能不能把它拆成两个子集,让两个子集的元素和相等。
我第一次做这道题的时候,完全没看出它是背包问题,硬是卡了两个小时。后来想通了,发现它的思路极其干净,能把背包问题的精髓讲得明明白白。这篇文章我就把 416 从头到尾拆开讲,从题面怎么翻译成背包模型、dp 数组为什么这么写、循环为什么要倒序,到代码怎么落地、实际刷题时容易踩哪些坑,尽量用大白话讲清楚。适合刚接触动态规划的新手,也适合准备面试想快速过一遍背包模型的老手。
1. 题面解读:先破解“分割等和”这句话背后的数学含义
1.1 读完题干先做两件事:算总和、判奇偶
原题描述很简单:给你一个只包含正整数的非空数组,问能不能把这个数组分割成两个子集,使得两个子集的元素和相等。注意两个限制:数组长度不超过 200,每个元素不超过 100。
很多人第一次看到“分割成两个子集”会下意识想到搜索:把所有子集枚举一遍,看有没有一半。如果你也这么想,说明你正在走我当初走过的弯路。其实这道题的第一步完全不用想复杂,先算一下数组总和。
假设数组总和是 sum,既然要分成两个和相等的子集,那每个子集的和都必须是 sum / 2。这里立刻出现一个数学上的硬条件:sum 必须是偶数。如果 sum 是奇数,比如 [1, 2, 4],总和是 7,你无论如何也不可能把它分成两个和相等的整数子集,直接返回 false 就行。
这一步是整道题最简单的剪枝,同时也是很多人容易忽略的地方。我见过不少同学一上来就写动态规划,结果边界条件里忘记判奇偶,最后在 sum 为奇数的用例上栽跟头。记住,在看到“等和分割”这四个字的时候,第一步永远是:算总和、判奇偶、除以二。
1.2 为什么说它是披着“子集”外衣的 0/1 背包
一旦我们把问题翻译成“能不能从数组中选出若干个数,使它们的和恰好等于 target = sum / 2”,这道题的背包本质就藏不住了。
你可以把每个数字想象成一件物品,它的“重量”和“价值”都是这个数字本身。每个数字只能用一次,因为每个元素只能放进其中一个子集。现在有一个容量为 target 的背包,问能不能恰好装满。这不就是典型的 0/1 背包吗?
但这里有一个容易混淆的点:传统背包问题问的是“在容量限制下能装下多少价值”,而 416 只问“能不能恰好装满”,是一个存在性问题。所以我们的 dp 数组不需要存最大价值,只需要存布尔值——某个和是否可达。
这也是为什么 416 特别适合入门的另一个原因:它把背包问题的模型拎出来,但去掉了“优化价值”的计算负担,让你把注意力全部集中在“选或不选”这个决策过程上。等你想明白布尔 dp 的更新逻辑,后面再碰到最大价值、方案数、最小消耗这些变体,思路都是一脉相承的。
1.3 什么时候可以暴力枚举,什么时候必须上 DP
当然,也不是所有情况都必须用动态规划。如果你面对的数组长度 n 很小,比如 n <= 20,那暴力枚举所有子集完全可行,2^20 也就是一百万种情况,算力上能接受。
但题目约束是 n <= 200,2^200 这个数字是天文学级别,搜索必然爆掉。你可能想用排序加 DFS 剪枝来抢救一下,比如从大到小排列,先凑大的数,发现超过 target 就剪枝。在一些数据分布比较“温和”的用例上,这种做法确实能跑得很快,但它的表现极不稳定:一旦数据里有很多小数字,搜索树会迅速膨胀,依然会超时。
所以面对通用解法,老老实实上动态规划才是正路。DP 的优势在于它把“重复计算”全部吃掉了——同一种子集和状态只需要记录一次,后续不需要再枚举产生它的具体路径。这也是从暴力搜索转向 DP 时最需要建立的直觉:如果你发现子问题会被反复求解,就优先考虑用状态数组把它缓存下来。
2. 核心思路拆解:状态定义和转移方程是整道题的灵魂
2.1 用“凑 target”重新定义问题
在动手写代码之前,先把问题彻底重构一遍。
给定数组 nums,总和为 sum。如果 sum 是奇数,直接返回 false。如果 sum 是偶数,设 target = sum / 2,我们需要回答的问题就是:是否存在一个子集,让它的元素和恰好等于 target。
为什么要重新定义成“凑 target”而不是“分成两个子集”?因为这两个子集是互补的——只要找到一个子集的和是 target,剩余元素自然组成另一个子集,它的和也是 sum - target = target,两个子集自然相等。这样一来,我们就把一个看起来很抽象的“分割”问题,变成了一个非常具体的“凑数”问题。
接下来的每一步都围绕这个目标做状态设计。dp[j] 表示“当前已经处理过的元素中,是否存在某个子集的和刚好为 j”。起始时,不选任何元素,和是 0,所以 dp[0] = true。我们的任务,就是逐个处理数组元素,不断更新这个 dp 数组,最后看 dp[target] 是否为 true。
2.2 二维布尔 dp:先写出最无脑的版本
为了让转移过程更直观,我们先不急着做空间优化。设 dp[i][j] 表示“前 i 个元素(也就是 nums[0..i-1])中,是否存在子集和为 j”。
对于第 i 个元素 num = nums[i-1],它只有两种命运:不选它,那么 dp[i][j] 完全继承 dp[i-1][j] 的结果;选它,那么前提是 j >= num,并且前 i-1 个元素已经能凑出 j - num,也就是 dp[i-1][j-num] 为 true。
于是状态转移方程就出来了:
dp[i][j] = dp[i-1][j] || dp[i-1][j-num] (当 j >= num) dp[i][j] = dp[i-1][j] (当 j < num)
拿题目里的经典例子 [1, 5, 11, 5] 来推一遍。总和是 22,target = 11。表格里记录“处理到某个元素之后,所有不超过 target 的可达和”:
| 已处理元素 | 可达和(不超过 target) |
|---|---|
| 无 | {0} |
| 1 | {0, 1} |
| 1, 5 | {0, 1, 5, 6} |
| 1, 5, 11 | {0, 1, 5, 6, 11} |
| 1, 5, 11, 5 | {0, 1, 5, 6, 10, 11} |
看到最后一行,11 已经出现在集合里,说明存在子集 {1, 5, 5} 的和是 11,另一半自然就是 {11},返回 true。这个“可达集合”视角非常有用,它把 dp 数组的动态变化摊开在纸面上,比对着公式空想要直观得多。
2.3 一维优化:为什么循环必须倒着走
二维 dp 的空间复杂度是 O(n * target),n 最大 200,target 最大 10000,大概两百万个布尔值,其实也能接受。但在实际代码里,我们几乎都用一维数组来写。原因很简单:dp[i] 这一行只依赖它上一行 dp[i-1],上一层用完就没用了,我们可以直接在原数组上滚动更新。
关键问题来了:如果用一维数组,内层循环必须从 target 往 num 方向倒着遍历,而不能正着遍历。为什么?
因为正序遍历时,当你在处理元素 num、更新 dp[j] 的时候,dp[j-num] 可能已经被这一轮循环更新过了。一旦 dp[j-num] 已经是“选过 num 之后”的结果,再拿它去更新 dp[j],就相当于把同一个 num 用了两次。这正好破坏了 0/1 背包“每个元素只能用一次”的约束,变成了完全背包的写法。
举个例子方便理解。假设数组里只有一个元素 2,我们想知道和 4 能不能凑出来(这只是为了演示机制而设的放大场景)。倒序遍历时,j 从 4 走到 2,更新 dp[4] 时读取的 dp[2] 是上一轮的旧值,也就是 false,于是 dp[4] 保持 false,正确;正序遍历时,j 从 2 走到 4,先用 2 更新出 dp[2] = true,等更新 dp[4] 时读取这个刚被改过的 dp[2],就会错误地得出 dp[4] = true。这就是“同一个 2 被用了两次”的直观体现。
记住这个结论:0/1 背包容量循环倒序,完全背包容量循环正序。面试时能说出这个区别,面试官会认为你真的理解了背包问题的内功,而不是背了一个模板。
3. 代码落地:三种写法,从模板到进阶
3.1 面试首选写法:Java 一维布尔数组
在实际面试和刷题中,我推荐最朴素的 Java 写法,逻辑清晰,不容易写错。
class Solution { public boolean canPartition(int[] nums) { int sum = 0; for (int num : nums) { sum += num; } if ((sum & 1) == 1) { return false; } int target = sum / 2; boolean[] dp = new boolean[target + 1]; dp[0] = true; for (int num : nums) { for (int j = target; j >= num; j--) { dp[j] = dp[j] || dp[j - num]; } if (dp[target]) { return true; } } return dp[target]; } }这里有两个细节值得说明。第一,dp[j] = dp[j] || dp[j - num] 这句,读作“不选 num 时保持原来的 dp[j],选 num 时看 dp[j - num]”,两者有一个为 true,dp[j] 就是 true。第二,提前返回直接写在 num 的循环里,因为如果在处理完当前元素后已经凑出 target,后面剩余的元素可以选择不放进这个子集,不影响结果。这个提前返回不改变正确性,只是加速。
3.2 Python 与 C++ 实现:语言差异与注意点
Python 版本几乎是 Java 的镜像,唯一要注意的是 range 的边界是左闭右开,所以倒序遍历要写成 range(target, num - 1, -1)。
def can_partition(nums): total = sum(nums) if total % 2 == 1: return False target = total // 2 dp = [False] * (target + 1) dp[0] = True for num in nums: for j in range(target, num - 1, -1): dp[j] = dp[j] or dp[j - num] if dp[target]: return True return dp[target]C++ 版本本质上没什么区别,用 vector 即可。vector 虽然被部分人诟病底层不是真正的 bool 数组,但在这种只关心每个位置 0/1 的场景里完全够用。
class Solution { public: bool canPartition(vector<int>& nums) { int sum = accumulate(nums.begin(), nums.end(), 0); if (sum & 1) return false; int target = sum / 2; vector<bool> dp(target + 1, false); dp[0] = true; for (int num : nums) { for (int j = target; j >= num; --j) { dp[j] = dp[j] || dp[j - num]; } if (dp[target]) return true; } return dp[target]; } };如果你平时用 C++ 刷题,注意 accumulate 需要包含 numeric 头文件,不过在在线评测环境里一般都已经替你准备好了。
3.3 进阶骚操作:位运算状态压缩
如果想让代码更“暴力美学”一点,可以用位运算来压缩整个 dp 数组。核心思想是:用一个整数的第 k 位表示“和 k 是否可达”。初始时第 0 位是 1,每读入一个数字 num,就执行 bits |= bits << num,表示在已有可达和的基础上,要么保持原状(不选 num),要么整体加上 num(选 num)。循环结束后,检查 target 位是否为 1。
def can_partition(nums): total = sum(nums) if total % 2 == 1: return False target = total // 2 bits = 1 for num in nums: bits |= bits << num return (bits >> target) & 1 == 1Python 的整数可以无限扩张,所以这个写法最顺手。C++ 可以用 bitset,不过需要提前确定最大位数。由于 nums 长度 <= 200、每个元素 <= 100,sum 最多 20000,target 最多 10000,所以 bitset<10001> 足够。
class Solution { public: bool canPartition(vector<int>& nums) { int sum = accumulate(nums.begin(), nums.end(), 0); if (sum & 1) return false; int target = sum / 2; bitset<10001> bits; bits[0] = 1; for (int num : nums) { bits |= bits << num; if (bits[target]) return true; } return bits[target]; } };位运算版本不是面试必答项,但它能帮你加深对 dp 的抽象理解。一旦你意识到 dp 数组本质上就是一组布尔状态,而布尔状态的更新可以看成一整块位运算,很多压位 DP 题目你也会豁然开朗。
3.4 复杂度分析与提前退出的收益
说完代码,来算一笔复杂度账。一维 dp 的空间复杂度是 O(target),也就是 O(sum/2)。在这道题的约束下,target 最大是 10000,布尔数组只有一万个位置,空间占用非常小。
时间复杂度是 O(n * target),最坏情况就是 200 * 10000 = 两百万次布尔运算。这个量级对现代 CPU 来说就是毫秒级,完全不用担心超时。这也是为什么这道题适合作为背包入门题——它有足够的规模让你体会到 dp 比暴搜强在哪里,又不会因为常数太大让你在优化技巧上分心。
提前退出是一个很实用的加速手段。比如数组里已经有一个元素恰好等于 target,比如 [100, 50, 50],target 是 100,那么处理完第一个元素后 dp[100] 就是 true,可以立刻返回。这样在最好的情况下,我们可能只需要扫描数组前面的几个元素就能收工。这个优化放在面试里说出来,也会显得你不只是背了一个解法,而是真的在思考运行时行为。
4. 现场避坑:循环方向、边界条件与面试变体
4.1 为什么你的答案偶尔对、偶尔错:循环方向问题
我在群里和评论区最常见到的一个问题是:“我写的代码和题解一模一样,为什么有时候能过,有时候就是错?”大概率就是内层循环方向写错了。这个问题我在 2.3 里详细拆过,这里再给一个更可操作的检查方法。
你写完代码之后,先别急着提交,拿一个只有一个元素的小数组在脑子里跑一遍。比如 nums = [3],target 设为 3 的场景(这里纯粹为了验证逻辑)。倒序遍历时,dp[3] 检查 dp[0],得到 true,正确;正序遍历时,dp[3] 在 dp[0] 变成 true 之后可能会读取到被本轮修改过的 dp[某个值],从而错误地让 dp[3] 也为 true,这显然就错了。
如果你总记不住方向,可以在代码旁边标注一句话:“容量循环倒序 = 每个物品只用一次;容量循环正序 = 每个物品可以用无限次。”这个口诀适用于所有背包类问题,从 416 到后面的完全平方数、零钱兑换,全都通用。
4.2 容易被忽略的边界条件与初始化错误
这一类“存在性”背包题,最容易翻车的往往不是转移方程,而是初始化和边界。
第一个坑是忘记 dp[0] = true。想想看,如果 dp[0] 保持 false,整个 dp 数组就全盘皆输,因为所有可行状态最终都要追溯到“空子集和为 0”这个源头。这个错误非常隐蔽,因为很多用例规模小,即使初始化错了也会碰巧通过部分测试,只有跑到某些用例才会暴雷。
第二个坑是没有判奇偶。有的同学上来就 target = sum / 2,忽略了奇数情况,等于说把小数当目标去凑,结果自然不对。这属于审题层面的失误,直接在开头写一行 if ((sum & 1) == 1) return false; 就能绝杀。
第三个坑是数组长度为 1 的极端情况。比如 nums = [5],总和为 5,奇数,直接返回 false,没毛病。如果要构造偶数场景,比如 nums = [2],sum = 2,target = 1,dp[1] 始终为 false,也正确。记得在写题时主动补一两个极端小用例,能省下很多无谓的提交失败。
4.3 面试官追问:返回方案、可重复选择、负数怎么办
如果在面试中做完这道题,面试官大概率会继续追问几个变体,提前想清楚再上考场会从容很多。
最常见的追问是:“如果不仅要判断存在,还要输出任意一组分割方案呢?”这时候一维 dp 就不够用了,你需要保留二维 dp 的所有历史状态,然后从最终状态往回倒推。具体做法是,如果 dp[i][target] 为 true,检查 dp[i-1][target] 是否为 true:如果是,说明第 i 个元素没有选,target 不变,跳到上一行;否则说明第 i 个元素被选中了,把 nums[i-1] 记入结果,target 减去这个数,再跳到上一行。这个“从结果反推路径”的技巧在背包问题里使用频率很高,建议自己手写一遍。
第二类追问是“如果把‘每个数只能用一次’改成‘每个数可以用无限次’”,那答案就是把内层循环从倒序改成正序。因为正序会让同一个数被重复使用,这正是完全背包的语义。这个追问其实是在考察你到底知不知道为什么要倒序。
第三类追问是“如果数组里有负数怎么办”。这个问题比较麻烦,负数的存在会让子集和不是单调变化的,简单的背包模型不再适用,通常需要把值域偏移,或者直接用暴搜加剪枝。面试中遇到这种问题,先冷静说清“负数会破坏当前一维 dp 的递推基础”,再给出优化方向,比硬着头皮写一堆复杂代码更稳妥。
4.4 把 416 的模型迁移到更多子集问题
416 的模型不是孤立的,它可以映射到一大类“子集存在性”问题。比如给你一个数组问“是否存在子集和为 K”,这就是把 target 换成 K,和 416 完全同构。如果题目从“是否存在”变成“有多少种方案凑出 target”,只需要把 dp 数组改成整数数组,转移方程换成 dp[j] += dp[j-num],同时注意初值 dp[0] = 1。如果题目变成“最少用几个数凑出 target”,那就是 int 数组加 min 操作,初始化为一个很大的数。
这些变体之间的关系,我用一张对照表汇总一下,方便你复习时快速定位模型:
| 问题形态 | dp 数组类型 | 核心转移 | 典型题目场景 |
|---|---|---|---|
| 能否凑出 target | boolean | dp[j] = dp[j] || dp[j-num] | 416、部分和判断 |
| 凑 target 的方案数 | int | dp[j] += dp[j-num] | 组合总和类变体 |
| 凑 target 的最少个数 | int | dp[j] = min(dp[j], dp[j-num] + 1) | 零钱兑换 |
| 每个元素无限使用 | 取决于需求 | 内层循环改正序 | 完全背包变体 |
这张表放在脑子里,比一道一道死记题解要有用得多。很多人刷题刷到后面觉得题目永远刷不完,就是因为没有把相似模型串起来,每道题都是零散记忆。416 是串起这些概念的一个很好起点。
最后说一点个人经验。我刷完 416 之后,会顺手把它改写成三个版本:输出任意一组可行方案、统计分割方案总数、把“每个数只能用一次”改成“每个数可以用无限次”。每次改写都会逼我重新审视状态定义和遍历顺序,几次下来,背包模型就不再是背出来的模板,而是真正长在脑子里的思考方式。建议你也试试这个练习,效果比闷头多刷十道同类型题还要明显。