【每日算法】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. 零钱兑换 中等 完全背包问题 物品可重复使用
相关推荐
多米Domi0112 小时前
0x3f 第19天 javase黑马81-87 ,三更1-23 hot100子串
python·算法·leetcode·散列表
坚持学习前端日记2 小时前
软件开发完整流程详解
学习·程序人生·职场和发展·创业创新
历程里程碑2 小时前
滑动窗口最大值:单调队列高效解法
数据结构·算法·leetcode
量子炒饭大师2 小时前
Cyber骇客的逻辑节点美学 ——【初阶数据结构与算法】二叉树
c语言·数据结构·c++·链表·排序算法
課代表3 小时前
从初等数学到高等数学
算法·微积分·函数·极限·导数·积分·方程
ullio3 小时前
arc206d - LIS ∩ LDS
算法
等等小何3 小时前
leetcode1593拆分字符串使唯一子字符串数目最大
算法
量子炒饭大师3 小时前
Cyber骇客神经塔尖协议 ——【初阶数据结构与算法】堆
c语言·数据结构·c++·二叉树·github·
XLYcmy4 小时前
TarGuessIRefined密码生成器详细分析
开发语言·数据结构·python·网络安全·数据安全·源代码·口令安全