力扣 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背包问题中,dpi存放的是当前i容量下存放的最大价值

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

七、DP代码实现

7.1 二维

7.1.1 递推公式

dpij

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

那么dpij = dpi - 1j + dpi - 1 j - nums\[i]

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

7.1.2 0的个数

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

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

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

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

dpij = dpi - 1j + dpi - 1 j - nums\[i]

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

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

dpij = dp i - 1j + dpi -10;

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

那么我们就可以推出 dpi -10 = 2**count0

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

我们要注意的一点是这里指的count0不是0的总个数而是前i个数字中0的个数, dpij就是用前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 第一个数字的初始化

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

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

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

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

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

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

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

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

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

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

所以我们可以直接不管了,赋值1,但是一定要写在0赋值前面,因为dp?0 = 2**count0,如果nums0 = 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 写法中把dpi0 = 2 **count0

当遇到 j == numsi的时候

dpij = dpi - 1j + dpi - 1 * 2 **count0(前i - 1的数字中的0的个数)

但其实我们也可以直接不计算count0 直接dpi0 = 1

那么我们在遇到j == numsi的时候

dpij = dpi - 1j + dpi - 10;

在count0版本中

我们只初始化了dp0nums\[0] = 1;

没有对dp00进行初始化,所以引入了count0

让dp00 = 2** 0 或者 2**1

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

只初始化dp00 = 1;

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

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

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

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

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

要么我们直接多加一行

原来的dpij

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

现在的dpij

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

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

如果nums0 = 0 = j

dpij = dpi - 1j + dpi - 10 = dp00 + dp00 = 2是不是就符合我们的要求了

那如果nums0 == j != 0

dpi0 = dpi - 10 = 1

dpij = dpi - 1j + dpi - 10 = 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

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

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

2.无count0

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

7.2 一维

7.2.1 递推公式

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

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

dpj = dpj + dpj - nums\[i]

dpj : 当前(隐形i)凑到j的个数

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

7.2.2 遍历条件

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

首先抓住关键词 上一次

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

假设

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

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

所以我们要逆序遍历 这样我们拿到**dpj - 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]];
                }

当numsi > j 时,dpij = dpi - 1j;

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

所以可以进一步优化

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

为什么长得不一样?

在二维中 dpij = dpi - 1j + dpi - 1j - 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];
    }
}
相关推荐
IT枫斗者枫哥7 分钟前
Java 文件导出:写完了,为什么还不能标记成功?
java
陈皮波比茶13 分钟前
java笔记-typora快捷键
java
蚂蚁雅嘿22 分钟前
maven标签日常总结
java·maven
洋不写bug33 分钟前
二叉树(二) 常见基础操作解析|结点数、树高、查找结点、判断完全二叉树
java·开发语言·数据结构·完全二叉树·二叉树结点数·树高·查找结点
菠萝加点糖44 分钟前
Maven 坐标系统说明
java·maven
hm宋1 小时前
Canal 升级 jar 包操作文档(以升级 FastJSON 为例)
java·canal·jar
后台模板学习1 小时前
学习的心态高频面试题
java·数据库·学习
用户3126874877201 小时前
线程池到底怎么管理线程的?从 ThreadPoolExecutor 到拒绝策略全链路拆解
java
cpolar技术支持2 小时前
Kafka Streams 窗口统计怎么验收:本地跑订单流聚合,用 cpolar 给同事看只读结果页
java·docker·kafka·cpolar·kafka streams
wabs6662 小时前
关于二叉树【429.N叉树的层序遍历的思考】
数据结构·c++·算法·leetcode·二叉树