文章目录
回溯法模板与伪代码
//返回值一般为void 先写逻辑再确定参数
//一般搜到叶子节点也就找到了满足条件的一条答案,存放该答案并结束本层递归
//for循环横向遍历集合区间,for循环执行次数=一个节点孩子数:处理节点 递归 回溯
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
46.全排列
三要素及思路
先写逻辑,再确定递归函数参数:
函数参数:
path,一维数组,存放符合条件的一个排列,全局变量
result,二维数组,存放排列子集集合,全局变量
nums,题目给的数组
used,标记数组,标记使用过的元素,一个排列中元素不重复
终止条件:
排列问题是找叶子节点,当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示遍历到了叶子节点,结束本层递归。
单层搜索:
- 和组合、切割、子集问题最大区别是,排列问题for循环不需要startindex,每次递归从0开始,因为排列有序,[1,2]和[2,1]是两个集合。
- 但是一个元素在一个排列中只能使用一次,因此需要used数组记录path里都用哪些元素使用过。
- 去重,一个排列元素不重复,used数组对应更新标记元素状态
- 然后保存节点、递归、回溯,注意递归时的起始位置,从0开始
代码
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
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;
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
used[i] = false;
path.pop_back();
}
}
vector<vector<int>> permute(vector<int>& nums) {
path.clear();
result.clear();
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
47.全排列Ⅱ
这道题目和46.全排列的区别在于,给定一个可包含重复数字的序列,要返回所有不重复的全排列,因此需要去重处理。
和组合、子集问题一样,去重前先排序,但是对于递增序列的去重不需要排序
三要素及思路
先写逻辑,再确定递归函数参数:
函数参数:
path,一维数组,存放符合条件的一个排列,全局变量
result,二维数组,存放排列子集集合,全局变量
nums,题目给的数组
used,标记数组,标记使用过的元素,一个排列中元素不重复
终止条件:
排列问题是找叶子节点,当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示遍历到了叶子节点,结束本层递归。
单层搜索:
- 但是一个元素在一个排列中只能使用一次,因此需要used数组记录path里都用哪些元素使用过。
- 去重,一个排列元素不重复,used数组对应更新标记元素状态
- 然后保存节点、递归、回溯,注意递归时的起始位置,从0开始
去重:
- 对同一父节点
- 两种实现方式,used数组和set
代码
- used数组去重
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& nums, vector<bool>& used)
{
if(path.size()==nums.size())//找到了一组排列
{
result.push_back(path);
return;
}
//used数组
for(int i=0; i<nums.size(); i++)
{
// used[i - 1] == true,说明同一树枝nums[i - 1]使用过
// used[i - 1] == false,说明同一树层nums[i - 1]使用过
// 如果同一树层nums[i - 1]使用过则直接跳过
if(i>0 && nums[i]==nums[i-1] && used[i-1]==false) continue;
if(used[i]==false)//同一树层nums[i]使用过
{
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
used[i] = false;
path.pop_back();
}
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
path.clear();
result.clear();
vector<bool> used(nums.size(), false);
sort(nums.begin(), nums.end());
backtracking(nums, used);//used数组;set去重
return result;
}
};
- set去重
class Solution {
public:
vector<int> path;
vector<vector<int>> result;
void backtracking(vector<int>& nums, vector<bool>& used)
{
if(path.size()==nums.size())//找到了一组排列
{
result.push_back(path);
return;
}
//set去重
unordered_set<int> uset;
for(int i=0; i<nums.size(); i++)
{
// used[i - 1] == true,说明同一树枝nums[i - 1]使用过
// used[i - 1] == false,说明同一树层nums[i - 1]使用过
// 如果找到nums[i],说明同一树层使用过,直接跳过
if(uset.find(nums[i]) != uset.end()) continue;
if(used[i]==false)//同一树层nums[i]使用过
{
uset.insert(nums[i]); // set去重 记录元素
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
used[i] = false;
path.pop_back();
}
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
path.clear();
result.clear();
vector<bool> used(nums.size(), false);
sort(nums.begin(), nums.end());
backtracking(nums, used);//used数组;set去重
return result;
}
};
去重补充
- 当
i > 0 && nums[i] == nums[i - 1]
时,有
- used[i - 1] == true,说明同一树枝nums[i - 1]使用过
- used[i - 1] == false,说明同一树层nums[i - 1]使用过
- 对于这道题,树枝去重和树层去重都可以,但树层去重效率更高。
- 树层去重,
used[i - 1] == false
,树层上对前一位去重非常彻底,效率很高
- 树枝去重,
used[i - 1] == true
,树枝上对前一位去重虽然最后可以得到答案,但是做了很多无用搜索
332.重新安排行程
分析 思路 步骤
这道题实际是图论的深度优先搜索,但深搜中使用了回溯。查找路径时,如果不回溯无法找到目标路径
题目难点:
一个行程中,如果航班处理不好容易变成一个圈,成为死循环,如何避免死循环?
有多种解法,字母序靠前排在前面,如何记录映射关系 ?
使用回溯法(也可以说深搜) 的话,终止条件是什么?
搜索过程中,如何遍历一个机场所对应的所有机场?
理解死循环:
出发机场和到达机场也可能会重复,如果没有对集合元素处理好,就造成死循环
记录映射关系:
1. 怎么建立映射: 一个机场映射多个机场,机场之间要靠字母序排列,可以使用std::unordered_map。如果让多个机场之间再有顺序的话,就用std::map或者std::multimap 或者 std::multiset。
2. 如何定义映射: 因此,一个机场映射多个机场,可以如下建立映射
unordered_map<string, multiset> targets
,含义是:unordered_map<出发机场, 到达机场的集合> targetsunordered_map<string, map<string, int>> targets
,含义是:unordered_map<出发机场, map<到达机场, 航班次数>> targets
3. 使用哪种定义方式建立映射: 如果使用unordered_map<string, multiset<string>> targets
,遍历multiset的时候,不能删除元素。一旦删除元素,迭代器就失效了。
4.为什么要增删元素: 出发机场和到达机场是可能会重复的,如果搜索过程没及时删除目的机场,就会死循环。因此搜索时要不断地删multiset里的元素,因此使用unordered_map<string, map<string, int>> targets
。
5.如何记录映射: 在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets 的过程中,可以使用航班次数这个字段的数字做相应的增减,来标记到达机场是否使用过了。
如果航班次数大于零,说明目的地还可以飞,如果航班次数等于零,说明目的地不能飞了,而不是对集合做实际的删除或增加元素的操作,只是标记
先写逻辑,再确定递归函数参数与返回值:
函数参数:
unordered_map<string, map<string, int>> targets,记录航班的映射关系,全局变量
result,一维数组,记录一条路径,局部变量,相当于之前的path
ticketnum,表示有多少个航班,终止条件控制
targets和result都需要初始化
函数返回值:
之前的回溯函数返回值都是void,这次是bool。因为我找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线
终止条件: 回溯遍历的时,如果遇到的机场个数=航班数量+1,说明找到了一个行程,把所有航班串在一起了
单层搜索: 要找到一个对数据进行排序的容器,而且还要容易增删元素,迭代器还不能失效,来建立机场直之间的映射,即unordered_map<string, map<string, int>> targets
。通过targets的int字段判断这个集合里的机场是否使用过,避免直接去删元素,然后再处理航班
代码
class Solution {
private:
// unordered_map<出发机场, map<到达机场, 航班次数>> targets
unordered_map<string, map<string, int>> targets;
bool backtracking(vector<string>& result, int ticketnum)
{
//终止条件 找到一条路径
if(result.size() == ticketnum+1) return true;
//单层搜索 用对组类型创建与targets.second同类型容器target 遍历targets.second这个map容器
//通过target.second这个字段 int类型元素 判断该机场是否飞过 并做对应的记录
//target.second>0 目的地还可以飞;target.second=0 目的地不能飞
for(pair<const string, int>& target : targets[result[result.size()-1]])
{
if(target.second > 0)//目的地还可以飞
{
result.push_back(target.first);//保存目的地
target.second--;
//递归函数是bool类型 写法和之前的不一样
if(backtracking(result, ticketnum)) return true;
result.pop_back();
target.second++;
}
}
return false;
}
public:
vector<string> findItinerary(vector<vector<string>>& tickets) {
targets.clear();
vector<string> result;
//初始化
for(const vector<string>& vec : tickets)
{
targets[vec[0]][vec[1]]++;//建立机场映射
}
result.push_back("JFK");// 起始机场
backtracking(result, tickets.size());
return result;
}
};
好难好难
51. N皇后
分析 思路 步骤
分析:
搜索皇后的位置可以抽象为一棵树,二维矩阵中,矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度
皇后们的约束条件:不能同行、不能同列、不能同斜线
利用皇后的约束条件回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置
先写逻辑,再确定递归函数参数与返回值:
函数参数:
chessboard, 一维数组,保存符合条件的单个子集,局部变量
result,二维数组,记录最终结果,全局变量
n,题目给的数,棋盘的大小
row,记录当前遍历到棋盘的第几层
函数没有返回值
终止条件: 根据前面的分析,当递归到棋盘最底层时,也就是遍历到叶子节点时,就可以收集结果并返回了,即row=n时,结束递归
单层搜索:
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜索,都是从0开始。
并且根据皇后约束条件,还需要验证位置是否合法
步骤: 验证位置合法、放置皇后(保存节点)、递归(从下一行row+1开始)、回溯(赋值覆盖皇后)
验证棋盘是否合法:
- 按照皇后约束条件去重,分别对同行、同列,同斜线(45度和135度角)检查
- 对列检查,相当于剪枝
- 对45度角检查,找角度时默认直角坐标系,45度 找左下角 [row, col] -> [row-1, col-1]
- 对135度角检查,找角度时默认直角坐标系,135度 找左上角 [row, col] -> [row-1, col+1]
- 没有对行检查,因为在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,所以不用去重了。
代码
class Solution {
private:
vector<vector<string>> result;
void backtracking(int n, int row, vector<string>& chessbord)
{
//递归终止条件 遍历到叶子节点 深度=宽度 正方形棋盘
if(row == n)
{
result.push_back(chessbord);
return;
}
//单层搜索
for(int col=0; col<n; col++)
{
//验证位置合法性 如果合法就放置皇后
if(isvalid(row, col, chessbord, n))
{
chessbord[row][col] = 'Q';//放置皇后
backtracking(n, row+1, chessbord);//从下一行开始
chessbord[row][col] = '.';//回溯 撤销皇后 题目中.表示空位 可以赋值来覆盖皇后
}
}
}
//验证位置合法性
bool isvalid(int row, int col, vector<string>& chessbord, int n)
{
//检查列 行变 列固定 相当于剪枝
for(int i=0; i<row; i++)
{
if(chessbord[i][col] == 'Q') return false;//说明这一列的某一行有皇后
}
//找角度时默认直角坐标系
//检查斜线-45度 找左下角 [row, col] -> [row-1, col-1]
for(int i=row-1, j=col-1; i>=0 && j>=0; i--, j--)
{
if(chessbord[i][j] == 'Q') return false;//说明这斜线 45度角的某个位置有皇后
}
//检查斜线-135度 找左上角 [row, col] -> [row-1, col+1]
for(int i=row-1, j=col+1; i>=0 && j<n; i--, j++)
{
if(chessbord[i][j] == 'Q') return false;//说明这斜线 135度角的某个位置有皇后
}
return true;
}
public:
vector<vector<string>> solveNQueens(int n) {
result.clear();
std::vector<std::string> chessbord(n, std::string(n, '.'));//初始化 正方形棋盘 n*n二维矩阵 .默认填充
backtracking(n, 0, chessbord);
return result;
}
};
37. 解数独
分析 思路 步骤
分析:
- 数独唯一解,意味着找到从根节点到叶子节点一条唯一路径就返回,因此回溯函数的返回值是bool类型,同332题
- N皇后问题是因为每一行每一列只放一个皇后,只需要一层for循环遍历行,递归遍历列,然后一行一列确定皇后的唯一位置。
- N皇后是一行只放一个皇后,属于一维递归。解数独问题则是棋盘的每一个位置都要放一个数字,而N皇后是一行只放一个皇后,因此本题属于二维递归,解数独的树形结构要比N皇后更宽更深。
先写逻辑,再确定递归函数参数与返回值:
函数参数: board, 题目给的数独。而且这个是二维递归,遍历整棵树,不需要行、列来记录深度、宽度
函数返回值: bool类型的函数返回值,数独唯一解,相当于找到从根节点到叶子节点一条唯一路径就返回
终止条件:
-
要遍历整棵树,找到唯一解,即找到可能的叶子节点就返回,因此不需要终止条件。
-
会不会死循环? 递归的下一层棋盘一定比上一层的棋盘多一个数,数填满了棋盘自然就终止,说明找到结果了,所以不需要终止条件
单层搜索:
二维递归,需要两个for循环嵌套实现,一个for循环遍历棋盘的行,一个for循环遍历棋盘的列。某个位置,即所在行与列确定之后,递归遍历这个位置放9个数字的可能性
会不会填不满? 一行一列确定后,如果9个数字都不合法,说明这个棋盘找不到数独问题的唯一解,直接返回假。 这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去
步骤:
遍历整棵树,默认返回真,相当于遍历整棵树之后一定找到一条唯一路径,数独有唯一解
- 遍历行
- 遍历列
- 确定位置,如果该位置没有数字,则处理,有数字就跳过
- 验证位置合法,验证该位置放9个数字的可能性:如果合法,放置数字、递归(找到一条路径就返回)、回溯(赋值覆盖)
- 9个数字都验证完了并且没有合适的数字,说明该位置对9个数字都不合法,意味着这个棋盘找不到数独问题的唯一解数独,就直接返回假
- 返回真
验证棋盘是否合法:
- 按照数独约束条件去重,分别对同行、同列,九宫格检查
- 对行检查,查看数字是否有重复
- 对列检查,查看数字是否有重复
- 对九宫格检查,查看数字是否有重复。注意九宫格的转换和遍历方式
代码
class Solution {
private:
//回溯函数
bool backtracking(vector<vector<char>>& board)
{
//不需要终止条件 找到唯一路径就返回 默认返回真 一定有唯一解
for(int i=0; i<board.size(); i++)//遍历行
{
//遍历列 board[0].size()列数 i、j确定位置
for(int j=0; j<board[0].size(); j++)
{
if(board[i][j] == '.')//该位置是空格,可以填数字
{
//递归遍历(i, j)位置 对9个数字的可能性
for(char k='1'; k<='9'; k++)
{
if(isvalid(i, j, k, board))//(i, j)位置放k合适
{
board[i][j] = k;//放k
if(backtracking(board)) return true;//递归 找到路径就返回真
board[i][j] = '.';//回溯
}
}
return false;//(i, j)位置对9个数字都不合适 找不到数独唯一解 返回假
}
}
}
return true;
}
//验证位置合法
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;
}
//九宫格 数字重复检查 九宫格每行每列只有3个数字 所以i<startrow+3 j<startcol+3
int startrow = (row / 3) * 3;//防止溢出
int startcol = (col / 3) * 3;//防止溢出
for(int i=startrow; i<startrow+3; i++)
{
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);
}
};
总结
1. 排列问题
排列问题:N个数按一定规则全排列,有几种排列方式——46.全排列、47.全排列Ⅱ、332.重新安排行程
-
排列强调顺序的,组合不强调元素顺序,即组合无序,排列有序。例如,{1, 2} 和{2, 1}在排列问题中是两个集合,在组合问题中是一个集合。
-
与组合、分割问题一样,排列问题找叶子节点,但子集问题找所有节点
-
和组合、切割、子集问题最大区别在于,排列for循环不需要startindex,每次递归从0开始,因为排列有序,[1,2]和[2,1]是两个集合
-
排列去重,排列的for循环每次从0开始遍历,需要使用标记数组used记录哪些元素使用过,因为一个排列中元素不重复,只能使用一次
-
两种去重实现方式,标记数组used和set,注意使用set去重时的错误写法,使用set去重的版本相对于used数组的版本效率都要低很多
-
去重前,先排序
-
46.全排列:标记数组used实现元素去重
-
47.全排列Ⅱ:
- 和46.全排列的区别在于,47给的是一个可包含重复数字的序列,要返回所有不重复的全排列
- 标记数组used或者set实现元素去重,注意使用set去重时的错误写法
- 树枝去重和树层去重都可以,但是树层去重效率更高
2. 棋盘问题
-
一维递归-N皇后,二维递归-解数独
-
位置合法性验证
-
51.N皇后:
- 二维矩阵的高就是树的高度,矩阵宽就是树中每一个节点的宽度
- 一维递归,只需要一层for循环遍历行,递归遍历列,因为每一行每一列只放一个皇后
- 有递归终止条件,遍历到叶子节点,即达到树的深度时,说明找到了皇后们的合理位置
- 没有递归函数返回值
- 利用皇后的约束条件(不能同行、不能同列、不能同斜线45度和135度)去重,只需要对列和斜线去重,对列去重相当于剪枝
-
37.解数独:
- 二维递归,两个for循环嵌套,一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,确定唯一位置,因为每行每列,即每个位置都需要遍历
- 没有递归终止条件,需要遍历整棵树
- 有递归函数返回值,数独有唯一解,找到符合条件的路径就返回
- 利用数独约束条件(同行同列及九宫格不能有重复数字)去重,注意九宫格的去重方式
- 会不会出现死循环?棋盘会不会填不满而无限递归?
3. 深度优先搜索中的回溯
332.重新安排行程
- 题目难点:
- 如何避免行程中出现死循环?
- 有多种解法,字母序靠前排在前面,如何记录映射关系 ?
- 使用回溯法(也可以说深搜) 的话,终止条件是什么?
- 搜索过程中,如何遍历一个机场所对应的所有机场?
- 使用哪种容器建立一个机场对多个机场的映射关系?
- 机场之间映射建立的过程
- 建立映射时,为什么使用
unordered_map<string, map<string, int>> targets
,而不是unordered_map<string, multiset> targets
,两者含义是什么?如何使用?
回溯法总结
- 回溯法本质,一种搜索算法,本质是穷举,故效率不高
- 如何理解回溯法的搜索过程?
- 所有回溯问题都可以抽象为树形结构,对应的问题有组合、切割、子集、排列、棋盘,组合无序,排列有序
- 回溯函数的三要素,参数及返回值、终止条件、单次搜索过程
- 参数: 先写逻辑再确定参数;回溯函数参数分析,什么时候用startIndex,什么时候不用?什么时候用目标值sum、val,什么时候不用?
- 返回值: 先写逻辑再确定返回值及其类型,一般不需要,如果找到唯一路径就有返回值及其类型
- **终止条件:**如果是找叶子节点,一般需要终止条件;如果遍历整棵树就不需要
- 单次搜索过程: for循环中体现剪枝操作
- 剪枝操作
- 去重操作
- 如何去重
- 如何理解树枝去重与树层去重
- 去重有几种方法?位置合法性验证,标记数组used与set实现方式
- 位置合法性验证,
- 一维递归与二维递归,如何理解二维递归?
- 回文判定