对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎
LeetCode 416. 分割等和子集
1. 题目描述
给定一个只包含正整数 的非空 数组 nums。判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
示例 1:
输入:nums = [1,5,11,5]
输出:true
解释:数组可以分割成 [1, 5, 5] 和 [11]。
示例 2:
输入:nums = [1,2,3,5]
输出:false
解释:数组不能分割成两个和相等的子集。
提示:
1 <= nums.length <= 2001 <= nums[i] <= 100
2. 问题分析
2.1 问题本质
这个问题可以转化为:能否从数组中选出一些数字,使它们的和等于整个数组总和的一半。
2.2 关键点
- 总和必须为偶数 :如果总和是奇数,不可能平分成两个整数和,直接返回
false - 目标值 :
target = sum / 2 - 每个元素只能使用一次 :这是典型的 0-1背包问题
- 前端应用场景:资源分配、任务调度、文件分割等场景
2.3 问题转化
- 数组总和:
sum - 目标值:
target = sum / 2 - 问题转化为:是否存在子集,其和为
target
3. 解题思路
3.1 思路概览
| 方法 | 时间复杂度 | 空间复杂度 | 是否最优 |
|---|---|---|---|
| 回溯法 | O(2ⁿ) | O(n) | 否 |
| 动态规划(二维) | O(n×target) | O(n×target) | 较好 |
| 动态规划(一维) | O(n×target) | O(target) | 最优 |
| 位运算优化 | O(n×target/word) | O(target/word) | 理论最优 |
3.2 最优解分析
动态规划(一维优化) 是最佳实践方案,理由:
- 时间效率:O(n×target),在题目约束下可接受
- 空间效率:O(target),显著优于二维DP
- 代码简洁:实现相对简单
4. 代码实现
4.1 回溯法(基础理解)
javascript
/**
* 方法1:回溯法(会超时,仅供理解)
* 思路:尝试所有可能的子集组合
* 时间复杂度:O(2^n) - 指数级,不可接受
* 空间复杂度:O(n) - 递归调用栈深度
*/
function canPartitionBacktrack(nums) {
const totalSum = nums.reduce((sum, num) => sum + num, 0);
// 如果总和为奇数,直接返回false
if (totalSum % 2 !== 0) return false;
const target = totalSum / 2;
// 回溯函数
function backtrack(index, currentSum) {
// 找到解
if (currentSum === target) return true;
// 超过目标值或已遍历完
if (currentSum > target || index >= nums.length) return false;
// 两种选择:选择当前元素或不选择当前元素
return backtrack(index + 1, currentSum + nums[index]) ||
backtrack(index + 1, currentSum);
}
return backtrack(0, 0);
}
4.2 动态规划-二维DP表
javascript
/**
* 方法2:动态规划(二维DP表)
* 思路:dp[i][j]表示前i个元素能否组成和为j
* 时间复杂度:O(n×target)
* 空间复杂度:O(n×target)
*/
function canPartition2D(nums) {
const n = nums.length;
const totalSum = nums.reduce((sum, num) => sum + num, 0);
// 总和为奇数,不可能平分
if (totalSum % 2 !== 0) return false;
const target = totalSum / 2;
// 特殊情况:最大元素超过target,直接返回false
const maxNum = Math.max(...nums);
if (maxNum > target) return false;
// 创建DP表:(n+1)行 × (target+1)列
// dp[i][j]表示前i个元素能否凑出和为j
const dp = Array.from({ length: n + 1 }, () =>
Array(target + 1).fill(false)
);
// 初始化:和为0时总是可以(不选任何元素)
for (let i = 0; i <= n; i++) {
dp[i][0] = true;
}
// 动态规划填表
for (let i = 1; i <= n; i++) {
const num = nums[i - 1]; // 当前元素值
for (let j = 1; j <= target; j++) {
if (j < num) {
// 当前元素值大于目标和j,不能选
dp[i][j] = dp[i - 1][j];
} else {
// 两种选择:不选当前元素 或 选当前元素
// dp[i-1][j]:不选当前元素,看前i-1个能否凑出j
// dp[i-1][j-num]:选当前元素,看前i-1个能否凑出j-num
dp[i][j] = dp[i - 1][j] || dp[i - 1][j - num];
}
}
}
return dp[n][target];
}
4.3 动态规划-一维优化(最优解)
javascript
/**
* 方法3:动态规划(一维数组优化)- 最优解
* 思路:dp[j]表示能否组成和为j
* 时间复杂度:O(n×target)
* 空间复杂度:O(target)
*
* 核心理解:从后向前遍历是为了保证每个物品只被使用一次
* 如果从前向后遍历,就变成了完全背包问题(物品可重复使用)
*/
function canPartition(nums) {
const totalSum = nums.reduce((sum, num) => sum + num, 0);
// 总和为奇数,不可能平分
if (totalSum & 1) return false; // 位运算判断奇偶更快
const target = totalSum >> 1; // 右移一位等于除以2
// 特殊情况:最大元素超过target,直接返回false
const maxNum = Math.max(...nums);
if (maxNum > target) return false;
// 创建一维DP数组:dp[j]表示能否凑出和为j
const dp = new Array(target + 1).fill(false);
// 初始化:和为0总是可以(不选任何元素)
dp[0] = true;
// 遍历每个数字
for (const num of nums) {
// 关键:从后向前遍历,确保每个数字只使用一次
for (let j = target; j >= num; j--) {
// dp[j] = dp[j] || dp[j - num]
// 当前状态 = 不选当前数字(保持原状) 或 选当前数字(需要j-num可达)
dp[j] = dp[j] || dp[j - num];
// 提前结束:如果target已经可达,直接返回true
if (dp[target]) return true;
}
}
return dp[target];
}
/**
* 方法4:动态规划(一维优化+剪枝+位运算)- 性能极致版
* 前端面试中展示此版本能体现深度优化能力
*/
function canPartitionOptimized(nums) {
const totalSum = nums.reduce((sum, num) => sum + num, 0);
// 快速判断:总和为奇数不可能
if (totalSum & 1) return false;
const target = totalSum >> 1;
// 剪枝1:最大值超过target不可能
if (Math.max(...nums) > target) return false;
// 剪枝2:排序后从大到小遍历,更快接近target
nums.sort((a, b) => b - a);
// 使用位运算加速DP(Bitmask DP)
// 每个bit代表一个和是否可达,从右向左第i位为1表示和为i可达
let dp = 1; // 初始:和为0可达(第0位为1)
for (const num of nums) {
// 左移num位并与原状态取或,相当于添加当前数字
dp |= dp << num;
// 如果target位为1,直接返回true
if (dp & (1 << target)) return true;
}
return false;
}
4.4 步骤分解说明(以一维DP为例)
步骤1:计算总和并检查奇偶性
javascript
const totalSum = nums.reduce((sum, num) => sum + num, 0);
if (totalSum % 2 !== 0) return false; // 奇数不可能平分
步骤2:计算目标和
javascript
const target = totalSum / 2; // 需要寻找的和
步骤3:初始化DP数组
javascript
const dp = new Array(target + 1).fill(false);
dp[0] = true; // 和为0总是可达(不选任何元素)
步骤4:遍历每个数字
javascript
for (const num of nums) {
// 从target向下遍历到num
for (let j = target; j >= num; j--) {
// 状态转移:dp[j] = dp[j] || dp[j - num]
dp[j] = dp[j] || dp[j - num];
}
}
步骤5:返回结果
javascript
return dp[target]; // 最终target是否可达
5. 复杂度与优缺点对比
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 回溯法 | O(2ⁿ) | O(n) | 实现简单,易于理解 | 指数级复杂度,必然超时 | 仅用于教学理解 |
| 动态规划(二维) | O(n×target) | O(n×target) | 直观展示状态转移 | 空间占用大 | 需要理解DP过程 |
| 动态规划(一维) | O(n×target) | O(target) | 空间优化,效率高 | 状态转移不易理解 | 生产环境推荐 |
| 位运算优化 | O(n×target/word) | O(target/word) | 极致性能,位运算快 | 可读性较差 | 性能敏感场景 |
前端场景思考:
- 在处理前端大量数据分组时(如文件分片上传),这种思路很有用
- 一维DP的空间优化思想在前端性能优化中很常见(如滚动数组)
6. 总结
6.1 通用解题模板
0-1背包问题通用模板(JavaScript):
javascript
function knapsackZeroOne(values, weights, capacity) {
const n = values.length;
// 一维DP数组
const dp = new Array(capacity + 1).fill(0);
// 遍历物品
for (let i = 0; i < n; i++) {
// 逆序遍历容量(确保每个物品只使用一次)
for (let j = capacity; j >= weights[i]; j--) {
// 状态转移:不选当前物品 vs 选当前物品
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[capacity];
}
分割等和子集问题模板:
javascript
function canPartitionTemplate(nums) {
// 1. 计算总和,检查奇偶
const sum = nums.reduce((a, b) => a + b, 0);
if (sum % 2 !== 0) return false;
// 2. 计算目标值
const target = sum / 2;
// 3. 初始化DP数组
const dp = new Array(target + 1).fill(false);
dp[0] = true;
// 4. 遍历每个数字
for (const num of nums) {
// 5. 逆序遍历
for (let j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
// 6. 返回结果
return dp[target];
}
6.2 类似题目推荐
| 题目 | 难度 | 关键点 | 与416题的关系 |
|---|---|---|---|
| 474. 一和零 | 中等 | 二维费用的0-1背包 | 扩展为二维条件 |
| 494. 目标和 | 中等 | 背包问题变形 | 类似但需考虑正负号 |
| 1049. 最后一块石头的重量 II | 中等 | 转换为子集和问题 | 几乎相同的思路 |
| 698. 划分为k个相等的子集 | 中等 | 416题的扩展 | 从2扩展到k个子集 |
| 322. 零钱兑换 | 中等 | 完全背包问题 | 物品可重复使用 |