目录
一、做题心得
今天是代码随想录打卡的第22天,也是成功来到了回溯章节。回溯的话,其实之前二叉树递归思路讲解的时候也有提到,回溯基本就是基于递归之下的一个实现。今天的题应该算是很经典的回溯的应用了:组合问题。作为回溯的入门,今天也是通过做题理解到了很多相关的知识,还有就是,模板的使用。
话不多说,直接开始今天的内容。
二、回溯基础知识
1.定义
回溯算法(Backtracking Algorithm)是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会通过在上一步进行一些变化来丢弃该解,即“回溯”到上一步,并尝试另一个候选解。这个过程一直进行,直到找到所有解或确定无解为止。
2.适用问题
回溯算法常用来解决那些可以分解为多个步骤或子问题的复杂问题,特别是在这些子问题之间存在相互依赖关系,且每个子问题的解空间可以明确地枚举出来时。它特别适用于寻找问题的所有解,而不是单一最优解的情况。
比如:
-
排列组合问题:如全排列、组合、子集、幂集等问题,可以通过递归地尝试每个可能的元素来构建解。
-
分割问题:如将集合划分为满足特定条件的子集,这类问题可以通过回溯来尝试不同的划分方式。
-
棋盘问题:如八皇后问题、N皇后问题、骑士巡逻(骑士在棋盘上遍历每个格子恰好一次)等,这类问题涉及在二维空间上放置或移动对象以满足特定条件。
-
图的着色问题:给定一个图,要求用最少的颜色给图中的每个顶点着色,使得任意两个相邻的顶点颜色不同。
-
布尔满足性问题(SAT):确定是否存在一组布尔变量的赋值,使得给定的布尔表达式为真。
-
子集和问题:从给定的整数数组中找出所有可能的子集,使得子集中的元素之和等于一个特定的目标值。
-
路径寻找问题:如迷宫问题、旅行商问题(TSP)的近似解可以通过回溯法得到(虽然TSP的精确解通常使用动态规划或其他更高效的算法)。
-
字符串处理问题:如字符串的排列、生成所有可能的括号组合等。
-
决策树/游戏树的遍历:在决策制定过程中,如棋类游戏或任何需要评估多个可能选择并作出最佳决策的场景中,回溯算法可以用来遍历所有可能的决策路径。
回溯法的本质还是枚举,效率并不高,但是为了解决以上这些问题,回溯依旧是最优的选择。
3.一个思想
回溯的搜索遍历过程类似于对树的搜索遍历:
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
树的宽度我们可以通过横向遍历实现:for循环
树的深度我们可以通过纵向遍历实现:递归
4.代码实现
回溯算法的实现往往采取同一套适用的模板,每个人的做题习惯不同,可能会存在一些差异,但是终归做题的思路是无异的。这里给出一套代码随想录回溯部分的模板:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
以上模板的具体含义以及如何通过这个模板实现解决各种问题,我将会在后边几道题中提到。
三、题目与题解
题目一:77. 组合
题目链接
给定两个整数
n
和k
,返回范围[1, n]
中所有可能的k
个数的组合。你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]示例 2:
输入:n = 1, k = 1 输出:[[1]]提示:
1 <= n <= 20
1 <= k <= n
题解:回溯
作为回溯章节的第一道题,也是解决组合问题中最经典的一道题,我们可以通过这道题初步感受一下回溯是如何实现的。
- 定义全局变量:
- ans:一个二维向量,用于存储所有生成的组合结果。每个内部向量代表一个组合。
- vec:一个一维向量,用于在回溯过程中临时存储当前正在构建的组合。
- 回溯函数:backtrack(int n, int k, int start):
- 参数 n 表示可选数字的上限(即从1到n中选择)。
- 参数 k 表示每个组合中应包含的元素数量。
- 参数 start 表示当前搜索的起始位置,用于避免生成重复的组合并优化搜索空间。
- 终止条件:
- 当vec的大小等于k时,说明已经找到了一个符合条件的组合,将其加入到ans中,并返回上一层递归(即return)。
- 递归搜索与剪枝:
- 使用一个循环从 start 开始遍历到 n - (k - vec.size()) + 1。这里的剪枝操作 n - (k - vec.size()) + 1 是为了提前结束不必要的搜索,因为如果剩余可选的数字不足以构成长度为 k 的组合,那么就没有必要继续搜索。
- 在循环内部,将当前数字 i 添加到 vec 中,然后递归调用 backtrace 函数,并将搜索的起始位置设为 i + 1(避免重复使用同一个数字)。
- 递归返回后,通过vec.pop_back()撤销vec中的最后一个元素,实现回溯,以便尝试其他可能的组合。
- 主函数:combine(int n, int k):
- 初始化ans和vec,并调用backtrace函数从数字1开始构建组合。
- 返回所有生成的组合结果ans
我对代码也进行了详细注释,代码如下:
class Solution {
public:
vector<vector<int>> ans; //用于存放所有生成的组合结果
vector<int> vec; //用于存放当前正在构建的组合
void backtrack(int n, int k, int start) { //回溯函数,用于递归地生成组合
if (vec.size() == k) { //终止条件:组合的大小等于k
ans.push_back(vec);
return; //找到了一个有效组合,返回上一级递归
}
for (int i = start; i <= n - (k - vec.size()) + 1; i++) { //从start开始遍历,i表示某次搜索的起始位置(注意剪枝操作的优化)
vec.push_back(i); //处理节点
backtrack(n, k, i + 1); //递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
vec.pop_back(); //回溯,撤销处理的节点
}
}
vector<vector<int>> combine(int n, int k) {
backtrack(n, k, 1); //调用回溯函数从数字1开始构建组合
return ans;
}
};
当然,剪枝操作主要是为了降低复杂度,如果实在想不到剪枝,也可以直接 for 循环从 i = start 到 i = n。
题目二:216.组合总和III
题目链接
找出所有相加之和为
n
的k
个数的组合,且满足下列条件:
- 只使用数字1到9
- 每个数字 最多使用一次
返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。
示例 1:
输入: k = 3, n = 7 输出: [[1,2,4]] 解释: 1 + 2 + 4 = 7 没有其他符合的组合了。示例 2:
输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]] 解释: 1 + 2 + 6 = 9 1 + 3 + 5 = 9 2 + 3 + 4 = 9 没有其他符合的组合了。示例 3:
输入: k = 4, n = 1 输出: [] 解释: 不存在有效的组合。 在[1,9]范围内使用4个不同的数字,我们可以得到的最小和是1+2+3+4 = 10,因为10 > 1,没有有效的组合。提示:
2 <= k <= 9
1 <= n <= 60
题解:回溯
这个题有了上一道题的基础其实就比较简单了,套我们提到的代码实现的模板。个人感觉唯一和上一道题不同的就是终止条件的地方了--即 if 判断语句那里。
这里先看看我自己写的代码(个人感觉更好理解,毕竟就是上一道题的变形,模板的套用):
并没有想到具体的剪枝的操作,只是套用模板照猫画虎给整出来了,还有击败100%,有点意外。
不过如要进行剪枝的话,我们只需要提前加上判断语句:判断目前取到的值的和是否大于目标值,若大于,则返回递归。
代码如下:
class Solution {
public:
vector<vector<int>> ans;
vector<int> vec;
void backtrace(int k, int n, int start) {
int sum = 0;
for (int i = 0; i < vec.size(); i++) { //对当前集合所有元素求和
sum += vec[i];
}
if (sum == n && vec.size() == k) { //当前组合元素和为n 且 组合size为k时就满足要求,放入ans中
ans.push_back(vec);
return;
}
if (sum > n) { //(关键)剪枝:已选取元素求和大于目标值时,就无需继续添加元素,直接返回递归
return;
}
for (int i = start; i <= 9; i++) {
vec.push_back(i);
backtrace(k,n,i + 1); //递归:纵向遍历,注意是从下一层i+1开始
vec.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtrace(k,n,1);
return ans;
}
};
这里我们再看看代码随想录的代码,或许更适合你的思路:
class Solution {
public:
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
void backtracking(int targetSum, int k, int sum, int startIndex) {
if (sum > targetSum) return; //剪枝操作
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
sum += i; // 处理
path.push_back(i); // 处理
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(n, k, 0, 1);
return result;
}
};
题目三:17.电话号码的字母组合
题目链接
给定一个仅包含数字
2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例 1:
输入:digits = "23" 输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]示例 2:
输入:digits = "" 输出:[]示例 3:
输入:digits = "2" 输出:["a","b","c"]提示:
0 <= digits.length <= 4
digits[i]
是范围['2', '9']
的一个数字。
题解:回溯
这道题的话,看上去就要难不少了。
其实题意挺简单,就是将给定的字符串中的数字全部转为它们可以表示的字母替换:这里需要注意的是,0和1并没有对应的字母,而其他数字分别对应着3个字母即对应着三种情况。
这其实也是组合问题的一种变形,我们首先就是要想到使用回溯。回溯的话,我们套用上述的模板,想清楚终止条件,横向遍历,纵向遍历等等。当然这道题还有一个关键点就是哈希表(哈希映射)的使用,我们要通过哈希表将每个数字对应的字母(由于一个数字对应多个字母,就会是字符串型)存储下来,以便于后续的转换。
class Solution {
public:
vector<string> ans; //用于存储所有可能的字母组合(结果)
string tmp; //用于构建存储当前字母组合
vector<string> hash = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; //哈希(映射)表,将数字映射到对应的字母字符串,注意0和1无效,对应的字母为空
void backtrace(int pos, string digits) { //回溯--用于递归地生成所有可能的字母组合
if (pos == digits.size()) { //终止条件:字符串中所有数字都已经处理完毕
ans.push_back(tmp);
return;
}
int num = digits[pos] - '0'; //取出当前位置(pos:从0开始)的数字(但以字符的形式),并转换为对应的索引num
for (int i = 0; i < hash[num].size(); i++) { //for循环:横向遍历字符串中每个字符
tmp.push_back(hash[num][i]); //处理当前位置
backtrace(pos + 1, digits); //递归:纵向遍历--注意从下个位置pos + 1开始
tmp.pop_back();
}
}
vector<string> letterCombinations(string digits) {
if (digits.size() == 0) return ans; //给定字符串为空
backtrace(0, digits);
return ans;
}
};
四、小结
今天的打卡到此也就结束了,后续回继续进行回溯的相关练习。最后,我是算法小白,但也希望终有所获。