回溯法
- 回溯的本质是一种**“试探性搜索”策略**
- 通过“逐步构建解空间,及时剪枝无效路径”来寻找问题的解,或者是枚举所有解
- 最简单的理解,就是对解空间进行深度优先搜索(DFS),每个解都可以构建成一颗搜索树
- 回溯最直接的写法,就是递归
贪心
- 贪心的本质是局部最优解导向全局最优
- 需要满足最优子结构,也就是必须先证明局部最优,一定可以导向全局最优
- 若不满足最优子结构,说明本场景不适用贪心算法
动态规划
- 动态规划的本质是“重叠子问题”和“最优子结构”
- 通过定义状态(存储子问题解)和状态转移方程(复用子问题解)”,以空间换时间
- 动态规划的题目一般都可以用回溯法,只不过数据量大可能会超时,动态规划 ≈ 回溯 + 剪枝
实战举例
leetcode 494.目标和
给你一个非负整数数组 nums 和一个整数 target 。
向数组中的每个整数前添加 +
或-
,然后串联起所有整数,可以构造一个 表达式 :
例如,nums = [2, 1] ,可以在 2 之前添加 +
,在 1 之前添加 -
,然后串联起来得到表达式 +2-1
。
返回可以通过上述方法构造的、运算结果等于 target 的不同 表达式 的数目。
思路拆解
目标和:nums数组里面每个数添加符号,组成目标值target,可以拆解成“+”的集合A,和“-”的集合B
目标和的答案是:sum(A) - sum(B) = target,而total = sum(A) + sum(B),可以推导出sum(A) = (total - target) / 2
题目等价于“找到所有子集A”,抽象层面就是“二元选择”和“累积效应”,满足动态规划,将需要重复计算的结果累计保存
之所以可以这么转化,是因为两个问题的解空间是完全同构的
回溯法
先用回溯法解本题,回溯一般分两种思路
- 输入的视角:“选or不选”
- 答案的视角:每个节点枚举所有可能答案,递归下一个节点
本题采用第一种解法
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
// 计算 sum(A)
int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
// sum(A) 必须是偶数,因为除以2,得出来的结果必须是整数,才能符合题目要求
if (s < 0 || s & 1 != 0) {
return 0;
}
int ans = 0;
function<void(int, int)> dfs = [&](int i, int c) { // i: f(i); c: sum(A) - nums[i]
// 退出条件
if (i < 0) {
// 遍历完成,并且c(容量)满足目标值
if (c == 0) {
ans++;
}
return;
}
// 不选,直接递归下一个节点
dfs(i - 1, c);
// 选,剩余容量减去待选取容量
if (c >= nums[i]) {
dfs(i - 1, c - nums[i]);
}
};
dfs(n - 1, s / 2);
return ans;
}
};
可证明,动态规划的题目,实际上也可以回溯解,只不过回溯会枚举所有的可能解,而本题实际上只需要计算满足解的数量,并不需要体现每一种解法
时间复杂度:O(2ⁿ):nums.length == n,每个元素选或者不选两种可能
空间复杂度:O(n),栈的递归深度
动态规划
记忆化搜索
记忆化搜索(Memoization),本质是自顶向下(Top-Down)的动态规划实现方式
记忆化搜索,采用 递归 + 记忆表(memo) 实现,从原问题递归到子问题
因此可以考虑使用动态规划的解法,上面提到动态规划 ≈ 回溯 + 剪枝
由以上的递归回溯解法,可以推导出一个状态转移方程:f(i, c) = f(i - 1, c) + f(i - 1, c - nums[i])
那么实际上在回溯每个解法的时候,退出条件都是递归到i < 0,也就是遍历完nums[i],才能知道是否符合条件
因此每个f(i)都可能存在被多次重复计算,可以剪枝
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
// 计算 sum(A)
int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
// sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
if (s < 0 || s & 1 != 0) {
return 0;
}
int m = s / 2; // 背包容量
vector memo(n, vector<int>(m + 1, -1)); // 初始化-1,表示没访问过,[0, m]
function<int(int, int)> dfs = [&](int i, int c) -> int { // i: f(i); c: sum(A) - nums[i]
// 退出条件
if (i < 0) {
return c == 0;
}
// 剪枝,不重复计算
if (memo[i][c] != -1) { // c最大就是容量m,不会越界
return memo[i][c];
}
// 剩余容量不足,只能不选
if (c < nums[i]) {
return memo[i][c] = dfs(i - 1, c);
}
return memo[i][c] = dfs(i - 1, c) + dfs(i - 1, c - nums[i]);
};
return dfs(n - 1, m);
}
};
此题转化成动态规划,实现上是一种记忆化搜索,记忆化搜索 = 递归搜索 + 保存计算结果(备忘录),本题本质上也是0-1背包问题,从i == n - 1开始递归计算,依次计算从[i, n - 1]组成容量c(总和c)的方案数,直到边界条件i == 0
时间复杂度:O(nm),n == nums.length,每个nums[i],都有m种背包容量的遍历,状态个数 = m * n
空间复杂度:O(nm),每个状态计算结果都保存
迭代(递推)
递推是动态规划自底向上(Bottom-up)的实现方式
采用 迭代 + 动态规划表(dp数组) 实现,从子问题逐步计算到原问题
因此,本题也有另外一种实现方式
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
// 计算 sum(A)
int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
// sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
if (s < 0 || s & 1 != 0) {
return 0;
}
int m = s / 2; // 背包容量
vector dp(n + 1, vector<int>(m + 1));
dp[0][0] = 1; // sum(A)的子集,表示总和0,也就是空集,算一种解决方案,初始化1
// dp[i][c]表示前i个元素,容量为c的方案数量
for (int i = 0; i < n; i++) {
for (int c = 0; c <= m; c++) {
// 只能不选
if (c < nums[i]) {
dp[i + 1][c] = dp[i][c];
} else {
dp[i + 1][c] = dp[i][c] + dp[i][c - nums[i]]; // i 表示dp的第i + 1元素,因为手动加入了dp[0],总长度是n + 1
}
}
}
return dp[n][m];
}
};
递推,一般采用循环的方式,本题中与记忆化搜索不同的写法是,从i == 0开始遍历,依次计算前i个元素组成容量c(总和c)的方案数,直到边界条件i == n
时间复杂度:O(nm)
空间复杂度:O(nm)
两种写法只是推演方向不同,一种自顶向下(递归),一种自底向上(递推),时间复杂度是一致的,简单讲下两种思路的区别
维度 | 记忆化搜索(递归) | 迭代(递推) |
---|---|---|
计算顺序 | 自顶向下(Top-Down) :原问题->子问题 | 自底向上(Bottom-Up):子问题->原问题 |
实现方式 | 递归 + 记忆表/备忘录(哈希表 / memo数组) | 迭代/循环 + dp数组 |
空间开销 | 额外包含递归栈空间(数据量大可能导致溢出) | 仅需dp表 |
适用场景 | 子问题稀疏型,无需全量计算,“懒加载” | 子问题密集型,需要遍历所有解 |
针对适用场景,举个例子,计算组合数C(n, k)
- 状态转移方程:C(n, k) = C(n - 1, k - 1) + C(n - 1, k)
- 当n = 1000, k = 2时,属于子问题稀疏型,不需要计算全量解,最终只涉及k ≤ 2的解,总数量约1000 * 3
- 当k无限接近n时,就属于子问题密集型
因此,当问题数据规模大,优先选择无递归栈调用的递推方式解决,但是以上两种解法,除了时间复杂度一样,空间复杂度也没区别
实际上,我们可以针对第二种解法,优化空间复杂度
思路拆解
状态转移方程:dp[i + 1][c] = dp[i][c] + dp[i][c - nums[i]]
可见,只需要依赖两个一维的dp数组,而不需要申请n个
所以,可以针对以上递推的代码,实现滚动数组的修改,降低空间复杂度
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
// 计算 sum(A)
int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
// sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
if (s < 0 || s & 1 != 0) {
return 0;
}
int m = s / 2; // 背包容量
vector dp(2, vector<int>(m + 1));
dp[0][0] = 1; // sum(A)的子集,表示总和0,也就是空集,算一种解决方案,初始化1
// dp[i][c]表示前i个元素,容量为c的方案数量
for (int i = 0; i < n; i++) {
for (int c = 0; c <= m; c++) {
// 只能不选
if (c < nums[i]) {
dp[(i + 1) % 2][c] = dp[i % 2][c];
} else {
dp[(i + 1) % 2][c] = dp[i % 2][c] + dp[i % 2][c - nums[i]]; // i 表示dp的第i + 1元素,因为手动加入了dp[0],总长度是n + 1
}
}
}
return dp[n % 2][m];
}
};
实现了,只需要两个滚动数组,依次替换dp保存中间计算结果
时间复杂度:O(nm)
空间复杂度: O(m)
但该题可以继续优化空间,只用一个一维数组搞定,只不过需要注意,必须倒序遍历
class Solution {
public:
int findTargetSumWays(vector<int>& nums, int target) {
int n = nums.size();
// 计算 sum(A)
int s = accumulate(nums.begin(), nums.end(), 0) - abs(target); // target可能是负数
// sum(A) 必须是偶数,因为除以2,的出来的结果必须是整数,才能符合题目要求
if (s < 0 || s & 1 != 0) {
return 0;
}
int m = s / 2; // 背包容量
vector<int> dp(m + 1);
dp[0] = 1; // sum(A)的子集,表示总和0,也就是空集,算一种解决方案,初始化1
// dp[c]表示前i个元素,容量为c的方案数量(i被隐藏)
for (int x : nums) {
// 需要倒序遍历,否则计算dp[c]时,dp[c - x]可能已经被覆盖
for (int c = m; c >= x; c--) {
dp[c] += dp[c - x];
}
}
return dp[m];
}
};
背包问题
背包问题作为动态规划的典型,不可不提
0 - 1背包
题目:有n块物品,每块有不同的价值v和重量w,现有一个容量上限为m的背包,如何使得装入的物品价值最大?
状态转移方程:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
怎么理解状态转移方程呢?
- dp[i][j]表示将前i块物品装入容量为j的背包中的最大价值;
- 如上述所言,动态规划实际上就是减少子问题的计算量,将中间量保存下来,因此需要计算从0开始到n每个物品的动作,即装入或者不装入两种选择(0 <= i <= n);
- 另外,还需要记录从1开始,到背包容量m的每个容量的最大价值,满足前置条件最优化,需要另一层循环(1 <= j <= m);
- 综上所述,0 - 1背包的时间复杂度就是O(n * m);
- 0 - 1背包依然可以采取滚动数组的处理方式,对空间进行降维处理,即空间复杂度为O(m);
代码如下:
int maxValue(vector<int>& weights, vector<int>& values, int m)
{
if (weights.empty() || values.empty()) {
return 0;
}
vector<int> dp(m + 1, 0);
int n = weights.size();
for (int i = 0; i < n; ++i) {
for (int j = m; j >= weights[i]; --j) { // 注意j的遍历顺序
dp[j] = max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[m];
}
为什么j的遍历顺序要从后往前推呢?
- 首先这是空间优化的结果,因为每个物品的装入状态计算,依赖于上一个物品的计算结果,从二维数组形式上看,每个dp[i][j],都依赖于上一层(i - 1)的计算结果;
- 比如dp[i - 1][j - w[i]],而优化后的数组仅有一维,若从左往右计算,会将dp[i - 1][j - w[i]]的值覆盖,造成计算结果错误;
- 通俗的理解,可参照上述题目最短路径和,从表的形式上看,每个数组元素的计算都依赖于上方元素和上一层“靠左”的元素,若从左往右遍历,会先刷新左边的元素(优化成一维),造成后续计算错误;
多重背包
未完待续。。。