解数独是回溯算法中非常经典的一道题。
它不是单纯的暴力枚举,而是 回溯搜索 + 状态记录 + 剪枝优化 的综合应用,非常适合作为回溯专题的代表题。
一、题目理解
数独规则如下:
- 每一行数字 1~9 不能重复
- 每一列数字 1~9 不能重复
- 每一个 3×3 宫内数字 1~9 不能重复
'.'表示空格,需要我们填充
题目保证:
- 一定存在解
- 解是唯一的
这意味着:
一旦我们在搜索过程中找到一个完整解,就可以直接返回,不需要继续尝试其他可能。
二、整体解题思路
可以把这道题理解为一个「填格子问题」:
- 从左到右、从上到下遍历棋盘
- 遇到空格,就尝试填入 1~9
- 如果当前填法合法,继续向下递归
- 如果后续走不通,撤销当前选择,换一个数再试
这正是回溯算法的标准流程:
- 选择一个可能的解
- 递归向下搜索
- 搜索失败则撤销选择
为了让搜索足够快,我们需要加入剪枝。
三、状态剪枝:三张 boolean 表
如果每次判断一个数是否合法,都去扫描整行、整列、整宫,效率会很低。
因此可以用三个二维数组来记录状态。
boolean[][] row = new boolean[9][9];
boolean[][] col = new boolean[9][9];
boolean[][] box = new boolean[9][9];
含义如下:
row[i][num]:第 i 行是否已经使用过数字 numcol[j][num]:第 j 列是否已经使用过数字 numbox[k][num]:第 k 个 3×3 宫是否已经使用过数字 num
这样判断一个数字能否放入某个位置,只需要 O(1) 时间。
四、九宫格编号的推导
关键公式:
int boxIndex = (i / 3) * 3 + j / 3;
推导思路:
i / 3:确定当前在第几行宫(0~2)j / 3:确定当前在第几列宫(0~2)- 每一行宫有 3 个宫,因此
(i / 3) * 3 - 再加上列偏移
j / 3
最终可以将 9 个宫映射为编号 0 ~ 8。
五、初始化已有数字状态
在回溯之前,先遍历一遍棋盘,把已经存在的数字记录到状态表中。
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (board[i][j] != '.') {
int num = board[i][j] - '1';
int boxIndex = (i / 3) * 3 + j / 3;
row[i][num] = true;
col[j][num] = true;
box[boxIndex][num] = true;
}
}
}
这一步和「LeetCode 36:有效的数独」的判定逻辑是完全一致的。
六、回溯函数设计
1. 函数签名
private boolean backtrack(char[][] board, int i, int j)
返回 boolean 的原因是:
- 一旦找到解,立即返回 true
- 利用题目“解唯一”的条件提前结束搜索
2. 递归终止条件
if (i == 9) {
return true;
}
当行号走到 9,说明整个棋盘已经成功填满。
3. 列走完,换下一行
if (j == 9) {
return backtrack(board, i + 1, 0);
}
按照从左到右、从上到下的顺序遍历棋盘。
4. 遇到非空格直接跳过
if (board[i][j] != '.') {
return backtrack(board, i, j + 1);
}
只对空格位置进行尝试。
七、尝试填入 1~9 并剪枝
for (int num = 0; num < 9; num++) {
int boxIndex = (i / 3) * 3 + j / 3;
if (row[i][num] || col[j][num] || box[boxIndex][num]) {
continue;
}
如果行、列或宫中已经使用过该数字,直接跳过,这一步是剪枝的核心。
八、做选择、递归与回溯
board[i][j] = (char) (num + '1');
row[i][num] = col[j][num] = box[boxIndex][num] = true;
if (backtrack(board, i, j + 1)) {
return true;
}
board[i][j] = '.';
row[i][num] = col[j][num] = box[boxIndex][num] = false;
这是典型的回溯三步:
- 做选择
- 递归尝试
- 撤销选择(回溯)
如果某条路径可以成功填完整个棋盘,就直接返回。
九、为什么必须用回溯?
因为数独具有明显的“局部正确不代表全局正确”的特点:
- 当前填法看起来合法
- 但后续可能无解
只能通过不断尝试和回退,才能保证最终解的正确性。
十、时间复杂度分析
理论上是指数级,但实际表现很好,原因包括:
- 状态表让合法性判断为 O(1)
- 剪枝大幅减少搜索空间
- 解唯一,找到即返回
因此在 LeetCode 上运行非常快。