《LeetCode 416 分割等和子集》

一、题目

416. 分割等和子集 - 力扣(LeetCode)

二、做题思路

1. 问题转化(前置分析)

要将数组分割成两个和相等的子集,等价于从数组中选出一些数,使它们的和等于总和的一半

设总和为 sum,若 sum 为奇数,则不可能平分,直接返回 false。若为偶数,目标值 aim = sum / 2

于是问题转化为一个01背包可行性问题 :有 n 个物品,每个物品重量(数值)为 nums[i],背包容量为 aim,问能否恰好装满。


2. 状态表示(核心基础,包含推导思路)

如何想到用 dp[i][j]

  • 我们需要记录处理到第几个数 以及当前已经凑出的和是多少

  • 对于每个数,只有"选"或"不选"两种决策,这符合 01 背包的特征。

  • 因此,定义 dp[i][j] 表示从前 i 个数中选取,能否凑出总和恰好为 jtrue 表示可行,false 表示不可行)。

  • 这里 i 范围从 0 到 n(0 表示一个数都不选),j 范围从 0 到 aim

    这个状态能覆盖所有前缀和组合,满足无后效性,是解决本问题的出发点


3. 状态转移方程(关键难点)

对于第 i 个数(下标 i-1,数值 nums[i-1]),我们决定不选

  • 不选dp[i][j] 继承前 i-1 个数的结果,即 dp[i-1][j]

  • :前提是 j >= nums[i-1],且前 i-1 个数能凑出 j - nums[i-1],即 dp[i-1][j - nums[i-1]]true

    只要两者任一为 truedp[i][j] 即为 true

    因此转移为:

    dp[i][j] = dp[i-1][j] || (j >= nums[i-1] && dp[i-1][j - nums[i-1]])


4. 初始化(边界防护)

  • 当目标 j = 0 时,不选任何数 即可凑出 0,因此 dp[i][0] = true (对所有 i 成立)。

  • i = 0(没有数可选)且 j > 0 时,无法凑出任何正数,dp[0][j] = false (默认 false 即可)。

    代码中通过循环 for (int i=0; i<=n; ++i) dp[i][0] = true; 实现第一列的初始化。


5. 填表顺序(递推方向)

dp[i][j] 依赖 dp[i-1][j](上一行同列)和 dp[i-1][j - nums[i-1]](上一行左列),因此必须按行从上到下i 从 1 到 n),列从左到右j 从 1 到 aim)遍历,确保前置状态已就绪。


6. 返回值(目标映射)

最终判断 dp[n][aim] 是否为 true,即所有数中能否选出若干个数凑成 aim。若为 true,则原数组可等和分割;否则不能。代码直接 返回 dp[n][aim]

三、代码

cpp 复制代码
class Solution 
{
public:
    bool canPartition(vector<int>& nums) 
    {
        // 1. 计算数组总和,若为奇数则不可能平分
        int sum = 0;
        for (auto x : nums) 
            sum += x;
        if (sum % 2 != 0) 
            return false;

        int target = sum / 2;           // 目标子集和
        int n = nums.size();

        // 2. 创建dp表
        // dp[i][j] 表示能否从前 i 个元素中选取若干个数,使其和恰好为 j
        vector<vector<bool>> dp(n + 1, vector<bool>(target + 1, false));

        // 3. 初始化
        // 前 i 个元素中,选出和为 0 的方案总是存在(即什么都不选),所以 dp[i][0] = true
        for (int i = 0; i <= n; i++) 
        {
            dp[i][0] = true;
        }

        // 4. 填表顺序:外层遍历物品(i 从 1 到 n),内层遍历容量(j 从 1 到 target)
        //    因为 dp[i][j] 依赖于 dp[i-1][j](不选当前数)和 dp[i-1][j-nums[i-1]](选当前数),
        //    所以按行从上到下、列从左到右即可。
        for (int i = 1; i <= n; i++) 
        {
            for (int j = 1; j <= target; j++) 
            {
                // 5. 状态转移方程:
                // 不选第 i 个数:继承上一行的状态 dp[i-1][j]
                // 选第 i 个数:前提是 j >= nums[i-1],且 dp[i-1][j-nums[i-1]] 为 true
                dp[i][j] = dp[i - 1][j];
                if (j >= nums[i - 1]) 
                {
                    dp[i][j] = dp[i][j] || dp[i - 1][j - nums[i - 1]];
                }
            }
        }

        // 6. 返回值:dp[n][target] 表示能否从前 n 个数中选出和为 target 的子集
        return dp[n][target];
    }
};

四、流程图

五、优化

1. 状态表示(核心基础,包含推导思路)

  • 若定义二维 dp[i][j] 表示前 i 个数能否凑出 j,观察转移方程发现,dp[i][j] 只依赖于上一行 dp[i-1][...] 的值。因此可以压缩为一维数组,用滚动更新表示当前处理到的物品。

  • 最终,我们定义 dp[j] 表示考虑已处理完的物品,能否凑出总和恰好为 jtrue 可行,false 不可行)。随着物品逐个加入,dp[j] 不断更新,遍历结束后即代表所有物品的状态。


2. 状态转移方程(关键难点)

对于当前物品 nums[i-1](第 i 个),我们决定是否将其加入子集:

  • 不选 :保持原状,dp[j] 不变(等号右边的 dp[j] 代表上一轮的状态)。

  • :前提是 j >= nums[i-1] 且上一轮 dp[j - nums[i-1]]true

    两者任一满足,则 dp[j]true,因此转移为:

    dp[j] = dp[j] || dp[j - nums[i-1]]jtarget 逆序到 nums[i-1])。


3. 初始化(边界防护)

  • 目标 j = 0 :不选任何数即可凑出 0,因此 dp[0] = true

  • 其余 dp[j]j > 0)初始化为 false,表示未处理任何物品时无法凑出正数。

    一维数组默认全 false,只需设置 dp[0] = true 即可。


4. 填表顺序(递推方向)

关键:必须逆序遍历容量 j(从 targetnums[i-1]

因为 dp[j] 的更新依赖 dp[j - nums[i-1]],若从小到大遍历,dp[j - nums[i-1]] 可能在当前物品 的处理中被提前更新,导致物品被重复使用 (违背 01 背包每个物品只能选一次)。逆序确保 dp[j - nums[i-1]] 仍为上一轮(未处理当前物品) 的状态,与二维的 dp[i-1][j - nums[i-1]] 等价。

外层遍历物品 i 从 1 到 n,内层 jtarget 递减到 nums[i-1]


5. 返回值(目标映射)

最终判断 dp[target] 是否为 true,即所有数中能否选出若干个数凑成 target。若为 true,则原数组可等和分割;否则不能。代码直接 返回 dp[target]

cpp 复制代码
class Solution 
{
public:
    bool canPartition(vector<int>& nums) 
    {
        // 1. 计算数组总和,若为奇数则不可能平分
        int sum = 0;
        for (auto x : nums) 
        {
            sum += x;
        }
        if (sum % 2 != 0) 
        {
            return false;
        }

        int target = sum / 2;           // 目标子集和(即背包容量)
        int n = nums.size();

        // 2. 创建dp表(一维滚动数组)
        // dp[j] 表示当前已考虑的部分元素中,是否存在一个子集,其和恰好为 j
        // 由于使用滚动数组,dp[j] 对应二维 dp[i][j] 的当前行状态(i为外层循环索引)
        vector<bool> dp(target + 1, false);

        // 3. 初始化
        // 和为0总是可达的(不选任何元素),因此 dp[0] = true
        // 其余 dp[j] 初始为 false,表示尚未找到和为 j 的子集
        dp[0] = true;

        // 4. 填表顺序:
        //    外层循环遍历每个物品(i 从 1 到 n),
        //    内层循环遍历容量 j 从 target 递减到 nums[i-1](倒序)
        //    原因:dp[j] 依赖于上一行较小容量 dp[j-nums[i-1]] 的状态,
        //          如果正序更新,dp[j-nums[i-1]] 可能已经被当前物品更新过,
        //          导致每个物品被重复使用,违背0/1背包的"每个物品最多选一次"原则。
        //          倒序则保证在更新 dp[j] 时,用到的 dp[j-nums[i-1]] 仍属于"上一轮"(未处理当前物品)的状态。
        for (int i = 1; i <= n; i++) 
        {
            for (int j = target; j >= nums[i - 1]; j--) 
            {
                // 5. 状态转移方程(一维形式):
                // 当前容量 j 是否可达,取决于:
                //   - 不选第 i 个数:dp[j] 保持原值(即上一轮的结果,对应二维的 dp[i-1][j])
                //   - 选第 i 个数:前提是 j >= nums[i-1],且上一轮容量 j-nums[i-1] 可达(即 dp[j-nums[i-1]] 为 true)
                //     如果可达,则当前状态 dp[j] 也变为 true
                dp[j] = dp[j] || dp[j - nums[i - 1]];
            }
        }

        // 6. 返回值:dp[target] 表示能否从所有数中选出和为 target 的子集
        return dp[target];
    }
};
相关推荐
Jasmine_llq1 小时前
《P15798 [GESP202603 五级] 有限不循环小数》
算法·有限小数判定数论定理·质因子约分检验·枚举生成合法数字·哈希集合去重·快速 io 优化
学计算机的计算基1 小时前
操作系统八股文:进程与线程全面梳理(附调度算法+IPC+锁机制)
java·算法
Mortalbreeze1 小时前
深入理解 Linux 线程机制(四):线程同步——条件变量与信号量
linux·运维·服务器·开发语言·c++
xyy1232 小时前
C# Polly 弹性策略库指南
算法
郝学胜-神的一滴2 小时前
中级OpenGL教程 020:巧用数组与循环实现多点点光源渲染,告别冗余代码重构方案
c++·unity·游戏引擎·godot·图形渲染·unreal
zmzb01032 小时前
C++课后习题训练记录Day160
开发语言·c++
沫璃染墨2 小时前
现代C++⊂C++11篇(一)列表初始化全解 & std::initializer_list
开发语言·c++
阿米亚波2 小时前
【C++ STL】std::forward_list
开发语言·c++·笔记·stl·visual studio·forward_list
geovindu3 小时前
go: Recursion Algorithm
开发语言·后端·算法·golang·递归算法