题源:Leetcode-解数独
数独(Sudoku)是一种古老的数学游戏。一个数独的解法需遵循以下规则:
- 数字 1-9 在每一行只能出现一次。
- 数字 1-9 在每一列只能出现一次。
- 数字 1-9 在每一个以粗实现分隔的 3x3 宫内只能出现一次。
一个初始的数独题目可以如下:
答案如下:
在我们的函数中,用 char[][] board
来表示数独游戏。其中空白格用 '.'
表示。
解决数独问题,有几种方法。
方法一:回溯法
对于回溯法,大家应该都不太陌生。在著名的算法问题:八皇后 问题中,我们都接触过回溯法。其实就是一种不断尝试的方法,我们对每一个可能的解进行尝试,如果成功即结束,不成功就往回退一步,继续尝试,直至找到解,或者全部尝试完毕,没有解。
在解决数独问题中,我们的回溯法步骤如下:
- 遍历所有空白格。对每一个空白格,从 1-9 逐一尝试。如果某一个值合法(不会引起行,列,宫 的冲突),则将其赋值给当前空白格,并继续往下寻找(递归)。
- 在这个赋值的前提下,如果最后能够找出解,则成功,返回True。如果不能找到解,则撤回当前的赋值,继续尝试下一个值。如果所有值均尝试过,仍是不能成功,则此数独无解,返回False。
- 如果遍历完所有空白格都没有返回,则说明此时board已经全部填充完毕,返回True。
用代码表示如下:(注释详尽,可读性强)
1 | private boolean backTrack(char[][] board) { |
isValid()函数分析
代码中,isValid函数即用来判断:在当前棋盘board中,在(i,j)位置填充字符c是否合法。在以上实现中,我们的做法是:分别在当前(i,j)所在的 行,列,宫 中 判断是否已经存在c,只要某处存在,即返回false。若全都不存在,则返回true。
在这里,每次判断valid的时候,我们都需要进行O(n)复杂度的遍历。
其实我们可以对此做一些改进,利用Hash的思想,将每一个 行,列,宫 用集合的方式(HashSet)表示出来,这样在判断是否存在c的时候,就不需要通过遍历,而可以直接用O(1)的复杂度在HashSet中判断出来。
改进后代码如下:
1 | public void solveSudoku(char[][] board) { |
时间复杂度分析
在这种方法中,最坏情况下,需要对board中所有的空白格进行1~9的尝试。从排列组合的角度,设空白格数目为m,可以轻易地得出最坏有 9^m 种可能。所以算法的时间复杂度为 O(9^m),为指数级别…
可以看出,如果数独的规模更加庞大,这种方法的可行性会大大降低,因为指数级的复杂度在增长速度上是十分恐怖的。
所以我们接下来尝试其他的方法,对解数独的过程进行优化。
网格更新传播+回溯法
我们对board中的每一个网格cell,封装一个struct,记录其中目前可以取的值,初始化时可以取1~9所有值。
然后,我们对board里所有的网格进行遍历,每次碰到已填充值的网格,就对其影响的所有网格的可取值进行更新,并且在这一过程中,如果某一个网格的可取值只剩一个,就对其进行赋值,并且进一步更新其他网格可取值…
以此状态,形成了一个动态的更新传播过程。在此次遍历中,能够将所有能够确定的值全部找出并且赋值。至此,80%以上的数独游戏已经解决完毕了。
如果还有不能确定值的网格,我们就只能进行backTrack回溯法了。回溯的过程中,为了减小尝试次数,我们从剩余可取值最少的网格开始尝试。
代码如下:
1 | public void solveSudoku(char[][] board) { |
其中尤其值得注意的是java中的 引用。当我们在给对象赋值的时候,其实赋值的是对对象的引用,而不是对象本身。在以上代码中,我们在backTrackRemains()
函数中需要保存住当前cells的值,以便后续恢复,所以引入了tempCells。