分割等和子集 图解题解
这道题到底在问什么
- 输入
- nums=[1,2,3,4]
- 输出
- true({1,4} 与 {2,3} 各和 5)
最优解:为什么这么做
一句话答案:LeetCode 416 分割等和子集的标准解法是 0-1 背包动态规划:先把「分成两个等和子集」翻译成「能否从数组里挑一些数凑出总和的一半」,总和为奇数直接返回 false;再用布尔 dp[w] 表示能否凑出和 w,每个数只能用一次,容量倒序更新。时间 O(n·target)、空间 O(target),target 为总和的一半。
分割等和子集为什么等价于凑出总和的一半
题目给一个正整数数组 nums,问能不能把它分成两个子集,让两边元素和相等。直接想「怎么分两堆」会很乱,先做一步翻译:两个子集和相等,每一份必然恰好是总和 sum 的一半;反过来,只要能挑出一些数凑出 sum/2,剩下的数自动构成另一半。于是问题变成判定题:能否选出和恰好等于 sum/2 的子集。
这一步还送一个提前剪枝:sum 是奇数时一半不是整数,正整数无论怎么选都凑不出,直接返回 false。以 nums=[1,2,3,4] 为例,sum=10、目标是 5,{1,4} 和 {2,3} 各凑 5,答案为 true。
为什么这是一个 0-1 背包问题
「从 n 个数里挑一些、每个至多用一次、凑出目标和」正是 0-1 背包的判定版:每个数是一件物品,体积就是数值本身,容量是 sum/2,只关心能不能装满。暴力枚举所有子集是 2 的 n 次方种,指数爆炸。
关键观察是:面对一个新的数 x,任何一种凑法要么用了 x、要么没用。没用时,能凑出哪些和与之前完全一样;用了时,能凑出 w 当且仅当之前能凑出 w - x。「前若干个数能凑出哪些和」这一份信息,足以推出加入下一个数后的全部答案,不必记住具体选了谁——这就是动态规划能压缩指数级搜索的原因。
dp 数组怎么定义,dp[0] 为什么必须是 true
定义布尔数组 dp,dp[w] 表示:用目前已考虑过的数,能否凑出和恰好为 w。初始只有 dp[0]=true——空集的和就是 0,其余全是 false。每加入一个数 x,转移是 dp[w] = dp[w] or dp[w - x]:前者对应不选 x,后者对应选 x、看之前能否凑出 w - x。
dp[0]=true 绝不能漏,它是所有递推的种子:一个数 x 能单独凑出 x,正是靠 dp[x - x] = dp[0] 传导出来的。写成 false,整张表永远推不出任何 true。
一维数组为什么必须倒序更新容量
用一维数组滚动时,内层循环必须让容量 w 从大到小(从 target 减到 x)。原因在于:算 dp[w] 时引用的 dp[w - x] 必须是还没被当前这个数更新过的旧值,代表「不含 x 的历史状态」;倒序扫描时小容量还没被碰过,引用到的正是旧值。
如果正序更新,dp[w - x] 可能已被本轮的 x 更新过,再拿它推 dp[w] 等于把同一个数用了两次——那是完全背包的语义,会把「凑不出」误判成「凑得出」。倒序这一个方向,就是「每个数只能用一次」在代码里的全部体现。
复杂度怎么算,哪些边界容易翻车
时间复杂度 O(n·target):n 个数,每个数把容量从 target 到自身扫一遍,target 是总和的一半。空间 O(target),一维布尔数组即可。这是和数值大小挂钩的伪多项式复杂度。
三个高频翻车点:忘了先判总和奇偶;一维数组正序更新,悄悄变成完全背包;漏掉 dp[0]=true 这个空集起点。另外题目保证正整数,若元素可能为负,容量非负的背包框架不再适用,需改用可达和集合等写法。
▶ 动画逐步走查(共 51 步)——想跟着动画一帧帧对照就展开
- 3下面用一张二维表逐格填,行=数一个个加入,列=要凑的容量。
- 4第 0 行:一个数都还没用。只有容量 0 可行(什么都不拿),其余全不可行。
- 5加入数 1,容量 0 比 1 还小,放不进,只能照搬上方 dp[0][0]=可行。
- 6两条路只要有一条通就可行 → dp[1][0]=✓。
- 7加入第 1 个数 1,看容量 1:不选它=看上方 dp[0][1]=不行;选它=看 dp[0][0]=可行(先腾出 1 的位置)。
- 8两条路只要有一条通就可行 → dp[1][1]=✓。
- 9加入第 1 个数 1,看容量 2:不选它=看上方 dp[0][2]=不行;选它=看 dp[0][1]=不行(先腾出 1 的位置)。
- 10两条路都走不通 → dp[1][2]=·,凑不出 2。
- 11加入第 1 个数 1,看容量 3:不选它=看上方 dp[0][3]=不行;选它=看 dp[0][2]=不行(先腾出 1 的位置)。
- 12两条路都走不通 → dp[1][3]=·,凑不出 3。
- 13加入第 1 个数 1,看容量 4:不选它=看上方 dp[0][4]=不行;选它=看 dp[0][3]=不行(先腾出 1 的位置)。
- 14两条路都走不通 → dp[1][4]=·,凑不出 4。
- 15加入第 1 个数 1,看容量 5:不选它=看上方 dp[0][5]=不行;选它=看 dp[0][4]=不行(先腾出 1 的位置)。
- 16两条路都走不通 → dp[1][5]=·,凑不出 5。
- 17加入数 2,容量 0 比 2 还小,放不进,只能照搬上方 dp[1][0]=可行。
- 18两条路只要有一条通就可行 → dp[2][0]=✓。
- 19加入数 2,容量 1 比 2 还小,放不进,只能照搬上方 dp[1][1]=可行。
- 20两条路只要有一条通就可行 → dp[2][1]=✓。
- 21加入第 2 个数 2,看容量 2:不选它=看上方 dp[1][2]=不行;选它=看 dp[1][0]=可行(先腾出 2 的位置)。
- 22两条路只要有一条通就可行 → dp[2][2]=✓。
- 23加入第 2 个数 2,看容量 3:不选它=看上方 dp[1][3]=不行;选它=看 dp[1][1]=可行(先腾出 2 的位置)。
- 24两条路只要有一条通就可行 → dp[2][3]=✓。
- 25加入第 2 个数 2,看容量 4:不选它=看上方 dp[1][4]=不行;选它=看 dp[1][2]=不行(先腾出 2 的位置)。
- 26两条路都走不通 → dp[2][4]=·,凑不出 4。
- 27加入第 2 个数 2,看容量 5:不选它=看上方 dp[1][5]=不行;选它=看 dp[1][3]=不行(先腾出 2 的位置)。
- 28两条路都走不通 → dp[2][5]=·,凑不出 5。
- 29加入数 3,容量 0 比 3 还小,放不进,只能照搬上方 dp[2][0]=可行。
- 30两条路只要有一条通就可行 → dp[3][0]=✓。
- 31加入数 3,容量 1 比 3 还小,放不进,只能照搬上方 dp[2][1]=可行。
- 32两条路只要有一条通就可行 → dp[3][1]=✓。
- 33加入数 3,容量 2 比 3 还小,放不进,只能照搬上方 dp[2][2]=可行。
- 34两条路只要有一条通就可行 → dp[3][2]=✓。
- 35加入第 3 个数 3,看容量 3:不选它=看上方 dp[2][3]=可行;选它=看 dp[2][0]=可行(先腾出 3 的位置)。
- 36两条路只要有一条通就可行 → dp[3][3]=✓。
- 37加入第 3 个数 3,看容量 4:不选它=看上方 dp[2][4]=不行;选它=看 dp[2][1]=可行(先腾出 3 的位置)。
- 38两条路只要有一条通就可行 → dp[3][4]=✓。
- 39加入第 3 个数 3,看容量 5:不选它=看上方 dp[2][5]=不行;选它=看 dp[2][2]=可行(先腾出 3 的位置)。
- 40两条路只要有一条通就可行 → dp[3][5]=✓。
- 41加入数 4,容量 0 比 4 还小,放不进,只能照搬上方 dp[3][0]=可行。
- 42两条路只要有一条通就可行 → dp[4][0]=✓。
- 43加入数 4,容量 1 比 4 还小,放不进,只能照搬上方 dp[3][1]=可行。
- 44两条路只要有一条通就可行 → dp[4][1]=✓。
- 45加入数 4,容量 2 比 4 还小,放不进,只能照搬上方 dp[3][2]=可行。
- 46两条路只要有一条通就可行 → dp[4][2]=✓。
- 47加入数 4,容量 3 比 4 还小,放不进,只能照搬上方 dp[3][3]=可行。
- 48两条路只要有一条通就可行 → dp[4][3]=✓。
- 49加入第 4 个数 4,看容量 4:不选它=看上方 dp[3][4]=可行;选它=看 dp[3][0]=可行(先腾出 4 的位置)。
- 50两条路只要有一条通就可行 → dp[4][4]=✓。
- 51加入第 4 个数 4,看容量 5:不选它=看上方 dp[3][5]=可行;选它=看 dp[3][1]=可行(先腾出 4 的位置)。
- 52两条路只要有一条通就可行 → dp[4][5]=✓。
- 53看最右下角 dp[4][5]=✓:用全部数能凑出 5,所以能平分成两个和为 5 的子集,返回 true。
⚠️ 容易写错的地方
✗ 错:不先判和是奇数
✓ 对:和为奇数直接返回 false
奇数无法平分成两个相等整数和
✗ 错:一维 dp 正序填
✓ 对:容量必须倒序 w 从大到小
正序会让一个数被重复使用,变成完全背包
✗ 错:忘了 dp[0]=true
✓ 对:凑出 0 永远可行(空集)
它是所有递推的起点
完整代码(Python / C++ / Java)
Python
def canPartition(nums):
s = sum(nums)
if s % 2: return False
t = s // 2
dp = [True] + [False] * t
for x in nums:
for w in range(t, x - 1, -1):
dp[w] = dp[w] or dp[w - x]
return dp[t]C++
bool canPartition(vector<int>& nums){
int s = 0; for(int x : nums) s += x;
if(s % 2) return false;
int t = s / 2;
vector<char> dp(t + 1, 0); dp[0] = 1;
for(int x : nums)
for(int w = t; w >= x; --w)
dp[w] = dp[w] || dp[w - x];
return dp[t];
}Java
boolean canPartition(int[] nums){
int s = 0; for(int x : nums) s += x;
if(s % 2 == 1) return false;
int t = s / 2;
boolean[] dp = new boolean[t + 1]; dp[0] = true;
for(int x : nums)
for(int w = t; w >= x; w--)
dp[w] = dp[w] || dp[w - x];
return dp[t];
}复杂度
时间
O(n·target)
n 个数 × 每个数扫一遍容量
空间
O(target)
一维滚动数组
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 分割等和子集 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么能从「分两等和子集」转成「凑出 sum/2」?+
两子集和相等 ⇒ 每份都是总和的一半。只要能凑出一半,剩下的元素自动构成另一半,所以只需判定能否凑出 sum/2。
如果元素可能为负数还成立吗?+
不成立。背包容量是非负的,含负数要改用基于偏移量或 set 可达和的写法。本题保证正整数。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 分割等和子集 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。