代码随想录算法训练营第30天 || 332.重新安排行程 || 51. N皇后 || 37. 解数独 || 总结
332.重新安排行程
题目介绍:
给你一份航线列表 tickets
,其中 tickets[i] = [fromi, toi]
表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。
所有这些机票都属于一个从 JFK
(肯尼迪国际机场)出发的先生,所以该行程必须从 JFK
开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。
- 例如,行程
["JFK", "LGA"]
与["JFK", "LGB"]
相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。
示例 1:
输入:tickets = [["MUC","LHR"],["JFK","MUC"],["SFO","SJC"],["LHR","SFO"]]
输出:["JFK","MUC","LHR","SFO","SJC"]
示例 2:
输入:tickets = [["JFK","SFO"],["JFK","ATL"],["SFO","ATL"],["ATL","JFK"],["ATL","SFO"]]
输出:["JFK","ATL","JFK","SFO","ATL","SFO"]
解释:另一种有效的行程是 ["JFK","SFO","ATL","JFK","ATL","SFO"] ,但是它字典排序更大更靠后。
这道题真的一点脾气都没有,可以说几乎一点想法都没有,字典排序不了解,也不知道如何记录路径过程
题解解析
这道题其实更像是图论中的深度优先搜索,但其实深度优先搜索也离不开回溯,这里就用回溯的思想来解决这道题。
本题几大难点:
- 一个行程中,航班处理不好可能会陷入一个死循环
- 有多种解法,字母序靠前的放在前面,如何记录映射关系
- 使用回溯的终止条件是什么
- 搜索过程如何遍历所有机场
死循环解释
类似示例2中,出发机场和终点机场出现重复时,如果没有做标记处理,可能就会在某几个机场来回跑出不来
如何记录映射关系
一个机场映射多个机场,机场之间还有靠字母排序
前者条件可使用HashMap,后者条件可使用TreeMap或TreeSet
如果使用TreeSet,我们遍历过的机场无法直接删除解决,这样迭代器会失效,另外还有一个问题,可能该机场映射的次数>1,而TreeSet又会覆盖掉,无法记录次数信息;所以我们考虑使用TreeMap记录相关信息
public HashMap<String, TreeMap<String, Integer>> targets = new HashMap<>();
//<起始飞机场,<终止飞机场,航班次数>>
//航班次数表示这段飞行区间还有多少次可用,为0就不能再使用了
回溯三部曲
- 确定参数和返回值
- 参数:ticketNum(还要飞行的次数)、result 返回结果集
- 返回值:Boolean,因为只要找到结果之后一路返回true,无需再去其他分支遍历
- 确定递归结束条件:
ticket == 0
,飞行任务结束了 - 确定单层递归逻辑
- 找到下一个目的地
- 修改两地航班次数
- 添加结果集
- 下一层递归
- 回溯次数和结果集
本人理解下的代码:
class Solution {
public HashMap<String, TreeMap<String, Integer>> targets = new HashMap<>();
//<起始飞机场,<终止飞机场,航班次数>>
public List<String> findItinerary(List<List<String>> tickets) {
initTargets(tickets);
List<String> result = new ArrayList<>();
result.add("JFK");
backtracking(tickets.size(), result);
return result;
}
public boolean backtracking(int flyNum, List<String> result) {
if (flyNum == 0)
return true;
String start = result.get(result.size() - 1);
TreeMap<String, Integer> map = targets.get(start);
if (map == null)//此路不通
return false;
Set<String> ends = map.keySet();
for (String end : ends) {
if (map.get(end) > 0) {
map.put(end, map.get(end) - 1);
targets.put(start, map);
result.add(end);
if (backtracking(flyNum - 1, result))
return true;
result.remove(result.size() - 1);
map.put(end, map.get(end) + 1);
targets.put(start, map);
}
}
return false;
}
//初始化HashMap集合
public void initTargets(List<List<String>> tickets) {
for (int i = 0; i < tickets.size(); i++) {
List<String> list = tickets.get(i);
if (targets.containsKey(list.get(0))) {
TreeMap<String, Integer> map = targets.get(list.get(0));
if (map.containsKey(list.get(1))) {
map.put(list.get(1), map.get(list.get(1)) + 1);
} else map.put(list.get(1), 1);
} else {
TreeMap<String, Integer> map = new TreeMap<>();
map.put(list.get(1), 1);
targets.put(list.get(0), map);
}
}
}
}
网上优化版本,优化了遍历HashMap的过程和初始化map集合的过程
因为get获取到的其实还是类似引用,所以可以直接修改,无需像原代码那么麻烦
class Solution {
private Deque<String> res;
private Map<String, Map<String, Integer>> map;
private boolean backTracking(int ticketNum){
if(res.size() == ticketNum + 1){
return true;
}
String last = res.getLast();
if(map.containsKey(last)){//防止出现null
for(Map.Entry<String, Integer> target : map.get(last).entrySet()){
int count = target.getValue();
if(count > 0){
res.add(target.getKey());
target.setValue(count - 1);
if(backTracking(ticketNum)) return true;
res.removeLast();
target.setValue(count);
}
}
}
return false;
}
public List<String> findItinerary(List<List<String>> tickets) {
map = new HashMap<String, Map<String, Integer>>();
res = new LinkedList<>();
for(List<String> t : tickets){
Map<String, Integer> temp;
if(map.containsKey(t.get(0))){
temp = map.get(t.get(0));
temp.put(t.get(1), temp.getOrDefault(t.get(1), 0) + 1);
}else{
temp = new TreeMap<>();//升序Map
temp.put(t.get(1), 1);
}
map.put(t.get(0), temp);
}
res.add("JFK");
backTracking(tickets.size());
return new ArrayList<>(res);
}
}
51.N皇后
题目介绍:
按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。
n 皇后问题 研究的是如何将 n
个皇后放置在 n×n
的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n
,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q'
和 '.'
分别代表了皇后和空位。
示例 1:
输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。
示例 2:
输入:n = 1
输出:[["Q"]]
个人思路:
n皇后问题其实就是在每一层找一个合适的位置,我们可以使用回溯法横向遍历作为树层宽度,竖向递归作为树枝深度
整体思路:一层层递归,每次确定一个位置之后就标记一下二维数组,如果不成功就回溯标记数组且退回上一层循环下一个位置
回溯三部曲:
- 确定参数和返回值:
- 参数:n(n*n的棋盘,其实可以不要此参数)、deep(当前深度)、tag(二维标记数组)
- 返回值:void
- 确定递归结束条件:
path.size() == n
,表示已经顺利填完每一层棋盘 - 确定单层搜索逻辑:
- 先当前看看是否被标记,如果没被标记则更新标记数组,添加结果,进入下一层递归
- 当下层递归结束就要进行标记数组回溯和结果回溯
代码:
class Solution {
public List<List<String>> result = new ArrayList<>();
public List<String> path = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
int[][] tag = new int[n][n];
backtracking(n, 0, tag);
return result;
}
public void backtracking(int n, int deep, int[][] tag) {
if (path.size() == n) {
result.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < n; i++) {
if (tag[i][deep] > 0)//标记过的不能放,直接树层剪枝
continue;
addPath(i, n);
updataTag(tag, i, deep, true);
backtracking(n, deep + 1, tag);
updataTag(tag, i, deep, false);
path.remove(path.size() - 1);
}
}
public void addPath(int x, int n) {
StringBuilder stringBuilder = new StringBuilder();
for (int i = 0; i < n; i++) {
if (i == x) stringBuilder.append("Q");
else stringBuilder.append(".");
}
path.add(stringBuilder.toString());
}
public void updataTag(int[][] tag, int x, int y, boolean updata) {
int n = tag[0].length;
if (updata) {
for (int i = 0; i < n; i++) {//横竖
tag[i][y]++;
tag[x][i]++;
}
for (int i = x + 1, j = y + 1; i < n && j < n; i++, j++) //右上斜
tag[i][j]++;
for (int i = x - 1, j = y - 1; i >= 0 && j >= 0; i--, j--) //左下斜
tag[i][j]++;
for (int i = x + 1, j = y - 1; i < n && j >= 0; i++, j--) //右下斜
tag[i][j]++;
for (int i = x - 1, j = y + 1; i >= 0 && j < n; i--, j++) //左上斜
tag[i][j]++;
} else {
for (int i = 0; i < n; i++) {
tag[i][y]--;
tag[x][i]--;
}
for (int i = x + 1, j = y + 1; i < n && j < n; i++, j++)
tag[i][j]--;
for (int i = x - 1, j = y - 1; i >= 0 && j >= 0; i--, j--)
tag[i][j]--;
for (int i = x + 1, j = y - 1; i < n && j >= 0; i++, j--)
tag[i][j]--;
for (int i = x - 1, j = y + 1; i >= 0 && j < n; i--, j++)
tag[i][j]--;
}
}
}
题解解析:
整体上还是差不多的递归思路,差别在于我是使用标记数组排除不符位置,而题解是每次放置皇后再去判断是否符合,这种写法相对效率更高一些
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;
}
这样写的好处,只需要往上去查找Q就行,而使用标记数组则要标记整个棋盘不符的位置
class Solution {
List<List<String>> res = new ArrayList<>();
public List<List<String>> solveNQueens(int n) {
char[][] chessboard = new char[n][n];
for (char[] c : chessboard) {
Arrays.fill(c, '.');
}
backTrack(n, 0, chessboard);
return res;
}
public void backTrack(int n, int row, char[][] chessboard) {
if (row == n) {
res.add(Array2List(chessboard));
return;
}
for (int col = 0;col < n; ++col) {
if (isValid (row, col, n, chessboard)) {
chessboard[row][col] = 'Q';
backTrack(n, row+1, chessboard);
chessboard[row][col] = '.';
}
}
}
public List Array2List(char[][] chessboard) {
List<String> list = new ArrayList<>();
for (char[] c : chessboard) {
list.add(String.copyValueOf(c));
}
return list;
}
public boolean isValid(int row, int col, int n, char[][] chessboard) {
// 检查列
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-1; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
}
37. 解数独
题目介绍:
编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字
1-9
在每一行只能出现一次。 - 数字
1-9
在每一列只能出现一次。 - 数字
1-9
在每一个以粗实线分隔的3x3
宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.'
表示。
示例 1:
输入:board = [["5","3",".",".","7",".",".",".","."],["6",".",".","1","9","5",".",".","."],[".","9","8",".",".",".",".","6","."],["8",".",".",".","6",".",".",".","3"],["4",".",".","8",".","3",".",".","1"],["7",".",".",".","2",".",".",".","6"],[".","6",".",".",".",".","2","8","."],[".",".",".","4","1","9",".",".","5"],[".",".",".",".","8",".",".","7","9"]]
输出:[["5","3","4","6","7","8","9","1","2"],["6","7","2","1","9","5","3","4","8"],["1","9","8","3","4","2","5","6","7"],["8","5","9","7","6","1","4","2","3"],["4","2","6","8","5","3","7","9","1"],["7","1","3","9","2","4","8","5","6"],["9","6","1","5","3","7","2","8","4"],["2","8","7","4","1","9","6","3","5"],["3","4","5","2","8","6","1","7","9"]]
解释:输入的数独如上图所示,唯一有效的解决方案如下所示:
个人想法:想像上一题一样标记,但是回溯过程有点困惑,如果纵向递归的某一层中横向遍历发现走不通不知道如何横向倒退回溯
题解思路:
本题看起来和N皇后类似,实则差别不小,这道题可以说是三维问题,相比N皇后提升了一个维度,因为N皇后每行每列只需要填一个标记,这道题每个格子都要填标记,同时还有一定的要求。
回溯三部曲:
- 确定参数和返回值:
- 参数:二维数组棋盘
- 返回值:Boolean(只需要找到1个答案就可以一路返回true
- 确定递归结束条件:不需要特意设置,当填完最后一个数字,自然可以返回true来结束
- 确定单层逻辑:
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,说明找到了合适棋盘位置了
}
注意返回true和false的位置,返回true的时候,确实是遍历完毕了,返回false表示此路不通
class Solution {
public void solveSudoku(char[][] board) {
backtracking(board);
}
public boolean backtracking(char[][] board) {
for (int i = 0; i < 9; i++) {//遍历行
for (int j = 0; j < 9; j++) {//遍历列
if (board[i][j] == '.') {
for (char k = '1'; k <= '9'; k++) {//三维空间试数据
if (isValue(board, k, i, j)) {
board[i][j] = k;
if (backtracking(board))
return true;
board[i][j] = '.';
}
}
return false;
}
}
}
return true;
}
public boolean isValue(char[][] board, char k, int x, int y) {
for (int i = 0; i < 9; i++) {//横竖检测
if (board[i][y] == k || board[x][i] == k)
return false;
}
int index_x = x / 3 * 3;
int index_y = y / 3 * 3;
//9宫格检测
for (int j = index_x; j < index_x + 3; j++) {
for (int l = index_y; l < index_y + 3; l++) {
if (board[j][l] == k)
return false;
}
}
return true;
}
}
总结
回溯算法主要问题:
- 组合问题
- 排列问题
- 切割问题
- 子集问题
- 棋盘问题
回溯算法模板:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
解决回溯问题基本思想了解之后关键在于剪枝,剪枝可以大致包括树枝剪枝(break)和树层剪枝(continue);去重问题还得考虑是否使用startIndex,还有sumTarget的比较来剪枝问题,isValue方法的使用
三道棋盘问题中得到的启发:
-
一对多的映射关系集合
-
二维、三维空间的递归遍历思想
return false;
}
}
return true;
}
}
### **总结**
回溯算法主要问题:
- 组合问题
- 排列问题
- 切割问题
- 子集问题
- 棋盘问题
回溯算法模板:
```text
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
解决回溯问题基本思想了解之后关键在于剪枝,剪枝可以大致包括树枝剪枝(break)和树层剪枝(continue);去重问题还得考虑是否使用startIndex,还有sumTarget的比较来剪枝问题,isValue方法的使用
三道棋盘问题中得到的启发:
- 一对多的映射关系集合
- 二维、三维空间的递归遍历思想