LeetCode 37 解数独面试攻略:回溯 + 位掩码 + MRV,从暴力到毫秒级求解

上个月有个读者面试被问住了:LeetCode 37. 解数独,暴力写法能跑,可面试官一句"还能怎么优化"直接卡壳。这题挂着 Hard 的名头,其实是回溯算法最好的入门题。今天就带大家从暴力穷举一路优化到毫秒级求解,连追问都给你备好了,可以直接背。

一、别急着写代码,先看穿题目本质

题目地址: LeetCode 37. 解数独

拿到题,很多人的第一反应是赶紧填格子。先别急,我们花一分钟把题目翻译成算法语言。

数独的规则就三条,没有第四条:

  • 行约束:每一行,数字 1~9 各出现一次;
  • 列约束:每一列,数字 1~9 各出现一次;
  • 宫约束:每个 3×3 粗线宫,数字 1~9 各出现一次。

每填一个数,都得同时过这三把锁,全开才能落子。

看一眼题目给的棋盘:

复制代码
5 3 . | . 7 . | . . .
6 . . | 1 9 5 | . . .
. 9 8 | . . . | . 6 .
------+-------+------
8 . . | . 6 . | . . 3
4 . . | 8 . 3 | . . 1

每个空格都是一个决策点,每个决策点最多 9 种选法。要是不管约束硬穷举,解空间是 9 的空格数次方,最多 9^81------天文数字,宇宙毁灭都算不完。所以必须边填边校验,撞墙就回退。

一句话点破本质:

这是一个约束满足问题(CSP) :在行、列、宫三重约束下给所有空格找一组合法赋值。没有贪心、没有 DP、没有数学公式,标准解法就是回溯 = DFS + 撤销选择,一场有组织的穷举。

面试开场可以这么说(建议直接背):

"数独要求每行、每列、每个 3×3 宫内 1~9 不重复,我要在所有空格上填数,本质是在约束条件下找一个可行解,典型的约束满足问题。数据规模固定 9×9,用 DFS 回溯最合适:能填就往下走,走不通就撤销换下一个数。题目保证唯一解,找到就可以直接返回。"

还有三个容易忽略的细节,提前交代清楚:

  • 题目保证唯一解,这点非常关键。意味着找到第一个解就能收工,不用搜完整棵树------这直接决定了后面"找到即停"的设计。
  • 和 N 皇后的区别 :N 皇后是单层递归(一行一行放,天然不回头);数独是二维递归,每个格子都要试数字,搜索树更宽更深。写过 N 皇后的话思路相通,但细节更多。
  • 和第 36 题「有效的数独」的关系 :36 题只判断棋盘是否合法,一次遍历就够;37 题要填出解,必须搜索。两题的约束检查逻辑完全可以复用。

可以把整个过程想象成走迷宫:每到岔路口选一条路,撞了死胡同就退回来换一条。整体流程长这样:

核心动作永远只有三个:做选择 → 递归 → 撤销选择。记住这三板斧,下面所有代码都是它的变形。

二、两个决定生死的细节

写代码前先交代两个小东西,我以前面试别人的时候,十个人里有七个栽在这。

2.1 宫格编号公式

格子属于哪个宫,是最经典的翻车点。记住这个公式:

java 复制代码
boxId = (i / 3) * 3 + j / 3

配合这张图理解,一辈子忘不了:

i \ j 0~2 3~5 6~8
0~2 box 0 box 1 box 2
3~5 box 3 box 4 box 5
6~8 box 6 box 7 box 8

i / 3 算出宫在第几个"大行"(乘 3 是大行的起始编号),j / 3 算出大行内的偏移。整数除法自动向下取整,刚刚好。

踩坑提醒 :最常见的错写是 i / 3 + j / 3,少乘一个 3,宫编号直接重叠,校验形同虚设,还特别难查。第一次写建议把上面这张 3×3 宫编号图在纸上画一遍,对着验一遍。

2.2 回解决策树:填错了就退回来换数

每个空格是树上的一个节点,9 个数字是 9 条分支。合法的分支往下走,非法的当场剪掉;某个节点 9 条分支全走不通,就退回上层换个数字再试:

这里有两个要命的细节:

① 递归函数一定要返回 boolean。 不少新手写成 void,结果找到解之后还得把整棵树搜完,直接超时。正确姿势:下层返回 true,上层立刻跟着 return true,剩下的数字一个都不再试------这就是"找到即停"的剪枝,是这道题最容易写错的地方。

② 撤销选择必须和做选择严格对称。 回溯的本质是"共享一份状态做试错",填了不撤销,上一个分支的状态污染会传到兄弟分支,答案直接错。这是回溯类题目最高频的 bug,没有之一。

我自己就踩过这个坑:撤销时忘了还原标记数组,样例居然全过了(恰好分支少),一提交满盘飘红,debug 了一下午。后来学乖了:做选择和撤销选择永远成对写,写一对、检查一对。

铺垫完毕,可以动手了。下面按面试节奏,由浅入深给三个版本------先写稳的保正确,再主动聊优化秀深度,这个节奏面试通过率最高。

三、青铜版:暴力校验,先保证做对

三个版本的演进路线先放这儿,心里有个地图:

最朴素的思路:双层循环找空格,逐个尝试 1~9,每次填之前扫一遍行、列、宫检查合法性。

java 复制代码
class Solution {
    public void solveSudoku(char[][] board) {
        backtrack(board);
    }

    private boolean backtrack(char[][] board) {
        for (int row = 0; row < 9; row++) {
            for (int col = 0; col < 9; col++) {
                if (board[row][col] != '.') continue;      // 只处理空格

                for (char num = '1'; num <= '9'; num++) {
                    if (!isValid(board, row, col, num)) continue;

                    board[row][col] = num;                 // 做选择
                    if (backtrack(board)) return true;     // 找到解, 立即返回
                    board[row][col] = '.';                 // 撤销选择
                }
                return false;  // 9 个数都不行, 此路不通
            }
        }
        return true;         // 所有空格填满, 找到解
    }

    private boolean isValid(char[][] board, int row, int col, char num) {
        for (int i = 0; i < 9; i++) {
            if (board[i][col] == num) return false;        // 查列
            if (board[row][i] == num) return false;        // 查行
            int boxRow = row / 3 * 3 + i / 3;              // 查 3×3 宫
            int boxCol = col / 3 * 3 + i % 3;
            if (board[boxRow][boxCol] == num) return false;
        }
        return true;
    }
}

这段代码有两个点值得记住:

亮点 ① :isValid 用一个 for 循环同时搞定行、列、宫三重校验 。宫的遍历靠 row/3*3 + i/3 和 col/3*3 + i%3 生成 9 个格子坐标------i 从 0 到 8 时,i/3 是 0,0,0,1,1,1,2,2,2,i%3 是 0,1,2,0,1,2,0,1,2,正好把整个宫按行扫一遍。这个细节很多人写不出来,面试写出来直接加分。
亮点 ② :两个 return 的位置很讲究------填满即成功,试完即失败 。双层循环走完没遇到空格,说明全填满了,返回 true;某个空格 9 个数字全试完都不行,返回 false 触发上层回溯。不需要额外写什么终止条件。

也别被 9^m 吓到:题目保证有解,isValid 又会提前剪掉大量非法分支,实际搜索树比理论值小得多。

这个版本逻辑无懈可击,能通过。但面试官一定会追问:每次填数都要扫 27 个格子,能不能更快?

能。

四、白银版:状态表换时间,校验降到 O(1)

每次校验都扫行、列、宫,其实是重复劳动------某一行、某一列、某一宫"用过哪些数字",完全可以提前记下来。经典的空间换时间:

方案 单次校验耗时 特点 适用场景
每次扫行/列/宫 O(27) 直观但啰嗦,重复计算多 暴力原型
boolean[9][10] × 3 状态表 O(1) 数组访问 好写好讲,不易错 面试稳妥之选
位掩码 int[9] × 3 O(1) + 单条位运算 空间最小,写法最秀 大厂加分项

白银版上三张状态表,行一张、列一张、宫一张,把用过的数字"登记"进去:

java 复制代码
class Solution {
    boolean[][] rows = new boolean[9][10];
    boolean[][] cols = new boolean[9][10];
    boolean[][] boxes = new boolean[9][10];

    public void solveSudoku(char[][] board) {
        // 先把题目给定的数字登记进状态表
        for (int i = 0; i < 9; i++)
            for (int j = 0; j < 9; j++)
                if (board[i][j] != '.')
                    mark(i, j, board[i][j] - '0', true);
        dfs(board, 0);
    }

    private int boxId(int i, int j) {
        return (i / 3) * 3 + j / 3;
    }

    /** 置位和清位走同一个入口, 做选择/撤销天然对称 */
    private void mark(int i, int j, int d, boolean on) {
        rows[i][d] = cols[j][d] = boxes[boxId(i, j)][d] = on;
    }

    private boolean dfs(char[][] board, int k) {
        if (k == 81) return true;                     // 81 格扫完, 找到解
        int i = k / 9, j = k % 9;
        if (board[i][j] != '.') return dfs(board, k + 1); // 已有数字, 跳过

        for (int d = 1; d <= 9; d++) {
            if (rows[i][d] || cols[j][d] || boxes[boxId(i, j)][d]) continue; // 剪枝
            board[i][j] = (char) ('0' + d);
            mark(i, j, d, true);                      // 做选择
            if (dfs(board, k + 1)) return true;
            mark(i, j, d, false);                     // 撤销选择
        }
        board[i][j] = '.';
        return false;
    }
}

两个设计点说一下:

① 把"设置/清除"封装进同一个 mark(i, j, d, on)。 填和撤走同一个入口,想不对称都难------这是"忘记撤销"病的特效药,亲测有效。

② 把 81 个格子拍平成一个下标 k ,i = k / 9、j = k % 9,递归只管 k + 1,不用操心换行越界,k == 81 就是天然的终止条件。

定位"下一个空格"还有两种常见写法,都对,挑顺手的用:

java 复制代码
// 写法一:i, j 坐标递归,处理边界换行
if (j == 9) return backtrack(board, i + 1, 0);   // 这行填完, 换下一行
if (i == 9) return true;                         // 全部填完, 找到解
if (board[i][j] != '.') return backtrack(board, i, j + 1);

// 写法二:while 顺推,直接滑到下一个空格
while (i < 9 && board[i][j] != '.') {
    if (++j == 9) { i++; j = 0; }
}
if (i == 9) return true;

还有一种预收集空格列表 的写法:开局把所有 . 存进 List,递归按列表下标推进。它是后面 MRV 优化的地基,黄金版会用到。

也有人把递归函数写成 void,另设一个全局 done 标志,找到解就置 true、每层检查后退出------效果和 boolean 返回值等价,看个人习惯。

这一版校验 O(1),速度快了一个档次。但面试官的追问马上就到:"还能不能少搜一点?"

真正的分水岭来了。

五、黄金版:位掩码 + MRV,毫秒级求解

正在准备面试的话,可以顺手点个关注,我这个"算法198题"会持续更新,一道一道把高频题从暴力讲到优化。

这一节内容多,拆成两半:位掩码让校验更快,MRV 让搜索更少。

5.1 用一个 int 装下 9 个数字

三张 boolean[9][10] 要用 270 个布尔值,每次判断要 3 次数组寻址。而数字 1~9 恰好 9 个状态,一个 int 的低 9 位就够了 :数字 d 对应第 d-1 位。

数字 1 2 3 4 5 6 7 8 9
位 0 1 2 3 4 5 6 7 8
掩码 1<<0 1<<1 1<<2 1<<3 1<<4 1<<5 1<<6 1<<7 1<<8

比如 row[0] = 0b001010011,第 0、1、4、6 位是 1,表示第 0 行已经用过 1、2、5、7 。27 个 int 顶替 243 个 boolean,全部塞进 CPU 缓存,访问速度完全不在一个量级。

有了位图,常用操作全部压成单条指令:

目的 表达式 说明
判断数字 d 是否已用 used & (1 << d) 非 0 即已用
标记使用 `used = (1 << d)`
撤销标记 used ^= (1 << d) 异或,精确清零
求全部候选 `~(row col
数候选个数 Integer.bitCount(mask) 一条 CPU 指令
取最低位候选 mask & (-mask) 经典 lowbit
跳到下一个候选 mask &= mask - 1 清掉最低位的 1

最妙的是求候选集那一行:行、列、宫三个位图按位或,得到"哪些数字不能填";取反,就是"哪些数字能填"。举个例子:

复制代码
used = row[i] | col[j] | box[b]
cand = ~used & 0x1FF = 0b010010010   → 第 1、4、7 位为 1 → 该格可填 2、5、8

一行代码搞定三重约束的合并,比三个 for 循环判重优雅太多。

踩坑提醒:千万别忘了 0x1FF 掩码。 Java 的 int 是 32 位,~used 取反之后第 9~31 位全是 1,不做掩码会凭空多出 23 个"幽灵候选数字",枚举直接乱套。这是位运算版本最隐蔽的 bug,记住一句话:取反必掩码 。顺带一提,used ^ 0x1FF 也能求出候选集------used 本身只有低 9 位,和全 1 异或等价于"取反 + 掩码",两种写法效果一样。

撤销推荐用异或 。填数前该位一定是 0(约束保证的),|= 置位之后,^= 一下就精确清零:无分支、无残留,和填数完美对称:

java 复制代码
row[i] |= bit;  col[j] |= bit;  box[b] |= bit;   // 填: 置位
row[i] ^= bit;  col[j] ^= bit;  box[b] ^= bit;   // 撤: 异或回退

枚举候选也不用 1~9 循环,lowbit 组合拳直接在合法候选之间跳跃:

java 复制代码
for (int mask = cand; mask != 0; mask &= mask - 1) {
    int bit = mask & (-mask);                     // 取最低位的 1
    int num = Integer.numberOfTrailingZeros(bit); // 位数 = 数字 - 1
    // 写成 Integer.bitCount(bit - 1) 也等价
}

mask &= mask - 1 每次消掉最低位的 1,循环次数恰好等于候选个数,一次递归都不浪费在非法数字上。

顺手把位图和布尔数组的差距摆出来:

维度 boolean[9][10] × 3 int[9] × 3
单次校验 3 次数组寻址 + 3 次布尔判断 2 次按位或 + 1 次取反与
数候选个数 for 循环扫 9 次 Integer.bitCount() 一条指令
空间占用 270 个布尔 27 个 int,缓存友好

5.2 MRV:先填最"紧"的格子

校验已经 O(1) 了,还能不能少搜?这就轮到 MRV(Minimum Remaining Values,最小剩余值)启发式 登场了------一句话:每次优先填候选数字最少的那个空格。

为什么?候选越少,说明这个格子受的约束越强、越"紧迫"。先填它,矛盾就能最早暴露------有死路就让它早点死,别让错误的数字深入几十层才被发现。这是典型的约束传播思想,CSP 求解器、八皇后优化里用的都是这一招。

效果对比非常直观:

搜索顺序 分叉情况 结果
顺序傻填 第一格 9 个候选 → 分 9 叉,第二格 8 个 → 分 8 叉 分支爆炸,错误发现得晚
MRV 填 专挑只剩 1~2 个候选的格子下手 搜索树瘦成一条线,死路当场暴露

实测 MRV 能带来一个数量级的提速,困难用例甚至快 100 倍以上。

实现上有三个要点:

  1. 每层递归扫描剩余空格,用 Integer.bitCount(cand) 数候选,记下最少的那个;
  2. 候选数为 0 说明是死格,直接 return false 剪枝------明知死路就别往下走了,这条剪枝非常关键;
  3. 用 Collections.swap 把选中的格子换到当前位置,O(1) 且零额外空间;回溯时记得再换回来,不然下一轮的顺序就乱套了。

两个高频疑问,提前回答:

  • MRV 会不会漏解? 不会。它只改变搜索顺序,不改变搜索空间,完备性不受影响------同一棵树,只是换了条更聪明的走法。
  • 每层都全盘扫描,开销值吗? 这是个权衡:极端困难的数独,MRV 的剪枝收益远大于扫描成本;特别简单的盘,可以退化成固定顺序遍历,不扫描。

5.3 完整代码

位掩码 + MRV 合体版,每一行都有它的作用,建议对着注释读:

java 复制代码
class Solution {
    private int[] row = new int[9], col = new int[9], box = new int[9];
    private List<int[]> blanks = new ArrayList<>();

    public void solveSudoku(char[][] board) {
        for (int i = 0; i < 9; i++)
            for (int j = 0; j < 9; j++) {
                if (board[i][j] == '.') {
                    blanks.add(new int[]{i, j});       // 预收集空格
                } else {
                    int bit = 1 << (board[i][j] - '1');
                    row[i] |= bit;
                    col[j] |= bit;
                    box[(i / 3) * 3 + j / 3] |= bit;   // 登记初始状态
                }
            }
        backtrack(board, 0);
    }

    private boolean backtrack(char[][] board, int pos) {
        if (pos == blanks.size()) return true;         // 空格全部填完

        // MRV: 在剩余空格里挑候选数最少的
        int best = pos, minCnt = 10, bestMask = 0;
        for (int k = pos; k < blanks.size(); k++) {
            int[] p = blanks.get(k);
            int mask = candidates(p[0], p[1]);
            int cnt = Integer.bitCount(mask);
            if (cnt < minCnt) {
                minCnt = cnt;
                best = k;
                bestMask = mask;
                if (cnt <= 1) break;                   // 已是最优, 无需再扫
            }
        }
        if (minCnt == 0) return false;                 // 无候选 = 死路, 立即剪枝

        Collections.swap(blanks, pos, best);           // 选中格换到当前位置
        int[] p = blanks.get(pos);
        int i = p[0], j = p[1], b = (i / 3) * 3 + j / 3;

        // lowbit 枚举: 只遍历真正合法的候选
        for (int mask = bestMask; mask != 0; mask &= mask - 1) {
            int bit = mask & (-mask);
            int num = Integer.numberOfTrailingZeros(bit);
            board[i][j] = (char) ('1' + num);
            row[i] |= bit; col[j] |= bit; box[b] |= bit;     // 做选择

            if (backtrack(board, pos + 1)) return true;      // 找到即停

            row[i] ^= bit; col[j] ^= bit; box[b] ^= bit;     // 异或回退
        }
        board[i][j] = '.';
        Collections.swap(blanks, pos, best);           // 恢复现场
        return false;
    }

    /** 一行求候选集: 取反 + 掩码 */
    private int candidates(int i, int j) {
        return ~(row[i] | col[j] | box[(i / 3) * 3 + j / 3]) & 0x1FF;
    }
}

这个版本在 LeetCode 上能跑到提交记录的前列。面试时把它写出来,再讲清楚每一处为什么这么写,面试官的眼睛是真的会亮。

5.4 另一种流派:让位表示"可用"

再补一个知识点:上面的位图用 1 表示"已用",还有一种等价流派(LeetCode 官方题解就是这种)------让位表示"可用":初始时行、列、宫的 9 位全是 1,每填一个数字就异或翻转对应位:

java 复制代码
private void flip(int i, int j, int d) {
    rows[i]            ^= 1 << d;
    cols[j]            ^= 1 << d;
    boxes[i / 3][j / 3] ^= 1 << d;
}

// 候选集 = 三者交集, 连取反都省了
int available = rows[i] & cols[j] & boxes[i / 3][j / 3];

异或的好处是填和撤是同一个操作------异或两次自动还原。两种流派没有高下之分,熟悉一种就够,面试时千万别混着用。

六、三个版本差多少?一张表看懂

回头看这张对比表,感受一下什么叫分层优化:

维度 青铜:暴力校验 白银:布尔状态表 黄金:位掩码 + MRV
合法性检查 O(27) 每次现扫 O(1) 数组访问 O(1) 位运算
候选枚举 循环 1~9 循环 1~9 lowbit 只走合法候选
搜索策略 按行顺序填 按行顺序填 优先填最"紧"的格
状态开销 无额外空间 270 个布尔 27 个 int
实测耗时(9×9) 50~200ms ~10ms 1~3ms
代码复杂度 低 低 中
面试效果 能过 稳妥 加分项

耗时是 LeetCode 实测的参考区间,不同用例有波动;相对比例更稳定:位图比布尔数组快 2~5 倍,加上 MRV 再快一个数量级。还有论文级的结论:融合人工求解策略(唯一候选、隐式数对等预处理)的回溯法,对绝大部分 9×9 数独平均求解时间不超过 0.15ms。

注意优化的本质:棋盘固定 81 格,理论上限变不了,我们做的全是砍树------O(1) 校验砍常数,MRV 砍分支因子,死格剪枝尽早砍掉死子树。在指数级搜索里,常数就是指数上的底数,底数小一点,世界大不同。

七、踩坑清单:这 10 个难点,我替你踩过了

把面试和刷题里真实踩过的坑整理成表,建议收藏:

# 难点 / 坑 典型症状 解决方案
1 合法性校验太慢 每次扫行/列/宫,O(27) 状态表 / 位掩码,O(1) 查、O(1) 改
2 宫格下标算错 写成 i/3 + j/3,宫编号重叠 (i/3)*3 + j/3,画宫编号图记忆
3 搜索空间爆炸 最坏 9^m,直接 TLE 预剪枝 + 死格剪枝 + MRV
4 忘记撤销选择 状态污染,后续分支全错 做选择/撤销严格对称,封装成 mark/flip 单一入口
5 找到解还在搜 超时跑不完 boolean 返回值或 done 标志,逐层短路
6 取反忘记掩码 幽灵候选、枚举越界 ~used 必须 & 0x1FF
7 字符数字转换 '0'+d 和 '1'+d 混用差一位 位掩码版统一 '1' + d(bit0 对应数字 1)
8 定位空格越界 i、j 走出棋盘 while 顺推,或预收集空格列表
9 MRV 扫描开销 每层多一次全盘扫描 难题收益远大于成本;简单题可退化为固定顺序
10 想扩展 16×16 数独 int 的 32 位不够装 位图换成 long 或 BitSet,框架零改动

八、面试高频追问,背完这 10 个就够了

代码写完,面试官一般还要追问。下面这 10 个出现率最高,答案可以直接背:

Q1:时间、空间复杂度各是多少?

时间最坏 O(9^m),m 是空格数。但棋盘固定 81 格,严格说就是 O(1)------常数上限;实际耗时取决于剪枝效果,加上 MRV 一般毫秒级。空间是 O(m) 的递归栈(最深 81 层)加 O(1) 的状态表,原地修改棋盘,没有额外大数组。

Q2:为什么不用 BFS?

解空间是树形结构,天然适合深度优先:一条路走到黑,全程只需一份棋盘状态。BFS 得把每个中间棋盘都存进队列,空间直接爆炸。

Q3:回溯和递归是什么关系?

回溯是递归的一种应用模式,核心是"做选择 → 递归 → 撤销选择"。递归提供往下走的框架,回溯提供试错回头的机制。

Q4:为什么用位运算,不用布尔数组或 HashSet?

布尔数组每次判断要 3 次寻址,数候选还得循环 9 次;HashSet 有哈希计算和装箱拆箱开销。位掩码是 CPU 指令级操作,还能用一行 | 把多个约束合并------在指数级搜索里,常数优化就是真金白银的时间。

Q5:回退为什么用异或,不用赋值?

填数前该位一定是 0(约束保证),| 置位之后 ^ 一下就精确还原:O(1)、无分支判断、无残留 bug,和填数完美对称。

Q6:MRV 会不会漏解?

不会。MRV 只改搜索顺序,不改搜索空间,完备性不变------同一棵树,换了条更聪明的走法而已。

Q7:还知道别的解法吗?

数独可以建模成精确覆盖问题,Knuth 的 Algorithm X + Dancing Links(DLX)是通解,理论上很优雅。但 DLX 编码复杂、建十字链表的开销未必划算,工程面试写"回溯 + 位运算 + MRV"性价比最高。主动提一句"我知道 DLX,但这里性价比不高",是加分不是减分。

Q8:如果要求所有解 / 验证唯一解呢?

去掉"找到即停"的提前返回:找到解时先深拷贝棋盘收集结果,再继续回溯搜索。必须拷贝------否则后续的撤销操作会污染已收集的答案。验证唯一解同理:不提前终止,若能找到第二个解,就不是唯一解。

Q9:递归会栈溢出吗?

深度最多 81 层,远小于默认栈容量,放心。如果棋盘扩展到 16×16 甚至更大,可以改成显式栈的迭代实现。

Q10:能用多线程加速吗?

理论上可以:在根节点分治,把第一步的 9 种填法分给不同线程各自回溯,找到解后用 AtomicBoolean 或 CompletableFuture.anyOf 短路。不过数独规模太小,线程调度开销可能大于收益,属于"理论可行、实际看场景"。能答到这一层,面试官就知道你有工程思维。

九、几条实战建议

内容讲完了,分享几条非技术但很好用的经验:

  1. 先讲思路再写代码。别上来就闷头写,把回溯框架、三重约束怎么处理、怎么回溯讲清楚,面试官点头了再动手。
  2. 先写基础版,再聊优化。先保证写出正确的布尔数组版,再主动说"还可以用位掩码和 MRV 优化"------分层优化的意识,比炫技更打动面试官。
  3. boolean 返回值是最易错点 。写 void 的人太多,找到解还在搜,用例半天跑不完。
  4. 盯住下标和转换细节 。i/3、j/3 算宫,num + '0'、c - '1' 做字符转换,差一个字符就是 WA。
  5. 主动展示举一反三。收尾时提一句:这题和 N 皇后、全排列、子集同宗同源,都是"做选择 → 递归 → 撤销选择"。面试官很喜欢会总结的候选人。

十、写在最后

今天内容有点多,压缩成一句话:

解数独 = 回溯打底 + 位运算提速 + MRV 剪枝。 回溯保证状态能进能退,决定正确性;位运算和 MRV 保证少走弯路,决定跑多快------前者是及格线,后者是你和 offer 的距离。

换个说法更好记:回溯是骨架,剪枝是灵魂,位运算是加速器,MRV 是点睛之笔。这道题真正考察的,不是你会不会 DFS,而是你能不能把"多一层循环"优化成"多一条指令",能不能让搜索树在长大之前就瘦下去。

解数独是回溯算法的母题。搞懂它,N 皇后、排列组合、子集问题就都通了,万变不离其宗,核心就是那三板斧:做选择 → 递归 → 撤销选择,剩下的全是剪枝。

题目链接放这里:LeetCode 37. 解数独,建议把三个版本都亲手敲一遍,体感和只看代码完全不一样。

最后问大家一个问题:你写回溯时踩过最深的坑是什么? 我是"忘记撤销选择",样例全过、提交全红,debug 了一下午。评论区聊聊你的惨痛经历,让我看看谁比我还惨。

觉得有用的话,别忘了点赞、在看,顺手关注一下,下一篇我们聊回溯家族的另一位经典成员 N 皇后------它和解数独到底差在哪?我们下期见。

相关推荐
Rain的Java大神实战圈12 天前
LeetCode 21 合并两个有序链表:一道 Easy 题,凭什么刷掉一半候选人?
算法198题
Rain的Java大神实战圈14 天前
面了 50 个候选人,90% 都挂在 LeetCode 3 无重复字符的最长子串:滑动窗口的 5 个致命坑
算法198题
Rain的Java大神实战圈18 天前
面了上百人,我最爱问的LeetCode 1「两数之和」:能背代码的很多,能讲清“先查后存”的没几个
算法198题