上个月有个读者面试被问住了: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 宫格编号公式
格子属于哪个宫,是最经典的翻车点。记住这个公式:
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,每次填之前扫一遍行、列、宫检查合法性。
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) + 单条位运算 | 空间最小,写法最秀 | 大厂加分项 |
白银版上三张状态表,行一张、列一张、宫一张,把用过的数字"登记"进去:
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 就是天然的终止条件。
定位"下一个空格"还有两种常见写法,都对,挑顺手的用:
// 写法一: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) | 该位置 1 |
| 撤销标记 | used ^= (1 << d) | 异或,精确清零 |
| 求全部候选 | ~(row | col | box) & 0x1FF | 低 9 位中为 1 即可填 |
| 数候选个数 | 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(约束保证的),|= 置位之后,^= 一下就精确清零:无分支、无残留,和填数完美对称:
row[i] |= bit; col[j] |= bit; box[b] |= bit; // 填: 置位
row[i] ^= bit; col[j] ^= bit; box[b] ^= bit; // 撤: 异或回退
枚举候选也不用 1~9 循环,lowbit 组合拳直接在合法候选之间跳跃:
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 倍以上。
实现上有三个要点:
- 每层递归扫描剩余空格,用
Integer.bitCount(cand)数候选,记下最少的那个; - 候选数为 0 说明是死格,直接
return false剪枝——明知死路就别往下走了,这条剪枝非常关键; - 用
Collections.swap把选中的格子换到当前位置,O(1) 且零额外空间;回溯时记得再换回来,不然下一轮的顺序就乱套了。
两个高频疑问,提前回答:
- MRV 会不会漏解? 不会。它只改变搜索顺序,不改变搜索空间,完备性不受影响——同一棵树,只是换了条更聪明的走法。
- 每层都全盘扫描,开销值吗? 这是个权衡:极端困难的数独,MRV 的剪枝收益远大于扫描成本;特别简单的盘,可以退化成固定顺序遍历,不扫描。
5.3 完整代码
位掩码 + MRV 合体版,每一行都有它的作用,建议对着注释读:
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,每填一个数字就异或翻转对应位:
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短路。不过数独规模太小,线程调度开销可能大于收益,属于"理论可行、实际看场景"。能答到这一层,面试官就知道你有工程思维。
九、几条实战建议
内容讲完了,分享几条非技术但很好用的经验:
- 先讲思路再写代码。别上来就闷头写,把回溯框架、三重约束怎么处理、怎么回溯讲清楚,面试官点头了再动手。
- 先写基础版,再聊优化。先保证写出正确的布尔数组版,再主动说"还可以用位掩码和 MRV 优化"——分层优化的意识,比炫技更打动面试官。
boolean返回值是最易错点。写void的人太多,找到解还在搜,用例半天跑不完。- 盯住下标和转换细节。
i/3、j/3算宫,num + '0'、c - '1'做字符转换,差一个字符就是 WA。 - 主动展示举一反三。收尾时提一句:这题和 N 皇后、全排列、子集同宗同源,都是"做选择 → 递归 → 撤销选择"。面试官很喜欢会总结的候选人。
十、写在最后
今天内容有点多,压缩成一句话:
解数独 = 回溯打底 + 位运算提速 + MRV 剪枝。 回溯保证状态能进能退,决定正确性;位运算和 MRV 保证少走弯路,决定跑多快——前者是及格线,后者是你和 offer 的距离。
换个说法更好记:回溯是骨架,剪枝是灵魂,位运算是加速器,MRV 是点睛之笔。这道题真正考察的,不是你会不会 DFS,而是你能不能把"多一层循环"优化成"多一条指令",能不能让搜索树在长大之前就瘦下去。
解数独是回溯算法的母题。搞懂它,N 皇后、排列组合、子集问题就都通了,万变不离其宗,核心就是那三板斧:做选择 → 递归 → 撤销选择,剩下的全是剪枝。
题目链接放这里:LeetCode 37. 解数独,建议把三个版本都亲手敲一遍,体感和只看代码完全不一样。
最后问大家一个问题:你写回溯时踩过最深的坑是什么? 我是"忘记撤销选择",样例全过、提交全红,debug 了一下午。评论区聊聊你的惨痛经历,让我看看谁比我还惨。
觉得有用的话,别忘了点赞、在看,顺手关注一下,下一篇我们聊回溯家族的另一位经典成员 N 皇后——它和解数独到底差在哪?我们下期见。