目录
引言
题目描述
示例
初步思路:回溯法
回溯法实现
分析
转变思路:动态规划
问题转换
状态定义
状态转移方程
二维动态规划实现
压缩到一维动态规划
一维动态规划实现
详细讲解:从回溯到动态规划的旅程
1. 从回溯到动态规划的转变
2. 问题转换的关键
3. 状态定义与转移
4. 压缩到一维的优化
二维动态规划
一维动态规划
举例说明
从后往前遍历
从前往后遍历
总结
总结
引言
算法学习之路充满了挑战和乐趣,其中背包问题更是经典中的经典。今天,我们一起探讨一道有趣的题目——“目标和”,看一看它如何从回溯变身为动态规划。
494. 目标和 - 力扣(LeetCode)
题目描述
给定一个非负整数数组 nums
和一个整数 target
。我们可以向数组中的每个整数前添加 '+' 或 '-',然后把它们串联起来形成表达式。我们的任务是找出所有可能的表达式,使其结果等于 target
。
示例
输入: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
初步思路:回溯法
第一眼看到这道题,或许你会想用回溯法。没错,这也是大多数人的第一反应:通过递归地添加 '+' 或 '-' 来尝试所有可能的表达式。
回溯法实现
class Solution {
int result = 0;
public int findTargetSumWays(int[] nums, int target) {
if (nums.length == 0) return 0;
backtrack(nums, 0, target);
return result;
}
void backtrack(int[] nums, int i, int remain) {
if (i == nums.length) {
if (remain == 0) {
result++;
}
return;
}
backtrack(nums, i + 1, remain - nums[i]);
backtrack(nums, i + 1, remain + nums[i]);
}
}
分析
回溯法通过递归地尝试每一种可能的组合,最终统计满足条件的组合数目。虽然直观,但时间复杂度为 O(2^n),在输入规模较大时效率较低。
转变思路:动态规划
接下来,我们将这道题转换为动态规划的问题。这里有一个巧妙的转换,让我们一起来看。
问题转换
其实,这个问题可以转化为一个子集划分问题,而子集划分问题又是一个典型的背包问题。
首先,如果我们把 nums
划分成两个子集 A
和 B
,分别代表分配 +
的数和分配 -
的数,那么他们和 target
存在如下关系:
sum(A) - sum(B) = target
sum(A) = target + sum(B)
sum(A) + sum(A) = target + sum(B) + sum(A)
2 * sum(A) = target + sum(nums)
我们可以将问题转换为找到两个子集,使得一个子集和为 (sum + target) / 2
,另一个子集和为 sum - (sum + target) / 2
,其中 sum
是数组 nums
的总和。
状态定义
-
定义
dp[i][j]
表示使用前i
个数字,是否可以组成和为j
的子集数。
状态转移方程
-
如果不使用当前数字
nums[i-1]
,即dp[i][j] = dp[i-1][j]
-
如果使用当前数字
nums[i-1]
,即dp[i][j] = dp[i-1][j-nums[i-1]]
(前提是j >= nums[i-1]
)
二维动态规划实现
int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int n : nums) sum += n;
// 这两种情况,不可能存在合法的子集划分
if (sum < Math.abs(target) || (sum + target) % 2 == 1) {
return 0;
}
return subsets(nums, (sum + target) / 2);
}
int subsets(int[] nums, int sum) {
int n = nums.length;
int[][] dp = new int[n + 1][sum + 1];
dp[0][0] = 1; // 初始状态,和为 0 的子集只有一个,即空集
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= sum; j++) {
if (j >= nums[i-1]) {
dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
} else {
dp[i][j] = dp[i-1][j];
}
}
}
return dp[n][sum];
}
压缩到一维动态规划
为了进一步优化空间复杂度,我们可以将二维数组压缩到一维数组。
对照二维 dp
,只要把 dp
数组的第一个维度全都去掉就行了,唯一的区别就是这里的 j
要从后往前遍历,原因如下:
因为二维压缩到一维的根本原理是,dp[j]
和 dp[j-nums[i-1]]
还没被新结果覆盖的时候,相当于二维 dp
中的 dp[i-1][j]
和 dp[i-1][j-nums[i-1]]
。
那么,我们就要做到:在计算新的 dp[j]
的时候,dp[j]
和 dp[j-nums[i-1]]
还是上一轮外层 for 循环的结果。
如果你从前往后遍历一维 dp
数组,dp[j]
显然是没问题的,但是 dp[j-nums[i-1]]
已经不是上一轮外层 for 循环的结果了,这里就会使用错误的状态,当然得不到正确的答案。
也就是说。在将二维动态规划压缩到一维动态规划时,需要确保在计算新的 dp[j]
时,dp[j]
和 dp[j - nums[i-1]]
不会在本轮内循环中被更新。这就是为什么我们在一维动态规划中需要从后往前遍历的原因。
一维动态规划实现
int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int n : nums) sum += n;
// 这两种情况,不可能存在合法的子集划分
if (sum < Math.abs(target) || (sum + target) % 2 == 1) {
return 0;
}
return subsets(nums, (sum + target) / 2);
}
/* 计算 nums 中有几个子集的和为 sum */
int subsets(int[] nums, int sum) {
int n = nums.length;
int[] dp = new int[sum + 1];
// base case
dp[0] = 1;
for (int i = 1; i <= n; i++) {
// j 要从后往前遍历
for (int j = sum; j >= 0; j--) {
// 状态转移方程
if (j >= nums[i-1]) {
dp[j] = dp[j] + dp[j-nums[i-1]];
} else {
dp[j] = dp[j];
}
}
}
return dp[sum];
}
详细讲解:从回溯到动态规划的旅程
1. 从回溯到动态规划的转变
最初的回溯法思路简单直观,但其时间复杂度为 O(2^n),在处理大规模数据时效率低下。为了提高效率,我们需要找到一种方法,将其转变为动态规划问题。
2. 问题转换的关键
通过将问题转换为背包问题,我们引入了动态规划的思想。具体来说,我们将问题转化为寻找两个子集,使得其中一个子集和为 (sum + target) / 2
。
3. 状态定义与转移
-
状态定义:
dp[i][j]
表示使用前i
个数字,是否可以组成和为j
的子集数。 -
状态转移方程:通过递推关系,逐步求解子问题,从而解决原问题。
4. 压缩到一维的优化
为了进一步优化空间复杂度,我们将二维动态规划压缩到一维动态规划。这一过程的关键在于从后往前更新数组,确保在计算新的 dp[j]
时,dp[j]
和 dp[j - nums[i]]
仍然是上一轮的结果,避免状态覆盖问题。
二维动态规划
在二维动态规划中,状态转移方程是: [ dpi = dpi-1 + dpi-1] ]
这个方程表明:
-
dp[i][j]
是在第i
轮计算的。 -
dp[i-1][j]
和dp[i-1][j-nums[i-1]]
都是上一轮(即第i-1
轮)计算的结果。
一维动态规划
在一维动态规划中,状态转移方程是: [ dp[j] = dp[j] + dp[j-nums[i]] ]
为了确保在计算新的 dp[j]
时,dp[j]
和 dp[j - nums[i]]
都是上一轮的结果,我们需要从后往前遍历 dp
数组。这样可以保证 dp[j - nums[i]]
在当前轮次还没有被更新。
举例说明
假设 nums = [1, 2, 3]
和 sum = 4
。
从后往前遍历
-
初始化:
dp = [1, 0, 0, 0, 0]
-
遍历
nums[0] = 1
:for (int j = 4; j >= 1; j--) { dp[j] += dp[j - 1]; }
更新后:
dp = [1, 1, 0, 0, 0]
-
遍历
nums[1] = 2
:for (int j = 4; j >= 2; j--) { dp[j] += dp[j - 2]; }
更新后:
dp = [1, 1, 1, 1, 0]
-
遍历
nums[2] = 3
:for (int j = 4; j >= 3; j--) { dp[j] += dp[j - 3]; }
更新后:
dp = [1, 1, 1, 2, 1]
从前往后遍历
-
初始化:
dp = [1, 0, 0, 0, 0]
-
遍历
nums[0] = 1
:for (int j = 1; j <= 4; j++) { dp[j] += dp[j - 1]; }
更新后:
dp = [1, 1, 1, 1, 1]
-
遍历
nums[1] = 2
:for (int j = 2; j <= 4; j++) { dp[j] += dp[j - 2]; }
更新后:
dp = [1, 1, 2, 2, 2]
-
遍历
nums[2] = 3
:for (int j = 3; j <= 4; j++) { dp[j] += dp[j - 3]; }
更新后:
dp = [1, 1, 2, 3, 3]
通过对比可以看出,从前往后遍历会导致在当前轮次中使用已经更新过的 dp[j - nums[i]]
,从而得到错误的结果。而从后往前遍历可以确保在计算新的 dp[j]
时,dp[j]
和 dp[j - nums[i]]
都是上一轮的结果。
总结
-
从后往前遍历:确保在计算新的
dp[j]
时,dp[j]
和dp[j - nums[i]]
仍然是上一轮循环的结果,避免在当前轮次中使用已更新的值。 -
从前往后遍历:可能会使用当前轮次已经更新的
dp[j - nums[i]]
,导致错误的结果。
希望这个解释能够帮助你更好地理解为什么在一维动态规划中需要从后往前遍历。如果还有任何问题,请随时告诉我!
总结
通过这道“目标和”问题,我们展示了如何从回溯法转换为动态规划。这不仅提高了算法的效率,也拓宽了我们的思路。在算法的世界里,转换思维方式,寻求优化方案,是解决复杂问题的重要手段。
希望这篇博客能够帮助你理解背包问题的变种及其动态规划的应用。如果你有任何问题或建议,欢迎在评论区留言。