【每日算法】LeetCode 416. 分割等和子集(动态规划)

对前端开发者而言,学习算法绝非为了"炫技"。它是你从"页面构建者"迈向"复杂系统设计者"的关键阶梯。它将你的编码能力从"实现功能"提升到"设计优雅、高效解决方案"的层面。从现在开始,每天投入一小段时间,结合前端场景去理解和练习,你将会感受到自身技术视野和问题解决能力的质的飞跃。------ 算法:资深前端开发者的进阶引擎

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 <= 200
  • 1 <= nums[i] <= 100

2. 问题分析

2.1 问题本质

这个问题可以转化为:能否从数组中选出一些数字,使它们的和等于整个数组总和的一半

2.2 关键点

  1. 总和必须为偶数 :如果总和是奇数,不可能平分成两个整数和,直接返回 false
  2. 目标值target = sum / 2
  3. 每个元素只能使用一次 :这是典型的 0-1背包问题
  4. 前端应用场景:资源分配、任务调度、文件分割等场景

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 最优解分析

动态规划(一维优化) 是最佳实践方案,理由:

  1. 时间效率:O(n×target),在题目约束下可接受
  2. 空间效率:O(target),显著优于二维DP
  3. 代码简洁:实现相对简单

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. 零钱兑换 中等 完全背包问题 物品可重复使用
相关推荐
地平线开发者5 小时前
SparseDrive 模型导出与性能优化实战
算法·自动驾驶
董董灿是个攻城狮5 小时前
大模型连载2:初步认识 tokenizer 的过程
算法
地平线开发者6 小时前
地平线 VP 接口工程实践(一):hbVPRoiResize 接口功能、使用约束与典型问题总结
算法·自动驾驶
罗西的思考6 小时前
AI Agent框架探秘:拆解 OpenHands(10)--- Runtime
人工智能·算法·机器学习
HXhlx9 小时前
CART决策树基本原理
算法·机器学习
Wect10 小时前
LeetCode 210. 课程表 II 题解:Kahn算法+DFS 双解法精讲
前端·算法·typescript
颜酱10 小时前
单调队列:滑动窗口极值问题的最优解(通用模板版)
javascript·后端·算法
Gorway17 小时前
解析残差网络 (ResNet)
算法
拖拉斯旋风17 小时前
LeetCode 经典算法题解析:优先队列与广度优先搜索的巧妙应用
算法
Wect17 小时前
LeetCode 207. 课程表:两种解法(BFS+DFS)详细解析
前端·算法·typescript