本文始發於個人公眾號:TechFlow,原創不易,求個關注
數獨是一個老少咸宜的益智游戲,一直有很多擁躉。但是有沒有想過,數獨游戲是怎么創造出來的呢?當然我們可以每一關都人工設置,但是顯然這工作量非常大,滿足不了數獨愛好者的需求。
所以常見的一種形式是,我們只會選擇難度,不同的難度對應不同的留空的數量。最后由程序根據我們選擇的難度替我們生成一個數獨問題。但是熟悉數獨的朋友都知道,並不是所有的數獨都是可解的,如果設置的不好可能會出現數獨無法解開的情況。所以程序在生成完數獨之后,往往還需要進行可行性檢驗。
所以今天文章的內容就關於如何解開一個數獨。
題意
LeetCode當中關於數獨的是36和37兩題,其中36要求判斷一個給出的數獨問題是否合法。37題則是給出一個必定擁有一個解法的數獨的解。36題只需要判斷在同行同列以及同區域當中是否有重復的數組出現,沒太多的意思,所以我們跳過,直接進入37題緊張刺激的解數獨問題。
題意沒什么好說的,就是解這樣一個數獨:

要求數獨當中的每行每列以及每個黑邊框加粗標記的3*3的區域當中1-9都只出現一次。這個就是日常數獨的規則,我想大家應該都能看明白。
解完成之后,是這樣的:

題目就介紹完了,下面進入正題,即試着去解開這個數獨。
解法
之前在寫題解的時候,經常寫的一句話就是從最簡單的暴力解法開始。然而之前也說過了,並不是所有的問題都有簡單粗暴的暴力解法的。比如這題就不行。不行的原因也很簡單,因為我們並不知道數獨當中留了多少空,我們很難簡單地用循環去遍歷所有填空的方法。
並且對於所有需要填數字的空格而言,前面的選擇的數字會影響后面的決策,所以從原理上我們也不能直接遍歷,需要用一個模式將這些待決策的區域串聯起來。前面選過的數字后面自動不選,如果之后選錯了數字還可以撤銷,回到之前的選擇。如果看過之前關於回溯算法文章的同學從我的描述當中應該能get到,這描述的其實是回溯算法的使用場景。
如果對回溯算法有所遺忘或者是新關注的同學可以點擊下方鏈接,回顧一下關於搜索和回溯算法的講解。
LeetCode 31:遞歸、回溯、八皇后、全排列一篇文章全講清楚
和八皇后的對比
如果你還記得八皇后問題,再來看這道題可能會有一些感覺。也許你會覺得這兩題好像有一些共通的部分,如果你再仔細思考一下,你也許會發現其實這看似迥異的兩個問題實則是在說同一件事情。
你看,在八皇后當中,我們需要考慮的是皇后的位置,經過我們的優化之后,我們把問題轉化成了每一行放置一個皇后。我們需要選擇,在當前行皇后應該放在那里。而在本題當中,空白的位置是固定的,我們要選擇的不再是位置,而是空白當中需要填什么數字。你看,一個是選擇放置的位置,一個是選擇放置的數字,表面上來看不太相同,但實際上都是在做同樣一件事情,就是選擇。再仔細分析一下,又會發現皇后可以選擇的位置是固定的,這題數獨上可供選擇的數字其實也是固定的。
這難道不是同一個問題嗎?
既然是同一個問題,那當然可以使用同一種方法。在八皇后當中我們通過回溯法枚舉了皇后放置的位置,通過回溯修改之前的選擇來找答案。這題本質上是一樣的,我們枚舉空白位置放置的數字,如果之后遍歷不成功,找不到解,說明之前的放置錯了,我們需要回溯回去修改之前的選擇。
我們再來看下回溯問題的代碼模板:
```python def dfs(depth): if depth >= 8: return for choice in all_choices(): record(choice) dfs(depth+1) rollback(choice) ```對照模板,八皇后當中遞歸深度是皇后的數量,這題當中就是空白位置的數量。八皇后選擇的是皇后放置的位置,這題當中就是選擇空白點放置的數字。八皇后當中回溯是將皇后移除,這題當中是將之前放的數字挪走。對照一下,想必你們肯定可以非常順利地寫出代碼:
```python def dfs(board, n, ret): if n == 81: # 判斷棋盤是否合法 if validateBoard(board): ret = board.copy() returnx, y = n / 9, n % 9
if board[x][y] != '.':
dfs(board, n+1, ret)
for i in range(9):
c = str(i+1)
board[x][y] = c
dfs(board, n+1, ret)
board[x][y] = '.'
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">這段代碼非常簡單,沒什么難的,只不過要在最后遞歸結束的時候<strong style="font-weight: bold; color: rgb(71, 193, 168);">判斷一下棋盤是否合法</strong>,要額外寫一個方法而已。但是如果你真的這么做了,妥妥的超時。原因也很簡單,這么做雖然看起來用到了回溯算法,但是回溯算法本質上只是解決了遍歷一個問題所有可能性的問題。我們可以算一下這道題所有擺放的可能性,一個空最多有9種放法,隨着空白位置的增多,這個復雜度是一個指數級的增長,顯然是一定會超時的。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">到這里給大家傳遞一個結論,<strong style="font-weight: bold; color: rgb(71, 193, 168);">純搜索或者是回溯算法本質就是暴力枚舉</strong>,只不過是高級一點的枚舉。</p>
<h2 data-tool="mdnice編輯器" style="margin-top: 40px; font-weight: bold; font-size: 24px; border-bottom: 2px solid rgb(89,89,89); margin-bottom: 30px; color: rgb(89,89,89);"><span style="font-size: 22px; display: inline-block; border-bottom: 2px solid rgb(89,89,89);">優化</span></h2>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">既然這樣做不行,那么就要想想怎么辦才可以。這道題並沒有給我們多少操作的空間,無論如何我們總是要試着去擺放的,我們也不可能設計出一個算法來能夠開天眼,不用枚舉就算得出來每一個位置應該填什么。所以<strong style="font-weight: bold; color: rgb(71, 193, 168);">回溯法是一定要用的</strong>,只是我們用的太簡單粗暴了,所以不行。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">於是,我們進入了一個很大的問題——<strong style="font-weight: bold; color: rgb(71, 193, 168);">搜索優化</strong>。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">這真的是一個很大的問題,在搜索問題上有各種各樣千奇百怪的優化方法,包括不僅限於各種各樣的剪枝技巧、A*, IDA*等啟發式搜索、蟻群算法、遺傳算法等智能算法……不過好在這些方法當中的許多並不是普適的,需要我們結合問題的實際去尋找適合的優化方法,有時候還需要一點運氣。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">比如我曾經聽學長講過一個故事,之前他在比賽的時候有一次他被一道搜索題卡住了。他把所有想到的優化方法都用盡了,還是超時,最后逼不得已構思了一個計算概率的方法,在每次搜索的時候只選擇概率最大的分支,其余的分支全部剪掉。這顯然不太合理,他抱着僥幸的想法提交了一下,沒想到通過了。他賽后查看題解才發現這就是正解,只是這一切原本背后是有一套數學證明和分析的,但他是靠着直覺猜測出的結論,以至於覺得不可思議。</p>
<h3 data-tool="mdnice編輯器" style="margin-top: 40px; margin-bottom: 20px; font-weight: bold; font-size: 20px; color: rgb(89,89,89);"><span>剪枝</span></h3>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">扯遠了,我們回到正題。面臨搜索問題的優化,最常用的方法還是<strong style="font-weight: bold; color: rgb(71, 193, 168);">剪枝</strong>。剪枝這個詞很形象,因為我們搜索的時候背后邏輯上其實是一棵樹形的搜索樹。而剪枝就是在做決策的時候,提前判斷一些不可能存在解的分支給剪掉。</p>
<figure data-tool="mdnice編輯器" style="margin: 0; margin-top: 10px; margin-bottom: 10px;"><img src="https://user-gold-cdn.xitu.io/2020/3/9/170bcd2968edc965?w=1212&h=615&f=png&s=277654" alt style="display: block; margin: 0 auto; width: auto; max-width: 100%;"></figure>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">從上圖我們可以看出來,剪枝發生的位置越接近上層,剪掉的搜索子樹就越大,節省的資源也就越多,效果也就越好。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">但是實際問題當中,<strong style="font-weight: bold; color: rgb(71, 193, 168);">往往越上層的信息越少,剪枝條件也就越難觸發</strong>。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">剪枝只有核心思想,就是減少當下做出的決策,但是沒有固定的套路,需要我們自己構思。同樣的問題,不同的剪枝方案得到的結果可能大相徑庭。好的剪枝方案一般都基於對問題的深入理解和思考。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">我們稍微想一下,就可以想到一個很簡單的思路,即把檢查是否合法的方法從遞歸結束之后挪到放置之前。</p>
```python
def dfs(board, n, ret):
if n == 81:
ret = board.copy()
return
x, y = n / 9, n % 9
if board[x][y] != '.':
dfs(board, n+1, ret)
for i in range(9):
c = str(i+1)
# 判斷棋盤是否合法
if validateBoard(board):
board[x][y] = c
dfs(board, n+1, ret)
board[x][y] = '.'
這也是常用的做法,對於當下已經出現重復的數字,我們沒必要再放一下試試看了,因為已經不可能構成合法解了。
如果你能想到這點,說明你對剪枝的理解已經入門了。但是很遺憾,如果你真這么干了,還是會超時。
原因也很簡單,因為我們判斷棋盤是否合法需要遍歷整個棋盤,會帶來大量的開銷。因為for循環當中的每一個決策,我們都需要判斷一次合法情況。所以這個剪枝判斷帶來的代價是隨着搜索的次數一直增加的。
這也是剪枝的另一個問題,即剪枝的判斷條件很多時候都是有代價的。隨着剪枝條件復雜性的增加,帶來的開銷也會增加。甚至可能出現剪枝了還不如不剪的情況發生。
降低剪枝的開銷
解決的方法也很簡單,既然我們剪枝的使用過程中帶來的開銷很大,我們第一想法就是降低這個開銷。
在這個問題當中,我們基於常規的思路去判斷整體是否合法,而判斷整體合法顯然需要遍歷整個board。但問題是我們做了許多無用功,因為board上可能會引起非法的數字只有當前放置的這個,之前的擺放的位置都經過校驗,顯然都是合法的。我們沒必要判斷那么多,只需要判斷當前的數字是否會引起新的非法就可以了。
也就是說我們把判斷的標准從整體細化到了局部,這么做能成立的條件有兩個,第一個是題目當中保證了數獨一定有解,也就是在我們搜索開始之前的起始狀態一定是合法的。第二點是,我們每一個合法的狀態可以累加,而不會出現意外。也就是說,有可能前面的選擇不合理導致后面沒有數字可以選的情況出現,但是不可能出現前面的擺放都合法,突然到后面變得非法了。
如果能想通了以上兩點,那么我們自然能做出這個結論:即我們不需要判斷board,只需要判斷當前待擺放的數字,這個做法是合理並且可行的。
剩下的問題就是我們怎么快速地判斷當前選擇的數字放在此處是否合法呢?
到這里,相信大家應該不難想到,原理也很簡單,因為題目當中說了我們需要保證每行、每列每個方塊當中的1-9只出現一次。所以我們用三種容器分別存儲每行、每列每個方塊當中1-9出現的次數即可。
具體來看代碼:
```python class Solution:# 全局變量,存儲每行、每列和每個block當中放置的數字的數量
# 用數組會比dict更快
rowDict = [[0 for _ in range(10)] for _ in range(10)]
colDict = [[0 for _ in range(10)] for _ in range(10)]
blockDict = [[0 for _ in range(10)] for _ in range(10)]
def dfs(self, cur, bd, board):
if cur == 81:
# 拼裝答案
for i in range(9):
for j in range(9):
board[i][j] = chr(ord('0') + bd[i][j])
return
x, y = cur // 9, cur % 9
# 如果原本就有數字,直接跳過
if bd[x][y] != 0:
self.dfs(cur+1, bd, board)
return
for i in range(1, 10):
# 如果在行或者列或者block中出現過,那么當下不能放入
blockId = (x // 3) * 3 + y // 3
if Solution.rowDict[x][i] > 0 or Solution.colDict[y][i] > 0 or Solution.blockDict[blockId][i] > 0:
continue
# 更新容器
bd[x][y] = i
Solution.rowDict[x][i] += 1
Solution.colDict[y][i] += 1
Solution.blockDict[blockId][i] += 1
# 往下遞歸
self.dfs(cur+1, bd, board)
# 回溯之后還原
bd[x][y] = 0
Solution.rowDict[x][i] -= 1
Solution.colDict[y][i] -= 1
Solution.blockDict[blockId][i] -= 1
def solveSudoku(self, board: List[List[str]]) -> None:
"""
Do not return anything, modify board in-place instead.
"""
bd = [[0 for _ in range(9)] for _ in range(9)]
for i in range(9):
for j in range(9):
if board[i][j] != '.':
# 將字符串轉成數字
bd[i][j] = ord(board[i][j]) - ord('0')
# 將已經填好的數字插入我們的容器當中
Solution.rowDict[i][bd[i][j]] += 1
Solution.colDict[j][bd[i][j]] += 1
# 計算一下在哪個block當中
blockId = (i // 3) * 3 + j // 3
Solution.blockDict[blockId][bd[i][j]] += 1
self.dfs(0, bd, board)
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">這段代碼不算短,除了回溯之外還涉及到了基礎的剪枝的分析,比無腦的回溯搜索復雜了一些。對這道題深入思考,可以加深對搜索問題的理解。而搜索算法是非常重要的算法之一,許多問題的本質都可以蛻化成搜索問題,因此對搜索算法能力的提升是非常必要的。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">今天的文章就是這些,希望大家都能把這題吃透。我們下周LeetCode專題再見。</p>
<p data-tool="mdnice編輯器" style="font-size: 16px; padding-top: 8px; padding-bottom: 8px; margin: 0; line-height: 26px; color: rgb(89,89,89);">如果覺得有所收獲,請順手點個<strong style="font-weight: bold; color: rgb(71, 193, 168);">關注</strong>吧,你們的舉手之勞對我來說很重要。</p>
