LeetCode 494:目标和(动态规划/01背包问题)—— 题解

👋 欢迎阅读

🎯 欢迎来到「目标和」题解之旅! 本文将带你深入理解动态规划与背包问题的巧妙结合。

在开始之前,建议你先:

  • 了解题目背景:这是一道经典的动态规划问题,涉及 01 背包的变种。
  • 明确学习目标:掌握将「目标和」问题转化为背包问题的思路,理解二维和一维解法的区别。
  • 准备好环境:建议在本地 IDE 或 LeetCode 在线编辑器中打开代码,边看边运行。

本文将从问题转化、状态定义、转移方程、初始化、填表顺序到代码实现,一步步带你深入理解。如果你对动态规划或背包问题还不熟悉,不用担心,我们会从最基础的原理讲起。


一、题目

494. 目标和 - 力扣(LeetCode)

二、做题思路

问题转化(前置分析)

💡 互动思考: 在继续阅读前,尝试自己推导一下这个转化过程。为什么 P = (sum + target) / 2?如果 (sum + target) 是奇数会怎样?

设所有添加 + 号的数之和为 P,添加 - 号的数之和为 N,则:

  • P + N = sum(数组总和)

  • P - N = target(目标结果)

联立解得 P = (sum + target) / 2

因此,原问题等价于:从数组中选择若干个数,使它们的和恰好等于 P,求方案数

(sum + target) 为奇数 或 P < 0,则无解,直接返回 0


1. 状态表示(核心基础)

本题转化为 01 背包求方案数 问题。定义 dp[i][j] 表示从前 i 个数中选取若干个数,使总和恰好为 j 的方案总数

i 范围 0n0 表示一个数都不选),j 范围 0PP 为目标和)。该状态能覆盖所有前缀的组合情况,便于累计方案数。


🤔 互动提问: 为什么状态定义中 i 要从 0 开始?如果从 1 开始定义 dp[i][j] 表示前 i 个数(i≥1),初始化会有什么不同?

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

对于第 i 个数(数值 nums[i-1]),有两种互斥决策:

  • 不选 :方案数为 dp[i-1][j]

  • :前提是 j >= nums[i-1],方案数为 dp[i-1][j - nums[i-1]]


3. 初始化(边界防护)

  • 代码中只设置 dp[0][0] = 1 ,其余元素默认 0。为什么这样足够?

  • dp[0][0] = 1:表示从前 0 个数中选取,凑出总和 0 的方案有且仅有 1 种,即空集(什么数都不选)。

  • dp[0][j] = 0j > 0):没有数可选时,无法凑出任何正数,方案数为 0,默认初始化即可。

  • dp[i][0]i > 0)的自动推导

    根据状态转移方程,当 j = 0 时,dp[i][0] = dp[i-1][0] + (0 >= nums[i-1] ? ... : 0)。由于 nums[i-1] > 0j >= nums[i-1] 不成立,因此选的情况为 0,所以 dp[i][0] = dp[i-1][0]。逐行传递,最终 dp[i][0] 始终等于 dp[0][0] = 1,表示"不选任何数"这一种方案始终有效。这完全符合预期,无需额外初始化。


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

dp[i][j] 依赖上一行 dp[i-1][j]dp[i-1][j - nums[i-1]],因此需要按行从上到下i 从 1 到 n),列从左到右j0P)遍历。这样能保证每个状态依赖的上一行值已经计算完成。

为什么 j 必须从 0 开始遍历?

  • 状态转移方程中,j 可以取 0,表示目标和为 0 的方案。

  • 在递推过程中,dp[i][0] 的值需要被正确计算 。由于 nums[i-1] > 0j >= nums[i-1]j=0 时不成立,因此选的情况不会发生,dp[i][0] 完全继承 dp[i-1][0],从而保持为 1(即始终存在"什么都不选"这一种方案)。

  • 若从 j=1 开始遍历,则 dp[i][0] 永远不会被更新,其值将停留在初始化时的 0(而非应有的 1),这会导致后续所有依赖 dp[i-1][0] 的转移(例如,当某个 nums[i-1] == j 时,dp[i-1][0] 为 0 就会丢失方案)。因此,为了保持状态的完整性,j 必须从 0 开始


5. 返回值(目标映射)

最终 返回 dp[n][P] ,即从全部 n 个数中选出若干个数,使总和恰好等于 P 的方案总数。该值即为原问题中满足运算结果为 target 的表达式个数。

三、代码

cpp 复制代码
class Solution 
{
public:
    int findTargetSumWays(vector<int>& nums, int target) 
    {
        // 1. 计算数组总和
        int sum = 0;
        for (auto x : nums) 
        {
            sum += x;
        }

        // 2. 转化为0/1背包问题:
        //    设正数部分和为 P,负数部分和为 N,则 P - N = target,P + N = sum,
        //    解得 P = (sum + target) / 2。
        //    问题变为:从 nums 中选出若干个数,使其和为 P 的方案数。
        int aim = (sum + target) / 2;

        // 3. 边界条件:若 (sum+target) 为奇数 或 aim < 0,则无解
        if ((sum + target) % 2 != 0 || aim < 0) 
        {
            return 0;
        }

        int n = nums.size();

        // 4. 创建dp表(二维)
        // dp[i][j] 表示从前 i 个数中选取若干个数,使其和恰好为 j 的方案数
        vector<vector<int>> dp(n + 1, vector<int>(aim + 1, 0));

        // 5. 初始化:
        //    前0个数中选出和为0的方案数为1(空集),即 dp[0][0] = 1。
        //    为什么只设这一个?因为 dp[0][j](j>0)显然为0(无法凑出正数和),
        //    且后续 dp[i][0](i>0)会通过状态转移自动计算:
        //      不选第 i 个数时 dp[i][0] += dp[i-1][0],
        //      由于 j >= nums[i-1] 不成立(nums[i-1] > 0),所以选的情况不会发生,
        //      因此 dp[i][0] 始终继承 dp[i-1][0],最终保持为1,符合"不选任何数"这一种方案。
        dp[0][0] = 1;

        // 6. 填表顺序:外层遍历物品(i从1到n),内层遍历容量(j从0到aim)
        //    为什么内层 j 从 0 开始?因为每个容量 j 都可能成为目标,且状态转移需要
        //    访问 dp[i-1][j](不选当前数)和 dp[i-1][j-nums[i-1]](选当前数)。
        //    即使 j=0,也需要继承上一行 dp[i-1][0] 的值(保持"空集"方案数),
        //    所以必须从 0 开始遍历,不能从 1 开始而跳过 0。
        for (int i = 1; i <= n; i++) 
        {
            for (int j = 0; j <= aim; j++) 
            {
                // 7. 状态转移方程:
                // 不选第 i 个数:继承 dp[i-1][j]
                dp[i][j] += dp[i - 1][j];

                // 选第 i 个数(前提:j >= nums[i-1]):加上 dp[i-1][j-nums[i-1]]
                if (j >= nums[i - 1]) 
                {
                    dp[i][j] += dp[i - 1][j - nums[i - 1]];
                }
            }
        }

        // 8. 返回值:dp[n][aim] 即为所有方案数
        return dp[n][aim];
    }
};

四、流程图

五、优化

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

**🧠 互动挑战:**尝试在不看下方代码的情况下,自己写出从二维状态转移方程推导到一维形式的过程。为什么一维必须倒序遍历?

  • 不选 :方案数保持不变,即 dp[j] 仍为上一轮的值。

  • :前提是 j >= nums[i-1],方案数为上一轮 dp[j - nums[i-1]]

因此一维转移为:dp[j] += dp[j - nums[i-1]] (当 j >= nums[i-1] 时)。


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

外层循环遍历每个物品(i 从 1 到 n),内层循环必须逆序遍历容量 j(从 aimnums[i-1]

为什么要倒序?

因为 dp[j] 的更新依赖 dp[j - nums[i-1]],这个值必须是上一轮(未处理当前物品) 的状态,才能保证每个物品最多被选一次(01 背包性质)。如果采用正序,dp[j - nums[i-1]] 可能已经在当前物品的处理中被提前更新,导致物品被重复使用,结果错误。

为什么 j 只降到 nums[i-1]

j < nums[i-1] 时,无法选取当前物品,dp[j] 保持原值无需更新,因此可以缩小遍历范围,减少无用计算。

cpp 复制代码
class Solution 
{
public:
    int findTargetSumWays(vector<int>& nums, int target) 
    {
        // 求和并计算目标正数和(即背包容量)------此部分与二维版本相同,略过详细推导
        int sum = 0;
        for (auto x : nums) sum += x;
        int aim = (sum + target) / 2;
        if ((sum + target) % 2 != 0 || aim < 0) return 0;

        int n = nums.size();

        // 1. 一维滚动数组 dp[j]:表示"当前已处理的部分元素"中,和为 j 的方案数
        //    初始时未处理任何元素,只有 dp[0] = 1(空集),其余为0
        vector<int> dp(aim + 1, 0);
        dp[0] = 1;

        // 2. 外层遍历每个物品(相当于二维中的行)
        for (int i = 1; i <= n; i++) 
        {
            // 3. 内层倒序遍历容量 j
            //    ① 为什么倒序?------ 因为 dp[j] 依赖上一轮的 dp[j - nums[i-1]],
            //       若正序,dp[j - nums[i-1]] 可能已被当前物品更新,导致重复选取。
            //       倒序确保用到的仍是"上一轮"的状态(即未考虑当前物品时)。
            //    ② 为什么 j 从 aim 降到 nums[i-1]?------ 
            //       当 j < nums[i-1] 时,无法选取当前物品,dp[j] 保持原值无需更新,
            //       因此只遍历可选的容量区间,减少无用计算。
            for (int j = aim; j >= nums[i - 1]; j--) 
            {
                // 4. 状态转移(一维形式):
                //    dp[j] 原有值 = 不选当前物品的方案数(继承自上一轮)
                //    加上 选当前物品的方案数 dp[j - nums[i-1]](也是上一轮的值)
                //    累加后即得到本轮的方案数
                dp[j] += dp[j - nums[i - 1]];
            }
        }

        // 5. 返回容量为 aim 的方案总数
        return dp[aim];
    }
};

3. 复杂度分析与对比

一维滚动数组解法复杂度分析:

  • 时间复杂度O(n × aim),其中 n 为数组长度,aim = (sum + target) / 2

    • 外层循环遍历 n 个物品(i 从 1 到 n),内层循环遍历容量 jaimnums[i-1]

    • 最坏情况下每个物品的内层循环都要遍历 aim 次,因此总操作次数约为 n × aim

    • 与二维解法的时间复杂度相同,因为两种解法都进行了双重循环,只是内部状态更新的方式不同。

  • 空间复杂度O(aim)

    • 一维滚动数组只使用了一个长度为 aim + 1dp 数组。

    • 相比二维解法的 O(n × aim) 空间,一维解法将空间消耗降低了一个数量级,尤其当 n 较大时优势明显。

与二维解法的对比:

维度 二维解法 一维滚动数组解法 对比说明
时间复杂度 O(n × aim) O(n × aim) 两者相同,都是双重循环
空间复杂度 O(n × aim) O(aim) 一维解法显著更优,节省了 O(n) 倍空间
状态定义 dp[i][j] 表示前 i 个数和为 j 的方案数 dp[j] 表示当前已处理物品中和为 j 的方案数 一维解法隐去了物品维度,通过滚动更新复用数组
状态转移 dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]] dp[j] += dp[j - nums[i-1]](倒序) 一维形式更简洁,但必须倒序遍历以保证正确性
初始化 dp[0][0] = 1,其余为 0 dp[0] = 1,其余为 0 两者本质相同,一维只需初始化一维数组
遍历顺序 外层 i 正序,内层 j 正序(0 → aim) 外层 i 正序,内层 j 倒序(aim → numsi-1 一维必须倒序,防止同一物品被重复使用
适用场景 教学、调试、需要保留中间状态 生产环境、内存敏感、大规模数据 一维解法是空间优化的标准实践

总结: 一维滚动数组解法在时间复杂度不变的前提下,将空间复杂度从 O(n × aim) 优化到 O(aim),是 01 背包问题空间优化的经典技巧。其核心在于通过倒序遍历容量来保证每个物品只被使用一次,同时复用同一数组记录状态。


🎯 闭幕

🎉 恭喜你完成了「目标和」问题的学习!

为了巩固知识并进一步拓展,建议你:

🚀 动手实践

在 LeetCode 上提交代码,尝试不同的测试用例。

💡 举一反三

思考如果数组包含负数或零,解法需要如何调整?

📚 延伸学习

探索其他背包变种问题,如「分割等和子集」「零钱兑换」等。

你认为哪个问题与「目标和」最相似?
如果你觉得本文对你有帮助,欢迎:

👍 点赞/收藏 👤 关注作者 💬 留言讨论

祝你刷题愉快,早日攻克动态规划!

相关推荐
spssau2 小时前
一文学会结构方程模型:从模型搭建、路径图绘制到不达标调整,实操全流程
人工智能·python·算法
zzz_23682 小时前
【Java实习面试算法冲刺】总复盘
java·算法·面试
ysa0510302 小时前
【板子】德州扑克模拟
c++·笔记·板子
DFT计算杂谈2 小时前
DeepSeek 集群服务器无root本地部署指南
数据库·人工智能·python·opencv·算法
qq_454245032 小时前
GraphFoundation — 通用图基础框架
算法·架构
慧都小项2 小时前
C/C++ 静态分析为什么要前移?Parasoft C/C++test 在 IDE 与 CI/CD 中的应用
c语言·c++·ide·功能测试·ai
qq_33776320193 小时前
国际期货资管系统开发方案|内外盘交易平台源码授权与二次开发支持
java·c语言·开发语言·c++·c#
不一样的故事1263 小时前
军工行业合规与基础认知
数据结构·经验分享·算法
圣保罗的大教堂8 小时前
leetcode 3867. 数对的最大公约数之和 中等
leetcode