一、引言
在数组与动态规划结合的面试题库中,LeetCode 494 目标和是一道极具代表性的经典题目。它以 "给数组元素添加正负号得到目标和" 为目标,既不依赖复杂数学推导,也不涉及冷门数据结构,却精准考察了回溯剪枝思想、状态定义设计、0-1 背包模型转化 以及空间优化四大核心能力。看似简单的符号选择,背后藏着从暴力穷举到最优子结构的完整算法演进逻辑,也是面试官常用来检验算法思维是否严谨的高频考题。
本文围绕两种最主流、最实用的解法展开:回溯穷举法 与动态规划(0-1 背包转化法)。
全文从题目本质出发,先明确问题模型与核心思路,再逐步推导状态转移逻辑,最后给出可直接提交的完整代码,力求让你不仅能 AC 题目,更能清晰讲清思路、从容应对同类子集和与目标和问题。

二、回溯
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]]);
}
}
}
那到底什么时候回溯需要循环,什么时候不需要循环呢?
我们来举一个简单的回溯例子

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 三个关键点
- 物品只能用一次(不能重复选)
- 每一步只有两个选择:选 / 不选
- 用前面的状态推后面的状态: 不选 = 继承上一个结果
选 = 上一个状态 + 当前物品
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版本注意点:
- 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];
}
}