1. N皇后
1.1 题目描述
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
例如,上图为 8 皇后问题的一种解法。
给定一个整数 n,返回所有不同的 n 皇后问题的解决方案。每一种解法包含一个明确的 n 皇后问题的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。
示例:
输入: 4
输出: [
[".Q..", // 解法 1
"...Q",
"Q...",
"..Q."],
["..Q.", // 解法 2
"Q...",
"...Q",
".Q.."]
]
解释: 4 皇后问题存在两个不同的解法。
提示:皇后是国际象棋中的棋子,意味着国王的妻子。皇后只做一件事,那就是“吃子”。当她遇见可以吃的棋子时,就迅速冲上去吃掉棋子。当然,她横、竖、斜都可走一到七步,可进可退。(引用自 百度百科 - 皇后 )
1.2 思考
N皇后问题是典型的回溯问题,题目的解只能枚举所有的情况,最后从中选择出所有可能的结果。根据题目的描述可知,如果棋盘上的某个格子中已经存在了一个皇后,那么它的同行同列,以及正负对角线上都不应该存在其他的皇后。因此,可以知道结果的数目是固定,可以通过回溯法找到所有可能的结果。
假设某个时刻棋盘中皇后的摆放如下所示:
此时
[0, 1]
和
[1, 3]
都已经放上了皇后,且此时的棋盘是不会出现冲突的。考虑第2行的摆放,根据合法性的原则,只能在
[2, 0]
处放置一个皇后。最后考虑当前棋盘下,最后一行应该如何放置,只能依次进行判断:
[3, 0]
:它会与[2, 0]
的皇后发生冲突,所以不合法[3, 1]
:它会和其他三个已经摆放的皇后都发生冲突,所以同样不合法[3, 2]
:它和已放的皇后都不冲突,因此是一个合法位置[3, 3]
:根据原则可知,前面已有合法的位置,那么此时后面的位置其实都可以不再进行判断
因此,根据图示的过程我们可以很容易将其归纳到回溯的框架中,判断的顺序可以为:
- 当前行标下所有列的摆放
- 当前位置的正对角线(↗)的摆放
- 当前位置负对角线(↖)的摆放
Java解题代码如下:
class Solution {
public List<List<String>> solveNQueens(int n) {
// 首先创建存放结果的List
// 棋盘的每一行都是一个List
List<List<String>> results = new LinkedList<>();
// 初始化棋盘
char[][] map = new char[n][n];
for (char[] chars : map) {
Arrays.fill(chars, '.');
}
// 以行开始进行判断,当然以列也可以
int row = 0;
// 深度优先搜索
dfs(map, row, results);
results.forEach(System.out :: println);
return results;
}
public void dfs(char[][] map, int row, List<List<String>> results){
int length = map.length;
// 终止条件,如果到达最后一行,判断结束
if(row == length){
List<String> path = new LinkedList<>();
for (char[] chars : map) {
path.add(String.valueOf(chars));
}
results.add(path);
return;
}
// 选择过程
for (int col = 0; col < length; col++) {
// 判断棋盘上当前位置是否合法
if(!isValid(map, row, col)){
continue;
}
// 如果合法,做出选择,判断下一行可能的摆放情况
map[row][col] = 'Q';
// 回溯
dfs(map, row + 1, results);
// 撤销选择
map[row][col] = '.';
}
}
private boolean isValid(char[][] map, int row, int col) {
int length = map.length;
// 判断当前行、列的位置是否合法
for (char[] chars : map) {
// 如果其他行的该列上已经放了皇后,则不能再放,说明当前位置不合法
if (chars[col] == 'Q') return false;
}
// 判断右上对角线情况,行减列加
for (int i = row - 1, j = col + 1; i >= 0 && j < length; i--, j++) {
// 如果对角线上已经放了,该位置不合法
if (map[i][j] == 'Q') return false;
}
// 检查左上对角线,行减列减
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
// 如果对角线上已经放了,该位置不合法
if (map[i][j] == 'Q') return false;
}
// 如果该列的所有行和所有对角线上都没有放,则说明为合法位置
return true;
}
}
2. 数独(Sudoku)
2.1 问题描述
编写一个程序,通过已填充的空格来解决数独问题。
一个数独的解法需遵循如下规则:
- 数字
1-9
在每一行只能出现一次。 - 数字
1-9
在每一列只能出现一次。 - 数字
1-9
在每一个以粗实线分隔的3x3
宫内只能出现一次。
空白格用 '.'
表示。
具体的一个数独解如下所示。
答案被标成红色。
Note:
- 给定的数独序列只包含数字
1-9
和字符'.'
- 你可以假设给定的数独只有唯一解
- 给定数独永远是
9x9
形式的
2.2 思考
数独问题显然无法一次就直接找到问题的解,我们需要对每个格子穷举所有可能填的数字,并在全局的规则约束下找到对应问题的解。因此,根据穷举的思路来想,回溯法显然是一个很好的选择。根据回溯法的思想可知,在真正求解问题之前,我们需要得出回溯法求解的三大核心:
- 选择列表:即某个时刻下,数独棋盘中空白格子已经填写的数字的情况
- 可供选择的列表:即在规定同行、同列及同个方格中不能填写同样的数字的规则下,有哪些数字可以合法的满足此时的需求
- 终止条件:即找到一个满足问题的解
例如,假设某个时刻棋盘的填写如下所示:
那么在考虑标?
的格子填写哪个数字时,具体想回溯法的三大核心要素在这里是什么:
- 选择列表:即此时棋盘,第0行已经填写的了
3、3、1、9、7
,第5列已经填写了5、3
,第2个方格中已经填写了1、5、7、8、9
- 可供选择的列表:根据已经填写的情况,此时可以选择填写的数字有
6、4、2
- 终止条件:即最后问题的解
在明白了具体的核心要素后,就可以编写解题代码了。棋盘这里是一个二维的char型数组char[][]
,其中空白的部分使用'.'
填充。由前面n皇后的解题思路可知,代码的核心部分包括回溯函数dfs()
和判断是否合法的isValid()
。
其中dfs()
的代码框架为:
public boolean dfs(char[][] board, int row, int col){
终止条件
for (char ch = '1'; ch <= '9'; ch++) {
做出选择
回溯
撤销选择
}
}
具体的解题代码如下:
/**
* @Author dyliang
* @Date 2020/8/5 23:27
* @Version 1.0
*/
public class Solution {
// 定义棋盘
static char[][] board = new char[][]{
{'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'}
};
public static void main(String[] args) {
dfs(board, 0, 0);
for (char[] chars : board) {
System.out.println(Arrays.toString(chars));
}
}
// 回溯
public static boolean dfs(char[][] board, int row, int col){
// 棋盘永远是 9 * 9
int m = 9, n = 9;
// 如果此时已经判断到了最后一列
if (col == n) {
// 则到下一行重新开始判断,列号归零
return dfs(board, row + 1, 0);
}
// 如果已经判断到了最后一行,算法结束
if (row == m) {
return true;
}
// 在每一次的判断过程中,如果当前位置已经有初始值,则跳过判断当前行下一列
if (board[row][col] != '.') {
// 如果有预设数字,不用我们穷举
return dfs(board, row, col + 1);
}
// 穷举所有可能的字符
for (char ch = '1'; ch <= '9'; ch++) {
// 如果遇到不合法的数字,就跳过
if (!isValid(board, row, col, ch))
continue;
// 做出选择
board[row][col] = ch;
// 假设只有唯一解
if (dfs(board, row, col + 1)) {
return true;
}
// 撤销选择
board[row][col] = '.';
}
// 无解
return false;
}
// 判断当前位置是否合法
public static boolean isValid(char[][] board, int row, int col, char ch) {
// 穷举1 - 9
for (int i = 0; i < 9; i++) {
// 判断行是否存在重复
if (board[row][i] == ch)
return false;
// 判断列是否存在重复
if (board[i][col] == ch)
return false;
// 判断 3 x 3 方框是否存在重复
if (board[(row / 3) * 3 + i / 3][(col / 3) * 3 + i % 3] == ch)
return false;
}
return true;
}
}
代码来自:回溯算法最佳实践:解数独
这里的代码同样采用的前面n皇后问题中,以行为基准来进行后续判断的方法,重要的部分是如何控制程序的跳转,其他只需要使用回溯法的解题框架即可。
另外,一个重要的问题是如何得到方格的索引,以及如果在该方格中进行判断?官方题解中说明了方块索引 = (行 / 3) * 3 + 列 / 3
。然后就需要知道如何遍历该方格内的9个格子,依次判断是否合法。作者这里采用了一个精妙的思路,这里转载lcx0528的回答:假设某个格子坐标为(row,col)
,那么[(row / 3) * 3,(col / 3) * 3]
对应的正好是[row, col]
所在的具体方格的左上角的坐标。如果知道了一个
3
×
3
3 \times 3
3×3方格左上角的坐标,遍历方格只需要考虑行列的增量
0
∼
3
0 \sim 3
0∼3。当前可以再额外的写一个循环来遍历方格,作者这里使用[i / 3, i % 3]
就实现了行列增量的控制。
例如格子坐标为[4, 5]
,那么[(row / 3) * 3,(col / 3) * 3]
对应的坐标是[1 * 3, ,1 * 3] = [3, 3]
,即第5个方格的左上角坐标。在循环判断过程中,当i取值为
0
∼
8
0 \sim 8
0∼8时,[i / 3, i % 3]
分别会是[0, 0], [0, 1], ..., [2, 1], [2, 2]
,即所有方格中的9个坐标。因此,使用[(row / 3) * 3 + i / 3][(col / 3) * 3 + i % 3]
就可以遍历完所有的9个格子。