二维场景下的回溯问题 -- N皇后 + 数独


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. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。

空白格用 '.' 表示。


img

具体的一个数独解如下所示。


img

答案被标成红色。

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 03。当前可以再额外的写一个循环来遍历方格,作者这里使用[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 08时,[i / 3, i % 3]分别会是[0, 0], [0, 1], ..., [2, 1], [2, 2],即所有方格中的9个坐标。因此,使用[(row / 3) * 3 + i / 3][(col / 3) * 3 + i % 3]就可以遍历完所有的9个格子。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值