本文章为个人学习笔记,学习资源:《A LeetCode Grinding Guide (C Version)》,代码随想录代码随想录,力扣题解等。
接01:
目录
1.子集问题
在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
(1)经典题——78子集
好好读题。
题解
树形图
遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
注意,由于幂集的特殊性(所有的都要),可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了。每次递归的下一层就是从i+1开始的,也无需担心死循环。
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
代码
//求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
class Solution {
public:
void backtracking(vector<int>& nums, vector<vector<int>>& rs, vector<int>& path,int startIndex) {
// 收集子集,要放在终止添加的上面,否则会漏掉自己
//注意空集也算
rs.push_back(path);
if (startIndex >= nums.size()) { // 终止条件可以不加
//终止条件在这里其实不需要了,因为反正你要找到所有的
return;
}
for (int i = startIndex; i < nums.size(); ++i) {
path.push_back(nums[i]);
backtracking(nums, rs, path, i + 1);
path.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> rs;
vector<int> path;
backtracking(nums, rs, path, 0);
return rs;
}
};
(2)需要去重的子集问题
90.子集2
和组合一样的套路,理解“树层去重”和“树枝去重”非常重要。
同时注意先排序不要忘记,我老是忘记
树形图
代码
class Solution {
public:
void backtracking(vector<int>& nums, vector<vector<int>> &result, vector<int>&path, vector<bool>& used,int startIndex) {
//再次强调,求子集问题,结果是树的所有结点,而不是树的叶子,终止条件可以不要,startIndex每次都会加1,并不会无限递归。
result.push_back(path);
//处理结点
for (int i = startIndex; i < nums.size(); ++i) {
if (i > 0 && nums[i] == nums[i - 1] && used[i-1] == false) {//i-1
//如果前两个条件满足,最后used[i-1]=true就说明这两个元素此时在一个树枝上而不是在一个树层上
continue;
}
// used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过
// 而我们要对同一树层使用过的元素进行跳过
path.push_back(nums[i]);
used[i] = true;
backtracking(nums, result, path, used, i + 1);
used[i] = false;
path.pop_back();
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
vector<vector<int>> result;
vector<int> path;
vector<bool> used(nums.size(), false);
//排序很重要
sort(nums.begin(), nums.end());
backtracking(nums, result, path, used, 0);
return result;
}
};
PS:去重还可以用stl的set unorder_map之类的
本题也可以不使用used数组来去重,因为递归的时候下一个startIndex是i+1而不是0。
如果要是全排列的话,每次要从0开始遍历,为了跳过已入栈的元素,需要使用used。
可见搜索算法——回溯总结01_chy响当当的博客-CSDN博客,这里就不赘述了。
(3)无法排序但需要去重的子集问题
其实很简单,上面说了用stl。
491. 递增子序列
这里不能排序,所以千万别掉陷阱里面。
树形图
同一父节点下的同层上使用过的元素就不能在使用了
回溯三部曲
- 终止条件
本题其实类似求子集问题,也是要遍历树形结构找每一个节点,所以子集和一样,可以不加终止条件,startIndex每次都会加1,并不会无限递归。
但本题收集结果有所不同,题目要求递增子序列大小至少为2,所以需要有判断。
单层搜索逻辑就看上面树形图,不过有一个注意的点,
unordered_set<int> uset;
是记录本层元素是否重复使用,新的一层uset都会重新定义(清空),所以要知道uset只负责本层!
代码
class Solution {
public:
void backtracking(vector<int>& nums , vector<vector<int>>& rs, vector<int> &path,int startIndex) {
if (path.size() > 1) {
//这里和之前的子集不同是因为,它不要空数组,子集可以有空子集
rs.push_back(path);
// 注意这里不要加return,要取树上的节点!!!!!!!!!!!
}
unordered_set<int> uset; // 使用set对本层元素进行去重
//这个位置很重要,针对是本层,不能写在循环里面,循环是对层,递归是对树枝
for (int i = startIndex; i < nums.size(); i++) {
if ((!path.empty() && nums[i] < path.back())//这是比它小的,不符合增序
|| uset.find(nums[i]) != uset.end()) {//这个是指同一层中有重复的
continue;
}
uset.insert(nums[i]); // 记录这个元素在本层用过了,本层后面不能再用了
path.push_back(nums[i]);
backtracking(nums, rs,path,i+1);
path.pop_back();
}
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
vector<vector<int>> rs;
vector<int> path;
backtracking(nums, rs, path, 0);
return rs;
}
};
优化
以上代码用了
unordered_set<int>
来记录本层元素是否重复使用。其实用数组来做哈希,效率就高了很多。就是数组映射
注意题目中说了,数值范围[-100,100],所以完全可以用数组来做哈希。
2.排列问题
有序,有序,有序,重要的事情说三遍。
(1)经典原始题
46.全排列
回溯三部曲
- 递归函数参数
首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。
但排列问题需要一个used数组,标记已经选择的元素,如图橘黄色部分所示:
- 递归终止条件
叶子节点,就是收割结果的地方。
那么什么时候,算是到达叶子节点呢?
当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
- 单层搜索的逻辑
和组合最大的不同就是for循环里不用startIndex了。
因为排列问题,每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。
而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次,其实是在一根树枝上防止重复使用某一元素。
树形图
代码
class Solution {
public:
void backtracking(vector<int>& nums, vector<bool>& used, vector<vector<int>>& result, vector<int> &path) {
// 此时说明找到了一组
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
//你看这就是从0开始,应该能明白哦
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,result,path);
path.pop_back();
used[i] = false;
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> result;
vector<int> path;
result.clear();
path.clear();
vector<bool> used(nums.size(), false);
backtracking(nums, used,result,path);
return result;
}
};
(2)需要去重的排列问题
47全排列2
给定一个可包含重复数字的序列,要返回所有不重复的全排列。这里又涉及到去重了 。
排列问题其实也是一样的套路。
还要强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了。
树形图
PS:
图中我们对同一树层,前一位(也就是nums[i-1])如果使用过,那么就进行去重。一般来说:组合问题和排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果。
由于思想都很相近,这里直接给答案
class Solution {
public:
void backtracking(vector<int>& nums, vector<vector<int>>& result, vector<int>& path, vector<bool>& used,int len) {
//终止条件
if (len >= nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); ++i) {
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
//本质还是一样的,树层去重
continue;
}
if (used[i] == false) {//这个必须要加哦,这个是作用在一根树枝上的,前面用的不能再用,只能用没用过的
path.push_back(nums[i]);
used[i] = true;
++len;
backtracking(nums, result, path, used,len);
--len;
used[i] = false;
path.pop_back();
}
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<vector<int>> result;
vector<int> path;
vector<bool> used(nums.size(), false);
sort(nums.begin(), nums.end());//又忘记排序了,这个方法去重你必须排序啊
backtracking(nums, result, path, used, 0);
return result;
}
};
单独提一下:if (used[i] == false) {//这个必须要加哦,这个是作用在一根树枝上的,前面用的不能再用,只能用没用过的。
然后如果想用set去重,位置一定要搞清楚:
class Solution {
private:
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;
}
unordered_set<int> uset; // 控制某一节点下的同一层元素不能重复
for (int i = 0; i < nums.size(); i++) {
if (uset.find(nums[i]) != uset.end()) {
continue;
}
if (used[i] == false) {
uset.insert(nums[i]); // 记录元素
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
}
}
}
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
result.clear();
path.clear();
sort(nums.begin(), nums.end()); // 排序
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
效率分析:
3.牛逼的玩法
(1)第一次遇到数据为二维的题
这题死循环特别需要注意,因为有四个方向的移动,很容易死循环,而也不能像有道深度搜索的大西洋太平洋题,一边往右下搜,一边往左上搜 。
这题是回溯加深度搜索
题解不同于排列组合问题,本题采用的并不是修改输出方式, 而是修改访问标记 。在我们对任意位置进行深度优先搜索时,我们先标记当前位置为已访问,以避免重复遍历(如防止向右搜索后 又向左返回);在所有的可能都搜索完成后,再回改当前位置为未访问,防止干扰其它位置搜索 到当前位置。使用回溯法,我们可以只对一个二维的访问矩阵进行修改,而不用把每次的搜索状 态作为一个新对象传入递归函数中。
亮点是选择了一个访问矩阵,并且利用回溯,解决了全部遍历且不重复遍历和死循环的问题
而处理上也有了区别,我们必须在主函数里遍历二维矩阵,每一个点都有一次搜索的过程。
递归时,有四个方向,对应的结束和判断条件也要变化。由于只要找到一个就行,还能剪枝。
代码
class Solution {
public:
void backtracking(vector<vector<char>>& board, const string& word, string& path, vector<vector<bool>>& used,bool& rs, int x, int y, int len) {
if (rs == true) {//剪枝
return;
}
//终止条件
if (path == word) {
rs = true;
return;
}
if (len >= word.size()) return;
if (x < 0 || y < 0 || x >= board.size() || y >= board[0].size()) {
return;
}
//横向for,纵向递归,但是这里有四个方向,其实用for不合适,用深度搜索的那种格式
if (!used[x][y]) {
if (board[x][y] == word[len]) {
//处理结点
path.push_back(board[x][y]);
++len;
used[x][y] = true;
//递归
backtracking(board, word, path, used, rs, x + 1, y, len);
backtracking(board, word, path, used, rs, x - 1, y, len);
backtracking(board, word, path, used, rs, x, y + 1, len);
backtracking(board, word, path, used, rs, x, y - 1, len);
path.pop_back(); //退回
--len;
used[x][y] =false;
}
}
}
bool exist(vector<vector<char>>& board, string word) {
bool rs = 0;
string path;
vector<vector<bool>> used=vector<vector<bool>>(board.size(), vector<bool>(board[0].size(),false) );
for (int i = 0; i < board.size(); ++i) {
for (int j = 0; j < board[0].size(); ++j) {
backtracking(board, word, path, used, rs, i, j, 0);
}
}
//仅仅这样是不行的,这搜的只是从0,0开始的串backtracking(board, word, path, used,rs, 0, 0, 0);
return rs;
}
};
(2)棋盘问题之N皇后
分析
约束条件:
- 不能同行
- 不能同列
- 不能同斜线
树形图
只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了
回溯三部曲
- 递归函数参数
依然是定义全局变量二维数组result来记录最终结果。
参数n是棋盘的大小,然后用row来记录当前遍历到棋盘的第几层
- 单层搜索的逻辑
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜,所以都是从0开始
终止条件就是到叶子结点
验证合法的函数其实还有点难度的,可以关注一下
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;
}
};
(3)棋盘问题之解数独
比N皇后还要复杂一些 题目链接: 力扣
题目自己去看,后面的提示也很重要
这一题就妙了,就是棋盘加上二维,进行二维递归
N皇后是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来来遍历列,然后一行一列确定皇后的唯一位置。其实就是组合排列那种树
本题就不一样了,本题中棋盘的每一个位置都要放一个数字,并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。
树形图:
回溯三部曲
- 递归函数以及参数
递归函数的返回值需要是bool类型,为什么呢?
因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值,这一点在回溯算法:N皇后问题 (opens new window)中已经介绍过了,一样的道理
- 递归终止条件
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
不用终止条件会不会死循环?
递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满当然好了,说明找到结果了),所以不需要终止条件!
- 递归单层搜索逻辑
在树形图中可以看出我们需要的是一个二维的递归(也就是两个for循环嵌套着递归)
一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的可能性!
bool backtracking(vector<vector<char>>& board) { for (int i = 0; i < board.size(); i++) { // 遍历行 for (int j = 0; j < board[0].size(); j++) { // 遍历列 if (board[i][j] != '.') continue; 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,说明找到了合适棋盘位置了 }
注意这里return false的地方,这里放return false 是有讲究的。
因为如果一行一列确定下来了,这里尝试了9个数都不行,说明这个棋盘找不到解决数独问题的解!
那么会直接返回, 这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去!
判断棋盘是否合法
判断棋盘是否合法有如下三个维度:
- 同行是否重复
- 同列是否重复
- 9宫格里是否重复
class Solution {
private:
bool backtracking(vector<vector<char>>& board) {
for (int i = 0; i < board.size(); i++) { // 遍历行
for (int j = 0; j < board[0].size(); j++) { // 遍历列
if (board[i][j] != '.') continue;
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);
}
};
over