前序
在做这题之前,有必要先来看看最简单的0-1背包问题,因为这两道题目有很深的关联,有相似但也有差异。本文先介绍动态规划求解0-1背包,再详述动态规划求解数组变号问题,并说明两者不同点,最后给出另一种思路——递归求解该问题的方法。
0-1背包问题
问题描述:给定N件物品,它们的重量用数组weights[0…N-1]表示,价值用values[0…N-1]表示,现在给定背包所能承受的重量为W,求背包可装下物品的最大价值是多少?
解题思路:这是一道最经典的二维动态规划问题(经过优化也可以降维)。动态规划的题目都需要至少创建一个一维数组来保存中间解,因为这类题目每求一个值需要借助前面步骤的结果,其次也是最为重要的是,每道动态规划的题目都需要一个状态转移方程,找出这个方程是解题的关键。
状态转移式:对于这道0-1背包问题,为了方便理解,先不考虑优化问题。创建一个二维数组dp[0…N][0…W],给定i,j,0<i<N+1,0<j<W+1,对于dp[i][j]表示,背包在重量不超过j的情况下装下前i件物品的最大价值,dp[N][W]表示在重量不超过W的情况下装下前N件物品的最大价值,即为所求。假设,第i件物品的价值为v,重量为w,那么对于第i件物品:
- 装得下的情况:dp[i][j] = max{ dp[i-1][j] , dp[i-1][j-w] + v };即当背包当前承载力为j,且j>=w(表示背包能装下重量为w的物品)时,可以考虑装第i件物品或者不装,这取决于装第i件物品时的价值:dp[i-1][j-w]+v 和 不装第i件物品的价值:dp[i-1][j]谁更大;
- 装不下的情况:dp[i][j] = dp[i-1][j],即当背包当前承载力为j,且j<w(表示背包不能装下当前物品)时,对于前i件物品不超过重量j的最大价值dp[i][j]取值为前i-1件物品不超过重量j下的最大价值,即dp[i-1][j];
由以上,可以得出0-1背包的状态转移式为:
dp[i][j] = max{ dp[i-1][j] , dp[i-1][j-w] + v },当j>=w;(或j-w>=0)
dp[i][j] = dp[i-1][j],当j<w;
0-1背包代码如下:
// W 为背包总体积
// N 为物品数量
// weights 数组存储 N 个物品的重量
// values 数组存储 N 个物品的价值
public int knapsack(int W, int N, int[] weights, int[] values) {
int[][] dp = new int[N + 1][W + 1];
for (int i = 1; i <= N; i++) {
int w = weights[i - 1], v = values[i - 1]; //循环遍历N个物品
for (int j = 1; j <= W; j++) {
if (j >= w) {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w] + v);
} else {
dp[i][j] = dp[i - 1][j];
}
}
}
return dp[N][W];
}
看完0-1背包问题,对于改变一数组的正负符号其和为给定值的问题也可以转化为0-1背包问题求解,需要注意的是使用动态规划,其dp[i][j]表达的含义有所变化,因此初始化dp数组以及状态转移方程也与0-1背包问题不同。
改变一组数的正负符号使其和为给定值
问题描述:对于给定的数组nums[0…n],其中数值为非负数(可以为0),可任意改变每个数的正负符号,求有多少种改变方法使得数组的和为给定值S?
输入示例:
nums[] = {1,1,1,1,1}
S = 3
输出示例:
5
表示有3种改变方法使得数组和为5:
-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
解题思路①:这题可以变换为求数组中有多少个子集的和为某个给定值,从而使用0-1背包方法求解。变换方法:将数组看成X,Y两个子集,sum(nums) = sum(X)+sum(Y)则:
sum(X) - sum(Y) = S;
sum(X) + sum(Y) + sum(X) - sum(Y) = S + sum(X) + sum(Y);由1式两边同时加上sum(X)和sum(Y);
2*sum(X) = S + sum(nums);
由上面3个等式可得:sum(X) = (S + sum(nums))/2 ; 上面的等式可以理解为X子集全部为正号,Y子集全部为负号,则可满足题意。那么由此原问题转化为求数组中某一子集X的和为(S + sum(nums))/2,这样的子集X有多少个?
0-1背包求解方法:二维数组dp[0…N][0…M],N为给定数组的长度加1,M为(S + sum(nums))/2+1;这里将nums数组看成是物品重量,求解背包重量为(S + sum(nums))/2+1的情况下的装法有几种。那么dp[i][j]的含义就应该是背包重量严格等于j的情况下前i中物品有多少种装法,dp[nums.length][(S+sum)/2]表示装下nums数组中的所有物品使得背包重量为(S+sum)/2的装法,即为所求。这里与0-1背包问题里的dp数组含义有很大差别,0-1背包中dp[i][j]表示背包重量不超过j的情况下前i件物品能装下的最大价值,重点应该关注的是,该问题求解的是严格等于j的情况,而0-1背包则是不超过j的情况,另外dp[i][j]的含义随题意变化就不必多言了。
状态转移式:由dp[i][j]的含义可分析出,在背包重量严格等于j的情况下讨论能不能装下第i件物品,假设第i件物品重量为w=nums[i]:
-
当j>=w时,表示能装下第i件物品:dp[i][j] = dp[i-1][j] + dp[i-1][j-w],表示在背包重量严格等于j的情况前i种物品的装法等于背包重量严格等于j的情况下前i-1种物品装法,即dp[i-1][j],这表示没装第i件物品的装法,加上背包重量严格等于j-w的情况下前i-1种物品的装法,即dp[i-1][j-w],这表示装了第i件物品的装法;两种装法的和即为背包重量为j并且可以装下第i件物品时的装法。总结来说,就是在背包能装下第i件物品时,装满重量j的装法等于装下第i间物品(且背包重量为j)的装法加上不装第i件物品(且背包重量为j)的装法。
-
当j<w时,表示不能装下第i件物品:dp[i][j] = dp[i-1][j],表示背包重量严格等于j的情况下前i种物品的装法等于背包重量严格等于j的情况下前i-1种物品的装法;
根据以上分析得到状态转移方程(w为第i件物品的重量即nums[i]):
dp[i][j] = dp[i-1][j] + dp[i-1][j-w],j>=w;
dp[i][j] = dp[i-1][j],j<w;
dp数组初始化:根据dp[i][j]的含义对dp进行初始化:dp[0][0]=1,装下前0件物品使得背包重量为0的装法有1种;那么对于每一件物品i的dp[i][0]有dp[i][0] = 1,表示装下前i件物品使得背包重量为0的装法有1种(就是不装),这里和状态转移方程中的dp[i][j] = dp[i-1][j]对应。该问题在dp的初始化上和0-1背包也不同,0-1背包中dp[i][0]表示不超过重量0时背包装下前i件物品的最大价值,显然dp[i][0]=0;而dp[0][j]表示不超过重量j时装下前0件物品的最大价值,显然dp[0][j]=0;
public int findTargetSumWays(int[] nums, int S) {
int sum = 0;
for (int num : nums) {
sum += num;
}
// 背包容量为整数,sum + S为奇数的话不满足要求
if (((sum + S) & 1) == 1) {
return 0;
}
// 目标和不可能大于总和
if (S > sum) {
return 0;
}
sum = (sum + S) >> 1;
int len = nums.length;
int[][] dp = new int[len + 1][sum + 1];
dp[0][0] = 1; //这步初始化不能少
for (int i = 1; i <= len; i++) {
for (int j = 0; j <= sum; j++) {
// 装不下
if (j - nums[i - 1] < 0) {
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][sum];
}
对于这个问题还有另一种解法是递归,也是最容易想到的解法。
解题思路②:对于数组中的每个元素num要么给该元素正号,要么给负号,即有两种情况:S = S+num(给正号)、S = S - num(给负号)那么对于一个元素来说有两种走法,这里可以生成一颗二叉树,关键就看所有元素都走完(即到达叶子结点)时,S是不是等于0;
public int findTargetSumWays(int[] nums, int S) {
return findTargetSumWays(nums,0,S);
}
private int findTargetSumWays(int[] nums,int start,int S){
if(start == nums.length){
if(S==0)return 1;
else return 0;
}else {
return findTargetSumWays(nums,start+1,S+nums[start])
+findTargetSumWays(nums,start+1,S-nums[start]);
}
}
小结:对于以上两个问题,改变数组符号的问题稍加转换可以使用0-1背包的解法,但是根据dp含义的不同状态转移方程不同,dp初始化也不同;两个问题的动态规划都可以压缩空间使用一维数组求解。