题目描述
解法1:暴力枚举 - 回溯/DFS(溢出)
1. 回溯
经常说回溯算法和递归算法有点类似,都涉及递归,有的问题如果实在想不出状态转移方程,尝试用回溯算法暴力解决也是一个聪明的策略,总比写不出来解法强。
回溯算法其实是一个暴力枚举的算法,模板如下
关键就是搞清楚什么是「选择列表」,而对于这道题,「选择列表」就是正号 + 或者负号 -,然后利用回溯模板穷举出来所有可能的结果,数一数到底有几种组合能够凑出 target
class Solution {
private:
int ans = 0;
public:
void DFS(vector<int>& nums, int index, int rest)
{
if(index == nums.size())
{
if(rest == 0)//正好凑成 S
ans++;
return;
}
//二叉树
//nums[index] 选择 - 号
rest += nums[index];
DFS(nums, index + 1, rest);//穷举 nums[index + 1]
rest -= nums[index];//回溯,撤销选择
//nums[index] 选择 + 号
rest -= nums[index];
DFS(nums, index + 1, rest);//穷举 nums[index + 1]
rest += nums[index];//回溯,撤销选择
}
int findTargetSumWays(vector<int>& nums, int S) {
if(nums.size() == 0) return 0;
DFS(nums, 0, S);
return ans;
}
};
时间复杂度为 O(2^N),N 为 nums 的大小
这是因为这个回溯算法就是个二叉树的遍历问题,树的高度就是 nums 的长度,所以说时间复杂度就是这棵二叉树的节点数,即为 O(2^N),其实是非常低效的,而且我发现提交上去会 int 溢出
最后总结的教训是 如果不是让列举所有情况,或者没有效果很好的剪枝办法,还是不要轻易选择回溯。
2. DFS
可以把数组中每个数字前面都用负号和正号,然后进行组合的求和,并判断这个和是否会等于S,然后就标记,最后统计出等于S的组合个数就好了。
具体使用dfs,就是一个前序遍历二叉树的实现,递归地+或-每个元素,到所有元素都遍历完成的时候,最后那个判断target是否等于零。
class Solution {
public:
int DFS(vector<int>& nums, uint index, int rest)
{
if(rest == 0 && index == nums.size()) return 1;
if(index >= nums.size()) return 0;
int ans = 0;
ans += DFS(nums, index + 1, rest + nums[index]);
ans += DFS(nums, index + 1, rest - nums[index]);
return ans;
}
int findTargetSumWays(vector<int>& nums, int S) {
if(nums.size() == 0) return 0;
return DFS(nums, 0, S);
}
};
时间复杂度为 O(2^N),N 为 nums 的大小。也是溢出。
解法2:动态规划(01背包问题)
首先,如果我们把 nums 划分成两个子集 A 和 B,分别代表分配 + 的数和分配 - 的数,这个问题就转化为一个子集划分问题,而子集划分问题又是一个典型的背包问题。动态规划总是这么玄学,让人摸不着头脑……
A、B 和 target 存在如下关系:
综上,可以推出 sum(A) = (target + sum(nums)) / 2,也就是把原问题转化成:nums 中存在几个子集 A,使得 A 中元素的和为 (target + sum(nums)) / 2?
这就变成了01背包问题:背包容量为 (target + sum(nums)) / 2,有 n 个物品,第 i 个物品的重量为 nums[i - 1](注意 1 <= i <= N),每个物品只有一个,问有几种不同的方法能够恰好装满这个背包
1. 确定「状态」和「选择」以及 dp数组以及下标的含义
状态就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。
dp[i][j] = x 表示,只在前 i 个物品中选择,当前背包的容量为 j,最多有 x 种方法可以恰好装满背包。
2. 确定递推公式
递推公式:dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i-1]];
dp[i-1][j] 表示不选第 i 个物品容量正好为 j;dp[i-1][j-nums[i-1]] 表示选上第 i 个物品容量正好为 j
3. dp数组如何初始化
dp[0][0]=1(递归结束出口),dp[0][1:]=0(无效状态)
4. 确定遍历顺序
- 如果求组合数就是外层for循环遍历物品,内层for遍历背包。如 518.零钱兑换II
- 如果求排列数就是外层for遍历背包,内层for循环遍历物品。如377. 组合总和 Ⅳ
组合 vs 排列
5 = 2 + 2 + 1
5 = 2 + 1 + 2
这是一种组合,都是 2 2 1。但是是两种排列。组合不强调元素之间的顺序,排列强调元素之间的顺序。
本题是求组合数。
5. 举例推导dp数组
略
二维写法
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
if(nums.size() == 0) return 0;
int n = nums.size(), sum = 0;
for(int i : nums) sum += i;
if(S > sum || (S + sum) % 2) return 0;//此时没有方案
int bagSize = (S + sum) / 2;
vector<vector<int> > dp(n + 1, vector<int>(bagSize + 1, 0));
dp[0][0] = 1;
for(int i = 1; i <= n; i++)//遍历物品
for(int j = 0; j <= bagSize; 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][bagSize];
}
};
一维写法
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
if(nums.size() == 0) return 0;
int n = nums.size(), sum = 0;
for(int i : nums) sum += i;
if(S > sum || (S + sum) % 2) return 0;//此时没有方案
int bagSize = (S + sum) / 2;
vector<int> dp(bagSize + 1, 0);
dp[0] = 1;
for(int i = 0; i < n; i++)
for(int j = bagSize; j >= nums[i]; j--)
dp[j] += dp[j - nums[i]];
return dp[bagSize];
}
};
01背包的一维写法内部循环必须倒序,完全背包必须正序。背包问题一般的套路模板
如果 bagSize = (S + sum) / 2; 想不到怎么办
也可以转化为01背包问题
1. 确定「状态」和「选择」以及 dp数组以及下标的含义
状态就是「背包的容量」和「可选择的物品」,选择就是不再是nums[i]的选与不选,而是nums[i]是加还是减
dp[i][j] = x 表示,只在前 i 个物品中选择,当前背包的容量为 j,最多有 x 种方法可以恰好装满背包。
2. 确定递推公式
递推公式:dp[i][j] = dp[i-1][j + nums[i-1] + dp[i-1][j - nums[i-1]];
可以理解为nums[i-1]这个元素我可以执行加,还可以执行减,那么我dp[i][j]的结果值就是加/减之后对应位置的和。
也可以写成递推的形式:
dp[i][j + nums[i]] += dp[i - 1][j]
dp[i][j - nums[i]] += dp[i - 1][j]
3. dp数组如何初始化
按上面的定义,dp[i][j] 的值在 [-sum, sum](sum是nums数组元素之和),为了防止负数下标不被识别,可以同时+sum,也就是 -sum -> 0; 0 -> sum; sum -> 2*sum
dp[0][sum]=1(即dp[0][0] = 1,递归结束出口),dp[0][1:]=0(无效状态)
4. 确定遍历顺序
本题是求组合数。外循环为物品,内循环为背包。
5. 举例推导dp数组
略
二维写法
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
if(nums.size() == 0) return 0;
int n = nums.size(), sum = 0;
for(int i : nums) sum += i;
if(S > sum) return 0;//此时没有方案
int bagSize = 2 * sum;
vector<vector<int> > dp(n + 1, vector<int>(bagSize + 1, 0));
//-sum 被映射到0
// 0 被映射到 sum
// sum 被映射到 2*sum
dp[0][sum] = 1;
for(int i = 1; i <= n; i++)//遍历物品
for(int j = 0; j <= bagSize; j++)//遍历背包
{
int left = (j - nums[i - 1]) >= 0 ? j - nums[i - 1] : 0;
int right = (j + nums[i - 1]) <= bagSize ? j + nums[i - 1] : 0;
dp[i][j] = dp[i - 1][left] + dp[i -1][right];
}
return dp[n][S + sum];
}
};
一维写法
这道题不能只使用一个一维数组,因为在动态转移方程里,每个状态dp[i][j]不仅依赖其上一行左边的值,也依赖上一行右边的值,因此无法实现一个一维数组更新,需要两个一维数组实现滚动更新
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int S) {
if(nums.size() == 0) return 0;
int n = nums.size(), sum = 0;
for(int i : nums) sum += i;
if(S > sum) return 0;//此时没有方案
int bagSize = 2 * sum;
//-sum 被映射到0
// 0 被映射到 sum
// sum 被映射到 2*sum
vector<int> dp(bagSize + 1, 0);
vector<int> cur(bagSize + 1, 0);
dp[sum] = 1;
for(int i = 1; i <= n; i++)
{
for(int j = bagSize; j >= 0; j--)
{
int left = (j - nums[i - 1]) >= 0 ? j - nums[i - 1] : 0;
int right = (j + nums[i - 1]) <= bagSize ? j + nums[i - 1] : 0;
cur[j] = dp[left] + dp[right];
}
dp.swap(cur);
}
return dp[S + sum];
}
};