代码随想录算法训练营第三十天 | 航班问题、二维回溯
回溯法小结
本周小结!(回溯算法系列三) | 代码随想录 (programmercarl.com)
性能分析
子集问题分析:
- 时间复杂度:O(n × 2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2n),构造每一组子集都需要填进数组,又有需要O(n),最终时间复杂度:O(n × 2^n)。
- 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)。
排列问题分析:
- 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:
result.push_back(path)
),该操作的复杂度为O(n)。所以,最终时间复杂度为:n * n!,简化为O(n!)。 - 空间复杂度:O(n),和子集问题同理。
组合问题分析:
- 时间复杂度:O(n × 2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度:O(n),和子集问题同理。
一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!
回溯算法去重问题的另一种写法
代码随想录 (programmercarl.com)
332.重新安排行程
文档讲解:代码随想录 (programmercarl.com)
视频讲解:无
状态:没做出来。
思路
这道题目有几个难点:
- 一个行程中,如果航班处理不好容易变成一个圈,成为死循环
- 有多种解法,字母序靠前排在前面,让很多同学望而退步,如何该记录映射关系呢 ?
- 使用回溯法(也可以说深搜) 的话,那么终止条件是什么呢?
- 搜索的过程中,如何遍历一个机场所对应的所有机场。
如何理解死循环
举一个有重复机场的例子:
所以,出发机场和到达机场也会重复的,如果在解题的过程中没有对集合元素处理好,就会死循环。
记录映射关系
题目说“有多种解法,字母序靠前排在前面”,那么,如何记录映射关系呢?
一个机场可以到达多个机场,机场之间要靠字母序排列。一个机场到达“多个机场”,可以使用unordered_map,如果让“多个机场”之间再有顺序的话,就是用map 或者multimap 或者 multiset。
这样存放映射关系可以定义为
unordered_map<string, multiset> targets
:unordered_map<出发机场, 到达机场的集合> targets
unordered_map<string, map<string, int>> targets
:unordered_map<出发机场, map<到达机场, 航班次数>> targets
这两个结构,我选择了后者,因为如果使用unordered_map<string, multiset<string>> targets
遍历multiset的时候,不能删除元素,一旦删除元素,迭代器就失效了。
再说一下为什么一定要增删元素呢,正如开篇我给出的图中所示,出发机场和到达机场是会重复的,搜索的过程没及时删除目的机场就会死循环。
所以搜索的过程中就是要不断的删multiset里的元素,那么推荐使用unordered_map<string, map<string, int>> targets
。
在遍历 unordered_map<出发机场, map<到达机场, 航班次数>> targets
的过程中,可以使用"航班次数"这个字段的数字做相应的增减,来标记到达机场是否使用过了。
如果“航班次数”大于零,说明目的地还可以飞,如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。相当于说我不删,我就做一个标记!
eg.以输入:[[“JFK”, “KUL”], [“JFK”, “NRT”], [“NRT”, “JFK”]为例,抽象为树形结构如下:
回溯三部曲
-
递归函数参数
使用
unordered_map<string, map<string, int>> targets;
来记录航班的映射关系,我定义为全局变量。参数里还需要ticketNum,表示有多少个航班(终止条件会用上)。因为tickets已经预处理完存到targets里,无法用tickets.size()了,只能用ticketNum标记。
由于map<string, int>已经对string排序了,所以只要找到第一条路径,就是想要的【字典排序返回最小的结果】,然后就不用继续搜了。所以找到第一条路径上的每个机场存放到
vector<string> path
中。vector<string> path; unordered_map<string, map<string, int>> targets; bool backtracking(int ticketNum){
**注意函数返回值我用的是bool!**因为我们只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线,所以找到了这个叶子节点了直接返回。
当然本题的targets和path都需要初始化,代码如下:
// 记录映射关系 for(vector<string> vec: tickets){ //预处理tickets到targets中 targets[vec[0]][vec[1]]++; //vec[0]到vec[1]的航班次数+1 } path.push_back("JFK"); //题目说了肯定从JFK开始
-
递归终止条件
拿题目中的示例为例,输入: [[“MUC”, “LHR”], [“JFK”, “MUC”], [“SFO”, “SJC”], [“LHR”, “SFO”]] ,这是有4个航班,那么只要找出一种行程,行程里的机场个数是5就可以了。
所以终止条件是:我们回溯遍历的过程中,遇到的机场个数,如果达到了(航班数量+1),那么我们就找到了一个行程,把所有航班串在一起了。
if(path.size() == ticketNum + 1){ return true; }
到叶子节点不必搜集结果,因为在如下单层搜索的逻辑中result就添加元素了。
-
单层搜索的逻辑
//path[path.size() - 1]表示path的最后一个元素,也就是航行上的当前机场 //targets[path[path.size() - 1]]表示当前机场可以到达的目的地 //!注意pair<const string, int>& target不能写出pair<string,int> target,如果没加引用,就会变成复制一份了,从而没修改到全局变量 for(pair<const string, int>& target : targets[path[path.size() - 1]]){ if(target.second > 0){ //如果可以还没被用完,也就是说可以飞 path.push_back(target.first); target.second--; if(backtracking(ticketNum)) return true; //只要找到第一条路径,就是想要的【字典排序返回最小的结果】,然后就不用继续搜 target.second++; path.pop_back(); } }
整体代码如下
class Solution { public: vector<string> path; unordered_map<string, map<string, int>> targets; //因为tickets已经预处理完存到targets里,无法用tickets.size()了,只能用ticketNum标记 //由于map<string, int>已经对string排序了,所以只要找到第一条路径,就是想要的【字典排序返回最小的结果】,然后就不用继续搜了,所以该函数用bool返回 bool backtracking(int ticketNum){ //递归过程中形参值不变 if(path.size() == ticketNum + 1){ return true; } //path[path.size() - 1]表示path的最后一个元素,也就是航行上的当前机场 //targets[path[path.size() - 1]]表示当前机场可以到达的目的地 //!注意pair<const string, int>& target不能写出pair<string,int> target,如果没加引用,就会变成复制一份了,从而没修改到全局变量 for(pair<const string, int>& target : targets[path[path.size() - 1]]){ if(target.second > 0){ //如果可以还没被用完,也就是说可以飞 path.push_back(target.first); target.second--; if(backtracking(ticketNum)) return true; //只要找到第一条路径,就是想要的【字典排序返回最小的结果】,然后就不用继续搜 target.second++; path.pop_back(); } } return false; } vector<string> findItinerary(vector<vector<string>>& tickets) { for(vector<string> vec: tickets){ //预处理tickets到targets中 targets[vec[0]][vec[1]]++; //vec[0]到vec[1]的航班次数+1 } path.push_back("JFK"); //题目说了肯定从JFK开始 backtracking(tickets.size()); return path; } };
51. N皇后
文档讲解:https://programmercarl.com/0051.N%E7%9A%87%E5%90%8E.html#%E6%80%BB%E7%BB%93
视频讲解:这就是传说中的N皇后? 回溯算法安排!| LeetCode:51.N皇后_哔哩哔哩_bilibili
状态:能直接写出来。
思路
棋盘的宽度就是for循环的长度,递归的深度就是棋盘的高度,这样就可以套进回溯法的模板里了。
搜索皇后的位置,可以抽象为一棵树。下面我用一个 3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:
可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了。
回溯三部曲
-
递归函数参数
定义全局变量二维数组result来记录最终结果。参数n是棋盘的大小。
因为path已经被初始化为n x n的’.'了,故无法用path.size()作为递归出口,只能用row来记录当前遍历到棋盘的第几层。
因为path要初始化为’.',需要用到n,所以无法用全局实现,只能作为参数。
vector<vector<string>> result; void backtracking(int n, vector<string>& path, int row)
-
递归终止条件
当递归到棋盘最底层(也就是叶子节点)的时候,就可以收集结果并返回了。
if(row == n){ //到了row的索引为n的行,物理上为第n+1行 result.push_back(path); return; }
-
单层搜索的逻辑
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜,所以都是从0开始。
for(int i = 0; i < n; ++i){ //当前行的第i列 if(isValid(path, row, i, n)){ //在第row行第i列插入棋子合法 path[row][i] = 'Q'; ++row; backtracking(n ,path, row); --row; path[row][i] = '.'; } }
-
验证棋盘是否合法
按照如下标准去重:
- 不能同行
- 不能同列
- 不能同斜线 (45度和135度角)
为什么没有在同行进行检查呢?
因为在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,所以不用去重了。
bool isValid(vector<string>& path, int row, int col, int n){ //若同一列有棋子,则不合法 for(int i = row - 1; i >= 0; --i){ if(path[i][col] == 'Q') return false; } //若左上角斜线有棋子,则不合法 int i = row - 1, j = col - 1; while(i >= 0 && j >= 0){ if(path[i][j] == 'Q') return false; --i; --j; } //若右上角斜线有棋子,则不合法 i = row - 1; j = col + 1; while(i >= 0 && j < n){ if(path[i][j] == 'Q') return false; --i; ++j; } return true; }
我写的整体代码
class Solution { public: vector<vector<string>> result; bool isValid(vector<string>& path, int row, int col, int n){ //若同一列有棋子,则不合法 for(int i = row - 1; i >= 0; --i){ if(path[i][col] == 'Q') return false; } //若左上角斜线有棋子,则不合法 int i = row - 1, j = col - 1; while(i >= 0 && j >= 0){ if(path[i][j] == 'Q') return false; --i; --j; } //若右上角斜线有棋子,则不合法 i = row - 1; j = col + 1; while(i >= 0 && j < n){ if(path[i][j] == 'Q') return false; --i; ++j; } return true; } //因为path要初始化为'.',需要用到n,所以无法用全局实现,只能作为参数; //因为path已经初始化为n*n,故无法用path.size()作为递归出口,只能用形参row表示当前递归到第几行 void backtracking(int n, vector<string>& path, int row){ if(row == n){ //到了row的索引为n的行,物理上为第n+1行 result.push_back(path); return; } for(int i = 0; i < n; ++i){ //当前行的第i列 if(isValid(path, row, i, n)){ //在第row行第i列插入棋子合法 path[row][i] = 'Q'; ++row; backtracking(n ,path, row); --row; path[row][i] = '.'; } } } vector<vector<string>> solveNQueens(int n) { vector<string> path(n, string(n, '.')); backtracking(n, path, 0); return result; } };
37. 解数独
文档讲解:代码随想录 (programmercarl.com)
视频讲解:回溯算法二维递归?解数独不过如此!| LeetCode:37. 解数独_哔哩哔哩_bilibili
状态:不会做。
思路
先写两层for循环,从而能够遍历表格里所有格子。对于每个格子,用递归来实现遍历1-9,判断每个当前数字是否合法,如果合法,就填入,然后继续递归,只要找到一组符合的结果就返回,因此回溯函数需要返回bool。
判断当前格子所在的九宫格需要小技巧:
int startRow = (row / 3) * 3; //!当前位置所在九宫格的起始行
int startCol = (col / 3) * 3; //!当前位置所在九宫格的起始列
本题中棋盘的每一个位置都要放一个数字(而N皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。因为这个树形结构太大了,我抽取一部分,如图所示:
回溯三部曲
-
递归函数以及参数
递归函数的返回值需要是bool类型,为什么呢?
因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。
bool backtracking(vector<vector<char>>& board)
-
递归终止条件
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
**不用终止条件会不会死循环?**递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满当然好了,说明找到结果了),所以不需要终止条件!
**那么有没有永远填不满的情况呢?**这个问题我在递归单层搜索逻辑里再来讲!
-
递归单层搜索逻辑
在树形图中可以看出我们需要的是一个二维的递归(也就是两个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宫格里是否重复
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; }
-
整体代码
class Solution {
public:
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){
for(int j = startCol; j < startCol + 3; ++j){
if(board[i][j] == val) return false;
}
}
return true;
}
bool backtracking(vector<vector<char>>& board){
for(int i = 0; i < board.size(); ++i){ //这两个for循环嵌套是为了遍历所有格子,让每个格子都能递归到0-9
for(int j = 0; j < board[0].size(); ++j){
if(board[i][j] != '.') continue;
for(char k = '1'; k <= '9'; ++k){
if(isValid(i, j, k, board)){ //如果合法,就放入数字
board[i][j] = k;
if(backtracking(board)) return true; //只要在填满空格时找到第一组符合的结果,就不必再往下找了,因为结果存放在board上,如果继续往下找,可能找不到,同时也破坏了存放的结果board
board[i][j] = '.';
}
}
return false;//如果某个格子把0-9都试完了,都不合法,说明没必要继续填后面的格子了,已经找不到了
}
}
return true;//遍历完所有格子没返回false,说明当前合法
}
void solveSudoku(vector<vector<char>>& board) {
backtracking(board);
}
};
回溯法大总结
代码随想录 (programmercarl.com)