题目:编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
- 数字 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"]]
解释:输入的数独如上图所示,唯一有效的解决方案如下所示:
----------------------------------------------
解题:
这道题与力扣51题 N皇后 的解题思路非常像,可以用穷举递归算法来遍历每个没有填数字的位置,即 '.'。
首先要把9x9的数独格子遍历一遍,找出并用一个数组记录 '.' 所在的位置,这个位置是以后递归需要处理的位置。遍历的同时,我们还需要用三个数组来记录每行,每列,每个3x3的九宫格中,1-9是否出现过。遍历完毕后,我们从记录 '.' 所在的位置数组的位置0开始递归,每个位置穷举填入1-9的情况,如果将要填进去的数字已经在此行,或者此列,或者九宫格内出现过了,不继续递归,检查下一个数字,如果还没有出现过,则继续在数组的下一个位置进行递归。如果能到达数组的最后一位,递归完毕,则表明能到了一个答案。题目曾提示说,能找到唯一有效的答案,所以一定能找出一个答案。
解题思路很好理解,下文的 思路一 提供了通用的数据结构递归回溯法。本题的难点在于如何做空间优化, 51. N皇后 和本题的数据结构优化都用到了位运算,如何实现优化会在下文的 思路二 中提及。
思路一,递归回溯。
此思路用到了三个记录bool的数组rows[9][9],columns[9][9],ninebox[9][9]来记录数字n是否在某行某列某九宫格里出现过了。比如,数字 2(n) 在第i行 第j列 出现过,则记录rows[i-1][1],columns[j-1][1],ninebox[i/3*3+j/3][1]为True。这里记录九宫格的位置比较自由,可以用本文的ninebox[i/3*3+j/3][n-1],也可以用更直观的方式ninebox[i/3][j/3][n-1],/此符号为向下取整。
具体的步骤可以看C++的代码:
class Solution {
public:
// 记录是否遍历到了最后一个位置,是,则不用回溯,否,则要回溯。这里对于回溯非常关键
bool valid;
// 记录行列九宫格中是否出现过1-9的数字
bool rows[9][9];
bool columns[9][9];
bool ninebox[9][9];
// 记录9x9的数独格子中需要遍历的位置
// 如果把所有位置进去递归,回溯的时候会改变解题前填入rows,columns,ninebox的检查状态
vector<pair<int, int>> emptypos;
// 递归公式
void dfs(int pos, vector<vector<char>>& board){
// 检查完最后一个'.'位置则返回,并不用回溯
if(pos == emptypos.size()) {
valid=true;
return;
}
// 提取此'.'所在行列位置
auto [i, j] = emptypos[pos];
// 还从1-9开始逐个检查是否能填
// 能填则递归下一个位置,不能填则返回。
for(int n =0;n<9&&!valid;n++){
// 能填,满足行列九宫格没出现过的条件
if(!rows[i][n]&&!columns[j][n]&&!ninebox[i/3*3+j/3][n]){
// 向9x9的数独格中填入数字
board[i][j]='1'+n;
// 修改状态
rows[i][n]=1;
columns[j][n]=1;
ninebox[i/3*3+j/3][n]=1;
// 进行下一个位置的递归
dfs(pos+1, board);
// 不满足条件,回溯后,需要退回到原来的状态
rows[i][n]=0;
columns[j][n]=0;
ninebox[i/3*3+j/3][n]=0;
}
}
}
// 解数独
void solveSudoku(vector<vector<char>>& board) {
// 初始化状态
memset(rows, false, sizeof(rows));
memset(columns, false, sizeof(columns));
memset(ninebox, false, sizeof(ninebox));
valid = false;
// 解数独前遍历每个格子,记录状态,查找空白格'.'位置
for(int i =0;i<9;i++){
for(int j=0;j<9;j++){
if(board[i][j]!='.'){
int n = board[i][j]-'1';
rows[i][n]=1;
columns[j][n]=1;
ninebox[i/3*3+j/3][n]=1;
}else emptypos.emplace_back(i, j);
}
}
// 从第一个位置递归起
dfs(0,board);
return;
}
};
思路二,位运算。
位运算思路并没有改变思路一中递归回溯的框架,而是在数据结构和运算上有所优化。思路一中,记录行,列,九宫格,用到了二维的数组来记录 0 False,1 True。只有0和1时,就可以用二进制表示。位运算可以只用一个9位的int来记录状态,三个数组变成了记录int的一维数组rows[9],columns[9],ninebox[9]。比如1在某个位置出现过,那么此位置的int就是000 000 001,8,5,4出现过,就是010 011 000。如何检查某一为的1或0,如果改1为0,改0为1,需要熟练运用位运算。这里会列出需要记住的位运算,以便理解后面的代码。
1. | 或运算: 0001|0000 = 0001。找出两数出现过1的所有位置。
2. ∼ 按位取反运算:~0101 = 1010。0变成1,1变成0。连高位都会变成1。
3. & 与运算:1010 & 0110 = 0010。找出两数中均为1的位置。
解题中有个地方做了 ∼ 按位取反运算,把高位变成了1,为了只算低位的则要与二进制的(111111111)2 = (1FF)16 & 与。把高位变回来0。
4. ∧ 按位异或运算:0101 ∧ 1111 = 1010,1111 ∧ 0100 = 1011,1011 ∧ 0100 = 1111。可通过此方法反转某位上的1 和 0。比如将第三位的数字反转,可以将此数与(1<<3)做异或运算。
5. n&(-n):可得到n在二进制表示中最低位的1。位运算中的(-n)为∼n+1。-(0110) = 1001+1=1010,0110&1010=0010。
6. 内部函数 __builtin_ctz:计算出最低位1的位数。__builtin_ctz(0010)=2。
7. n&=(n-1):1110&(1110-1) = 1110&1101 = 1100。把最低位的1变成0。
记住了这些运算法则,就可以开始写代码实现解题了。具体步骤参照以下的C++代码:
class Solution {
public:
bool valid;
// 这里数组的类型和维度要改
int rows[9];
int columns[9];
int ninebox[9];
vector<pair<int, int>> emptypos;
// 反转n位上的0和1,我们用到了与 1<<n 异或^
void flip(int i, int j, int n){
rows[i] ^= (1<<n);
columns[j] ^= (1<<n);
ninebox[i/3*3+j/3] ^= (1<<n);
}
void dfs(int pos, vector<vector<char>>& board){
if(pos == emptypos.size()) {
valid=true;
return;
}
auto [i, j] = emptypos[pos];
// 算出那些位置是能用的,就是用1记录可以使用的数,进行异或运算
// 反转所有0为1,还原高位上不必要的反转 &0x1ff
int mask = ~(rows[i]|columns[j]|ninebox[i/3*3+j/3])&0x1ff;
// 循环逐一把低位的1变成0,mask&=(mask-1)
for(;mask&&!valid;mask&=(mask-1)){
// 只保留最低位的1,其余变成0 mask&(-mask)
int digitMask = mask & (-mask);
// 求最低位的1在那个位上
int n = __builtin_ctz(digitMask);
board[i][j]='1'+n;
flip(i,j,n);
dfs(pos+1, board);
flip(i,j,n);
}
}
void solveSudoku(vector<vector<char>>& board) {
memset(rows, 0, sizeof(rows));
memset(columns, 0, sizeof(columns));
memset(ninebox, 0, sizeof(ninebox));
valid=false;
for(int i =0;i<9;i++){
for(int j=0;j<9;j++){
if(board[i][j]!='.'){
int n = board[i][j]-'1';
// 反转n位上的0
flip(i,j,n);
}else{
emptypos.emplace_back(i,j);
}
}
}
dfs(0,board);
return;
}
};