回溯系列-数独游戏

一、数独游戏介绍

​ 数独游戏(SUDOKU)是一种数学智力拼图游戏,起源于18世纪末的瑞士,当时的瑞士数学家莱昂哈德·欧勒发明了“拉丁方块”游戏,但并没有受到人们的重视。直到20世纪70年代,美国杂志才以“数字拼图”(number place puzzles)游戏的名称将它重新推出,结果风靡一时。日本随后接受并推广了这种游戏,并且将它改名为“数独”,大致的意思是“独个的数字”或“只出现一次的数字”。数独游戏在日本非常流行,许多报纸和杂志都会刊登数独游戏。在日本的地铁上经常看到手拿报纸和铅笔、眉头紧锁的人,那就是在玩数独游戏。玩数独游戏不需要学习额外的知识,也不像字谜游戏那样需要很大的词汇量,大人和小孩都适合玩数独游戏。

​ 数独游戏在流行的过程中产生了很多变形数独,比如格子数演变成6×6,12×12,甚至是16×16,还有的规则要求对角线上的数字也要满足不重复的要求。不过,总的来说,这些变种都没有偏离这个游戏的基本规则。本文就介绍一下传统的9×9格子的数独游戏(九宫数独),并给出一种以候选数法为基础的求解数独游戏的算法实现。

二、游戏规则

​ 9×9格子数独游戏的形式如下图所示,为了方便描述,一般用大写字母A - I来标识行,用数字1-9来标识列,这样每个小单元格就有了坐标。数独游戏的规则非常简单,就是在9x9=81个单元格中填入数字1~9。这81个单元格又组成3×3=9个小九宫格,要求填入的数字在每行和每列都不能有重复,同时在每个小九宫格中也不能有重复。游戏开始时会将一些位置上的数字固定下来,这称为提示数(或起始数),根据提示数的位置和数量可以将数独游戏分成不同的难度级别。

在这里插入图片描述

三、解题思想

​ 回溯方法的思想及模板参考回溯系列-算法思想与模板,根据此算法的模板我们就可以解决此题。

​ 回溯非常适合求解数独谜题.因此本文我们将用数独谜题来阐述回溯这项算法技术.我们的状态空间将会是由空格组成的序列,每个空格最终都得填入一个数字.空格(i,j)的备选数恰为:尚未出现在行,也没有出现在j列,更没有出现在包含(i,j)的那个3×3区中1到9之间的整数.一旦排除了某格中的备选数,我们马上就进行回溯.

​ 为了方便解题定义了一些常量,见SudokuConstant.java文件,如下:

package com.design.backtrack.sudoku;

/**
 * 速度常量约束
 *
 *  @author hh
 *  @date 2021-5-31 12:51
 */
public class SudokuConstant {

    /**
     * 小九宫格的尺寸
     */
    public static int base = 3;

    /**
     * 数独尺寸
     */
    public static int dimension = 9;

    /**
     * 单元格总数
     */
    public static int totalCells = dimension * dimension;
}

​ 为了方便表示表格中方格的位置定义了Point.java,如下所示:

package com.design.backtrack.sudoku;

/**
 * 数独面板坐标
 *
 *  @author hh
 *  @date 2021-5-31 12:47
 */
public class Point {

    private int x;

    private int y;

    public Point() {
    }

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }
}

​ 为了方便解题我们定义一个对象用于存储表格中的数据和表格已经填充信息,如下类Board.java

package com.design.backtrack.sudoku;

import java.io.*;
import java.util.Arrays;

/**
 * 数独表格面板
 *
 *  @author hh
 *  @date 2021-5-31 12:49
 */
public class Board {

    /**
     * 保存每个格子的数字
     */
    private int[][] cells;

    /**
     * 还有多少空格没有填?
     */
    private int emptyCellCounter;

    public Board(String fileName) {
        this.cells = new int[SudokuConstant.dimension + 1][SudokuConstant.dimension + 1];
        this.emptyCellCounter = 0;
        //从文件创建
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(fileName))) {
            String line = null;
            int i = 0;
            while ((line = bufferedReader.readLine()) != null){
                i++;
                String[] splits = line.trim().split(" ");
                for(int j = 1; j <= splits.length; j++){
                    int covert = Integer.parseInt(splits[j-1]);
                    if(covert == 0){
                        this.emptyCellCounter ++;
                    }
                    this.cells[i][j] = covert;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public int[][] getCells() {
        return cells;
    }

    public void setCells(int[][] cells) {
        this.cells = cells;
    }

    public int getEmptyCellCounter() {
        return emptyCellCounter;
    }

    public void setEmptyCellCounter(int emptyCellCounter) {
        this.emptyCellCounter = emptyCellCounter;
    }
}

​ 该类中有两个字段,cells用于存储9宫格中每个方格所填充的数字,没有填写数字默认存储0;emptyCellCounter字段用于存储还有多少个方格没有填充。本类中的构造方法用于从文件中读取题目,题目格式如下:

8 0 0 0 0 0 0 0 0
0 0 3 6 0 0 0 0 0
0 7 0 0 9 0 2 0 0
0 5 0 0 0 7 0 0 0
0 0 0 0 4 5 7 0 0
0 0 0 1 0 0 0 3 0
0 0 1 0 0 0 0 6 8
0 0 8 5 0 0 0 1 0
0 9 0 0 0 0 4 0 0

其中方格中数字为0表示未填充数字。

首先我们要选出一个下次想要去填的空格(nextPosition),随后要确定出哪些数字是待填入该空格的备选数(getCandidates).这些程序从根本上来说是类似于记账的工作,不过有关它们如何运行的精巧细节可能会对算法性能产生极大的影响.

/**
     * 构建当前坐标位置填入数字的候选集合
     *
     * @param currentPosition 输出:要填充数字的位置
     * @param board 数独面板
     * @param c 输出:候选集合
     * @return 候选集合元素数量
     */
    private int constructCandidates(Point currentPosition,Board board, int[] c)	  {
        //获取最优位置
        this.nextPosition(board,currentPosition);
        if(currentPosition.getX() == -1 && currentPosition.getY() == -1){
            return -1;
        }
        //获取当前位置的候选集合
        return this.getCandidates(currentPosition,board,c);
    }

​ 我们必须更新上述程序中board的数据结构以反映将一个备选数填入格中的结果,而我们要从该位置离开并回溯时还应该移除这些数据变更.这些数据更新工作通过makeMove和unmakeMove来处理,二者都直接从backtrack调用:

 /**
     * 将当前位置填充数字
     *
     * @param currentPosition 当前位置
     * @param value 填充的值
     * @param board 数组面板
     */
    private void makeMove(Point currentPosition,int value,Board board){
        int oldValue = board.getCells()[currentPosition.getX()][currentPosition.getY()];
        if(oldValue == 0){
            board.setEmptyCellCounter( board.getEmptyCellCounter() -1);
        }
        board.getCells()[currentPosition.getX()][currentPosition.getY()] = value;
    }

    /**
     * 清除当前位置填充的数字
     *
     * @param currentPosition 当前填充位置
     * @param board 数独面板
     */
    private void unmakeMove(Point currentPosition,Board board){
        int oldValue = board.getCells()[currentPosition.getX()][currentPosition.getY()];
        if(oldValue != 0){
            board.setEmptyCellCounter( board.getEmptyCellCounter() + 1);
        }
        board.getCells()[currentPosition.getX()][currentPosition.getY()] = 0;
    }

​ 这些更新board的子程序有一个重要任务:维护数据以标明数独纸板还剩多少空格.当不再剩下待填空格时,我们就可以找到一个解:

/**
     * 判断是否存在解
     *
     * @param board 数独面板
     * @return true表示有解
     */
    private boolean isSolution(Board board){
        return board.getEmptyCellCounter() == 0;
    }

​ 找到解之后我们马上打印排布,并通过激发全局性标记finished(即设其为true)以关掉回溯搜索.你可以放心地这样去做而不必担心有什么后果,因为“官方”数独谜题只允许有一个解.而对于非官方的数独游戏,可能会有大量的解答方法.事实上,空数独(最初所有数字都不确定)的可行填法共有6670903752021072936960种.我们可以保证,关掉搜索之后多余的解一个都看不见:

/**
     * 打印结果
     *
     * @param board 数独面板
     */
    private void processSolution(Board board){
        for(int i = 1; i <= SudokuConstant.dimension; i++){
            for(int j = 1; j <= SudokuConstant.dimension; j++){
                System.out.print(board.getCells()[i][j] + " ");
            }
            System.out.println();
        }
        //有一个解之后标记结束
        this.finished = true;
    }

​ 下面将完成两个程序模块的细节:找出接下来想去填的空格(nextPosition);对于指定空格找出可填入其中的所有候选数(getCandidates).先看nextPosition,选择下一空格有两种合理方案:

  • 选择任意空格:选取我们最先遇到的空格,有可能选第一个,最后一个,或者一个随机空格.要是看起来没有任何理由相信某种启发式方法比另一个效果更好时,所有选法都一样.
  • 选择约束最多的空格:这种方案中我们检查每个空格(i,j),看看该空格还剩几个侯选数,即没有在行和列还有包含(i,j)的小9宫格之中用到的数字.我们选择那个候选数字数目最少的空格.

​ 尽管两种方案都能够正确运行,但第二种方案要好得多得多.通常都会有一些空格只剩一个候选数.对于这些方格,没有别的填法只能填一个数字.我们不如赶紧填好此类空格,特别是鉴于将这些数定下来之后会有助于减少其他空格所能填的候选数数目.当然我们在每次找候选空格时将会比第一种方案花更多的时间,但是如果题目够简单的话,我们压根也用不到回溯.
​ 如果约束最多的空格有两个可选数字,相比于完全无限制的空格的1/9概率而言,我们第一次就有1/2的概率猜对结果.例如,将我们每次空格可选数字数目的平均值从3降低到2会是一个极大的战果,因为这种平均值对每个位置都会乘进去.比如说,如果我们有20个位置要去填,只需枚举220=1048576个解.对于20个位置,每个位置的分支因子要是3的话,将会导致运行时间变为3000多倍!

​ 所以我们选择约束最多的空格的方案。

​ 最后要快策的是关于getCandidates的万案,它对每个空格可返回容许的数字取值情况.我们有两种可行方案:

  • 局部清点——为数独纸板位置(i,j)创建候选数的子程序是getCandidates,要是该程序完成了它该做的基本任务,并且包容1到9里面那些未出现在给定行和列还有区(均由(i,j)确定)之中的所有剩余数字,那么我们的回溯搜索便正确运行.
  • 向前探查——不过,如果在局部清点准则下我们现在所面临的部分解中有其他一些空格已经无候选数留存,又该怎么办呢?肯定没有办法将这种部分解扩展成完整解,也即无法填满数独网格。因此,由于纸板上的其他位置的情况,对于此类(i,j)位置,确实想不出来任何走子.
    只要选择此类空格进行扩展,随后就会发现这种部分解己无法走子,这样我们就必须回溯,因此程序最终肯定会发现这种障碍.但是为什么要一直等到所有努力完全付诸东流的时候呢?我们最好马上停止回溯而去进行其他走子方案.

成功的剪枝需要向前探查策略,这样可以发现何时一个解注定无子可走,并尽可能早地进行回溯.

四、代码实现

package com.design.backtrack.sudoku;

/**
 * 回溯法解决数独问题
 *
 *  @author hh
 *  @date 2021-5-31 12:42
 */
public class SudokuSolution {

    /**
     * 是否完成
     */
    private boolean finished = false;

    /**
     * 回溯核心算法
     *
     * @param board 数独面板
     */
    public void backtrack(Board board){
        //保存下一位置候选集合
        int[] c = new int[SudokuConstant.dimension + 1];
        if(isSolution(board)){
            processSolution(board);
        }else{
            Point currentPosition = new Point();
            //下一位位置候选者数目
            int nCandidates = constructCandidates(currentPosition,board,c);
            for(int i = 0; i < nCandidates; i++){
                makeMove(currentPosition,c[i],board);
                backtrack(board);
                if(finished){
                    //提前终止
                    return;
                }
                unmakeMove(currentPosition,board);
            }
        }

    }

    /**
     * 判断是否存在解
     *
     * @param board 数独面板
     * @return true表示有解
     */
    private boolean isSolution(Board board){
        return board.getEmptyCellCounter() == 0;
    }

    /**
     * 打印结果
     *
     * @param board 数独面板
     */
    private void processSolution(Board board){
        for(int i = 1; i <= SudokuConstant.dimension; i++){
            for(int j = 1; j <= SudokuConstant.dimension; j++){
                System.out.print(board.getCells()[i][j] + " ");
            }
            System.out.println();
        }
        //有一个解之后标记结束
        this.finished = true;
    }

    /**
     * 构建当前坐标位置填入数字的候选集合
     *
     * @param currentPosition 输出:要填充数字的位置
     * @param board 数独面板
     * @param c 输出:候选集合
     * @return 候选集合元素数量
     */
    private int constructCandidates(Point currentPosition,Board board, int[] c){
        //获取最优位置
        this.nextPosition(board,currentPosition);
        if(currentPosition.getX() == -1 && currentPosition.getY() == -1){
            return -1;
        }
        //获取当前位置的候选集合
        return this.getCandidates(currentPosition,board,c);
    }

    /**
     * 选择策略:选择下一个约束条件最多的空格,即候选集数量最少的那个位置
     *
     * @param board 数独面板
     * @param currentPosition 输出:选择下一位置将保存此对象中
     */
    private void nextPosition(Board board,Point currentPosition){
        int maxCandidateCount = Integer.MAX_VALUE;
        Point bestPoint = new Point(-1,-1);
        int[] candidates = new int[SudokuConstant.dimension +1];
        //循环遍历每个位置如果没填数字则计算,否则跳过
        for(int i = 1; i <= SudokuConstant.dimension; i++){
            for(int j = 1; j <= SudokuConstant.dimension; j++){
                if(board.getCells()[i][j] != 0) {
                    continue;
                }
                Point tempPoint = new Point(i,j);
                int temp = this.getCandidates(tempPoint,board,candidates);
                if(maxCandidateCount > temp){
                    maxCandidateCount = temp;
                    bestPoint = tempPoint;
                }
            }
        }
        currentPosition.setX(bestPoint.getX());
        currentPosition.setY(bestPoint.getY());
    }

    /**
     * 获取当前填充位置的候选集合
     *
     * @param position 当前填充位置
     * @param board 数独面板
     * @param candidates 输出:候选集合
     * @return 候选集合元素数量
     */
    private int getCandidates(Point position,Board board,int[] candidates){
        boolean[] c = new boolean[SudokuConstant.dimension + 1];
        //获取当前行坐标x
        int x = position.getX();
        for(int i = 1; i <= SudokuConstant.dimension; i++){
            if(board.getCells()[x][i] != 0){
                c[board.getCells()[x][i]] = true;
            }
        }
        //获取当前列坐标y
        int y = position.getY();
        for(int i = 1; i <= SudokuConstant.dimension; i++){
            if(board.getCells()[i][y] != 0){
                c[board.getCells()[i][y]] = true;
            }
        }
        //标记当前位置的小九宫格
        //求横坐标上下限
        int xLeft = (x-1) / SudokuConstant.base * SudokuConstant.base + 1;
        int xRight = xLeft + SudokuConstant.base;
        //求纵坐标上下限
        int yLeft = (y-1) / SudokuConstant.base * SudokuConstant.base + 1;
        int yRight = yLeft + SudokuConstant.base;
        for(int i = xLeft; i < xRight; i++){
            for(int j = yLeft; j < yRight; j++){
                if(board.getCells()[i][j] != 0){
                    c[board.getCells()[i][j]] = true;
                }
            }
        }
        int candidateCount = 0;
        for(int i = 1; i <= SudokuConstant.dimension; i++){
            if(!c[i]){
                candidates[candidateCount] = i;
                candidateCount ++;
            }
        }
        return candidateCount;
    }

    /**
     * 将当前位置填充数字
     *
     * @param currentPosition 当前位置
     * @param value 填充的值
     * @param board 数组面板
     */
    private void makeMove(Point currentPosition,int value,Board board){
        int oldValue = board.getCells()[currentPosition.getX()][currentPosition.getY()];
        if(oldValue == 0){
            board.setEmptyCellCounter( board.getEmptyCellCounter() -1);
        }
        board.getCells()[currentPosition.getX()][currentPosition.getY()] = value;
    }

    /**
     * 清除当前位置填充的数字
     *
     * @param currentPosition 当前填充位置
     * @param board 数独面板
     */
    private void unmakeMove(Point currentPosition,Board board){
        int oldValue = board.getCells()[currentPosition.getX()][currentPosition.getY()];
        if(oldValue != 0){
            board.setEmptyCellCounter( board.getEmptyCellCounter() + 1);
        }
        board.getCells()[currentPosition.getX()][currentPosition.getY()] = 0;
    }

    public static void main(String[] args){
        Board board = new Board( "D:\\Desktop\\java项目学习\\leetcode\\src\\com\\design\\backtrack\\sudoku\\test\\hard.txt");
        SudokuSolution sudokuSolution = new SudokuSolution();
        sudokuSolution.backtrack(board);
    }

}

五、运行截图

文件hard.txt内容如下:

8 0 0 0 0 0 0 0 0
0 0 3 6 0 0 0 0 0
0 7 0 0 9 0 2 0 0
0 5 0 0 0 7 0 0 0
0 0 0 0 4 5 7 0 0
0 0 0 1 0 0 0 3 0
0 0 1 0 0 0 0 6 8
0 0 8 5 0 0 0 1 0
0 9 0 0 0 0 4 0 0

运行截图如下:
在这里插入图片描述

六、更多相关文章

  • 2
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值