本题是深搜,深搜一般都会使用回溯的思想。
这道题目有几个难点:
- 一个行程中,如果航班处理不好容易变成一个圈,成为死循环
- 有多种解法,字母序靠前排在前面,让很多同学望而退步,如何该记录映射关系呢 ?
- 使用回溯法(也可以说深搜) 的话,那么终止条件是什么呢?
- 搜索的过程中,如何遍历一个机场所对应的所有机场。
如何理解死循环
对于死循环,我来举一个有重复机场的例子:
出发机场和到达机场也会重复的,如果在解题的过程中没有对集合元素处理好,就会死循环。
记录映射关系
这里要递归的信息就是机票的信息,要正确记录映射关系才能避免死循环 和 按字典排序。
正确处理死循环,就要在递归过程中,对使用过的机票及时删除,或者在记录上标记“无效”,避免再次使用。结合需要字典序排序,决定使用下面的数据结构存储机票信息:
Map<String, Map<String, Integer>>
其中外层map的key是起点,内层map是该起点对应的终点及航班次数,每使用一次内层的终点,航班次数就减1,航班次数为0时不能使用。内层的map是TreeMap,自动根据字典序排序。
回溯法
- 递归函数参数
(1)本题所要递归的信息设在全局变量里。
同时还要记录tickets.size(),这要作为递归终止的条件。
(2)本题只需要返回一条字典序最小的路径,不需要搜索整棵树,因此将返回值设为boolean,后面在递归的时候也要注意对返回值的处理,及时返回。
- 递归终止条件
if (result.size() == ticketNum + 1) {
return true;
}
- 单层搜索的逻辑
首先判断是否满足终止条件 res.size() == tickets.size() +1 ,满足则返回,否则:
(1)获取当前res的lastDestination
(2)在map中寻找lastDestination对应的ticketInfo
(3)对ticketInfo的 entry形式 迭代,自动满足字典序,当机票数>=1时,res加入新的lastDestination再次迭代,同时注意处理返回值,若递归返回true则本层传递结果,return true, 否则应当恢复现场(ticketInfo加回次数,res去掉最后的地点)
本题本质上还是回溯法,但是回溯的信息复杂,要选择合适的数据结构。
代码如下:
class Solution {
List<String> res = new ArrayList<>();
int len ;
Map<String,Map<String,Integer>> map;
public List<String> findItinerary(List<List<String>> tickets) {
map = new HashMap<>();
len = tickets.size() + 1;
for(List<String> ticket : tickets){
Map<String,Integer> desInfo;
if(map.containsKey(ticket.get(0))){
String des = ticket.get(1);
desInfo = map.get(ticket.get(0));
desInfo.put(des,desInfo.getOrDefault(des,0)+1);
}else{
desInfo = new TreeMap<>();
desInfo.put(ticket.get(1),1);
map.put(ticket.get(0),desInfo);
}
}
res.add("JFK");
dfs();
return res;
}
public boolean dfs(){
if(res.size() == len){
return true;
}
String last = res.get(res.size()-1);
if(map.containsKey(last)){
for(Map.Entry<String,Integer> nextInfo : map.get(last).entrySet()){
if(nextInfo.getValue() > 0){
nextInfo.setValue(nextInfo.getValue()-1);
res.add(nextInfo.getKey());
if(dfs()) return true;
nextInfo.setValue(nextInfo.getValue()+1);
res.remove(res.size()-1);
}
}
}
return false;
}
}
2.51. N皇后
上一题难在对信息的处理,保证不死循环 和 最小字典序,单层逻辑的回溯处理相对正常,本题还要思考如何在单层逻辑处理中满足游戏规则。
用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了。
- 递归函数参数
棋盘大小n , 当前所处行数(相当于之前的startIndex) row , 定义全局变量二维数组result来记录最终结果
- 递归终止条件
叶子节点返回
if (row == n) {
result.push_back(chessboard);
return;
}
- 单层搜索的逻辑
从递归树可以看出,要遍历该row所有的可能得位置,若位置合法,则继续递归。
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] = '.'; // 回溯,撤销皇后
}
}
- 验证棋盘是否合法
按照如下标准去重:
- 不能同行
- 不能同列
- 不能同斜线 (45度和135度角)
代码如下:
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;
}
本题还有难点是数据结构的处理,进行数组和String,List<String>间转换、组成。
代码如下:
class Solution {
List<List<String>> res = new ArrayList<>();
char[][] board;
int n;
public List<List<String>> solveNQueens(int n) {
this.n = n;
board = new char[n][n];
for(char[] c : board){
Arrays.fill(c,'.');
}
dfs(0);
return res;
}
public void dfs(int row){
if(row == n){
res.add(Array2List());
return ;
}
for(int i = 0 ; i < n ; i++){
if(isValid(row,i)){
board[row][i]='Q';
dfs(row+1);
board[row][i]='.';
}
}
}
public boolean isValid(int row , int col){
for(int i = 0 ; i < n ; i++){
if(board[i][col]=='Q') return false;
}
for(int i = row - 1 , j = col - 1 ; i >= 0 && j >= 0 ; i--,j--){
if(board[i][j]=='Q') return false;
}
for(int i = row - 1 , j = col + 1 ; i >= 0 && j < n ; i--,j++){
if(board[i][j]=='Q') return false;
}
return true;
}
public List<String> Array2List(){
List<String> list = new ArrayList<>();
for(char[] c : board){
list.add(String.copyValueOf(c));
}
return list;
}
}
也可以使用boolean 数组表示已经占用的列、主对角线、副对角线。
列的boolean数组大小为n,主、副对角线的boolean数组大小都为2*n - 1,因为主、副对角线的条数都分别有2*n - 1 条,同一条主对角线 row - col 一致,同一条副对角线 row + col 一致。
代码如下:
// 方法2:使用boolean数组表示已经占用的直(斜)线
class Solution {
List<List<String>> res = new ArrayList<>();
boolean[] usedCol, usedDiag45, usedDiag135; // boolean数组中的每个元素代表一条直(斜)线
public List<List<String>> solveNQueens(int n) {
usedCol = new boolean[n]; // 列方向的直线条数为 n
usedDiag45 = new boolean[2 * n - 1]; // 45°方向的斜线条数为 2 * n - 1
usedDiag135 = new boolean[2 * n - 1]; // 135°方向的斜线条数为 2 * n - 1
//用于收集结果, 元素的index表示棋盘的row,元素的value代表棋盘的column
int[] board = new int[n];
backTracking(board, n, 0);
return res;
}
private void backTracking(int[] board, int n, int row) {
if (row == n) {
//收集结果
List<String> temp = new ArrayList<>();
for (int i : board) {
char[] str = new char[n];
Arrays.fill(str, '.');
str[i] = 'Q';
temp.add(new String(str));
}
res.add(temp);
return;
}
for (int col = 0; col < n; col++) {
if (usedCol[col] | usedDiag45[row + col] | usedDiag135[row - col + n - 1]) {
continue;
}
board[row] = col;
// 标记该列出现过
usedCol[col] = true;
// 同一45°斜线上元素的row + col为定值, 且各不相同
usedDiag45[row + col] = true;
// 同一135°斜线上元素row - col为定值, 且各不相同
// row - col 值有正有负, 加 n - 1 是为了对齐零点
usedDiag135[row - col + n - 1] = true;
// 递归
backTracking(board, n, row + 1);
usedCol[col] = false;
usedDiag45[row + col] = false;
usedDiag135[row - col + n - 1] = false;
}
}
}
3.37. 解数独
本题是二维递归。
N皇后问题是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来遍历列,然后一行一列确定皇后的唯一位置。
本题就不一样了,本题中棋盘的每一个位置都要放一个数字(而N皇后是一行只放一个皇后),并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。
回溯三部曲
- 递归函数以及参数
递归函数的返回值需要是bool类型,为什么呢?
因为解数独找到一个符合的条件(就在树的叶子节点上)立刻就返回,相当于找从根节点到叶子节点一条唯一路径,所以需要使用bool返回值。
- 递归终止条件
本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。(即递归结束由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宫格里是否重复
public boolean isValid(int row, int col , char c){
for(int j = 0 ; j < board[0].length ; j++){
if(board[row][j] == c) return false;
}
for(int i = 0 ; i < board.length ; i++){
if(board[i][col] ==c) 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] == c) return false;
}
}
return true;
}
完整代码如下:
class Solution {
char[][] board;
public void solveSudoku(char[][] board) {
this.board = board;
dfs();
}
public boolean dfs(){
for(int i = 0 ; i < board.length ; i++){
for(int j = 0 ; j < board[0].length ; j++){
if(board[i][j] != '.') continue;
for(char c = '1' ; c <= '9' ; c++){
if(isValid(i,j,c)){
board[i][j] = c;
if(dfs()) return true;
board[i][j] = '.';
}
}
return false;
}
}
return true;
}
public boolean isValid(int row, int col , char c){
for(int j = 0 ; j < board[0].length ; j++){
if(board[row][j] == c) return false;
}
for(int i = 0 ; i < board.length ; i++){
if(board[i][col] ==c) 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] == c) return false;
}
}
return true;
}
}
回溯法总结参考:回溯总结篇