代码随想录1刷—回溯篇(二)
- [90. 子集 II](https://leetcode.cn/problems/subsets-ii/)
- [491. 递增子序列](https://leetcode.cn/problems/increasing-subsequences/)
- [46. 全排列](https://leetcode.cn/problems/permutations/)
- [47. 全排列 II](https://leetcode.cn/problems/permutations-ii/)
- [332. 重新安排行程](https://leetcode.cn/problems/reconstruct-itinerary/)(难TAT)
- [51. N 皇后](https://leetcode.cn/problems/n-queens/)
- [37. 解数独](https://leetcode.cn/problems/sudoku-solver/)
- 回溯篇总结
90. 子集 II
![90.子集II](https://i-blog.csdnimg.cn/blog_migrate/1ad2d748cf65cc7dd2a0f277faa59ab3.png)
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums,int startIndex,vector<bool>& used){
result.push_back(path);
if(startIndex >= nums.size()){
return;
}
for(int i = startIndex;i < nums.size();i++){
if(i > 0 && nums[i] == nums[i-1] && used[i-1] == false){
continue;
}
path.push_back(nums[i]);
used[i] = true;
backtracking(nums,i + 1,used);
used[i] = false;
path.pop_back();
}
}
public:
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(),false);
sort(nums.begin(),nums.end());
backtracking(nums,0,used);
return result;
}
};
491. 递增子序列
本题求自增子序列,是不能对原数组进行排序的,所以不能使用之前的去重逻辑!
![491. 递增子序列1](https://i-blog.csdnimg.cn/blog_migrate/5df6979eb40ca235dbe7a30e0b25c434.png)
同一父节点下的同层上使用过的元素就不能在使用了。
利用set进行去重
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums,int startIndex){
if(path.size() > 1){
result.push_back(path);
} //要取树上的结点,所以不要return
unordered_set<int> uset; // 使用set对本层匀速进行去重 //记录本层元素是否重复使用
// 新的一层uset都会重新定义(清空),所以不需要对应的回溯pop
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,i + 1);
path.pop_back();
}
}
public:
vector<vector<int>> findSubsequences(vector<int>& nums) {
result.clear();
path.clear();
backtracking(nums,0);
return result;
}
};
利用数组去重
程序运行的时候对unordered_set 频繁的insert效率是比较低的,因为unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值),相对来说比较费时间,而且每次重新定义set,insert的时候其底层的符号表也要做相应的扩充。
而本题的数值范围[-100,100],所以完全可以用数组来做哈希。数组,set,map都可以做哈希表,而且数组干的活,map和set都能干,但如果数值范围小的话能用数组尽量用数组。
class Solution {
private:
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();
}
}
public:
vector<vector<int>> findSubsequences(vector<int>& nums) {
result.clear();
path.clear();
backtracking(nums,0);
return result;
}
};
46. 全排列
首先排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,所以处理排列问题就不用使用startIndex了。但排列问题需要一个used数组,标记已经选择的元素。
![46.全排列](https://i-blog.csdnimg.cn/blog_migrate/c95f465284fc7f38c0adad4f249dc037.png)
可以看出叶子节点,就是收割结果的地方。当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
本题目和 77.组合问题、131.切割问题、78.子集问题 最大的不同就是for循环里不用startIndex了。排列问题每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。
used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums,vector<bool>& uesd){
if(path.size() == nums.size()){
result.push_back(path);
return;
} //此时说明找到了一组~
for(int i = 0;i < nums.size();i++){
if(uesd[i] == true) continue; //该元素已经使用过了,不能重复使用,跳过
uesd[i] = true;
path.push_back(nums[i]);
backtracking(nums,uesd);
path.pop_back();
uesd[i] = false;
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(),false);
backtracking(nums,used);
return result;
}
};
47. 全排列 II
这道题目和 46.全排列 的区别在与给定的是一个可包含重复数字的序列。因此又涉及到去重的问题了。去重一定要对元素进行排序,这样才方便通过相邻的节点来判断是否重复使用了。
一般来说:组合问题和排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果。
![47.全排列II1](https://i-blog.csdnimg.cn/blog_migrate/08fb6bf17e3d9769248b24d831ed3a35.png)
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;
}
for(int i = 0;i < nums.size();i++){
if(i > 0 && nums[i] == nums[i-1] && used[i-1] == false){
continue;
}
if(used[i] == true) continue;
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;
}
};
如果改成 used[i - 1] == true
, 也是正确的!,去重代码如下:
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true) {
continue;
}
如果要对树层中前一位去重,就用used[i - 1] == false
,如果要对树枝前一位去重用used[i - 1] == true
。对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!
用输入: [1,1,1] 来举一个例子。
树层上去重(used[i - 1] == false),的树形结构如下:
树枝上去重(used[i - 1] == true)的树型结构如下:
可很清晰的看到,树层上对前一位去重非常彻底,效率很高,树枝上对前一位去重虽然最后可以得到答案,但是做了很多无用搜索,因此,对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!
332. 重新安排行程(难TAT)
class Solution {
private:
unordered_map<string,map<string,int>> targets; //<出发机场,<到达机场,航班次数>>
bool backtracking(int ticketsNum,vector<string>& result){
//因为找的是一条路径,所以找到就要返回true
if(result.size() == ticketsNum + 1){ //3张机票的话4段航程,所以以此作为终止条件
return true; //不需要result.push_back之类的,因为这个操作在每层中已经进行了
}
for(pair<const string, int>& target : targets[result[result.size() - 1]] ){
// targets: {["LHR"] = {["SFO"] = 1}, ["JFK"] = {["MUC"] = 1}, ["SFO"] = {["SJC"] = 1}, ["MUC"] = {["LHR"] = 1}}
// 第一次遍历时,result.size()=1,result[0] = "JFK",所以target: {first = "MUC", second = 1}
// 第二次遍历时,result.size()=2,result[1] = "MUC",所以target: {first = "LHR", second = 1}
// ……
if(target.second > 0){ // 记录到达机场是不是已经飞过了,second是航班次数
// 如果“航班次数”大于零,说明目的地还可以飞
// 如果“航班次数”等于零说明目的地不能飞了,而不用对集合做删除元素或者增加元素的操作。
result.push_back(target.first);
target.second--;
if(backtracking(ticketsNum,result)) 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]]++; //记录映射关系 //vec就是引用遍历一遍tickets然后映射到targets上
//++是赋值1次的航班次数
//[vec[0]][vec[1]]是每个tickets行程元素的两个值,遍历下一次就是下一次行程元素的两个值
}
result.push_back("JFK"); //起始机场
backtracking(tickets.size(),result);
return result;
}
};
51. N 皇后
![51.N皇后](https://i-blog.csdnimg.cn/blog_migrate/f201e37a362eebb4d83b4277504ccbe9.jpeg)
可以看出,当递归到棋盘最底层(也就是叶子节点)的时候,就可以收集结果并返回了。
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。
每次都是要从新的一行的起始位置开始搜,所以都是从0开始。
按照如下标准去重:
- 不能同行
- 不能同列
- 不能同斜线 (45度和135度角)
class Solution {
private:
vector<vector<string>> result;
void backstracking(int n,int row,vector<string>& chessboard){
//n为棋盘大小,row为递归的行数,chessbord为当前的放置情况
if(row == n){ //当遍历到叶子结点时即遍历成功
result.push_back(chessboard);
return;
}
for(int col = 0; col < n;col++){ //col为列
if(isVaild(row,col,chessboard,n)){ //验证合法则可以放
chessboard[row][col] = 'Q'; //放置皇后
backstracking(n,row + 1,chessboard); //递归,row不可以重复~
chessboard[row][col] = '.'; //回溯,撤销皇后
}
}
}
bool isVaild(int row,int col,vector<string>& chessboard,int n){
//检查列是否冲突
for(int i = 0;i < row;i++){
if(chessboard[i][col] == 'Q'){
return false;
}
}
//没有检查行是否冲突是因为在每一层递归中只会选for循环中的一个元素,所以不需要去重啦
//检查135°角是否冲突
for(int i = row - 1,j = col - 1;i >= 0 && j >= 0;i--,j--){
//因为是一行行递归的,所以只考虑上面部分就行
//row-1,然后如果是左上,就col-1,右上,就col+1
if(chessboard[i][j] == '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;
}
}
return true;
}
public:
vector<vector<string>> solveNQueens(int n) {
result.clear();
vector<string> chessboard(n,string(n,'.'));
//std::vector<std::string> chessboard(n,std::string(n,'.'));
//std是命名空间,如果不写的话,那前面要写using namespace std
//但使用using namespace std在大项目当中要谨慎,可能会出现重名等情况,后果严重
backstracking(n,0,chessboard);
return result;
}
};
37. 解数独
解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满说明找到结果了),所以不需要终止条件!
一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放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++){
if(isVaild(i, j, k, board)){
board[i][j] = k;
if(backtracking(board)) return true;
board[i][j] = '.';
}
}
return false; // 9个数都试完了,都不行,那么就返回false
//说明这个棋盘找不到解决数独问题的解!这也就是为什么没有终止条件也不会永远填不满棋盘而无限递归下去!
}
}
return true;
}
bool isVaild(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;
}
public:
void solveSudoku(vector<vector<char>>& board) {
backtracking(board);
}
};
回溯篇总结
回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
回溯算法能解决的问题
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
回溯法的模板
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
回溯的时间空间复杂度分析
子集问题分析:
- 时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为 O ( 2 n ) O(2^n) O(2n),构造每一组子集都需要填进数组,又有需要 O ( n ) O(n) O(n),最终时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)。
- 空间复杂度: O ( n ) O(n) O(n),递归深度为 n n n,所以系统栈所用空间为 O ( n ) O(n) O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为 O ( n ) O(n) O(n)。但如果采用set去重,空间复杂度就变成了 O ( n 2 ) O(n^2) O(n2),因为每一层递归都有一个set集合,系统栈空间是n,每一个空间都有set集合。
排列问题分析:
- 时间复杂度: O ( n ! ) O(n!) O(n!),这个可以从排列的树形图中很明显发现,每一层节点为 n n n,第二层每一个分支都延伸了 n − 1 n-1 n−1个分支,再往下又是 n − 2 n-2 n−2个分支,所以一直到叶子节点一共就是$ n * n-1 * n-2 * … 1 = n! 。 每 个 叶 子 节 点 都 会 有 一 个 构 造 全 排 列 填 进 数 组 的 操 作 ( 对 应 代 码 为 ‘ r e s u l t . p u s h b a c k ( p a t h ) ‘ ) , 该 操 作 的 复 杂 度 为 。每个叶子节点都会有一个构造全排列填进数组的操作(对应代码为`result.push_back(path)`),该操作的复杂度为 。每个叶子节点都会有一个构造全排列填进数组的操作(对应代码为‘result.pushback(path)‘),该操作的复杂度为O(n) 。 所 以 , 最 终 时 间 复 杂 度 为 : 。所以,最终时间复杂度为: 。所以,最终时间复杂度为:n * n! , 简 化 为 ,简化为 ,简化为O(n!)$。
- 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。
组合问题分析:
- 时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。
N皇后问题分析:
- 时间复杂度:$O(n!) , 其 实 如 果 看 树 形 图 的 话 , 直 觉 上 是 O ( n n ) , 但 皇 后 之 间 不 能 见 面 所 以 在 搜 索 的 过 程 中 是 有 剪 枝 的 , 最 差 也 就 是 ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是 ,其实如果看树形图的话,直觉上是O(nn),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!) , , ,n! 表 示 表示 表示n * (n-1) * … * 1$。
- 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。
解数独问题分析:
- 时间复杂度:$O(9^m) $, m m m是 ′ . ′ '.' ′.′的数目。
- 空间复杂度: O ( n 2 ) O(n^2) O(n2),递归的深度是 n 2 n^2 n2。