494. 目标和 (target-sum)

3,863 阅读3分钟

"你的背包,不再让我走得好缓慢"

494. 目标和 : 给你一个整数数组 numsnums 和一个整数 targettarget,向数组中的每个整数前添加 "++" 或 "-",然后串联起所有整数,可以构造一个 表达式 。例如,nums=[2,1]nums = [2, 1] ,可以在 22 之前添加 "++" ,在 11 之前添加 '-' ,然后串联起来得到表达式 "+21+2-1" 。返回可以通过上述方法构造的、运算结果等于 targettarget 的不同表达式的数目。

提示

  • 1nums.length201 \le nums.length \le20
  • 0nums[i]10000 \le nums[i] \le 1000
  • 0sum(nums[i])10000 \le sum(nums[i]) \le 1000
  • 1000target1000-1000 \le target \le 1000
示例1示例2
输入: nums=[1,1,1,1,1]nums = [1,1,1,1,1], target = 33
输出: 55
解释: 一共有 55 种方法让最终目标和为 33
1+1+1+1+1=3-1 + 1 + 1 + 1 + 1 = 3
+11+1+1+1=3+1 - 1 + 1 + 1 + 1 = 3
+1+11+1+1=3+1 + 1 - 1 + 1 + 1 = 3
+1+1+11+1=3+1 + 1 + 1 - 1 + 1 = 3
+1+1+1+11=3+1 + 1 + 1 + 1 - 1 = 3
输入: nums=[1]nums = [1], target=1target = 1
输出: 11

中规中矩的 0-1 背包

数组 numsnums 中定义,

  • 要添加 ++ 的元素集合,定义为 leftleft

  • 要添加 - 的元素集合,定义为 rightright

故有两个集合有如下推导关系,

  • Sumleft+Sumright=sumSum_{left} + Sum_{right} = sum

  • SumleftSumright=targetSum_{left} - Sum_{right} = target

其中,sumsumnumsnums 所有元素之和。

sumsumtargettarget 一定是确定的,故两式相加/相减分别得,即

  • Sumleft=(sum+target)/2Sum_{left} = (sum + target) / 2

  • Sumright=(sumtarget)/2Sum_{right} = (sum - target) / 2

从题干条件获知一些边界情况。numanuma 元素均为非负整数,故 sumsum0\ge 0,同理 SumleftSum_{left}SumrightSum_{right}0\ge 0。我们总结几点已知“线索”:

  • 一旦 leftleft 集合确定,那么 rightright 集合也一定确定了

  • sum+targetsum + targetsumtargetsum - target 一定为偶数

  • targettarget 的算数绝对值一定 sum\le sum

    • 如果 target0target \ge 0targettarget 必须 sum\le sum(因为 Sumright=(sumtarget)/20Sum_{right} = (sum - target) / 2 \ge 0,所以 targetsumtarget \le sum 成立)

    • 如果 target<0target \lt 0target|target| 必须 sum\le sum(因为 Sumleft=(sum+target)/20Sum_{left} = (sum + target) / 2 \ge 0,所以 targetsum|target| \le sum 成立)

当前问题可以转化成 0-1背包 问题:

1、元素不重复使用

2、背包容量为:SumleftSum_{left}SumrightSum_{right} 的较小值

1、确定 dp 数组及含义

💥 numsnums[0,i][0, i] 区间选取元素,这些元素和等于 jj 时,共有 dp[i][j]dp[i][j] 种方法。其中,

  • i[0,n)i \in [0, n)n=nums.lengthn=nums.length

  • j[0,bagWeight]j \in[0,bagWeight]bagWeight=min(Sumleft,Sumright)bagWeight=min(Sum_{left}, Sum_{right})

2、确定 dp 状态方程

如果当前元素已经大于元素和,即 j<nums[i]j<nums[i],肯定放弃 nums[i]nums[i],故有 dp[i][j]=dp[i1][j]dp[i][j] = dp[i - 1][j]

如果当前元素不大于元素和,即 jnums[i]j \ge nums[i],

  • 如果主动不选取 nums[i]nums[i],故有 dp[i][j]=dp[i1][j]dp[i][j] = dp[i - 1][j]

  • 如果主动选取 nums[i]nums[i],故有 dp[i][j]=dp[i1][jnums[i]]dp[i][j] = dp[i-1][j-nums[i]]

故,dp[i][j]=dp[i1][j]+dp[i1][jnums[i]]dp[i][j] = dp[i - 1][j] + dp[i-1][j-nums[i]]

3、确定 dp 初始状态

dp[0][0]dp[0][0] 初始化numsnums[0,0][0,0] 区间选取元素(即,只能在 nums[0]nums[0] 一个元素中选择),这些元素和等于 00 时,共有 dp[0][0]dp[0][0] 种方法。

  • 不选择 nums[0]nums[0],元素和也为 00(一个元素也不选择),即 dp[0][0]=1dp[0][0] = 1

  • 如果 nums[0]nums[0] 的值恰为 00 时,那么 dp[0][0]=2dp[0][0] = 2 (有选择 nums[0]nums[0]、不选择 nums[0]nums[0] 两种情况)

故, dp[0][0]={2,nums[0]==01,nums[0]0dp[0][0] = \begin{cases} 2, & nums[0] == 0 \\ 1, & nums[0] \neq 0 \end{cases}


dp[i][0]dp[i][0] 初始化numsnums[0,i][0,i] 区间选取元素,这些元素和等于 00 时,共有 dp[i][0]dp[i][0] 种方法。dp[i][0]dp[i][0] 的初始化要严格遵循上述定义的状态转移方程。从 i=1i = 1 开始,到 i=n1i= n - 1 结束。

故, dp[i][0]={dp[i1][0],0<nums[i]dp[i1][0]+dp[i1][0nums[i]],0>=nums[i]dp[i][0] = \begin{cases} dp[i-1][0], & 0 < nums[i] \\ dp[i-1][0] + dp[i-1][0-nums[i]], & 0 >= nums[i] \end{cases}


dp[0][j]dp[0][j] 初始化numsnums[0,0][0,0] 区间选取元素(即,只能在 nums[0]nums[0] 一个元素中选择),这些元素和等于 jj 时,共有 dp[0][j]dp[0][j] 种方法。从 j=1j=1 开始,到 j=bagWeightj=bagWeight 结束,如果 nums[0]nums[0] 恰与当前背包容量 jj 相等,就有 00 种方案,否则为 00

故,dp[0][j]={1,nums[0]==j0,nums[0]jdp[0][j] = \begin{cases} 1, & nums[0] == j\\ 0, & nums[0] \neq j \end{cases}

4、确定遍历顺序

参照经典二维背包遍历顺序,即,

  • 第一层循环从 i=1i=1i=n1i= n - 1

  • 第二层循环从 j=0j=0j=bagWeightj=bagWeight

5、确定返回值

numsnums 在 [0,n)[0,n) 区间选取元素,这些元素和等于 bagWeightbagWeight 时,共有 dp[n1][bagWeight]dp[n-1][bagWeight]

6、示例代码

/**
 * 空间复杂度 O(n * (target + sum)),n是nums数组长度,sum是nums元素之和
 * 时间复杂度 O(n * (target + sum))
 */
function findTargetSumWays(nums: number[], target: number): number {
    const sum = nums.reduce((p, v) => p + v, 0);

    if (Math.abs(target) > sum || ((target + sum) & 1)) {
        return 0;
    }

    // ----- 初始值准备开始 ------
    const length = nums.length;
    const bagWeight = Math.min((sum + target) >> 1, (sum - target) >> 1);
    const dp = Array.from({ length }, () => new Array(bagWeight + 1).fill(0));

    dp[0][0] = nums[0] === 0 ? 2 : 1;

    for(let i = 1; i < length; i++) {
        dp[i][0] = dp[i - 1][0] + (0 < nums[i] ? 0 : dp[i - 1][0 - nums[i]]);
    }

    for(let j = 1; j <= bagWeight; j++) {
        dp[0][j] = j === nums[0] ? 1 : 0;
    }
    // ----- 初始值准备结束 ------

    for(let i = 1; i < length; i++) {
        for(let j = 1; j <= bagWeight; j++) {
            dp[i][j] = dp[i - 1][j] + (j < nums[i] ? 0 : dp[i - 1][j - nums[i]]);
        }
    }

    return dp[length - 1][bagWeight];
};

拓展方法(哨兵站岗)

上个方法中 dpdp 状态初始化过于复杂:不仅要考虑 nums[0]nums[0] 的非零状态,nums[0]nums[0] 与当前背包容量是否相等,对于 dp[i][0]dp[i][0] 状态还要按照状态转移方程执行一遍,这些细枝末节都不利于面试场景。故我们对 dpdp 含义做如下改动:

1、确定 dp 数组及含义

numsnums[0,i)[0,i) 区间选取元素,这些元素和等于 jj 时,共有 dp[i][j]dp[i][j] 种方法。

其中,i[0,n]i \in [0, n]n=nums.lengthn=nums.lengthj[0,bagWeight]j \in[0,bagWeight]

💥 dpdpii 项维度上向延伸了一个单位,故 ii 的取值范围将从 [0,n)[0,n) 变为 [0,n][0,n]

2、确定 dp状态方程

因为 ii 维度延伸了一个单位,故在 numsnums 数组中取值要格外小心,第 ii 项对应的值为 nums[i1]nums[i-1],故状态转移方程为,

dp[i][j]={dp[i1][j],j<nums[i1]dp[i1][j]+dp[i1][jnums[i1]],jnums[i1]dp[i][j] = \begin{cases} dp[i - 1][j], & j \lt nums[i-1] \\ dp[i - 1][j] + dp[i-1][j-nums[i-1]], & j \ge nums[i-1] \end{cases}

3、确定 dp 初始状态

  • dp[0][0]dp[0][0] 的初始化numsnums 在[0,0)区间选取元素,这些元素和等于 00 时,共有 dp[0][0]dp[0][0] 种方法,这个区间是没有元素的,故 dp[0][0]=1dp[0][0] = 1

  • dp[i][0]dp[i][0] 的初始化:无须再初始化,按照状态转移方程遍历计算即可。

  • dp[0][j]dp[0][j] 的初始化numsnums 在[0,0)区间选取元素(这个区间是没有元素的),这些元素和等于 jj 时,共有 dp[0][j]dp[0][j] 种方法。只要 j>0j \gt 0,那么 dp[0][j]=0dp[0][j] = 0

综上所述,初始化 dpdp 数组时,仅需要创建一个 n×bagWeightn \times bagWeight 的二维数组,并将 dp[0][0]dp[0][0] 设置为 11 即可。

4、确定遍历顺序

  • 第一层循环从 i=1i=1i=ni= n

  • 第二层循环从 j=0j=0j=bagWeightj=bagWeight

5、确定返回值

numsnums 在[0,n)区间选取元素,这些元素和等于 bagWeightbagWeight 时,共有 dp[n][bagWeight]dp[n][bagWeight] 种方法。

6、示例代码

/**
 * 空间复杂度 O(n * (target +/- sum)),n是nums数组长度,sum是nums元素之和
 * 时间复杂度 O(n * (target +/- sum))
 */
function findTargetSumWays(nums: number[], target: number): number {
    const sum = nums.reduce((p, v) => p + v, 0);

    if (Math.abs(target) > sum || ((target + sum) & 1)) {
        return 0;
    }

    const n = nums.length;
    const bagWeight = Math.min((sum + target) >> 1, (sum - target) >> 1);
    const dp = Array.from({ length: n + 1 }, () => new Array(bagWeight + 1).fill(0));
    dp[0][0] = 1;

    for(let i = 1; i <= n; i++) {
        for(let j = 0; j <= bagWeight; j++) {
            dp[i][j] = dp[i - 1][j] + (j < nums[i - 1] ? 0 : dp[i - 1][j - nums[i - 1]]);
        }
    }

    return dp[n][bagWeight];
};

状态压缩

/**
 * 空间复杂度 O(target +/- sum),sum是nums元素之和
 * 时间复杂度 O(n * (target +/- sum)),n是nums数组长度,
 */
function findTargetSumWays(nums: number[], target: number): number {
    const sum = nums.reduce((p, v) => p + v, 0);

    if (Math.abs(target) > sum || ((target + sum) & 1)) {
        return 0;
    }

    const bagWeight = Math.min((sum + target) >> 1, (sum - target) >> 1);
    const dp = new Array(bagWeight + 1).fill(0);
    dp[0] = 1;

    for(let i = 0, len = nums.length; i < len; i++) {
        for(let j = bagWeight; j >= nums[i]; j--) {
            dp[j] += dp[j - nums[i]];;
        }
    }

    return dp[bagWeight];
};

参考

# 重识背包问题(上)

# 重识背包问题(下)