最近偶尔有玩数独,有的题太复杂了不好解,刚好看到LeetCode上有这样的题,就尝试写了个Java的解法。
1. 数独介绍
数独盘面是个九宫,每一宫又分为九个小格。在这八十一格中给出一定的已知数字和解题条件,利用逻辑和推理,在其他的空格上填入1-9的数字。使1-9每个数字在每一行、每一列和每一宫中都只出现一次,所以又称“九宫格”。
左边是数独的题目,右边是完成后的结果。
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 唯一余数法
- 唯一余数法:用格位去找唯一可填数字,称为余数法,格位唯一可填数字称为唯余解。比如一行9个格子其中8个都填满了,那剩下的一个毫无疑问已经确定了。
- 我这里的做法是,依次遍历map的每个key,也就是每个未获得解的格子。
- 检查它所在的行、列、小方格,将不可能的数字剔除(见下面的代码)。例如所在行已经有2、5、7了,那这个格子肯定不会是这3个数字中的一个。这个步骤可以剔除不少数字。
- 可以看到代码中我定义了一个变量叫change,这个用于记录本轮执行过程中,map是否有发生变化。这个用途后面会说到。
- 对那些剔除后,只剩下一种可能数字的格子,进行移除操作,并更新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 摒除法
- 摒除法:用数字去找单元内唯一可填空格,称为摒除法。例如9这个数字,在第一行和第二行都出现过了,那第三行的9一定不会在前2行出现过的宫格(9个格子的小方块)中,所以只剩下一个宫格的第三行会出现9,那如果那个宫格的那一行只有一个空位,那9就确定了。
- 与唯一余数法不同的是,这个时候可能该行、该列、该宫格都不止一个空位。这2种方法其实是使用不同的维度求解,一个从格子的维度,一个是从数字的维度,是互相补充的。一种方法无法继续求解后,可尝试第二种方法,就有可能求解了。
- 具体做法是,遍历1-9这9个数字,例如我们选择1这个数字进行说明
- 对1这个数字,遍历每一行的每个格子,如果该行没有1的话,那我们会检查剩下的格子的可能数字。
- 如果剩下的格子中,只有一个格子可能是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 假设法
- 经过上面的递归尝试数独的2种基本求解方法,我发现这种方式,可以应对软件中的难度为简单和中等的数独题目,但是针对难度为困难的题目,就会卡住无法继续求解了(也就是状态为UNDONE)。
- 数独还有一些其他的复杂解法,例如区块摒除法)、数组、二链列、唯一矩形、全双值格致死解法、同数链、异数链及其他数链的高级技巧等等,这些解法代码层面实现起来较为复杂,故放弃。
- 这里我选择了比较适合代码编写的方式,也就是假设法,或者叫暴力求解法。也就是在上述方法得到的结果的基础上,找出那些可能数字比较少的一个格子。
- 针对这个格子,我分别设它为可能的每一个数字,并各自启动一个新的实例去求解。例如某个格子可能为3、5这2个数字,那我创建2个新的board,分别填入3、5这2个数字,并重新调用上面的求解办法。
- 按上面的方式继续求解,那会得到一个结果列表,里面可能有的是UNDONE,有的是FAIL和有的是SUCCESS,如果有SUCCESS,那我们的求解过程就结束了,将成功的board返回即可。而其中如果出现了FAIL,说明这个实例不可能存在,那就排除掉。
- 比较复杂的是出现UNDONE了,说明这一轮的假设仍然求不出解。那我们可以再继续假设。例如第一轮假设我们假设了3、5。然后返回了2个UNDONE,那我们还要继续在3的结果里面,继续假设,例如还是有2个可能性。这样我们可能就产生了4个假设的结果,再进行上面的判断。
- 说起来可能蛮复杂,但是代码实现起来挺简单的,也就是再次应用了递归的方法。如下所示:
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 ,里面还有一些测试的用例。