(动态规划+DFS)494. 目标和

题目

给你一个非负整数数组 nums 和一个整数 target 。

向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :

  • 例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。

返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。

示例 1:

复制代码
输入:nums = [1,1,1,1,1], target = 3
输出:5
解释:一共有 5 种方法让最终目标和为 3 。
-1 + 1 + 1 + 1 + 1 = 3
+1 - 1 + 1 + 1 + 1 = 3
+1 + 1 - 1 + 1 + 1 = 3
+1 + 1 + 1 - 1 + 1 = 3
+1 + 1 + 1 + 1 - 1 = 3

示例 2:

复制代码
输入:nums = [1], target = 1
输出:1

提示:

  • 1 <= nums.length <= 20
  • 0 <= nums[i] <= 1000
  • 0 <= sum(nums[i]) <= 1000
  • -1000 <= target <= 1000

算法

定义两个变量:

  • 数组所有元素总和为 S;
  • 最终表达式里,带正号的元素和为 p ,带负号的元素和为 q。

得出下面两个公式

  1. 所有元素要么带正号、要么带负号,所以 p + q = S
  2. 题目要求表达式结果等于 target,即 p - q = target

可以推导出:

  • p = ( S + target) / 2
  • q = ( S - target) / 2

不管是选 p 还是 q,是等价的

p 和 q 是 互补关系 :只要你选出一个子集的和等于 p,剩下的所有元素自然就组成和为 q 的集合;反过来,选出一个子集和为 q,剩下的自然就是 p。两种选择对应的符号分配方案数完全一一对应,最终算出的结果一模一样,不存在选谁对、选谁错的区别。

为了更快的获取数据,所以需要选更小的值

外层遍历 n 个元素,内层从容量倒序遍历到 0, 容量越大,内层循环的总轮次越多,计算开销越高 。

既然选 p、选 q 结果完全一样,那肯定选数值更小的那个当容量,让内层循环的范围尽可能小,计算效率最高。

target 正负两种情况,对比 p 和 q 的大小

  • target ≥ 0

    • p = (S + target)/2,q = (S - target)/2
    • 显然 q < p(分子里减去了 target,结果更小)
    • 这时候更小的容量是 q = (S - target)/2
  • target < 0:比如 target=-3,代入公式:

    • p = (S + target)/2 = (S - |target|)/2
    • q = (S - target)/2 = (S + |target|)/2
    • 显然 p < q(分子里减去了 | target|,结果更小)
    • 这时候更小的容量是 p = (S - |target|)/2
  • 所以,不管 target 是正还是负,两个子集和里更小的那个值,永远等于 (S - |target|) / 2

记忆化每次的计算结果:memo[i][c]

  • 只使用 nums0 到 numsi 这些数字,凑出总和 c,一共有多少种选法。
  • 只考虑 nums[0..i] 这些数字,凑出总和恰好为 c 的方案数
  • i 的范围是 0 到 nums.length-1
  • c 的范围是 0 到 (S - |target|) / 2
  • 初始化这个数组
    • -1 表示该状态尚未计算:用 -1 作为特殊标记。因为方案数量不会是负数,所以不会和正常答案冲突。
    • 0 表示计算过,但确实没有方案

上面的思想实现方法有两种:

  • DFS:递归搜索
  • 动态规划:后续可以进行空间优化

动态规划优化:将二维 DP 数组压缩为一维数组,减少空间占用,同时通过倒序遍历,保证每个数字最多使用一次。

  • 二维数组状态转移方程为:fi+1c=fic+fic-x

    • fic:不选当前数字,方案数不变
    • fic-x:选择当前数字,之前只需要凑出 (c-x)。
  • 计算第 (i+1) 行时,只依赖第 (i) 行,并不需要更早的行。

  • 使用一维数组实现:fc += fc-x

    • f[c]:目前已经处理的数字中,凑出总和 c 的方案数。
    • f[c] += f[c-x]:保留原有方案,再加入"选择当前数字"的方案 。
      • c 必须倒序遍历:让 f[c-x] 保持本轮更新前的值,避免重复使用当前数字。
    • 含义是:处理到当前数字时,凑出总和 c 的方案数。
    • 必须倒序遍历:每个数字只能使用一次 。
      • 倒序遍历:读取的 f[c-x] 仍然是处理当前数字之前的旧值,确保当前数字最多使用一次。
      • 正序遍历:读取的 f[c-x] 可能已经被当前数字更新过,导致同一个数字重复使用。这对应完全背包的转移方式。

代码

递归:记忆化搜索

java 复制代码
import java.util.Arrays;

class Solution {

    public int findTargetSumWays(int[] nums, int target) {

        // 1. 计算数组中所有数字的总和
        int s = 0;
        for (int x : nums) {
            s += x;
        }

        // 2. 将问题转换为子集和问题
        // s 原本是数组总和
        // 减去 |target|,得到两倍的较小子集之和
        s -= Math.abs(target);

        // 如果 s < 0,说明数组总和小于 |target|,
        // 无论如何添加正负号,都不可能得到 target
        //
        // 如果 s 是奇数,那么 s / 2 不是整数,
        // 不存在满足条件的子集
        if (s < 0 || s % 2 == 1) {
            return 0;
        }

        // 3. 转换成:统计和恰好等于 m 的子集数量
        int m = s / 2; // 背包容量,即目标子集的和
        int n = nums.length;

        // memo[i][c] 表示:
        // 只考虑 nums[0..i] 这些数字,
        // 凑出总和恰好为 c 的方案数
        //
        // i 的范围是 0 到 n-1
        // c 的范围是 0 到 m
        int[][] memo = new int[n][m + 1];

        // 初始化记忆化数组
        // -1 表示该状态尚未计算
        // 0 表示计算过,但确实没有方案
        for (int[] row : memo) {
            Arrays.fill(row, -1);
        }

        // 4. 从最后一个数字开始搜索
        // 统计所有和为 m 的子集数量
        return dfs(n - 1, m, nums, memo);
    }

    /**
     * 统计只使用 nums[0..i] 时,
     * 凑出总和恰好为 c 的方案数
     */
    private int dfs(int i, int c, int[] nums, int[][] memo) {

        // 递归终止条件:
        // 所有数字都考虑完了
        if (i < 0) {

            // 剩余容量为 0:
            // 一个数字都不选,也恰好凑出了目标值
            // 这种方案有 1 种
            //
            // 剩余容量不为 0:
            // 已经没有数字可以选,因此方案数为 0
            return c == 0 ? 1 : 0;
        }

        // 如果该状态之前已经计算过,直接返回缓存结果
        if (memo[i][c] != -1) {
            return memo[i][c];
        }

        // 选择一:不选 nums[i]
        // 目标容量仍然是 c,只考虑前面的数字
        int res = dfs(i - 1, c, nums, memo);

        // 选择二:选 nums[i]
        // 只有 nums[i] <= c 时才能选择
        // 选择后,剩余容量变成 c - nums[i]
        if (c >= nums[i]) {
            res += dfs(i - 1, c - nums[i], nums, memo);
        }

        // 缓存当前状态的结果,避免重复计算
        return memo[i][c] = res;
    }
}

动态规划

java 复制代码
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int s = 0;
        for (int x : nums) {
            s += x;
        }

        s -= Math.abs(target);
        if (s < 0 || s % 2 == 1) {
            return 0;
        }

        int m = s / 2;
        int n = nums.length;
        int[][] f = new int[n + 1][m + 1];
        f[0][0] = 1;

        for (int i = 0; i < n; i++) {
            for (int c = 0; c <= m; c++) {
                if (c < nums[i]) {
                    f[i + 1][c] = f[i][c]; // 只能不选
                } else {
                    f[i + 1][c] = f[i][c] + f[i][c - nums[i]]; // 不选 + 选
                }
            }
        }

        return f[n][m];
    }
}

空间优化:1个数组

java 复制代码
class Solution {
    public int findTargetSumWays(int[] nums, int target) {

        // 第一步:计算所有数字的总和
        int s = 0;
        for (int x : nums) {
            s += x;
        }

        // 第二步:将目标和问题转换成子集和问题
        // 转换后的目标子集和为 (sum - |target|) / 2
        s -= Math.abs(target);

        // 总和不足以达到目标,或者无法整除 2,都不可能有方案
        if (s < 0 || s % 2 == 1) {
            return 0;
        }

        int m = s / 2; // 背包容量
        int[] f = new int[m + 1];

        // 一个数字都不选,凑出总和 0,只有一种方案
        f[0] = 1;

        // 依次处理每个数字
        for (int x : nums) {

            // 倒序遍历,避免当前数字被重复使用
            for (int c = m; c >= x; c--) {

                // 不选 x:原有的 f[c] 种方案
                // 选 x:原本凑出 c-x 的方案,每种都可以加上 x
                f[c] += f[c - x];
            }
        }

        // 返回凑出目标和 m 的方案数
        return f[m];
    }
}
相关推荐
信奥卷王1 小时前
[GESP202406 六级] 二叉树(含视频)
数据结构·算法
惊鸿一博1 小时前
Plücker直线_ 用两个三维向量描述一条空间直线
线性代数·算法·直线·几何表示
小道士写程序1 小时前
Rust 中的字符串与切片(String 与 &str)
算法
Tisfy2 小时前
LeetCode 3863.将一个字符串排序的最小操作次数:分类讨论(一次遍历)——简单易懂版
算法·leetcode·字符串·题解·分类讨论
Young Doro2 小时前
从平衡表征到最小充分因果表征:端到端神经网络因果效应估计
算法
0+1112 小时前
算法 --递归
算法·leetcode
打工仔折腾 AI2 小时前
介入式脑机接口的跨尺度解码与闭环调控:从算法落地到后端工程实践
人工智能·后端·python·深度学习·算法·ai agent 实战
大陈子编程3 小时前
C++ 背包九讲:从入门到精通的动态规划专题
开发语言·c++·动态规划
凤山老林3 小时前
授权模型只会RBAC?深入 ABAC 授权模型,实现权限判断做成策略引擎
大数据·数据库·算法