📌 题目链接:416. 分割等和子集 - 力扣(LeetCode) 🔍 难度:中等 | 🏷️ 标签:数组、动态规划、0-1 背包
⏱️ 目标时间复杂度:O(n × target)
💾 空间复杂度:O(target)(空间优化后)
🧠 题目分析
给定一个只包含正整数的非空数组 nums,判断是否可以将其分割成两个子集,使得这两个子集的元素和相等。
这个问题乍一看像是“分组”或“回溯”,但其实它是一个经典的 NP 完全问题(Partition Problem),属于 0-1 背包问题 的变种。
💡 关键转化:
若整个数组的总和为 sum,那么要分成两个和相等的子集,必须满足:
sum是偶数 → 否则无法平分;- 存在一个子集,其元素和等于
target = sum / 2。
于是问题转化为:能否从数组中选出若干数,使其和恰好等于 target?
这正是 0-1 背包问题 的标准形式:
每个物品(数字)只能选一次,背包容量为
target,问是否能恰好装满?
🎒 核心算法及代码讲解:0-1 背包动态规划
✅ 什么是 0-1 背包?
- 给定
n个物品,每个物品有重量w[i]和价值v[i](本题中重量=价值=nums[i]); - 背包容量为
W; - 每个物品最多选一次;
- 目标:在不超过容量的前提下,最大化价值(或判断是否能恰好装满)。
本题是 “恰好装满” 的判定型 0-1 背包。
🧩 动态规划状态定义
我们定义:
dp[j]表示:是否存在一种选取方案,使得所选数字之和恰好等于j。- 初始状态:
dp[0] = true(不选任何数,和为 0)。
🔁 状态转移方程
对于每个数字 num,我们从 target 倒序遍历到 num(防止重复使用):
dp[j] = dp[j] || dp[j - num];
dp[j]:不选当前num;dp[j - num]:选当前num,前提是j >= num。
⚠️ 为什么倒序?
如果正序更新,dp[j - num]可能已经被本轮更新过(即已包含当前num),导致同一个数被多次使用,违背 0-1 背包“每个物品只能用一次”的规则。
🛑 边界剪枝(面试高频!)
在 DP 前先做以下判断,可大幅提前终止无效计算:
n < 2→ 无法分割;sum % 2 == 1→ 总和为奇数,无法平分;maxNum > target→ 最大数比一半还大,不可能凑出另一半。
这些剪枝不仅提升效率,更是体现工程思维和边界意识的关键点,面试官非常看重!
🧭 解题思路(分步详解)
-
预处理与剪枝
- 计算数组总和
sum和最大值maxNum; - 若
sum为奇数,直接返回false; - 若
maxNum > sum / 2,也返回false。
- 计算数组总和
-
设定目标
target = sum / 2。
-
初始化 DP 数组
- 创建长度为
target + 1的布尔数组dp; dp[0] = true(基础情况)。
- 创建长度为
-
遍历每个数字,更新 DP 表
-
对每个
num,从j = target到j = num倒序更新:dp[j] |= dp[j - num];
-
-
返回结果
dp[target]即为答案。
📊 算法分析
| 项目 | 内容 |
|---|---|
| 时间复杂度 | O(n × target),其中 n = nums.size(),target = sum / 2。最坏情况下 sum ≈ 200 × 100 = 20000,故 target ≈ 10000,总操作数约 2e6,完全可接受。 |
| 空间复杂度 | O(target),使用一维 DP 数组优化后。若用二维数组,则为 O(n × target)。 |
| 是否最优 | 在伪多项式时间内最优(因问题是 NP 完全,不存在多项式解法)。 |
| 面试考点 | 0-1 背包建模、状态压缩、倒序更新原因、边界剪枝、NP 问题认知。 |
💻 代码
✅ C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
bool canPartition(vector<int>& nums) {
int n = nums.size();
if (n < 2) {
return false; // 至少需要两个数才能分割
}
int sum = 0, maxNum = 0;
for (auto& num : nums) {
sum += num;
maxNum = max(maxNum, num);
}
if (sum & 1) {
return false; // 总和为奇数,无法平分
}
int target = sum / 2;
if (maxNum > target) {
return false; // 最大数超过一半,无法分割
}
// dp[j] 表示能否凑出和为 j
vector<int> dp(target + 1, 0);
dp[0] = true; // 和为 0 总是可以(不选任何数)
for (int i = 0; i < n; i++) {
int num = nums[i];
// 倒序遍历,防止同一元素被重复使用
for (int j = target; j >= num; --j) {
dp[j] |= dp[j - num]; // 选或不选当前 num
}
}
return dp[target];
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
vector<int> nums1 = {1, 5, 11, 5};
cout << "Test 1: " << (sol.canPartition(nums1) ? "true" : "false") << "\n"; // true
vector<int> nums2 = {1, 2, 3, 5};
cout << "Test 2: " << (sol.canPartition(nums2) ? "true" : "false") << "\n"; // false
vector<int> nums3 = {1, 1};
cout << "Test 3: " << (sol.canPartition(nums3) ? "true" : "false") << "\n"; // true
return 0;
}
✅ JavaScript(等效实现)
/**
* @param {number[]} nums
* @return {boolean}
*/
var canPartition = function(nums) {
const n = nums.length;
if (n < 2) return false;
let sum = 0, maxNum = 0;
for (const num of nums) {
sum += num;
if (num > maxNum) maxNum = num;
}
if (sum % 2 !== 0) return false;
const target = sum / 2;
if (maxNum > target) return false;
// dp[j]: 能否凑出和为 j
const dp = new Array(target + 1).fill(false);
dp[0] = true;
for (let i = 0; i < n; i++) {
const num = nums[i];
// 倒序更新
for (let j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
};
// 测试
console.log(canPartition([1,5,11,5])); // true
console.log(canPartition([1,2,3,5])); // false
console.log(canPartition([1,1])); // true
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!