数独题的生成与解决方法
前言
最近在学习Java,在梁勇的 Introduction to Java Programming 10ed 中看到了一个数独问题的例子,这个例子其实是引导学习二维数组的例子,书本中给出的例子也比较简单,就是判断一个数独答案是不是正确的。
其实进行到这,学习知识的目的已经达到了,但是只能输入一个数独答案判断一下是否正确,这实在是太太太太太傻了,不知道有多傻。我始终按耐不住心中那股探索欲,我要做一个生成数独题的程序,同时它还能自己解决。于是这就开启了潘多拉的魔盒。
背景
数独是一种源自18世纪末的瑞士,后在美国发展,并在日本得以发扬光大的数学智力拼图游戏,其游戏规则为:在由9个小九宫格组成的大九宫格里,已经填有若干数字,需用数字1~9填满剩下的空格,使得
- 每行9个格子填入9个不同的数字
- 每列9个格子填入9个不同的数字
- 每宫9个格子填入9个不同的数字
问题 答案
0 0 0 0 0 0 0 0 0 1 2 3 4 6 5 7 8 9
0 0 0 0 0 0 1 6 2 4 5 7 3 8 9 1 6 2
0 0 0 0 2 7 0 0 3 8 6 9 1 2 7 4 5 3
0 0 4 0 0 1 0 0 0 3 7 4 5 9 1 6 2 8
0 0 0 0 0 0 3 9 0 5 8 1 6 7 2 3 9 4
0 0 6 0 3 4 0 0 0 2 9 6 8 3 4 5 1 7
0 4 0 0 0 0 0 0 1 6 4 8 2 5 3 9 7 1
0 0 5 0 4 8 2 0 6 7 1 5 9 4 8 2 3 6
0 3 0 7 1 6 8 0 0 9 3 2 7 1 6 8 4 5
难度等级的度量
对于我这个数独游戏的门外汉,我只能通过感性认识来度量一道数独题的难度。
一个人类解决一道数独题是在已有的信息之上来解决的,已有的信息包括剩余数字的数量以及数字的分布。一个数独题中,剩余数字的数量以及数字分布的均匀、对称性是决定问题难度的关键。因此可以通过两个衡量因素:数字个数、数字分布,来衡量一个数独问题的难度。难度可以这样划分:
- 已知格总数
- 行中已知格数
- 列中已知格数
那么问题来了,一道题最少可以留下几个格子,人们才有可能解决呢?这个问题目前仍无定案,不过听数学家说是17个,不过那将是骨灰级难度了。一般来说,数独题是在22~30个左右。因此我就把数独题设置成这个样子。
其次,就是数字的分布,从出题者的角度看,数字的分布也就是在一个数独答案之上选择按照什么顺序挖洞(把某个数字挖掉),为了使得剩余数字分布均匀一些,可以随机挖洞,或者隔开一个挖一个。为了把难度加大,就让剩余数字分布不均匀一些,比方说按照从左到右从上到下的顺序挖洞。嘻嘻,我就是这样干的。
其实还可以通过写程序解决问题,并且统计解决时间来衡量一个问题的难度。不过那就是研究数独的人干的事儿了,我们是Coder,只需要在脑子里有一个难度的印象就行了。
算法分析
我们的目标是让程序生成一道题,并且自己解决这道题。
求解算法
这里我采用的是深度优先搜索的方式解决一道题,算法从上到下,从左到右依次尝试填入每个数字,最终寻找出正确解决,十分暴力。
/*
* DFS解数独问题
*/
public static boolean dfs(int[][] f, boolean[][] r, boolean[][] c, boolean[][] b) {
for(int i = 0; i < 9; i++)
for(int j = 0; j < 9; j++)
if(f[i][j] == 0) {
int k = i / 3 * 3 + j / 3;
// 尝试填入1~9
for(int n = 1; n < 10; n++) {
if(!r[i][n] && !c[j][n] && !b[k][n]) {
// 尝试填入一个数
r[i][n] = true;
c[j][n] = true;
b[k][n] = true;
f[i][j] = n;
// 检查是否满足数独正解
if(dfs(f, r, c, b))
return true;
// 不满足则回溯
r[i][n] = false;
c[j][n] = false;
b[k][n] = false;
f[i][j] = 0;
}
}
// 尝试所有数字都不满足则回溯
return false;
}
return true;
}
函数的(f,r,c,b)二维数组分别表示
- f : 九宫格的数字,f[i][j]的范围是1~9
- r : r[0][1] = true 表示第0行里已经有1填入了
- c : c[0][1] = true 表示第0列里已经有1填入了
- b : b[0][1] = true 表示第0宫里已经有1填入了
利用这4个全局的二维数组可以比较快速的判断当前解决方案的状态是否满足数组的限制条件,其实也可以专门写函数来判断,不过我这算是用空间换时间了。
生成算法
我的生成算法首先使用拉斯维加斯随机算法来生成一个数独答案,是数独答案。然后按照从上到下从左到右的顺序依次挖洞,不过这个挖洞可没那么简单,这一挖还得使得生成的数独题只有唯一解,因此就得多做一步判断唯一解的工作。
/*
* 拉斯维加斯随机算法生成一个随机数独问题
*/
public static boolean lasVegas(int n) {
int i, j, k, value;
Random random = new Random();
// 初始化
for(i = 0; i < 9; i++) {
for(j = 0; j < 9; j++) {
field[i][j] = 0;
rows[i][j+1] = false;
cols[i][j+1] = false;
blocks[i][j+1] = false;
}
}
// 随机填入数字
while(n > 0) {
i = random.nextInt(9);
j = random.nextInt(9);
if(field[i][j] == 0) {
k = i / 3 * 3 + j / 3;
value = random.nextInt(9) + 1;
if(!rows[i][value] && !cols[j][value] && !blocks[k][value]) {
field[i][j] = value;
rows[i][value] = true;
cols[j][value] = true;
blocks[k][value] = true;
n--;
}
}
}
// 检查并且生成一个数组解
if(dfs(field, rows, cols, blocks))
return true;
else
return false;
}
拉斯维加斯算法中的n表示随机填入几个位置,你可以自己取值,不过我取的是11,因为取11的时候粗略测量已经有99%的概率生成一个正解了,可以参照:
public static void main(String[] args) {
// 拉斯维加斯算法生成数独
while(!lasVegas(11));
// 输入剩余数字数
Scanner input = new Scanner(System.in);
System.out.print("Enter the level(22 - 30): ");
int level = input.nextInt();
while(level < 22 || level > 30) {
System.out.print("Enter the level(22 - 30): ");
level = input.nextInt();
}
// 生成数独题
generateByDigMethod(level);
printer();
// 提示答案
System.out.print("Wanan answer ? (input 1): ");
int hint = input.nextInt();
if(hint == 1) {
dfs(field, rows, cols, blocks);
printer();
}
}
那么如果判断唯一解呢?其实用的是反证法的思想,挖掉一个洞,比如是第三行第三个,原来的数字是9,这下我们把它换成1~8,然后让上面的程序解一下。如果它还能解出答案,那么这个问题就有至少两个解了,这就不对了。于是乎我们跳过它,去挖第三行第四个,然后继续判断。最终我们就生成唯一解的题目了!
/*
* 挖洞法生成一个数独问题
* level: 剩余数字
*/
public static void generateByDigMethod(int level) {
// 从上到下从左到右的顺序挖洞
for(int i = 0; i < 9; i++)
for(int j = 0; j < 9; j++)
if(checkUnique(i, j)) {
int k = i / 3 * 3 + j / 3;
rows[i][field[i][j]] = false;
cols[j][field[i][j]] = false;
blocks[k][field[i][j]] = false;
field[i][j] = 0;
level++;
if(81 == level)
break;
}
}
/*
* 判断唯一解
* 挖掉[r, c]位置的数字判断是否得到唯一解
*/
public static boolean checkUnique(int r, int c) {
// 挖掉第一个位置一定有唯一解
if(r == 0 && c == 0)
return true;
int k = r / 3 * 3 + c / 3;
boolean[][] trows = new boolean[9][10];
boolean[][] tcols = new boolean[9][10];
boolean[][] tblocks = new boolean[9][10];
int[][] tfield = new int[9][9];
// 临时数组
for(int i = 0; i < 9; i++) {
for(int j = 0; j < 9; j++) {
trows[i][j+1] = rows[i][j+1];
tcols[i][j+1] = cols[i][j+1];
tblocks[i][j+1] = blocks[i][j+1];
tfield[i][j] = field[i][j];
}
}
// 假设挖掉这个数字
trows[r][field[r][c]] = false;
tcols[c][field[r][c]] = false;
tblocks[k][field[r][c]] = false;
for(int i = 1; i < 10; i++)
if(i != field[r][c]) {
tfield[r][c] = i;
if(!trows[r][i] && !tcols[c][i] && !tblocks[k][i]) {
trows[r][i] = true;
tcols[c][i] = true;
tblocks[k][i] = true;
// 更换一个数字之后检查是否还有另一解
if(dfs(tfield, trows, tcols, tblocks))
return false;
trows[r][i] = false;
tcols[c][i] = false;
tblocks[k][i] = false;
}
}
// 已尝试所有其他数字发现无解即只有唯一解
return true;
}
判断结果正确与否
最后送上一段判断正解的算法,很简单的算法
/**
* 数独答案检查
* @author trav
*/
public class CheckSudokuSolution {
public static void main(String[] args) {
int[][] grid = readSolution();
System.out.println(isValid(grid) ? "Valid solution" : "Invalid solution");
}
public static int[][] readSolution() {
Scanner input = new Scanner(System.in);
System.out.println("Enter a Sudoku puzzle solution:");
int[][] grid = new int[9][9];
for(int i = 0; i < 9; i++)
for(int j = 0; j < 9; j++)
grid[i][j] = input.nextInt();
return grid;
}
public static boolean isValid(int[][] grid) {
for(int i = 0; i < 9; i++)
for(int j = 0; j < 9; j++)
if(grid[i][j] < 1 || grid[i][j] > 9 || !isValid(i, j, grid))
return false;
return true;
}
public static boolean isValid(int i, int j, int[][] grid) {
// 检查列唯一性
for(int column = 0; column < 9; column++)
if(column != j && grid[i][column] == grid[i][j])
return false;
// 检查行唯一性
for(int row = 0; row < 9; row++)
if(row != i && grid[row][j] == grid[i][j])
return false;
// 检查格唯一性
for(int row = (i / 3) * 3; row < (i / 3) * 3 + 3; row++)
for(int col = (j / 3) * 3; col < (j / 3) * 3 + 3; col++)
if(row != i && col != j && grid[row][col] == grid[i][j])
return false;
return true;
}
}
算法复杂度分析
聪明的读者应该已经发现了,生成算法十分依赖求解算法,因此分析时间复杂度的关键在于调用了多少次求解算法,因为DFS的时间复杂度大家都知道是O(V+E)。
在生成算法中,包括生成一个最终解以及挖洞。生成一个最终解由于采用的是随机算法,因此分析起来比较复杂,不过将n取11的时候已经有99%概率生成正解了,也就是99%的概率只需要尝试一次,因此不妨就设为O(V+E)。
而挖洞的过程中,需要尝试81次,也就是 81 * O(V+E),然而V也就是81,因此时间复杂度是O(V^2),还是挺大的,有待改进。
总结
程序中还有许多可以改进的地方,比如设置难度级别、生成的题目可以进行对称轮换、挖洞的顺序可以按难度分为多种等等。算法时间复杂度还是挺高的,不过还好数独只有81个格子,在我的机子上还是跑得飞快的。
听说多做做数独题可以防止老年痴呆,这下舒服了。
Reference
[1]薛源海,蒋彪彬,李永卓,闫桂峰,孙华飞.基于“挖洞”思想的数独游戏生成算法[J].数学的实践与认识,2009,39(21):1-7.
[2]Sudoku Wikipedia, 2018. https://en.wikipedia.org/wiki/Sudoku