编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 '.' 表示。
输入:
board = [["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"]]
输出:[["5","3","4","6","7","8","9","1","2"]
,["6","7","2","1","9","5","3","4","8"]
,["1","9","8","3","4","2","5","6","7"]
,["8","5","9","7","6","1","4","2","3"]
,["4","2","6","8","5","3","7","9","1"]
,["7","1","3","9","2","4","8","5","6"]
,["9","6","1","5","3","7","2","8","4"]
,["2","8","7","4","1","9","6","3","5"]
,["3","4","5","2","8","6","1","7","9"]]
解释:输入的数独如上图所示,唯一有效的解决方案如下所示:
方法一:递归
最容易想到的方法是用一个数组记录每个数字是否出现。由于我们可以填写的数字范围为[1,9],而数组的下标从0开始,因此在存储时,我们使用一个长度为9的布尔类型的数组,其中i个元素的值为True,当且仅当数字i+1出现过。例如我们用line[2][3]=True 表示数字4在第2行已经出现过,那么当我们在遍历到第2行的空白格时,就不能填入数字4。
首先对整个数独数组进行遍历,当我们遍历到第i行第j列的位置:
如果该位置是一个空白格,那么我们将其加入一个用来存储空白格位置的列表中,方便后续的递归操作;
如果该位置是一个数字x,那么我们需要将line[i][x−1],column[j][x−1] 以及block[⌊i/3⌋][⌊j/3⌋][x−1] 均置为True。
当我们结束了遍历过程之后,就可以开始递归枚举。当递归到第i行第j列的位置时,我们枚举填入的数字x。根据题目的要求,数字x不能和当前行、列、九宫格中已经填入的数字相同,因此line[i][x−1],column[j][x−1] 以及block[⌊i/3⌋][⌊j/3⌋][x−1] 必须均为False。
当我们填入了数字x之后,我们要将上述的三个值都置为True,并且继续对下一个空白格位置进行递归。在回溯到当前递归层时,我们还要将上述的三个值重新置为 False。
class Solution{
private boolean[][] line=new boolean[9][9];
private boolean[][] column=new boolean[9][9];
private boolean[][][] block=new boolean[3][3][9];
private boolean valid=false;
private List<int[]> spaces=new ArrayList<int[]>();
public void solveSudoku(char[][] board){
for(int i=0;i<9;i++){
for(int j=0;j<9;j++){
if(board[i][j]=='.'){
spaces.add(new int[]{i,j});
}else{
int digit=board[i][j]-'0'-1;
line[i][digit]=column[j][digit]=block[i/3][j/3][digit]=true;
}
}
}
dfs(board,0);
}
public void dfs(char[][] board,int pos){
if(pos==spaces.size()){
valid=true;
return;
}
int[] space=spaces.get(pos);
int i=space[0],j=space[1];
for(int digit=0;digit<9 && !valid;digit++){
if(!line[i][digit] && !column[j][digit] && !block[i/3][j/3][digit]){
line[i][digit]=colum[j][digit]=block[i/3][j/3][digit]=true;
board[][]=(char)(digit+'0'+1);
dfs(board,pos+1);
line[i][digit]=column[j][digit]=block[i/3][j/3][digit]=false;
}
}
}
}
方法二:位运算优化
在方法一中,我们使用了长度为9的数组表示每个数字是否出现过。我们同样也可以借助位运算,仅使用一个整数表示每个数字是否出现过。
具体地,数b的二进制表示的第i位(从低到高,最低位为第0位)为1,当且仅当数字i+1已经出现过。例如当 bb 的二进制表示为 (011000100)_2时,就表示数字 33,77,88 已经出现过。
位运算有一些基础的使用技巧。下面列举了所有在代码中使用到的技巧:
对于第i行第j列的位置,line[i] ∣ column[j] ∣ block[⌊i/3⌋][⌊j/3⌋] 中第k位为1,表示该位置不能填入数字k+1(因为已经出现过),其中|表示按位或运算。如果我们对这个值进行∼ 按位取反运算,那么第k位为1就表示该位置可以填入数字k+1,我们就可以通过寻找1来进行枚举。由于在进行按位取反运算后,这个数的高位也全部变成了1,而这是我们不应当枚举到的,因此我们需要将这个数和 (111111111)_2 =(1FF)_16进行按位与运算&,将所有无关的位置为0;
我们可以使用按位异或运算∧,将第i位从0变为1,或从1变为0。具体地,与数 1 << i1<<i 进行按位异或运算即可,其中<<表示左移运算;
我们可以用b & (−b) 得到b二进制表示中最低位的1,这是因为(−b) 在计算机中以补码的形式存储,它等于∼b+1。bb 如果和∼b 进行按位与运算,那么会得到0,但是当∼b 增加1之后,最低位的连续的1都变为0,而最低位的0变为1,对应到b中即为最低位的1,因此当b 和∼b+1 进行按位与运算时,只有最低位的1会被保留;
当我们得到这个最低位的1时,我们可以通过一些语言自带的函数得到这个最低位的1究竟是第几位(即i值),具体可以参考下面的代码;
我们可以用b和最低位的1进行按位异或运算,就可以将其从b中去除,这样就可以枚举下一个1。同样地,我们也可以用b和b-1进行按位与运算达到相同的效果,读者可以自行尝试推导。
实际上,方法二中整体的递归 + 回溯的框架与方法一是一致的。不同的仅仅是我们将一个数组「压缩」成了一个数而已。
class Solution{
private int[] line=new int[9];
private int[] column=new int[9];
private int[][] block=new int[3][3];
private boolean valid=false;
private List<int[]> spaces=new ArrayList<int[]>();
public void dolveSudoku(char[][] board){
for(int i=0;i<9;i++){
for(int j=0;j<9;j++){
if(board[i][j]=='.'){
spaces.add(new int[]{i,j});
}else{
int digit=board[i][j]-'0'-1;
flip(i,j,digit);
}
}
}
dfs(board,0);
}
public void dfs(char[][] board,int pos){
if(pos==spaces.size()){
valid=true;
return;
}
int[] space=spaces.get(pos);
int i=space[0],j=space[l];
int mask=~(line[i] | column[j] | block[i/3][j/3]) & 0xlff;
for(;mask!=0 && !valid ;mask&=(mask-1)){
int digitMask=mask & (-mask);
int digit=Integer.bitCount(digitMask-1);
flip(i,j,digit);
board[i][j]=(char)(digit+'0'+1);
dfs(board,pos+1);
flip(i,j,digit);
}
}
public void flip(int i,int j,int digit){
line[i]^=(1<<digit);
coliumn[j]^=(1<<digit);
block[i/3][j/3]^=(1<<digit);
}
}
方法三:枚举优化
我们可以顺着方法二的思路继续优化下去:
如果一个空白格只有唯一的数可以填入,也就是其对应的b值和b−1进行按位与运算后得到 00(即b中只有一个二进制位为1)。此时,我们就可以确定这个空白格填入的数,而不用等到递归时再去处理它。
这样一来,我们可以不断地对整个数独进行遍历,将可以唯一确定的空白格全部填入对应的数。随后我们再使用与方法二相同的方法对剩余无法唯一确定的空白格进行递归 + 回溯。
class Solution {
private int[] line = new int[9];
private int[] column = new int[9];
private int[][] block = new int[3][3];
private boolean valid = false;
private List<int[]> spaces = new ArrayList<int[]>();
public void solveSudoku(char[][] board) {
for (int i = 0; i < 9; ++i) {
for (int j = 0; j < 9; ++j) {
if (board[i][j] != '.') {
int digit = board[i][j] - '0' - 1;
flip(i, j, digit);
}
}
}
while (true) {
boolean modified = false;
for (int i = 0; i < 9; ++i) {
for (int j = 0; j < 9; ++j) {
if (board[i][j] == '.') {
int mask = ~(line[i] | column[j] | block[i / 3][j / 3]) & 0x1ff;
if ((mask & (mask - 1)) == 0) {
int digit = Integer.bitCount(mask - 1);
flip(i, j, digit);
board[i][j] = (char) (digit + '0' + 1);
modified = true;
}
}
}
}
if (!modified) {
break;
}
}
for (int i = 0; i < 9; ++i) {
for (int j = 0; j < 9; ++j) {
if (board[i][j] == '.') {
spaces.add(new int[]{i, j});
}
}
}
dfs(board, 0);
}
public void dfs(char[][] board, int pos) {
if (pos == spaces.size()) {
valid = true;
return;
}
int[] space = spaces.get(pos);
int i = space[0], j = space[1];
int mask = ~(line[i] | column[j] | block[i / 3][j / 3]) & 0x1ff;
for (; mask != 0 && !valid; mask &= (mask - 1)) {
int digitMask = mask & (-mask);
int digit = Integer.bitCount(digitMask - 1);
flip(i, j, digit);
board[i][j] = (char) (digit + '0' + 1);
dfs(board, pos + 1);
flip(i, j, digit);
}
}
public void flip(int i, int j, int digit) {
line[i] ^= (1 << digit);
column[j] ^= (1 << digit);
block[i / 3][j / 3] ^= (1 << digit);
}
}