332.重新安排行程
在记录机场与机场之间的映射关系时,使用的是 unordered_map<string, map<string, int>> targets
, unordered_map<出发机场, map<到达机场, 航班次数>> targets
原因为:
- 出发机场 和 到达机场可能会重复,例如【1,2】【2,1】,所以对于使用过的票,需要及时删除,不然程序就会进入死循环
- 由于要返回最小的行程组合,所以到达机场 的容器 需要是 有序的,可以用multiset 和 map, 但是对于multiset 而言,删除元素之后,迭代器就会失效,所以使用 map 里面 的int 来存储 航班次数,以此来标记 到达机场是否使用过,相当于通过标记来达到 删除的目的,实际上并没有删。
回溯函数的返回值 类型为 bool
是因为 只需要找到一个行程就行(题目:如果存在多种有效的行程,请按字典排序返回最小的行程组合),而不是需要找到所有的
参数里面 定义了 票的数量 int ticketNum
,把所有票用完 即终止,vector<string> result
存储 航班,当航班的数量 等于 票的数量 + 1 时, 票即用完
class Solution {
public:
unordered_map<string, map<string, int>> targets;
bool backtracking(int ticketNum, vector<string>& result) {
if (result.size() == ticketNum + 1) {
return true;
}
// 访问 result 存储的最后一个航班 对应的 到达机场
// pair里要有const,因为map中的key是不可修改的,所以是pair<const string, int>
for (pair<const string, int>& target : targets[result[result.size() - 1]]) {
if (target.second > 0) { // 判断机场 是否到达过了
result.push_back(target.first);
target.second--;
if (backtracking(ticketNum, result)) return true;
result.pop_back();
target.second++;
}
}
return false;
}
vector<string> findItinerary(vector<vector<string>>& tickets) {
targets.clear();
// 变量初始化
for (const vector<string>& vec : tickets) {
targets[vec[0]][vec[1]]++; // 记录映射关系
}
vector<string> result;
result.push_back("JFK");
backtracking(tickets.size(), result);
return result;
}
};
51. N皇后
N皇后问题可以抽象为 1 棵树, 二维矩阵的高 即为 树高, 二维矩阵的宽 即为 树宽,用皇后的约束条件 来 回溯搜索 这棵树,只要搜索到了 树的叶子结点,即说明找到了所以皇后的合理位置。
定义了row 代表当前遍历的层数, 当遍历到最后一层 即 结束
关键点在于 如何去 约束
如果同列 , 斜对角 存在皇后就不合法,
在单层搜索中,每一层递归,只会选for 循环 (同行)里的一个元素,所以不用去验证同行是否合法
class Solution {
public:
// vector<string> path;
vector<vector<string>> result;
bool isValid(int row, int col, vector<string>& chessboard, int n) {
// 不能同列
for (int i = row - 1, j = col; i >= 0; i--) {
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;
}
}
// 不能135°斜对角
for (int i = row - 1, j = col + 1; i >= 0 && j <= n - 1; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
void backtracking (int n, int row, vector<string>& chessboard) {
if (row == n) {
result.push_back(chessboard);
return;
}
for (int i = 0; i < n; i++) {
if (isValid(row, i, chessboard, n)) {
chessboard[row][i] = 'Q';
backtracking(n, row + 1, chessboard);
chessboard[row][i] = '.';
}
}
return;
}
vector<vector<string>> solveNQueens(int n) {
result.clear();
vector<string> chessboard(n, string(n, '.'));
backtracking(n, 0, chessboard);
return result;
}
};
37. 解数独
因为题目保证数独只有一个解,所以返回值类型 为 bool
, 找到符合条件的就返回
注意返回值的位置, return false
也是有讲究的, 当在一个位置 试了9个数都不行的时候,说明这个棋盘找不到解决数独问题的解
如果把整个棋盘遍历完 都没有返回false
, 即说明找到了合适的棋盘位置, return true
。
在单层递归逻辑里面,如果找到合适的一组就立刻返回。
该题主要是 使用了 二维递归 (卡哥词汇)
class Solution {
public:
bool backtracking (vector<vector<char>>& board) {
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (board[i][j] != '.') continue;
else {
for (char k = '1'; k <= '9'; k++) {
if (isValid(i, j, k, board)) {
board[i][j] = k;
if (backtracking(board)) return true;
board[i][j] = '.';
}
}
}
return false;
}
}
return true;
}
// i : 行 j : 列 k (i, j) 位置的值
bool isValid(int row, int col, char val, vector<vector<char>>& board) {
// 同行只能出现一次
for (int j = 0; j < 9; j++) {
if (board[row][j] == val) {
return false;
}
}
// 同列只能出现一次
for (int i = 0; i < 9; i++) {
if (board[i][col] == val) {
return false;
}
}
// 3 × 3 宫内 只能出现一次
for (int i = row / 3 * 3; i < row / 3 * 3 + 3; i++) {
for (int j = col / 3 * 3; j < col / 3 * 3 + 3; j++) {
if (board[i][j] == val) {
return false;
}
}
}
return true;
}
void solveSudoku(vector<vector<char>>& board) {
backtracking(board);
}
};
总结
优化回溯算法只有剪枝一种方法
(1)组合问题
对于求组合问题而言,剪枝的精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了。
在for循环上做剪枝操作是回溯法剪枝的常见套路!
对于求组合总和而言,可以再加上一个剪枝:已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉。
对于组合问题而言,什么时候需要startIndex呢,
如果是一个集合来求组合的话,就需要startIndex;如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex
对于集合中包含重复元素,去求组合总和,但要求解集不能包含重复的组合:需要树枝去重 和 树层去重, bool used
数组
(2)切割问题
利用startIndex 模拟切割线, [startIndex, i] 子串的截取
(3)子集问题
在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
所以基本不需要添加终止条件, 如果要加的话,一定加在 result.push_back(path)的后面
对于递增子序列 :同一父节点下的同层上使用过的元素就不能再使用了
(4)排列问题
排列是有序的
- 每层都是从0开始搜索而不是startIndex
- 需要used数组记录path里都放了哪些元素了
(5)性能分析
以下在计算空间复杂度的时候都把系统栈(不是数据结构里的栈)所占空间算进去。
子集问题分析:
- 时间复杂度:O(2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2n)
- 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)
排列问题分析:
- 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
- 空间复杂度:O(n),和子集问题同理。
组合问题分析:
- 时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度:O(n),和子集问题同理。
N皇后问题分析:
- 时间复杂度:O(n!) ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * … * 1。
- 空间复杂度:O(n),和子集问题同理。
解数独问题分析:
- 时间复杂度:O(9^m) , m是’.'的数目。
- 空间复杂度:O(n2),递归的深度是n2
一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!