数独的Java版解法

最近偶尔有玩数独,有的题太复杂了不好解,刚好看到LeetCode上有这样的题,就尝试写了个Java的解法。

1. 数独介绍

数独盘面是个九宫,每一宫又分为九个小格。在这八十一格中给出一定的已知数字和解题条件,利用逻辑和推理,在其他的空格上填入1-9的数字。使1-9每个数字在每一行、每一列和每一宫中都只出现一次,所以又称“九宫格”。

左边是数独的题目,右边是完成后的结果。
《数独的Java版解法》 《数独的Java版解法》

2. 求解思路

2.1 方法定义与初始化

传入的是一个char[9][9]的数组,其中空白的部分用点号’.’代替,算出的解直接填进去即可。

public void solveSudoku(char[][] board)

我定义了一个Map<Integer, List<Character>> unsolve,它的key是当前未得到解的格子下标(从左上角开始到右下角分别是0-80),它的value是该格子可能的数值。

定义了一个初始化方法,用于初始化上面的那个map,即将每个未得到解的格子的可能数字填入1-9这9个数字。

private Map<Integer, List<Character>> initUnsolveMap(char[][] board) {
    Map<Integer, List<Character>> unsolve = new TreeMap<>();
    final List<Character> initChars = Arrays.asList('1', '2', '3', '4', '5', '6', '7', '8', '9');
    for (int y = 0; y < board.length; y++) {
        char[] chars = board[y];
        for (int x = 0; x < chars.length; x++) {
            char aChar = chars[x];
            if (aChar == '.') {
                unsolve.put(y * 9 + x, new ArrayList<>(initChars));
            }
        }
    }
    return unsolve;
}

当这个map的某个key的值只有一个元素时,那这个格子就确定了,我会将它从map中删除,并更新board。

数独最基本的解法是唯一余数法和摒除法。我采取的做法是用程序模拟人工去求解的办法。
下面开始正式求解过程。

2.1 唯一余数法

  1. 唯一余数法:用格位去找唯一可填数字,称为余数法,格位唯一可填数字称为唯余解。比如一行9个格子其中8个都填满了,那剩下的一个毫无疑问已经确定了。
  2. 我这里的做法是,依次遍历map的每个key,也就是每个未获得解的格子。
  3. 检查它所在的行、列、小方格,将不可能的数字剔除(见下面的代码)。例如所在行已经有2、5、7了,那这个格子肯定不会是这3个数字中的一个。这个步骤可以剔除不少数字。
  4. 可以看到代码中我定义了一个变量叫change,这个用于记录本轮执行过程中,map是否有发生变化。这个用途后面会说到。
  5. 对那些剔除后,只剩下一种可能数字的格子,进行移除操作,并更新board。
// 检查未解出的格子的每行、每列、每个方块,剔除已经出现的数字
for (Integer integer : unsolve.keySet()) {
    boolean thisChange = false;
    List<Character> resX = checkX(board, unsolve, integer);
    if (resX.size() > 0) {
        thisChange = true;
    }
    List<Character> resY = checkY(board, unsolve, integer);
    if (resY.size() > 0) {
        thisChange = true;
    }
    List<Character> resC = checkCube(board, unsolve, integer);
    if (resC.size() > 0) {
        thisChange = true;
    }
    if (thisChange) {
        change = true;
    }
}
// 对那些剔除后,只剩下一种可能数字的格子,进行移除操作,并更新board
List<Integer> needRemove = new ArrayList<>();
for (Integer integer : unsolve.keySet()) {
    List<Character> integers = unsolve.get(integer);
    if (integers.size() == 1) {
        board[integer / 9][integer % 9] = integers.get(0); needRemove.add(integer); } if (integers.size() == 0) { return FAIL; } } for (Integer integer : needRemove) { unsolve.remove(integer); }

2.2 摒除法

  1. 摒除法:用数字去找单元内唯一可填空格,称为摒除法。例如9这个数字,在第一行和第二行都出现过了,那第三行的9一定不会在前2行出现过的宫格(9个格子的小方块)中,所以只剩下一个宫格的第三行会出现9,那如果那个宫格的那一行只有一个空位,那9就确定了。
  2. 与唯一余数法不同的是,这个时候可能该行、该列、该宫格都不止一个空位。这2种方法其实是使用不同的维度求解,一个从格子的维度,一个是从数字的维度,是互相补充的。一种方法无法继续求解后,可尝试第二种方法,就有可能求解了。
  3. 具体做法是,遍历1-9这9个数字,例如我们选择1这个数字进行说明
  4. 对1这个数字,遍历每一行的每个格子,如果该行没有1的话,那我们会检查剩下的格子的可能数字。
  5. 如果剩下的格子中,只有一个格子可能是1,那1就确认了。确认后进行删除map中的key并更新board。
//进行摒除法
for (char i = '1'; i <= '9'; i++) {
    boolean thisChange = checkNumX(board, unsolve, i);
    if (thisChange) change = true;
}

checkNumX方法:

private boolean checkNumX(char[][] board, Map<Integer, List<Character>> unsolve, char c) {
    boolean change = false;
    for (int y = 0; y < 9; y++) {
        boolean found = false;
        List<Integer> poss = new ArrayList<>();
        for (int x = 0; x < 9; x++) {
            if (board[y][x] == c) {
                found = true;
                break;
            }
            if (board[y][x] == '.') {
                int num = y * 9 + x;
                List<Character> characters = unsolve.get(num);
                if (characters.contains(c)) {
                    poss.add(num);
                }
            }
        }
        if (!found) {
            if (poss.size() == 1) {
                int integer = poss.get(0);
                board[integer / 9][integer % 9] = c;
                unsolve.remove(integer);
                change = true;
            }
        }
    }
    return change;
}

2.3 递归调用

  • 当使用上述方法求解后,可能board和unsolve这个map已经发生了变化(例如某个格子已经解出来了),那这个时候,重新进行上述两种方法,将可以排除新的数字。这也是我们前2个方法中有记录change变量的用处。
  • 在下面的代码中,我们判断change是否为true,也就是本轮是否发生了变化。如果发生了变化,我们进行新一轮求解。
  • 如果没有发生变化,有3种可能:
    • 已经全部解出来了,这个时候我们标记为SUCCESS
    • 前2种方法已经无法找出解了,这个时候我标记为UNDONE
    • 求解失败,当前无解,标记为FAIL
  • 这里我们用到了一个方法isValidSudoku,用来校验当前board的有效性,比较简单,就是检查一下每行、每列、每宫格有没有重复的数字,这里就不说了。
  • 当本轮change为true时,将重新递归调用本方法进行求解,直至某一轮未改变停止,将状态归为上面3种中的一种。
if (change) {
    return solve(board, unsolve);
} else {
    if (unsolve.size() > 0) {
        return UNDONE;
    } else {
        boolean valid = isValidSudoku(board);
        if (valid) {
            return SUCCESS;
        } else {
            return FAIL;
        }
    }
}

2.4 假设法

  1. 经过上面的递归尝试数独的2种基本求解方法,我发现这种方式,可以应对软件中的难度为简单和中等的数独题目,但是针对难度为困难的题目,就会卡住无法继续求解了(也就是状态为UNDONE)。
  2. 数独还有一些其他的复杂解法,例如区块摒除法)、数组、二链列、唯一矩形、全双值格致死解法、同数链、异数链及其他数链的高级技巧等等,这些解法代码层面实现起来较为复杂,故放弃。
  3. 这里我选择了比较适合代码编写的方式,也就是假设法,或者叫暴力求解法。也就是在上述方法得到的结果的基础上,找出那些可能数字比较少的一个格子。
  4. 针对这个格子,我分别设它为可能的每一个数字,并各自启动一个新的实例去求解。例如某个格子可能为3、5这2个数字,那我创建2个新的board,分别填入3、5这2个数字,并重新调用上面的求解办法。
  5. 按上面的方式继续求解,那会得到一个结果列表,里面可能有的是UNDONE,有的是FAIL和有的是SUCCESS,如果有SUCCESS,那我们的求解过程就结束了,将成功的board返回即可。而其中如果出现了FAIL,说明这个实例不可能存在,那就排除掉。
  6. 比较复杂的是出现UNDONE了,说明这一轮的假设仍然求不出解。那我们可以再继续假设。例如第一轮假设我们假设了3、5。然后返回了2个UNDONE,那我们还要继续在3的结果里面,继续假设,例如还是有2个可能性。这样我们可能就产生了4个假设的结果,再进行上面的判断。
  7. 说起来可能蛮复杂,但是代码实现起来挺简单的,也就是再次应用了递归的方法。如下所示:
private int recursion(char[][] rawBoard, Map<Integer, List<Character>> unsolve) {
    int status = solve(rawBoard, unsolve);//进行常规的求解过程,并获得当前求解的状态
    if (status == UNDONE) {//只有UNDONE的状态需要继续假设,SUCCESS和FAIL都不需要
        List<char[][]> boards = findPossBoard(rawBoard, unsolve);//根据当前结果进行假设,获得假设的board列表
        for (char[][] board : boards) {
            int newStatus = recursion(board, initUnsolveMap(board));//递归调用本方法,展开新的求解与假设过程
            if (newStatus == SUCCESS) {
                for (int i = 0; i < 9; i++) {
                    System.arraycopy(board[i], 0, rawBoard[i], 0, 9);//成功时,将成功的board赋值给原始的board数组。如果这不是最外层的递归,那将一层一层往上传递,直至传给最原始的board数组。
                }
                return SUCCESS;
            }
        }
        return FAIL;
    } else {
        return status;
    }
}

3. 总结

经过上面的4种方法共同求解,目前只要有解的数独都可以解出来。完整的代码见我的github:https://github.com/lnho/LeetCode_Java/blob/master/src/main/java/com/lnho/leetcode/solution/Solution037Simple.java ,里面还有一些测试的用例。

    原文作者:九宫格问题
    原文地址: https://blog.csdn.net/Lnho2015/article/details/78632158
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞