第七章 回溯算法 Part 04
文章目录
491. 递增子序列
本题和大家刚做过的 90. 子集 II 非常相似,但也有很多不同,很容易掉进坑里。建议大家结合两题一起理解。
- 题目讲解链接:递增子序列
- 视频讲解:Bilibili 视频
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking( vector<int>& nums ,int startIndex){
if(path.size()>1)
result.push_back(path);
unordered_set<int> used;
for(int i=startIndex;i<nums.size();i++)
{
if (used.find(nums[i]) != used.end())
continue;
if (!path.empty() && nums[i] < path.back())
continue;
used.insert(nums[i]);
path.push_back(nums[i]);
backtracking(nums,i + 1); //继续递归
path.pop_back();//回溯
}
//满足条件,return
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(nums, 0);
return result;
}
};
回溯算法确实主打一个一看就会,一做就废,自己研究了半个上午,总是出各种各样的问题,最后还是在gpt帮助下完成的。
递增子序列的查找:通过回溯算法,遍历数组中的所有可能组合,检查当前路径中的元素是否递增。
去重:使用 unordered_set 记录每一层递归中已经使用过的元素,避免同一层中出现重复子序列。
递归与回溯:递归过程中,继续尝试加入后续元素;通过 path.pop_back() 实现回溯,恢复状态。
用数组来做哈希,效率就高了很多。
注意题目中说了,数值范围[-100,100],所以完全可以用数组来做哈希。
程序运行的时候对unordered_set 频繁的insert,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且每次重新定义set,insert的时候其底层的符号表也要做相应的扩充,也是费事的。数组,set,map都可以做哈希表,而且数组干的活,map和set都能干,但如果数值范围小的话能用数组尽量用数组。
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking( vector<int>& nums ,int startIndex){
if(path.size()>1)
result.push_back(path);
int used[201] = {0};
for(int i=startIndex;i<nums.size();i++)
{
if ((!path.empty() && nums[i] < path.back())|| used[nums[i] + 100] == 1)
continue;
used[nums[i] + 100] = 1;
path.push_back(nums[i]);
backtracking(nums,i + 1); //继续递归
path.pop_back();//回溯
}
//满足条件,return
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(nums, 0);
return result;
}
};
46. 全排列
本题重点是体会排列问题与组合问题、组合总和、子集问题的区别。为什么排列问题不需要 startIndex
?建议细细体会。
- 题目讲解链接:全排列
- 视频讲解:Bilibili 视频
还是要将代码拆分才能理解思路。
首先先判断回溯终止条件,当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点,这时候把一次结果push到result中
去重 used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。
最大的特点就是:每次都要从头开始搜索。
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking (vector<int>& nums, vector<bool>& used) {
// 此时说明找到了一组
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i] == true) continue; // path里已经收录的元素,直接跳过
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
}
}
vector<vector<int>> permute(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
47. 全排列 II
这道题是40. 组合总和 II 去重逻辑与 46. 全排列 的结合。可以先自己做一下,再重点看文章中的拓展内容。特别关注 used[i - 1] == true
和 used[i - 1] == false
之间的区别。
- 题目讲解链接:全排列 II
- 视频讲解:Bilibili 视频
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking (vector<int>& nums, vector<bool>& used) {
// 此时说明找到了一组
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i] == true) continue; // path里已经收录的元素,直接跳过
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
}
}
vector<vector<int>> permute(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
大家发现,去重最为关键的代码为:
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
continue;
}
如果改成 used[i - 1] == true, 也是正确的!,去重代码如下:
这是为什么呢,就是上面我刚说的,如果要对树层中前一位去重,就用used[i - 1] == false,如果要对树枝前一位去重用used[i - 1] == true。对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!用输入: [1,1,1] 来举一个例子。树层上去重(used[i - 1] == false),的树形结构如下:按照这个顺序顺几遍,发现这个就是回溯算法的核心,搞懂这个就搞懂回溯.先从下面开始,反正第一个肯定会被压入其中,大的for循环(层遍历)后序发现重复,如果前面是0,就不管。
树枝上去重(used[i - 1] == true)的树型结构如下:
反正第一个肯定会被压入其中,for循环后并置1,在接下来的循环中发现重复,如果前面是1,就说明,已经用过了,纵向就断了。本质上是从最后一个重复的往前遍历,这样used[i - 1]永远不为真。
大家应该很清晰的看到,树层上对前一位去重非常彻底,效率很高,树枝上对前一位去重虽然最后可以得到答案,但是做了很多无用搜索。
难度确实挺大,
建议跳过的一些难题
以下三道题较难,建议一刷时可以选择适当跳过,等到二刷时随着对回溯算法的深入理解,再尝试解决这些问题。
332. 重新安排行程(可跳过)
这道题难度较大,很多同学在一刷时可能会比较费劲,建议留到二刷时再来解决。这道题当初没有录制视频,原因是录制时参考的是《代码随想录》出版的目录,当时没有这道题。
我的评价是这题TM难了,看了半个小时只懂些许思路。放弃了放弃了。
- 题目讲解链接:重新安排行程
51. N 皇后(适当跳过)
N 皇后问题是经典题目,但建议一刷时了解大体思路即可。如果时间紧张,可以直接跳过,二刷时再深入解决。实话实说,这题不算太难,就是稍微复杂了点,考虑多种情况,就是很简单的回溯,试错。
class Solution {
private:
vector<vector<string>> result;
// n 为输入的棋盘大小
// row 是当前递归到棋盘的第几行了
void backtracking(int n, int row, vector<string>& chessboard) {
if (row == n) {
result.push_back(chessboard);
return;
}
for (int col = 0; col < n; col++) {
if (isValid(row, col, chessboard, n)) { // 验证合法就可以放
chessboard[row][col] = 'Q'; // 放置皇后
backtracking(n, row + 1, chessboard);
chessboard[row][col] = '.'; // 回溯,撤销皇后
}
}
}
bool isValid(int row, int col, vector<string>& chessboard, int n) {
// 检查列
for (int i = 0; i < row; i++) { // 这是一个剪枝
if (chessboard[i][col] == 'Q') {
return false;
}
}
// 检查 45度角是否有皇后
for (int i = row - 1, j = col - 1; i >=0 && j >= 0; i--, j--) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
// 检查 135度角是否有皇后
for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
public:
vector<vector<string>> solveNQueens(int n) {
result.clear();
std::vector<std::string> chessboard(n, std::string(n, '.'));
backtracking(n, 0, chessboard);
return result;
}
};
- 题目讲解链接:N 皇后
- 视频讲解:Bilibili 视频
37. 解数独(适当跳过)
和 N 皇后问题类似,解数独也是一道经典的回溯问题,建议一刷时了解大体思路即可,二刷时再深入解决。
- 题目讲解链接:解数独
- 视频讲解:Bilibili 视频
代码是看懂了,但写肯定写不出来,确实思路和N皇后差不多,就是多了个二维数组,
最大的问题是:递归函数的返回值需要是bool类型,为什么呢?
因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。如果数独棋盘已经全部填满(没有空格 ‘.’),递归函数返回 true,表示已经找到了解。如果某一步填入的数字 k 无法满足数独规则,则函数返回 false,表示当前路径无解,回到上一层递归,撤销刚才的操作(回溯)。
class Solution {
private:
bool backtracking(vector<vector<char>>& board) {
for (int i = 0; i < board.size(); i++) { // 遍历行,// 行不一定为9
for (int j = 0; j < board[0].size(); j++) { // 遍历列
if (board[i][j] == '.') {
for (char k = '1'; k <= '9'; k++) { // (i, j) 这个位置放k是否合适
if (isValid(i, j, k, board)) {
board[i][j] = k; // 放置k
if (backtracking(board)) return true; // 如果找到合适一组立刻返回
board[i][j] = '.'; // 回溯,撤销k
}
}
return false; // 9个数都试完了,都不行,那么就返回false
}
}
}
return true; // 遍历完没有返回false,说明找到了合适棋盘位置了
}
bool isValid(int row, int col, char val, vector<vector<char>>& board) {
for (int i = 0; i < 9; i++) { // 判断行里是否重复
if (board[row][i] == val) {
return false;
}
}
for (int j = 0; j < 9; j++) { // 判断列里是否重复
if (board[j][col] == val) {
return false;
}
}
int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++) { // 判断9方格里是否重复
for (int j = startCol; j < startCol + 3; j++) {
if (board[i][j] == val ) {
return false;
}
}
}
return true;
}
public:
void solveSudoku(vector<vector<char>>& board) {
backtracking(board);
}
};
总结
刷完了这么多回溯算法的题目,大家可以尝试做一下总结。反思这些题目的共性和区别,尤其是在递归和回溯的应用场景中,巩固对回溯法的理解。不得不说,卡尔确实牛逼,总结的非常好。