【LeetCode Hot100 刷题日记 (89/100)】416. 分割等和子集 —— 数组、动态规划、0-1 背包💼

1 阅读5分钟

📌 题目链接: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 前先做以下判断,可大幅提前终止无效计算:

  1. n < 2 → 无法分割;
  2. sum % 2 == 1 → 总和为奇数,无法平分;
  3. maxNum > target → 最大数比一半还大,不可能凑出另一半。

这些剪枝不仅提升效率,更是体现工程思维和边界意识的关键点,面试官非常看重!


🧭 解题思路(分步详解)

  1. 预处理与剪枝

    • 计算数组总和 sum 和最大值 maxNum
    • sum 为奇数,直接返回 false
    • maxNum > sum / 2,也返回 false
  2. 设定目标

    • target = sum / 2
  3. 初始化 DP 数组

    • 创建长度为 target + 1 的布尔数组 dp
    • dp[0] = true(基础情况)。
  4. 遍历每个数字,更新 DP 表

    • 对每个 num,从 j = targetj = num 倒序更新:

      dp[j] |= dp[j - num];
      
  5. 返回结果

    • 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!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!