【LeetCode 每日一题】37. 解数独
Problem: 37. 解数独
文章目录
- 整体思路
- 完整代码
- 时空复杂度
- 时间复杂度:O(1) 或 O(9^E)
- 空间复杂度:O(1) 或 O(E)
整体思路
这段代码的目的是用一个高效的回溯算法(Backtracking)来解决一个 9x9 的数独谜题。它不仅实现了基本的回溯,还引入了一个重要的性能优化启发式策略:最少剩余价值(Minimum Remaining Values, MRV),也称为 最受约束优先 (Most Constrained First)。
算法的整体思路可以分解为以下几个核心阶段:
-
预处理与状态初始化:
- 状态记录:创建了三个布尔数组
row
,col
,area
来实时跟踪每一行、每一列、每一个 3x3 子区域中哪些数字已经被使用。这使得检查一个数字在某个位置是否合法的时间复杂度降低到 O(1)。 - 收集空位:遍历一次初始棋盘,完成两件事:
a. 用预设的数字填充row
,col
,area
状态。
b. 将所有待填充的空单元格(值为.
)的坐标(i, j)
收集到一个列表emptyPos
中。
- 状态记录:创建了三个布尔数组
-
应用MRV启发式策略:
- 这是该算法与朴素回溯算法最大的区别和优势所在。它不按固定的顺序(如从左到右,从上到下)填充空格,而是优先填充选择最少的空格。
- 候选数计算:遍历
emptyPos
列表中的每个空位,调用getCandidates
函数计算出该位置有多少个合法的数字可以填(即候选数)。 - 优先队列:将每个空位的信息打包成
[候选数, 行索引, 列索引]
的形式,并存入一个最小优先队列 (Min-Priority Queue)emptyPQ
中。这样,队列的顶部永远是候选数最少的那个空格。 - 为什么这样做? 优先处理选择最少的空格,可以让我们更早地发现错误的路径(如果一个位置一个候选数都没有,那么之前的选择肯定是错的),从而进行更早地“剪枝”,极大地减少了搜索空间。
-
深度优先搜索 (DFS) / 回溯:
dfs
函数是回溯算法的核心。- 基准情形 (Base Case):如果优先队列
emptyPQ
为空,说明所有的空格都已经被成功填充,数独已解,返回true
。 - 递归步骤:
a. 选择:从优先队列中取出poll()
一个“最受约束”的空格(i, j)
。
b. 遍历候选:对数字 1 到 9 进行遍历,检查每个数字是否在(i, j)
位置合法(通过查询row
,col
,area
状态)。
c. 尝试 (Act):如果一个数字x
合法:
* 将board[i][j]
设置为该数字。
* 更新row
,col
,area
状态,标记该数字已被使用。
* 进行递归调用dfs(...)
,尝试解决剩下的谜题。
d. 判断与回溯 (Undo):
* 如果递归调用返回true
,说明找到了一个解,直接将true
向上层返回,终止搜索。
* 如果递归调用返回false
,说明基于当前数字x
的尝试是死胡同。必须进行回溯:撤销刚才的尝试,将row
,col
,area
状态恢复到放置x
之前的状态(设置为false
)。然后继续尝试下一个合法的候选数字。
e. 恢复状态:如果对当前空格(i, j)
的所有候选数字都尝试失败了,说明上一步的选择有误。此时需要将取出的空格信息重新放回优先队列(这是为了恢复队列状态,以便上层递归函数能正确运行),然后返回false
。
完整代码
import java.util.ArrayList;
import java.util.List;
import java.util.PriorityQueue;class Solution {/*** 使用带MRV优化的回溯算法解决数独问题。* @param board 9x9 的数独棋盘*/public void solveSudoku(char[][] board) {// 状态记录数组boolean[][] row = new boolean[9][9]; // row[i][x] 表示第i行是否已使用数字x+1boolean[][] col = new boolean[9][9]; // col[j][x] 表示第j列是否已使用数字x+1boolean[][][] area = new boolean[3][3][9]; // area[i/3][j/3][x] 表示对应3x3区域是否已使用数字x+1// 存储所有空位的坐标List<int[]> emptyPos = new ArrayList<>();// 步骤1: 预处理棋盘,初始化状态并收集空位for (int i = 0; i < 9; i++) {for (int j = 0; j < 9; j++) {if (board[i][j] == '.') {emptyPos.add(new int[]{i, j});} else {int x = board[i][j] - '1';row[i][x] = col[j][x] = area[i / 3][j / 3][x] = true;}}}// 步骤2: 应用MRV启发式策略,创建优先队列// 队列按“候选数”升序排列,候选数越少,优先级越高PriorityQueue<int[]> emptyPQ = new PriorityQueue<>((a, b) -> a[0] - b[0]);for (int[] pos : emptyPos) {int i = pos[0];int j = pos[1];// 计算每个空位的候选数int candidates = getCandidates(i, j, row, col, area);// 将 [候选数, 行, 列] 存入优先队列emptyPQ.offer(new int[]{candidates, i, j});}// 步骤3: 开始回溯搜索dfs(board, row, col, area, emptyPQ);}/*** 计算 (i, j) 位置有多少个合法的候选数字。*/private int getCandidates(int i, int j, boolean[][] row, boolean[][] col, boolean[][][] area) {int candidates = 9;for (int x = 0; x < 9; x++) {if (row[i][x] || col[j][x] || area[i / 3][j / 3][x]) {candidates--;}}return candidates;}/*** 核心回溯函数*/private boolean dfs(char[][] board, boolean[][] row, boolean[][] col, boolean[][][] area, PriorityQueue<int[]> emptyPQ) {// 基准情形:如果优先队列为空,说明所有空位都已填满,找到解if (emptyPQ.isEmpty()) {return true;}// 选择:从队列中取出候选数最少的空位int[] top = emptyPQ.poll();int i = top[1];int j = top[2];// 用于记录失败尝试的次数,以便恢复状态时使用。int failedAttempts = 0;// 遍历所有可能的数字 (1-9)for (int x = 0; x < 9; x++) {// 检查数字 x+1 是否合法if (row[i][x] || col[j][x] || area[i / 3][j / 3][x]) {continue;}// 尝试:放置数字并更新状态board[i][j] = (char) ('1' + x);row[i][x] = col[j][x] = area[i / 3][j / 3][x] = true;// 递归:解决剩下的谜题if (dfs(board, row, col, area, emptyPQ)) {return true; // 成功找到解,直接返回}// 回溯:撤销尝试,恢复状态row[i][x] = col[j][x] = area[i / 3][j / 3][x] = false;// board[i][j] 不需要恢复为'.', 因为下一次循环会覆盖它failedAttempts++;}// 如果所有数字都尝试失败,恢复优先队列状态并返回 false// **注意**: 此处的实现逻辑可能存在问题。正确的做法是直接将 `top` 重新 offer 回去。// `new int[]{candidates, i, j}` 中的 `candidates` 是一个本地变量,其值不反映真实的候选数。// 但为了忠于原代码分析,我们保留此实现。emptyPQ.offer(new int[]{top[0], i, j}); // 应该 offer(top)return false;}
}
时空复杂度
时间复杂度:O(1) 或 O(9^E)
- 理论分析:
- 对于一个通用的 N x N 数独,如果有
E
个空格,每个空格最多有N^2
个选择,所以一个非常宽松的上限是 O(N2E)。 - 对于 9x9 数独,
E
是空单元格的数量(最多为81)。在最坏的情况下,每个空位都有9个选择,所以理论上的时间复杂度是 O(9^E)。
- 对于一个通用的 N x N 数独,如果有
- 实际分析:
- 由于数独的板子大小是固定的 9x9,
E
的最大值也是一个常数 (81)。因此,从严格的算法复杂度理论来看,解决一个固定大小问题的操作数是一个巨大的常数。所以时间复杂度可以认为是 O(1)。 O(9^E)
更好地描述了算法的搜索行为,而O(1)
描述了它在固定问题规模下的性能。- MRV启发式策略会极大地剪枝,使得实际运行时间远小于理论上界。
- 由于数独的板子大小是固定的 9x9,
- 预处理部分:扫描棋盘和建立优先队列的时间是 O(81) + O(E log E),其中 E <= 81,这部分是常数时间,可以忽略不计。
空间复杂度:O(1) 或 O(E)
- 状态数组:
row
,col
,area
数组的大小都是固定的(9x9
,9x9
,3x3x9
),总共占用81 + 81 + 81 = 243
个布尔值的空间。这是 O(1) 的。 - 数据结构:
emptyPos
和emptyPQ
最多存储E
个元素,其中E <= 81
。这也是 O(E) 或 O(1) 的。 - 递归栈深度:回溯算法的递归深度在最坏情况下等于需要填充的空格数
E
。因此,调用栈会使用 O(E) 的空间。
综合分析:
空间开销主要由递归栈深度决定。因此,空间复杂度为 O(E),其中 E
是空单元格的数量。同样,由于 E
有一个固定的上限81,所以严格来说空间复杂度也是 O(1)。
参考灵神