LeetCode37 使用回溯算法實現解數獨,詳解剪枝優化

本文始發於個人公衆號:TechFlow,原創不易,求個關注


數獨是一個老少咸宜的益智遊戲,一直有很多擁躉。但是有沒有想過,數獨遊戲是怎麼創造出來的呢?當然我們可以每一關都人工設置,但是顯然這工作量非常大,滿足不了數獨愛好者的需求。

所以常見的一種形式是,我們只會選擇難度,不同的難度對應不同的留空的數量。最後由程序根據我們選擇的難度替我們生成一個數獨問題。但是熟悉數獨的朋友都知道,並不是所有的數獨都是可解的,如果設置的不好可能會出現數獨無法解開的情況。所以程序在生成完數獨之後,往往還需要進行可行性檢驗。

所以今天文章的內容就關於如何解開一個數獨

題意

LeetCode當中關於數獨的是36和37兩題,其中36要求判斷一個給出的數獨問題是否合法。37題則是給出一個必定擁有一個解法的數獨的解。36題只需要判斷在同行同列以及同區域當中是否有重複的數組出現,沒太多的意思,所以我們跳過,直接進入37題緊張刺激的解數獨問題。

題意沒什麼好說的,就是解這樣一個數獨:

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

解完成之後,是這樣的:

題目就介紹完了,下面進入正題,即試着去解開這個數獨。

解法

之前在寫題解的時候,經常寫的一句話就是從最簡單的暴力解法開始。然而之前也說過了,並不是所有的問題都有簡單粗暴的暴力解法的。比如這題就不行。不行的原因也很簡單,因爲我們並不知道數獨當中留了多少空,我們很難簡單地用循環去遍歷所有填空的方法。

並且對於所有需要填數字的空格而言,前面的選擇的數字會影響後面的決策,所以從原理上我們也不能直接遍歷,需要用一個模式將這些待決策的區域串聯起來。前面選過的數字後面自動不選,如果之後選錯了數字還可以撤銷,回到之前的選擇。如果看過之前關於回溯算法文章的同學從我的描述當中應該能get到,這描述的其實是回溯算法的使用場景。

如果對回溯算法有所遺忘或者是新關注的同學可以點擊下方鏈接,回顧一下關於搜索和回溯算法的講解。

LeetCode 31:遞歸、回溯、八皇后、全排列一篇文章全講清楚

和八皇后的對比

如果你還記得八皇后問題,再來看這道題可能會有一些感覺。也許你會覺得這兩題好像有一些共通的部分,如果你再仔細思考一下,你也許會發現其實這看似迥異的兩個問題實則是在說同一件事情。

你看,在八皇后當中,我們需要考慮的是皇后的位置,經過我們的優化之後,我們把問題轉化成了每一行放置一個皇后。我們需要選擇,在當前行皇后應該放在那裏。而在本題當中,空白的位置是固定的,我們要選擇的不再是位置,而是空白當中需要填什麼數字。你看,一個是選擇放置的位置,一個是選擇放置的數字,表面上來看不太相同,但實際上都是在做同樣一件事情,就是選擇。再仔細分析一下,又會發現皇后可以選擇的位置是固定的,這題數獨上可供選擇的數字其實也是固定的。

這難道不是同一個問題嗎?

既然是同一個問題,那當然可以使用同一種方法。在八皇后當中我們通過回溯法枚舉了皇后放置的位置,通過回溯修改之前的選擇來找答案。這題本質上是一樣的,我們枚舉空白位置放置的數字,如果之後遍歷不成功,找不到解,說明之前的放置錯了,我們需要回溯回去修改之前的選擇。

我們再來看下回溯問題的代碼模板:

def dfs(depth):
if depth >= 8:
return
for choice in all_choices():
record(choice)
dfs(depth+1)
rollback(choice)

對照模板,八皇后當中遞歸深度是皇后的數量,這題當中就是空白位置的數量。八皇后選擇的是皇后放置的位置,這題當中就是選擇空白點放置的數字。八皇后當中回溯是將皇后移除,這題當中是將之前放的數字挪走。對照一下,想必你們肯定可以非常順利地寫出代碼:

def dfs(board, n, ret):
if n == 81:
# 判斷棋盤是否合法
if validateBoard(board):
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)
board[x][y] = c
dfs(board, n+1, ret)
board[x][y] = '.'

這段代碼非常簡單,沒什麼難的,只不過要在最後遞歸結束的時候判斷一下棋盤是否合法,要額外寫一個方法而已。但是如果你真的這麼做了,妥妥的超時。原因也很簡單,這麼做雖然看起來用到了回溯算法,但是回溯算法本質上只是解決了遍歷一個問題所有可能性的問題。我們可以算一下這道題所有擺放的可能性,一個空最多有9种放法,隨着空白位置的增多,這個複雜度是一個指數級的增長,顯然是一定會超時的。

到這裏給大家傳遞一個結論,純搜索或者是回溯算法本質就是暴力枚舉,只不過是高級一點的枚舉。

優化

既然這樣做不行,那麼就要想想怎麼辦纔可以。這道題並沒有給我們多少操作的空間,無論如何我們總是要試着去擺放的,我們也不可能設計出一個算法來能夠開天眼,不用枚舉就算得出來每一個位置應該填什麼。所以回溯法是一定要用的,只是我們用的太簡單粗暴了,所以不行。

於是,我們進入了一個很大的問題——搜索優化

這真的是一個很大的問題,在搜索問題上有各種各樣千奇百怪的優化方法,包括不僅限於各種各樣的剪枝技巧、A*, IDA*等啓發式搜索、蟻羣算法、遺傳算法等智能算法……不過好在這些方法當中的許多並不是普適的,需要我們結合問題的實際去尋找適合的優化方法,有時候還需要一點運氣。

比如我曾經聽學長講過一個故事,之前他在比賽的時候有一次他被一道搜索題卡住了。他把所有想到的優化方法都用盡了,還是超時,最後逼不得已構思了一個計算概率的方法,在每次搜索的時候只選擇概率最大的分支,其餘的分支全部剪掉。這顯然不太合理,他抱着僥倖的想法提交了一下,沒想到通過了。他賽後查看題解才發現這就是正解,只是這一切原本背後是有一套數學證明和分析的,但他是靠着直覺猜測出的結論,以至於覺得不可思議。

剪枝

扯遠了,我們回到正題。面臨搜索問題的優化,最常用的方法還是剪枝。剪枝這個詞很形象,因爲我們搜索的時候背後邏輯上其實是一棵樹形的搜索樹。而剪枝就是在做決策的時候,提前判斷一些不可能存在解的分支給剪掉。

從上圖我們可以看出來,剪枝發生的位置越接近上層,剪掉的搜索子樹就越大,節省的資源也就越多,效果也就越好。

但是實際問題當中,往往越上層的信息越少,剪枝條件也就越難觸發

剪枝只有核心思想,就是減少當下做出的決策,但是沒有固定的套路,需要我們自己構思。同樣的問題,不同的剪枝方案得到的結果可能大相徑庭。好的剪枝方案一般都基於對問題的深入理解和思考。

我們稍微想一下,就可以想到一個很簡單的思路,即把檢查是否合法的方法從遞歸結束之後挪到放置之前。

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出現的次數即可。

具體來看代碼:

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)

這段代碼不算短,除了回溯之外還涉及到了基礎的剪枝的分析,比無腦的回溯搜索複雜了一些。對這道題深入思考,可以加深對搜索問題的理解。而搜索算法是非常重要的算法之一,許多問題的本質都可以蛻化成搜索問題,因此對搜索算法能力的提升是非常必要的。

今天的文章就是這些,希望大家都能把這題吃透。我們下週LeetCode專題再見。

如果覺得有所收穫,請順手點個關注吧,你們的舉手之勞對我來說很重要。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章