文章目录
前言
本专栏文章为《LeetCode 热题 100》的刷题题解,相关内容如有侵权,立即删除。
一、题目
1、原题链接
2、题目描述
二、个人思路整理
1、思路分析
核心思路:转化为标准的0-1背包问题
- 问题转化
题目要求将数组分成两个和相等的子集:- 设数组所有元素和为
sum。如果sum是奇数,显然无法平分成两个整数和,直接返回false。 - 如果
sum是偶数,目标就转变为:能否在数组中挑选出若干个数字,使其和恰好等于target = sum / 2。 - 每个元素只能使用一次,这恰好对应容量为
target的 0-1 背包问题(判断是否能恰好装满)。
- 设数组所有元素和为
- 动态规划定义与转移
- 状态定义:定义一维布尔数组
dp[j],表示是否能选出某些元素使其和恰好为j( 0 ≤ j ≤ t a r g e t 0 \le j \le target 0≤j≤target)。 - 初始状态:
dp[0] = true:和为 0 时不选任何元素即可满足。- 其余
dp[j] = false。
- 状态转移方程:遍历每个元素
x,对于每个容量j(从target倒序遍历到x): d p j = d p j ∨ d p j − x dpj = dpj \lor dpj - x dpj=dpj∨dpj−x
- 状态定义:定义一维布尔数组
注意:为了保证每个元素只被计算一次(0-1 背包特性),内层循环必须从大到小倒序遍历。若正序遍历则会变成完全背包(元素可重复使用)。
2、解题代码
cpp
class Solution {
public:
bool canPartition(vector<int>& nums) {
int sum = 0;
int max_val = 0;
// 1. 统计数组总和与最大元素
for (int x : nums) {
sum += x;
max_val = max(max_val, x);
}
// 2. 如果总和为奇数,不可能拆分两个和相等的整数子集
if (sum % 2 != 0) {
return false;
}
// 目标容量:寻找一个子集使其和等于 sum / 2
int target = sum / 2;
// 剪枝:如果单个元素已经超过目标和,其余元素之和必然小于 target,直接返回 false
if (max_val > target) {
return false;
}
// 3. 动态规划定义:dp[j] 表示是否能挑选若干元素使得和恰好为 j
// 范围为 0 到 target,初始全为 false
vector<bool> dp(target + 1, false);
// 基本状态:和为 0 时不选择任何元素即可达成
dp[0] = true;
// 4. 0-1 背包状态转移
for (int x : nums) {
// 内层循环必须从大到小倒序遍历:
// 确保当前物品 x 只被使用一次(若正序则会复用本轮刚更新的状态,退化为完全背包)
for (int j = target; j >= x; j--) {
dp[j] = dp[j] || dp[j - x];
}
// 贪心剪枝:如果已经能够凑出 target,无需继续遍历剩余元素
if (dp[target]) {
return true;
}
}
return dp[target];
}
};
复杂度分析
- 时间复杂度: O ( n × t a r g e t ) O(n \times target) O(n×target),其中 n n n 为数组长度, t a r g e t = sum 2 target = \frac{\text{sum}}{2} target=2sum。本题数据范围 n ≤ 200 , n u m s i ≤ 100 ⟹ t a r g e t ≤ 10000 n \le 200, numsi \le 100 \implies target \le 10000 n≤200,numsi≤100⟹target≤10000,运算量约 2 × 10 6 2 \times 10^6 2×106,完全可以在毫秒级通过。
- 空间复杂度: O ( t a r g e t ) O(target) O(target),通过一维滚动数组将空间压缩到几千个
bool。
三、知识风暴
动态规划(Dynamic Programming) 是本题的核心算法思想。它通过将原问题拆解为若干重叠子问题,并利用「最优子结构」性质,用子问题的最优解递推得到全局最优解。对于「分割等和子集」这类 0-1 背包问题,动态规划能以 O ( n × t a r g e t ) O(n \times target) O(n×target) 的复杂度高效求解。
算法核心思想:
- 最优子结构 :能否选出若干元素使其和恰好为 j j j,可以由「前 i − 1 i-1 i−1 个元素能否凑出 j j j」与「前 i − 1 i-1 i−1 个元素能否凑出 j − n u m s i j - numsi j−numsi」共同决定。只要子问题 d p j dpj dpj、 d p j − x dpj - x dpj−x 已知,就能递推得到当前位置的最优解。
- 重叠子问题 :在递推过程中,同一个容量 j j j 会被多个元素反复查询。例如计算 d p j dpj dpj 时,不同元素 x x x 都会访问 d p j − x dpj - x dpj−x 的状态,因此用滚动数组缓存结果可避免重复计算。
- 与贪心的区别:贪心每一步只做当前最优选择、不回溯;而动态规划会枚举「选」与「不选」当前元素两种可能来源,从而保证结果的正确性。
常见对比:动态规划 vs 贪心
- 动态规划 :时间复杂度 O ( n × t a r g e t ) O(n \times target) O(n×target),空间复杂度 O ( t a r g e t ) O(target) O(target)。适合需要同时考虑「选/不选」两种决策、且局部最优不能直接决定全局最优的场景,通用性更强。
- 贪心算法 :时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。适合每一步的局部最优能直接推导全局最优的场景,代码简洁高效,但本题无法直接证明贪心成立(如
nums = [1, 5, 11, 5],若贪心只取当前最大元素,就可能错过恰好凑出target的组合)。 - 共同点:两者都依赖「最优子结构」性质。区别在于贪心只保留一个当前最优状态,而动态规划需要同时维护「选」与「不选」两种决策状态。
动态规划的设计思想:
- 核心思想 :把大问题拆成小问题,先解决小问题,再用小问题的答案拼出大问题的答案。本题中,先求出容量为 0 0 0 的 d p 0 = t r u e dp0 = true dp0=true,再逐个元素递推出后续容量的状态。
- 与本题的联系 :分割等和子集问题天然具有递推结构------每个容量 j j j 的最优解都可以由「不选当前元素 x x x 时的 d p j dpj dpj」与「选当前元素 x x x 时的 d p j − x dpj - x dpj−x」共同得到。因此无需回溯或搜索,只需按顺序滚动更新即可。
- 注意事项 :动态规划的正确性依赖于「最优子结构」与「无后效性」。本题中 d p j dpj dpj 只由前一个元素的状态决定,与未来的状态无关,因此递推顺序合法。
使用要点:
- 状态变量 :
dp[j]记录是否能选出若干元素使其和恰好为 j j j( 0 ≤ j ≤ t a r g e t 0 \le j \le target 0≤j≤target),用于判断能否恰好装满容量为 t a r g e t target target 的背包。 - 初始化 :
dp[0] = true,其余dp[j] = false,以「不选任何元素」作为递推基准。 - 转移时机 :外层循环遍历每个元素 x x x,内层循环从 t a r g e t target target 倒序遍历到 x x x,执行
dp[j] = dp[j] || dp[j - x]。倒序保证每个元素只被使用一次(0-1 背包特性)。 - 结果返回 :遍历结束后,返回
dp[target],表示能否凑出目标和的一半。
算法变体与扩展:
- 0-1 背包问题(LeetCode 494):一维动态规划的经典入门题,只需维护一个计数状态即可,思路与本题一脉相承。
- 完全背包问题(LeetCode 322):元素可重复使用,内层循环需正序遍历,与本题的「倒序」形成鲜明对比。
- 子集和问题(LeetCode 416 变体):在本题基础上引入「恰好装满」的约束,需要同时考虑「选」与「不选」两种情形。
- 多重背包问题(LeetCode 1049):多状态动态规划的经典题目,通过维护多个状态变量来刻画不同阶段的约束。
相关 LeetCode 例题:
- 494. 目标和(0-1 背包 + 计数)
- 322. 零钱兑换(完全背包 + 最小值)
- 1049. 最后一块石头的重量 II(0-1 背包 + 差值最小化)
- 474. 一和零(二维 0-1 背包)
