解数独(Sudoku)
别小看这玩意儿,好像就是填格子,但里面的门道可不少。一个题,能玩出好几种花样,从新手小白到算法老鸟,都能在里面找到自己的乐趣和挑战。今天,我就带你走一遍我的心路历程,看看怎么从最笨的方法,一步步优化到让面试官都眼前一亮的程度。
题目要求很简单:给你一个9x9的数独棋盘,里面有些数字已经填好了,有些是空的(用 . 表示),让你把空的地方填满,让整个数独成立。
规则大家都懂:
每一行,数字1-9都得出现一次,不能重复。
每一列,数字1-9都得出现一次,不能重复。
每一个3x3的小九宫格里,数字1-9也得出现一次,不能重复。
解法一:无脑爆搜,暴力递归走起!
拿到这种要在二维矩阵里填东西,还得满足一堆限制条件的题,我脑子里第一个蹦出来的词就是“递归”、“回溯”。这思路最直观,最符合人的思维。
咋想的呢?
很简单,咱就模拟人肉填数独的过程。从左上角第一个格子 (0, 0) 开始,一个一个往后走。
如果这个格子已经有数了,那好,不用我管,直接去看下一个格子。
如果这个格子是空的,机会来了!我从1到9挨个试。
比如我先试填个
1。填完之后,我得检查一下,填1合不合法?也就是当前行、当前列、当前3x3小方块里,是不是已经有1了。如果合法,太棒了!我就当它暂时就是
1了,然后继续去處理下一个格子(递归调用)。如果后面的格子一路绿灯,最后都填完了,说明我这条路走对了,解出来了!如果填
1不合法,或者填了1之后,后面的路走不通(递归返回了false),那说明1不行。咋办?回溯啊!把刚才填的1擦掉,恢复现场(变回.),然后试试填2。如果1到9都试完了,发现哪个数字放进去,后面的路都走不通,那就说明之前的决策有问题。这层递归就得返回
false,告诉上一层:“你给我的这个盘面,我搞不定,你换个别的数吧”。
这就是最纯粹的回溯思想。写成代码,大概是这么个结构:
public void solveSudoku(char[][] board) {
if (board == null || board.length != 9 || board[0].length != 9) {
return;
}
backtrack(board, 0, 0);
}
private boolean backtrack(char[][] board, int row, int col) {
// base case: 如果行号走到头了,说明整个棋盘都填完了
if (row == 9) {
return true;
}
// 计算下一个要处理的格子坐标
int nextRow = col == 8 ? row + 1 : row;
int nextCol = col == 8 ? 0 : col + 1;
// 如果当前格子已经有数字了,直接去处理下一个
if (board[row][col] != '.') {
return backtrack(board, nextRow, nextCol);
}
// 如果是空格,开始尝试填1-9
for (char num = '1'; num <= '9'; num++) {
if (isValid(board, row, col, num)) {
board[row][col] = num; // 做出选择
// 深入下一层递归
if (backtrack(board, nextRow, nextCol)) {
return true; // 如果下一层成功了,直接返回true
}
// 回溯,撤销选择
board[row][col] = '.';
}
}
// 1-9都试完了还不行,说明这条路走不通
return false;
}
// 检查在(row, col)位置放num是否合法
private boolean isValid(char[][] board, int row, int col, char num) {
// 检查行
for (int i = 0; i < 9; i++) {
if (board[row][i] == num) {
return false;
}
}
// 检查列
for (int i = 0; i < 9; i++) {
if (board[i][col] == num) {
return false;
}
}
// 检查3x3小方块
int startRow = (row / 3) * 3;
int startCol = (col / 3) * 3;
for (int i = startRow; i < startRow + 3; i++) {
for (int j = startCol; j < startCol + 3; j++) {
if (board[i][j] == num) {
return false;
}
}
}
return true;
}
评价一下: 这个解法,逻辑清晰,代码好懂。但性能嘛...你懂的。瓶颈就在 isValid 这个函数。每次我要尝试填一个数,都要把整行、整列、整个小方块扫一遍。递归深度又那么深,这重复计算量可就大了去了。面试时写出这个,算及格,但想拿高分,还得继续。
解法二:空间换时间,加个状态记录
暴力解法慢在哪?慢在信息不流通。每次都得重新去盘面里找,这一行用过啥数了?这一列呢?这个小方块呢?
那咱能不能提前把这些信息存起来?当然可以!这就是典型的空间换时间思想。
我们可以用几个二维数组来记录状态:
boolean[][] rowUsed = new boolean[9][10];rowUsed[i][j]表示第i行是否已经用过数字j。boolean[][] colUsed = new boolean[9][10];colUsed[i][j]表示第i列是否已经用过数字j。boolean[][] boxUsed = new boolean[9][10];boxUsed[i][j]表示第i个小方块是否已经用过数字j。
小方块的编号怎么算?从左到右,从上到下,可以给9个小方块编个号0-8。一个格子 (row, col) 所在的小方块编号就是 (row / 3) * 3 + (col / 3)。
有了这三个状态数组,isValid 的检查就从 O(N) 变成了 O(1)!
改造思路:
初始化:在递归开始前,先遍历一遍整个棋盘。把那些已经填好的数字,在我们的
rowUsed,colUsed,boxUsed数组里做好标记。递归过程:
当我要在
(row, col)位置尝试填数字num的时候,不再傻乎乎地去遍历棋盘了。直接查表:
if (!rowUsed[row][num] && !colUsed[col][num] && !boxUsed[boxId][num])。如果都没用过,那这个数就能填!做选择:填上数字后,别忘了同步更新我们的状态数组,把对应的位置都设为
true。撤销选择 (回溯):如果后面的路走不通,要回溯。不仅要把棋盘上的数字擦掉,更重要的是,要把我们刚才在状态数组里做的标记也给还原了,设回
false。
代码就变成了这样:
// 状态数组,作为成员变量
boolean[][] rowUsed = new boolean[9][10];
boolean[][] colUsed = new boolean[9][10];
boolean[][] boxUsed = new boolean[9][10];
public void solveSudoku2(char[][] board) {
if (board == null || board.length != 9 || board[0].length != 9) {
return;
}
// 1. 初始化状态数组
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (board[i][j] != '.') {
int num = board[i][j] - '0';
int boxId = (i / 3) * 3 + (j / 3);
rowUsed[i][num] = true;
colUsed[j][num] = true;
boxUsed[boxId][num] = true;
}
}
}
backtrack2(board, 0, 0);
}
private boolean backtrack2(char[][] board, int row, int col) {
if (row == 9) {
return true;
}
int nextRow = col == 8 ? row + 1 : row;
int nextCol = col == 8 ? 0 : col + 1;
if (board[row][col] != '.') {
return backtrack2(board, nextRow, nextCol);
}
for (int num = 1; num <= 9; num++) {
int boxId = (row / 3) * 3 + (col / 3);
// O(1) 检查合法性
if (!rowUsed[row][num] && !colUsed[col][num] && !boxUsed[boxId][num]) {
// 做选择 + 更新状态
board[row][col] = (char) ('0' + num);
rowUsed[row][num] = true;
colUsed[col][num] = true;
boxUsed[boxId][num] = true;
if (backtrack2(board, nextRow, nextCol)) {
return true;
}
// 回溯 + 恢复状态
board[row][col] = '.';
rowUsed[row][num] = false;
colUsed[col][num] = false;
boxUsed[boxId][num] = false;
}
}
return false;
}
评价一下: 这个版本比第一个强太多了。通过引入额外的空间记录状态,把每次决策前的判断成本降到了最低。执行效率上了一个大台阶。面试的时候能写到这一步,基本上已经很不错了,说明你懂的用空间换时间的优化思想。
解法三:位运算,秀出你的基本功!
用 boolean[9][10] 感觉还是有点笨重。每一行/列/块,我们只是想记录9个数字(1-9)到底用没用过,这不就是一个典型的集合嘛!一个只有9个元素的集合,用布尔数组来表示,其实有点浪费。
这时候,就该位运算登场了!
一个int类型有32位,别说存9个状态了,存30个都绰绰有余。咱们可以用一个int的低9位(第0位到第8位)来分别代表数字1到9。
比如,第
i位是1,就代表数字i+1已经用过了。第
i位是0,就代表数字i+1还没用过。
这样,我们只需要三个一维数组就够了:
int[] rowMask = new int[9];int[] colMask = new int[9];int[] boxMask = new int[9];
位运算怎么操作?
标记数字
num已使用:mask |= (1 << (num - 1))。比如要标记数字5,就是把第4位置为1。检查数字
num是否已使用:(mask & (1 << (num - 1))) == 0。如果结果是0,说明那一位是0,没用过。取消标记(回溯):
mask &= ~(1 << (num - 1))。把那一位重新变回0。
代码整体结构和解法二一模一样,就是把对布尔数组的操作,换成对整数的位运算操作。
// 位运算版本状态记录
int[] rowMask = new int[9];
int[] colMask = new int[9];
int[] boxMask = new int[9];
public void solveSudoku3(char[][] board) {
if (board == null || board.length != 9 || board[0].length != 9) {
return;
}
// 1. 初始化状态
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (board[i][j] != '.') {
int num = board[i][j] - '0';
int boxId = (i / 3) * 3 + (j / 3);
int bit = 1 << (num - 1);
rowMask[i] |= bit;
colMask[j] |= bit;
boxMask[boxId] |= bit;
}
}
}
backtrack3(board, 0, 0);
}
private boolean backtrack3(char[][] board, int row, int col) {
if (row == 9) return true;
int nextRow = col == 8 ? row + 1 : row;
int nextCol = col == 8 ? 0 : col + 1;
if (board[row][col] != '.') {
return backtrack3(board, nextRow, nextCol);
}
for (int num = 1; num <= 9; num++) {
int boxId = (row / 3) * 3 + (col / 3);
int bit = 1 << (num - 1);
// O(1) 检查合法性
if ((rowMask[row] & bit) == 0 &&
(colMask[col] & bit) == 0 &&
(boxMask[boxId] & bit) == 0) {
// 做选择 + 更新状态
board[row][col] = (char)('0' + num);
rowMask[row] |= bit;
colMask[col] |= bit;
boxMask[boxId] |= bit;
if (backtrack3(board, nextRow, nextCol)) {
return true;
}
// 回溯 + 恢复状态
board[row][col] = '.';
rowMask[row] &= ~bit;
colMask[col] &= ~bit;
boxMask[boxId] &= ~bit;
}
}
return false;
}
评价一下: 这个解法在时间复杂度上和解法二是一样的,都是 O(1) 的判断。但是空间上更优,而且代码看起来B格满满,充分展现了你扎实的计算机基础和对位运算的熟练掌握。在性能上,位运算通常会比数组访问更快一点点(虽然在现代JVM上这点差异可能微不足道),但它代表的是一种更极致、更底层的优化思路。
总结
暴力回溯:最直观,但有大量重复计算,性能差。
数组状态记录:用空间换时间,把判断耗时从
O(N)降到O(1),性能大幅提升。位运算优化:解法二基础上,用更紧凑、更高效的方式来存储和操作状态,是压榨性能和空间的终极骚操作。