力扣 494. 目标和 —— 回溯 & 动态规划双解法全解(Java 实现)

一、引言

在数组与动态规划结合的面试题库中,LeetCode 494 目标和是一道极具代表性的经典题目。它以 "给数组元素添加正负号得到目标和" 为目标,既不依赖复杂数学推导,也不涉及冷门数据结构,却精准考察了回溯剪枝思想、状态定义设计、0-1 背包模型转化 以及空间优化四大核心能力。看似简单的符号选择,背后藏着从暴力穷举到最优子结构的完整算法演进逻辑,也是面试官常用来检验算法思维是否严谨的高频考题。

本文围绕两种最主流、最实用的解法展开:回溯穷举法动态规划(0-1 背包转化法)。

全文从题目本质出发,先明确问题模型与核心思路,再逐步推导状态转移逻辑,最后给出可直接提交的完整代码,力求让你不仅能 AC 题目,更能清晰讲清思路、从容应对同类子集和与目标和问题。

494. 目标和 - 力扣(LeetCode)

二、回溯

2.1 定义

回溯 = 一条路走到底 → 不行就退一步 → 换条路再走 → 直到把所有可能都试一遍

本质就是:暴力枚举 + 递归走深度 + 走错了就 "回退"

2.2 思路

2.2.1 每一步都有 "选择"

对第 i 个数:

  • 选 +
  • 选 −

这就是回溯的分叉点

2.2.2 递归往下走,一直走到最后一个数

这就是一条路走到底

比如数组 [1,1,1,1,1]你会一路选:

+1 → +1 → +1 → +1 → +1

这是一条完整路径。

2.2.3 到达终点,判断是否满足 target

如果满足,答案 +1。

2.2.4 关键:回溯 "回退" 的体现

当你走完最后一个数,函数返回上一层。上一层会自动尝试另一个选择

比如:

+1 → +1 → +1 → +1 → +1 走完了

退回到第四个 1

换成:

+1 → +1 → +1 → -1 → +1

这个退回去换选择 的过程,就是回溯

三、回溯代码实现

3.1 代码

java 复制代码
class Solution {
    int res;
    public int findTargetSumWays(int[] nums, int target) {
        dps(nums , 0 , target , 0);
        return res;
    }

    private void dps(int[] nums , int start , int target , int sum){
        if (start == nums.length){
            if (target == sum){
                res++;
            } 
            return;
        }
        dps(nums , start + 1 , target , sum + nums[start]);
        dps(nums , start + 1 , target , sum - nums[start]);
    }
}

3.2 循环

在这里我们很容易写错一个点就是在递归调用的时候循环了

也就是写成下面这个样子

java 复制代码
class Solution {
    int res;
    public int findTargetSumWays(int[] nums, int target) {
        dps(nums , 0 , target , 0);
        return res;
    }

    private void dps(int[] nums , int start , int target , int sum){
        if (start == nums.length){
            if (target == sum){
                res++;
            } 
            return;
        }
        for (int i = start ; i < nums.length ; i++){
            dps(nums , i + 1 , target , sum + nums[i]);
            dps(nums , i + 1 , target , sum - nums[i]]);
        }   
    }
}

那到底什么时候回溯需要循环,什么时候不需要循环呢?

我们来举一个简单的回溯例子

77. 组合 - 力扣(LeetCode)

java 复制代码
class Solution {
    public List<List<Integer>> combine(int n, int k) {
        List<List<Integer>> res = new ArrayList<>();
        combinePath(1 , n , k , res , new ArrayList<Integer>());
        return res;
    }

    private void combinePath(int start , int end , int k , List<List<Integer>> res , List<Integer> path){
        if (path.size() == k){
            res.add(new ArrayList<>(path));
            return;
        }
        for (int i = start ; i <= end ; i++){
            path.add(i);
            combinePath(i + 1, end , k , res , path);
            path.removeLast();
        }
    }
}

我们来仔细看一下循环和不循环的区别

我们先来搞清楚循环的作用,已知在一整个循环内,都是不断地给path中添加和移除元素,这个元素的下表是不变的,所以说这个循环是在遍历当前path元素的所有可能

而我们目标和中,只有加当前数,减当前数,数据和索引是定死的,不需要去循环。

java 复制代码
        dps(nums , start + 1 , target , sum + nums[start]);
        dps(nums , start + 1 , target , sum - nums[start]);

如果我们加上了循环

java 复制代码
        for (int i = start ; i < nums.length ; i++){
            dps(nums , i + 1 , target , sum + nums[i]);
            dps(nums , i + 1 , target , sum - nums[i]]);
        } 

那我们这一整个循环就是加整个数组的数,减整个数组的数,这种情况适合什么呢,要求**目标和相等并且元素全排列的情况,**但是我们仔细看一下题目就会发现我们不能去改变顺序,所以不加循环。

四、回溯代码实现(循环)

为了方便大家更透彻的理解,这里我们再来写一下循环的版本,看看到底什么时候用,什么时候不用

4.1 思路分析

这里我们来思考一下,在上面的代码,我们之所以不用回溯,是因为总共就两种方式,要么加,要么减,我们直接罗列出来,就完事了。但是如果遇到数据量比较大的时候,是不是就要用上循环了。

这里我们先来推断一个公式:

我们将一个数组分成两份,一份前面符号全是加,这份和我们称为加和

一份前面全是减,这份和我们称为减和,注意这里减和是正数。

那么加和 - 减和 = target

加和 + 减和 = 总和

加和 = (target + 总和) / 2

并且我们要注意,加和和减和一定是整数,如果加和非整数,那么说明没有解。

那么我们现在就要遍历数组,去寻找哪一部分的和等于我们的加和

现在我们回溯的情况就不是加减了,现在我们全是加,并且要用循环来判断要加哪一个。

4.2 代码实现

java 复制代码
class Solution {
    int res;
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        for (int num : nums){
            sum += num;
        }
        if ((target + sum) % 2 == 1 || sum < Math.abs(target)){
            return res;
        }
        int positiveSum = (target + sum) / 2;
        dps(nums , 0 , positiveSum);
        return res;
    }

    private void dps(int[] nums , int start , int target){
        if (target == 0){
            res++;
            // 注意我们在这里不能直接return,因为后续如果有0的话,也可以加上作为解
        }
        for (int i = start ; i < nums.length ; i++){
            dps(nums , i + 1 , target - nums[i]);
        }
    }
}

五、动态规划

5.1 定义

把复杂问题拆成若干个子问题,记住子问题的答案,避免重复计算,用子问题结果推出原问题结果。

更直白一点:不重复算、用前面的结果推后面的结果。

5.2 思路

直接暴力是 2ⁿ 复杂度,n=20 还能过,n=100 就彻底炸。而这里存在大量重复子问题,比如:

  • 前 i 个数凑出和为 j 的方案数后面会反复用到(可以参考一下回溯循环版的实现 ,确实每次都会用到),所以适合 DP。

六、01背包问题

6.1 定义

有一个背包,容量固定;

有一堆物品,每个物品只能选一次(要么装,要么不装);

每个物品有重量、有价值;

问:不超容量的前提下,最大价值是多少?

核心就两个字:01

  • 0:不选
  • 1:选

6.2 三个关键点

  1. 物品只能用一次(不能重复选)
  2. 每一步只有两个选择:选 / 不选
  3. 用前面的状态推后面的状态: 不选 = 继承上一个结果

    选 = 上一个状态 + 当前物品

6.3 思路

那么在普通01背包问题中,dp[i]存放的是当前i容量下存放的最大价值

在目标和问题中,dp[i]存放的是能凑出i的情况

七、DP代码实现

7.1 二维

7.1.1 递推公式

dp[i][j]

i代表当前参与的前i个数字 , j 代表这些数字凑到 j ,dp[i][j]就是用前i个数字凑到j的数量

那么dp[i][j] = dp[i - 1][j] + dp[i - 1] [j - nums[i]]

前i个数字凑到j的数量 = 前i-1个数字凑到j的数量 + 前i-1个数字凑到j - nums[i]的数量(j - nums[i] 再凑上当前的数字,就是j)

7.1.2 0的个数

我们在回溯算法中也提到过,不是结果凑到加和就完事了,我们还需要考虑0的问题

所以我们这里先把0的个数求出来算好

假设不带0的路线是一条,如果我们要给它加上0 ,就有 2**count0

那么我们再来思考一下2**count0,到底应该放在哪?

dp[i][j] = dp[i - 1][j] + dp[i - 1] [j - nums[i]]

已知每种不含0的都该配上2*count0种组合

我们拿初始化的时候来说,当j == nums[i],说明当前一个数字就可以组成j,这时我们的解法应该是

dp[i][j] = dp[ i - 1][j] + dp[i -1][0];

那么我们也显然知道当j == nums[i],这种解法的组合不是1,是2**count0

那么我们就可以推出 dp[i -1][0] = 2**count0

以此类推,dp[?][0] = 2** count0

我们要注意的一点是这里指的count0不是0的总个数而是前i个数字中0的个数, dp[i][j]就是用前i个数字凑到j的数量,那么count0也肯定是前i个数字当中的,千万不可以放count0的总数

java 复制代码
        int sum = 0;
        int count0 = 0;
        int len = nums.length;
        for (int num : nums){
            sum += num;
        }
        if ((target + sum) % 2 == 1 || sum < Math.abs(target)){
            return 0;
        }
        int positiveSum = (target + sum)/2;
        int[][] dp = new int[len][positiveSum + 1];
        for (int i = 0 ; i < len ; i++){
            if (nums[i] == 0){
                count0++;
            }
            dp[i][0] = (int) Math.pow(2 , count0);
        }

7.1.3 第一个数字的初始化

我们再来回顾一下递推公式dp[i][j] = dp[i - 1][j] + dp[i - 1] [j - nums[i]]

我们可以发现一个问题,就是如果i == 0时,i-1该怎么办?

这样就需要我们去初始化处理了

首先我们要明确的一点,如果nums[0] > 加和,那么我们肯定就不用初始化了

java 复制代码
if (nums[0] <= positiveSum){
    //初始化
}

那接下来我们来思考一个问题------我们对dp[0][nums[0]]到底是该初始化1还是初始化2**count0呢?

这里我们分两种情况来讨论

第一种 count0 = 0,那么此时初始化2 ** 0 = 1,没区别

第二种 count0 = 1 , 那么此时nums[0] = 0,应该填的是 2**1而不是1啊

所以,这里可以用2**count,但是说实话代码有点繁琐

所以我们可以直接不管了,赋值1,但是一定要写在0赋值前面,因为dp[?][0] = 2**count0,如果nums[0] = 0,就算给dp赋值了1,也在下面覆盖掉了

总结一下,初始化两种形式,要么写2 ** count0 ,要么直接写1,但要写在0赋值前面

java 复制代码
        int sum = 0;
        int count0 = 0;
        int len = nums.length;
        for (int num : nums){
            sum += num;
        }
        if ((target + sum) % 2 == 1 || sum < Math.abs(target)){
            return 0;
        }
        int positiveSum = (target + sum)/2;
        int[][] dp = new int[len][positiveSum + 1];
        // 一定要写在0前面
        if (nums[0] <= positiveSum){
            dp[0][nums[0]] = 1;
        }
        for (int i = 0 ; i < len ; i++){
            if (nums[i] == 0){
                count0++;
            }
            dp[i][0] = (int) Math.pow(2 , count0);
        }

完整代码

java 复制代码
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        int count0 = 0;
        int len = nums.length;
        for (int num : nums){
            sum += num;
        }
        if ((target + sum) % 2 == 1 || sum < Math.abs(target)){
            return 0;
        }
        int positiveSum = (target + sum)/2;
        int[][] dp = new int[len][positiveSum + 1];
        if (nums[0] <= positiveSum){
            dp[0][nums[0]] = 1;
        }
        for (int i = 0 ; i < len ; i++){
            if (nums[i] == 0){
                count0++;
            }
            dp[i][0] = (int) Math.pow(2 , count0);
        }
        for (int i = 1 ; i < len ; i++){
            for (int j = 1 ; j <= positiveSum ; j++){
                if (nums[i] > j){
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
                }
            }
        }
        return dp[len - 1][positiveSum];
    }
}

7.1.5 无count0 写法

我们在有count0 写法中把dp[i][0] = 2 **count0

当遇到 j == nums[i]的时候

dp[i][j] = dp[i - 1][j] + dp[i - 1] * 2 **count0(前i - 1的数字中的0的个数)

但其实我们也可以直接不计算count0 直接dp[i][0] = 1

那么我们在遇到j == nums[i]的时候

dp[i][j] = dp[i - 1][j] + dp[i - 1][0];

在count0版本中

我们只初始化了dp[0][nums[0]] = 1;

没有对dp[0][0]进行初始化,所以引入了count0

让dp[0][0] = 2** 0 或者 2**1

并且我们在下面遍历后直接从j = 1开始遍历,dp[?][0]我们不会再遍历计算了,要用直接拿去了
但是我们在无count0版本中

只初始化dp[0][0] = 1;

我们不再计算count0 ,在下面遍历的时候也是从j = 0开始遍历

也就是说在无count0版本中,我们其实也是隐形计算了count0,只是把它融合到了循环中

但是同时我们也要注意一个问题,就是在count0版本中我们特意把dp[0][nums[0]] = 1写在count0前面就是防止nums[0] = 0 的问题导致求解出错

那么在无count0的版本里面我们该怎么做?显然nums[0] == 0时dp[0][0] = 2,不能赋值为1

这里呢,我们有两种方法,要么你直接特殊处理

要么我们直接多加一行

原来的dp[i][j]

i代表当前参与的前i个数字 , j 代表这些数字凑到 j ,dp[i][j]就是用前i个数字凑到j的数量

现在的dp[i][j]

i代表当前参与的前i - 1个数字 , j 代表这些数字凑到 j ,dp[i][j]就是用前i - 1个数字凑到j的数量

那么我们处理的时候直接从i = 1 , j = 0开始处理

如果nums[0] = 0 = j

dp[i][j] = dp[i - 1][j] + dp[i - 1][0] = dp[0][0] + dp[0][0] = 2是不是就符合我们的要求了

那如果nums[0] == j != 0

dp[i][0] = dp[i - 1][0] = 1

dp[i][j] = dp[i - 1][j] + dp[i - 1][0] = 0 + 1 = 1

是不是也符合?而却不需要对第一个数字初始化了

java 复制代码
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        int count0 = 0;
        int len = nums.length;
        for (int num : nums){
            sum += num;
        }
        if ((target + sum) % 2 == 1 || sum < Math.abs(target)){
            return 0;
        }
        int positiveSum = (target + sum)/2;
        int[][] dp = new int[len + 1][positiveSum + 1];
        dp[0][0] = 1;
        for (int i = 1 ; i <= len ; i++){
            for (int j = 0 ; j <= positiveSum ; j++){
                if (nums[i - 1] > j){
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];
                }
            }
        }
        return dp[len][positiveSum];
    }
}

那接下来我们总结一下count0版本和无count0版本注意点:

  1. count0

先对dp[0][nums[0]] = 1初始化 ,再提前算出前i个数字中的count0数量,再放到dp[i][0]

接下来的遍历从i = 1, j =1开始遍历

2.无count0

只对dp[0][0] = 1初始化,不再显式计算count0,而是j = 0开始遍历隐式计算count0,不需要dp[0][nums[0]] = 1初始化,但是一定要注意多加了一行, dp[i][j]就是用前i - 1个数字凑到j的数量

7.2 一维

7.2.1 递推公式

回顾一下之前的递推公式:dp[i][j] = dp[i - 1][j] + dp[i - 1] [j - nums[i]]

现在我们直接缩短为dp[j] = dp[j] + dp[j - nums[i]]

dp[j] = dp[j] + dp[j - nums[i]]

dp[j] : 当前(隐形i)凑到j的个数

dp[j] + dp[j - nums[i]]:上一次凑到j的个数 + 上一次凑到j - nums[i] 的个数

7.2.2 遍历条件

我们一维dp的时候很多时候都要逆序遍历,这里我就拿这个例子好好讲一讲

首先抓住关键词 上一次

我们知道用一维 ,就是利用覆盖了上一次的结果

假设

java 复制代码
for(int j = 1; j <= positiveSum; j++) 

那么我们取到的dp[j] + dp[j - nums[i]], dp[j]没有被覆盖,那是dp**[j - nums[i]]**肯定是被覆盖过了的,我们就无法拿到上一次的结果

所以我们要逆序遍历 这样我们拿到**dp[j - nums[i]],**就是未覆盖的结果

java 复制代码
for(int j = positiveSum; j > 0; j--) 

我们再来看一下原来二维的代码

java 复制代码
                if (nums[i] > j){
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i]];
                }

当nums[i] > j 时,dp[i][j] = dp[i - 1][j];

在一维数组里面,不就是不变吗

所以可以进一步优化

java 复制代码
for(int j = positiveSum; j >= nums[i] ; j--) 

7.2.3 初始化

那么在二维数组里面我们提到了count0版本和无count0版本

这里我们就直接给出无count0版本,更加简洁

我们先来确定i的遍历范围

有count0 i = 1 -> i = len -1

无count0 i = 1 -> i = len

那么我们一维的遍历范围就应该是 i = 0 -> i = len -1

为什么长得不一样?

在二维中 dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]];

其实我们针对的i取到的是i - 1的数,并且我们从1开始遍历,也是因为从0开始索引会越界

但是在一维中,我们就不用担心越界的问题了,因为我们是直接覆盖的

所以可以直接从0开始,也不绕弯了

java 复制代码
for (int i = 0 ; i < len ; i++)

7.2.4 完整代码

java 复制代码
class Solution {
    public int findTargetSumWays(int[] nums, int target) {
        int sum = 0;
        int count0 = 0;
        int len = nums.length;
        for (int num : nums){
            sum += num;
        }
        if ((target + sum) % 2 == 1 || sum < Math.abs(target)){
            return 0;
        }
        int positiveSum = (target + sum)/2;
        int[] dp = new int[positiveSum + 1];
        dp[0] = 1;
        for (int i = 0 ; i < len ; i++){
            for(int j = positiveSum; j >= nums[i] ; j--) {
                dp[j] += dp[j - nums[i]];
            }
        }
        return dp[positiveSum];
    }
}
相关推荐
YXWik62 小时前
Langchain4j(3) Prompt 提示词工程 + PromptTemplate + SystemMessage 高级用法
java·ai·prompt
StarShip2 小时前
JVM堆栈溢出监测原理
android·java
北顾笙9802 小时前
day23-数据结构力扣
数据结构·算法·leetcode
Robot_Nav2 小时前
RC-ESDF 详解:以机器人为中心的欧几里得有符号距离场
人工智能·算法·机器人
北极的代码2 小时前
2026年Java后端热点科普:Java 26新特性+Java 21落地实战,解锁后端开发新范式
java·后端
敖正炀2 小时前
深入对比 Java 并发工具:CyclicBarrier、CountDownLatch 与 Semaphore
java
橘子编程2 小时前
Tomcat全栈指南:从入门到精通
java·tomcat
hrhcode2 小时前
【java工程师快速上手go】三.Go Web开发(Gin框架)
java·spring boot·golang
田梓燊2 小时前
leetcode 234
算法·leetcode·职场和发展