LeetCode 37:解数独(回溯 + 状态剪枝,一次讲透)

10 阅读4分钟

解数独是回溯算法中非常经典的一道题。
它不是单纯的暴力枚举,而是 回溯搜索 + 状态记录 + 剪枝优化 的综合应用,非常适合作为回溯专题的代表题。


一、题目理解

数独规则如下:

  • 每一行数字 1~9 不能重复
  • 每一列数字 1~9 不能重复
  • 每一个 3×3 宫内数字 1~9 不能重复
  • '.' 表示空格,需要我们填充

题目保证:

  • 一定存在解
  • 解是唯一的

这意味着:
一旦我们在搜索过程中找到一个完整解,就可以直接返回,不需要继续尝试其他可能。


二、整体解题思路

可以把这道题理解为一个「填格子问题」:

  • 从左到右、从上到下遍历棋盘
  • 遇到空格,就尝试填入 1~9
  • 如果当前填法合法,继续向下递归
  • 如果后续走不通,撤销当前选择,换一个数再试

这正是回溯算法的标准流程:

  1. 选择一个可能的解
  2. 递归向下搜索
  3. 搜索失败则撤销选择

为了让搜索足够快,我们需要加入剪枝。


三、状态剪枝:三张 boolean 表

如果每次判断一个数是否合法,都去扫描整行、整列、整宫,效率会很低。
因此可以用三个二维数组来记录状态。

boolean[][] row = new boolean[9][9];
boolean[][] col = new boolean[9][9];
boolean[][] box = new boolean[9][9];

含义如下:

  • row[i][num]:第 i 行是否已经使用过数字 num
  • col[j][num]:第 j 列是否已经使用过数字 num
  • box[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;

这是典型的回溯三步:

  1. 做选择
  2. 递归尝试
  3. 撤销选择(回溯)

如果某条路径可以成功填完整个棋盘,就直接返回。


九、为什么必须用回溯?

因为数独具有明显的“局部正确不代表全局正确”的特点:

  • 当前填法看起来合法
  • 但后续可能无解

只能通过不断尝试和回退,才能保证最终解的正确性。


十、时间复杂度分析

理论上是指数级,但实际表现很好,原因包括:

  • 状态表让合法性判断为 O(1)
  • 剪枝大幅减少搜索空间
  • 解唯一,找到即返回

因此在 LeetCode 上运行非常快。