题意
给你一个整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 '+' 或 '-' ,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 '+' ,在 1 之前添加 '-' ,然后串联起来得到表达式 "+2-1" 。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
提示:
1 <= nums.length <= 20
0 <= nums[i] <= 1000
0 <= sum(nums[i]) <= 1000
-1000 <= target <= 1000
思路分析
对于给定的数组nums,每个元素添加+或-两种符号,再将它们串联起来构造成为一个表达式,使得表达式的最终计算结果 为target,要求得这样子不同的表达式的数目。本题看起来和前面的 分割等和子集 与 最后一块石头的重量II 是类似的,分割等和子集 中是将给定的数组分成两个子集,最后一块石头的重量II 中是将给定的stone数组分成两堆,本题中也可将nums分两部分:添加+的部分 以及 添加-的部分。
设 为 数组的元素和、添加+的元素的和为 、添加-的元素的和为 ,则:
,可得: 。题目给定的数组为非负整数,则 、 、 都为非负整数。
那么可得目标和:,其中为添加+的部分,对加上 - 后表达式计算的结果为。对进行变化得: ,、 都为常数,这两个量确定后也就确定了。
因此,考虑数组的全部元素,可以组成 的方案数与原题所求的不同表达式的数目是相同的。
为什么考虑数组 的全部元素,可以组成 的方案数与原题所求的不同表达式的数目是相同的?
整个表达式中,数组的元素要么是添加+号要么是添加-号,如果添加+号的元素确定了,那么添加-号的元素也就确定了,上面已经确定了,同样也是可以确定的(详见下面有解释),那么就是 在给定的数组选出一些数用于添加+号,这些被选出的数的和已经确定了为 (或者在给定的数组选出一些数用于添加-号,这些被选出的数的和已经确定了为),问组成之后的数的和为的不同的方案数有多少种(或和为的不同的方案数有多少种)。这里分出来的两种情况二选一去解决本题即可。
如果添加+或添加-的方案数确定了,原题所求的数目也就确定了,因为添加完+剩下的数就都是添加-,或添加完-剩下的数就都是添加+。
因此,考虑数组,可以组成的方案数 和 原题所求的不同表达式的数目是相同的。
是组成 或 都可以吗?
都可以的。
若用 、 来表示 ,则式子为:,变换后得到:;
若用 、 来表示 ,则式子为:,变换后得到:。
后面的步骤无论是使用 还是 都是可以的,只有一些细节处理上差别,这两种都会在后面的代码中体现并解释差别。
写了这么多,是为了把 为什么本题可以使用01背包解决 给写细一些。问题转换为01背包问题后,计算出的 或 就是背包容量,那么就是要从 数组中选出一些数组成 或 ,求有多少种不同的组法/方案数。
动态规划
本题用二维数组及一维数组两种实现。先使用二维数组利于理解,用一维数组用于节省内存空间。且使用作为01背包中背包的容量。
使用二维数组
确定dp数组含义
代表:考虑数组 中下标为 0~ i 的元素,组成 和为 j 的不同的方案数。其中 的取值范围为,这和数组的初始化相关。
注意本题 的含义与前面做过的题目不同,分割等和子集中是 最大的求和结果为 j 的情况下,考虑 数组中下标为 0~i 的元素,所组成的子集的元素之和最大为;最后一块石头的重量II中是 在最大质量为 j 的情况下,考虑 数组中下标为0~i 的元素,所组成的一堆石头的质量之和最大为。这两题的 的含义都和给定数组元素的值直接相关,但本题的数组元素是 数,而 是 方案数,不直接相关,这点需要特别注意,也因为这一区别,递推公式也会不同。
确定递推公式
既然是01背包,那在满足一定条件下就有 选 与 不选 两种选择。满足什么条件?
如果 ,代表当前元素的值超过最大的元素和的限制值(即背包的容量),一定不能选取该,则递推公式为:;
如果 ,那就有 选 与 不选 两种选择,若 不选 来组成 ,则递推公式为 ;若 选 来组成,则递推公式为:。因为是求 方案数 ,因此将这两者相加即这种情况下 的值:。
因为 数组 这一行是作为起始条件的,真正开始遍历是从 开始遍历,因此 数组与 数组的下标间相差 1,当遍历到 时应当是在遍历 数组下标为 的元素。
综上,递推公式为:
dp[i][j] = dp[i - 1][j];
if (j >= nums[i - 1]) dp[i][j] += dp[i - 1][j - nums[i - 1]];
初始化dp数组
本题需要初始化 。 代表 不考虑数组元素的情况下,组成 0 的方案数为1。
为什么初始化为 1 ?
在确定dp数组含义中写到了:本题的 是 方案数,一定要有一个来作为 "方案数的开头 1",这个开头一定不能是 0,这个的原因可以在纸上根据递推公式推导一下 数组就知道了:
如果 ,往后推导出的 就都为 0,结果也为0。另外,原因之二,因为组成0的方案数就是 不选 这一种。
其余 的 需要初始化吗?
如果默认值不为0则需要初始化为0。下, 表示:不考虑数组元素的情况下,组成 的方案数为0。举个例子,当 时,由于不考虑数组元素即不从数组中选取元素来组成 ,那么方案数就是0。
为什么 就不为0而是1呢?原因之一已经在上面解释过,因为后续的推导需要它为 1,原因之二,因为组成0的方案数就是 不选 这一种,而不从数组选择数却组成大于0的数是不可能的。
于是,最后数组的初始化如下:
dp[0][0] = 1;
遍历顺序
二维 数组的遍历顺序可以是 先遍历背包再遍历物品 或 先遍历物品再遍历背包,即可以是 外层for循环遍历 内层for循环遍历 或 外层 for 循环遍历 内层for循环遍历 。因为 来源于 数组中的上一行左上的元素,两层循环都是从大到小遍历。
// 外层for循环遍历subSum 内层for循环遍历nums
for (int j = 0; j <= subSum; j++) {
for (int i = 1; i <= nums.length; i++) {
dp[i][j] = dp[i - 1][j];
if (j >= nums[i - 1]) dp[i][j] += dp[i - 1][j - nums[i - 1]];
}
}
// 外层 for 循环遍历nums 内层for循环遍历subSum
for (int i = 1; i <= nums.length; i++) {
for (int j = 0; j <= subSum; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= nums[i - 1]) dp[i][j] += dp[i - 1][j - nums[i - 1]];
}
}
遍历中取到了,因为数组与数组的下标相差1,这在写 数组初始化时有示意图。
举例推导dp数组
使用一维数组
为节省空间,可优化二维数组为一维数组。使用一维数组下, 数组的含义、递推公式、初始化都和二维数组相似。
dp数组的含义
代表:考虑数组 中下标为 0~ i 的元素,组成 和为 j 的不同的方案数。其中 的取值范围为。
递推公式
由于使用一维数组,因此每次遍历 的过程都会覆盖原先的值。
当 时,;
当 时,.
综上得出一维数组下的递推公式为:
if (j >= nums[i - 1]) dp[j] += dp[j - nums[i - 1]];
初始化dp数组
一维数组下 的初始化和二维数组是一样的,初始化 。
遍历顺序
一维数组和二维数组的遍历顺序是不太一样的,这点在写 0-1 背包理论篇(下)中已经写到。一维数组下,背包的遍历顺序为:先遍历数组再遍历,遍历是从大到小遍历的。
需要注意的一点是:不能取到 ,因为初始未开始遍历时一维的 数组即为初始状态,与二维数组的情况相同。
for (int i = 0; i < nums.length; i++) {
for (int j = leftSum; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
举例推导dp数组
完整Java代码实现
/***
使用一维dp数组
subSum作为背包容量
*/
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int i : nums) sum += i;
if (target > sum) return 0;//目标和比数组元素和都大 方案数为0
if ((sum - target) % 2 != 0) return 0;//sum - target可被2整除才能在数组中取元素组成subSum
int subSum = (sum - target) / 2;
int[] dp = new int[subSum + 1];
dp[0] = 1;
for (int i = 0; i < nums.length; i++) {
for (int j = subSum; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[subSum];
}
}
/***
addSum作为背包容量
*/
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int i : nums) sum += i;
//addSum作为背包容量要注意sum+target是否小于0
if (target > sum || sum + target < 0) return 0;
if ((sum + target) % 2 != 0) return 0;
int addSum = (sum + target) / 2;
int[] dp = new int[addSum + 1];
dp[0] = 1;
for (int i = 0; i < nums.length; i++) {
for (int j = addSum; j >= nums[i]; j--) {
dp[j] += dp[j - nums[i]];
}
}
return dp[addSum];
}
}
/***
使用二维dp数组
*/
class Solution {
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for (int i : nums) sum += i;
if (target > sum || sum + target < 0) return 0;
if ((sum + target) % 2 != 0) return 0;
int leftSum = (sum + target) / 2;
int[][] dp = new int[nums.length + 1][leftSum + 1];
dp[0][0] = 1;
for (int i = 1; i <= nums.length; i++) {
for (int j = 0; j <= leftSum; j++) {
dp[i][j] = dp[i - 1][j];
if (j >= nums[i - 1]) dp[i][j] += dp[i - 1][j - nums[i - 1]];
}
}
return dp[nums.length][leftSum];
}
}
《代码随想录》刷题记