[算法系列] 搞懂DFS(1)——經典例題(數獨遊戲, 部分和, 水窪數目)圖文詳解
本文是遞歸系列的四篇文章, 往期回顧:
- [算法系列] 搞懂遞歸, 看這篇就夠了 !! 遞歸設計思路 + 經典例題層層遞進
- [算法系列] 遞歸應用: 快速排序+歸併排序算法及其核心思想與拓展 … 附贈 堆排序算法
- [算法系列] 深入遞歸本質+經典例題解析——如何逐步生成, 以此類推,步步爲營
在前面的遞歸相關的設計思路, 例題介紹的基礎上, 本文通過圖文並茂的方式詳細介紹三道比較經典的dfs題的思考方向和解題步驟, 以此介紹dfs的一般思路,以及加深對遞歸設計的認識. 覺得不錯就小贊一下啦~
1. 數獨遊戲
數獨遊戲大家一定都玩過吧: 簡單來說就如下的格子中, 填上剩餘空白處的數字1-9,使得每行每列以及所在的小九宮格的所有數字均不同.
我以前並沒有玩過數獨…也不知道這類題有什麼奇技淫巧沒, 下面介紹下大概是普通人能夠想到思路 :(1a代表左上第一個格子)
-
根據規則,1a不能填3,4,5,7,8. 爲了體現規律性, 我們對剩下的可選數字排序, 每次選都從小開始往上挑 — 選1a爲1
-
接下來是1b, 選1b爲2,符合; 接下來1e爲4; 1f6; 1g爲8;1h爲7;目前有如下結果:
- 現在1i只剩下9可選了,由於7i已經是9,所以該填法出錯了… 然後我們拿着小橡皮, 將1h上的7 “擦掉”, 填上剩下的一種可能–9,現在1i只能填7了, 檢查一下,完美. 接下來繼續是第二行…第三行…
好了, 現在引出今天的主題: dfs(深度優先搜索), 以及 回溯
dfs通俗來講, 就像小時走大迷宮一樣. 遇到岔路口後, 選擇其中一條 ,不撞南牆不回頭不回頭. 遇到盡頭後, 回溯 到之前的岔路的位置, 然後選擇另一條路徑. 如果所有的岔路都試完了均是死路的話, 就說明我正處的這個岔路所在的路徑是走錯了, 因而就得再一次 回溯 到前一個岔路口, 選擇另一個岔路…
抽理一下:
在上述走迷宮中, 站在每一個岔路口時,我們都定義是一種 狀態 Si, 當我們(通常按照一定順序) 選擇 某一條路徑時 ,:
- 要麼是死路, 這時我們需要 回到剛剛的狀態 Si(回溯), 選擇另一條路徑
- 要麼達到下一個路口, 就進入了下一個狀態 Si+1
而對這個 下一個狀態 Si+1 , 我們使用和上述同樣的做法 . 這就是DFS的精髓了.
下面繼續通過數獨題目介紹dfs及其解法思路
輸入數獨遊戲題目, 格式爲 9 * 9 的二維數組 ,0 表示未知,其他數字已知
每個零處需填入數字1-9,使得每行 每列 以及 所在的小九宮格 的所有數字均不同.
輸入:
005 300 000
800 000 020
070 010 500
400 005 300
010 070 006
003 200 080
060 500 009
004 000 030
000 009 700
下面給出 dfs 思路,
- 定義狀態 : 座標爲(x,y), 且需要填數字的格子
- 狀態轉移: 當前位置填好後, 填它右邊最近那個需要填數字的格子, 若是最後一個則提行
- 選擇路徑順序(這裏是選擇數字順序): 從1~9中選出滿足條件的最小的那個, 回溯後, 選倒數第二小的, 依次類推
而通過走迷宮的方法可以看出, 解決Si和解決Si+1的方法相同, 這其實更是個遞歸問題:
- 找出口: 當遍歷到 x = 9時 , 則說明下標爲0-8的9行全部填完, 即可退出.
- 找重複: 對每一個狀態,判斷填入數字的合法規則, 以及選擇填入數字的順序是相同的
- 找變化: 很顯然, 每個狀態的數組的完成度是不同的, 同時待填入格子的下標也是不同的 .
上述三部曲也是前面提到過的遞歸設計方法,詳情鏈接:
搞懂遞歸, 看這篇就夠了 !! 遞歸設計思路 + 經典例題層層遞進
好了, 僞代碼也能上了:
dfs(table ,x,y): #table爲當前的數組, x,y爲當前狀態所需填的格子座標
#出口條件
if x==9:
exit(0)
if table[x][y] == 0: #如果爲0, 表示需要填
for i in range (1,10):# 選1-9之間的數字放進去, 從小的開始選
flag = checked(table,x,y,i) # 判斷是否符合同行同列等
if flag: # 如果滿足就填入 i
table[x][y] = i
#然後轉移到下一個狀態
dfs(table, x + (y+1) /9 , (y+1) % 9)
table[x][y] = 0 #for循環完了, 都不滿足, 先將此處恢復成0
# 該層代碼 完成, 返回上一層調用 ==> 回溯
else:
#選擇下一個需要處理的位置
dfs(table, x + (y+1) / 9 , (y+1)%9)
剛開始學的時候可能對其中核心部分還是有些疑惑:
for i in range (1,10):# 選1-9之間的數字放進去, 從小的開始選
flag = checked(table,x,y,i) # 判斷是否符合同行同列等
if flag: # 如果滿足就
table[x][y] = i # 填入 i
dfs(table, x + (y+1) /9 , (y+1) % 9) #遞歸調用 ,轉移到下一個狀態
table[x][y] = 0 #for循環完了, 都不滿足, 先將此處恢復成0
# 函數執行完成, 返回上一層調用處 ==> 回溯
從1-9中選了一個數字, 如果滿足, 則填上此數, 同時考察下一個位置 ;
如果不滿足, 即flag = false: 就會對1-9中的下一個數進行考察, 如果全都不滿足flag = true, 則說明無路可走(死路), 此時需要先將該處恢復成0 , 然後緊接着函數執行完成, 也就返回到上一次調用的地方, 依然在for循環中, 會重新選擇上次的數字(比如:上次選了i=5滿足, 遞歸調用後發現下一個位置是怎麼填都是死路, 那麼回溯後 i 就會繼續遍歷得到下個滿足的數字)
代碼如下:
def shudu(table, x, y):
if x == 9 : #此時表明x已經將0-8的9行全部搞定了
print_matrix(table)
exit(0) #找到一個解即可退出
if table[x][y] == 0:
# 選1-9之間的數字放進去
for i in range(1, 10):
flag = checked(table, x, y, i)
if flag:
table[x][y] = i
# 轉移到下一個狀態
shudu(table, x + (y+1) // 9 , (y+1)%9)
table[x][y] = 0 #恢復該位置爲0, 並進行回溯回溯
else:
# 選擇下一個需要處理的位置
shudu(table, x + (y + 1) // 9, (y + 1) % 9)
def checked(table, x, y, k):
# 檢查同行同列
for i in range(0, 9):
if table[x][i] == k:
return False
if table[i][y] == k:
return False
# 檢查小九宮格
sx = (x // 3) * 3
ex = (x // 3 + 1)*3
sy = (y // 3) * 3
ey = (y // 3 + 1) * 3
for i in range(sx, ex):
for j in range(sy, ey):
if table[i][j] == k:
return False
return True
2. 部分和
給定整數序列a1,a2,...,an,判斷是否可以從中選出m個數,使它們的和恰好爲k
1<= n <= 20
-10^8 < ai < 10^8
-10^8 < k < 10^8
輸入: n = 4
a=[1,4,2,7]
k = 13
輸出: [[4,2,7]]
解法1:
針對每一個數字, 都有取(1)和不取(0)兩種可能, 換句話說, 在不考慮元素重複的情況下, 和爲 k 的情況一定是從 原序列的子集 中產生. 回憶上一篇文章[算法系列] 深入遞歸本質+經典例題解析——如何逐步生成, 以此類推,步步爲營 中求解全部子集的討論. 其中提到了一個很強勁的二進制表示法來求解. 下面簡述該法:
用一個長度爲n的二進制數來表示該序列中某個元素選還是不選的情況, 其中 n= len(arr), 位置 i 上爲1表示選上arr[i] , 爲0表示arr[i]不選.
比如 arr = [1,2,3] , 0 表示全部不選 ;101表示選出1,3; 111 表全部選出. 那麼我們從0開始是遍歷二進制數到2^n-1 對每一種情況考察是否和爲k 即可.
僞代碼
for each binary_num from 0 to 2^n-1:
for 每一位i in binary_num :
if 該爲上爲1:
k = k-arr[i]
遍歷完後,若此時k = 0:
return true
實際代碼也相當簡潔:
def bin_part_sum(arr , k):
n = len(arr) # n爲arr的長度
k_copy = k # 備份一個k的值
for bin in range(1 , 2**n): # 遍歷0到2^n-1的所有二進制數
for i in range(0 ,len(arr) ): #考察這些數的每一位
if (bin >> i) & 1 == 1: #若此位上爲1
k = k - arr[i]
if k == 0 : #k 爲0, 表示 選出來的各數字和爲k
return True
else:
k = k_copy #恢復k
return False
要是想把所有的解都打印出來也很方便:
def bin_part_sum(arr , k):
n = len(arr)
k_copy = k
res = list() #結果集
item = list()
for bin in range (1, 2 ** n):
for i in range(0 , len (arr)):
if (bin >> i) & 1 == 1:
k = k - arr[i]
item.append(arr[i])
if k == 0:
p_item = item.copy() # 注意這裏item是個引用, 而我們實際上需要放的是他的內容
item.clear()
res.append(p_item)
else:
item.clear()
k = k_copy
print(res)
arr = [1,2,3,4]
bin_part_sum(arr , 6)
'''
[[1, 2, 3], [2, 4]]
'''
解法2:
很顯然此題也是可以用dfs進行求解的, 如下爲dfs思路:
- 定義狀態 : 當前考察的數字: arr[cur] , 已選擇的數字集合
- 狀態轉移: 當前已選數字和小於k 時 , 順次cur +1, 若大於了k, 則爲死路, 考慮回退
- 選擇路徑順序 : 對每一個狀態時, 先考慮 選 , 其回溯後考慮 不選
和上面數獨題類似, 也要考慮遞歸設計三要素:
- 找出口: 要麼sum=k, 表示找到一個解, 此時退出, 若要尋找所有解, 則應當返回; 要麼sum > k , 則說明此路徑錯誤, 返回
- 找重複: 對於每一個狀態的選擇方法一致, 要麼選要麼不選, 屬於同問題不同規模
- 找變化: sum變化, cur變化, 若需要保留每一組解, 則需引入的最終結果集res, 以及每個結果item 均在變化 (回憶找變化的作用: 往往用來定參數)
下面依然以 arr = [1, 2, 4,7 ] , k =13 爲例, 其中cur爲當前考慮的arr下標, sum爲當前選了的數字和, item用於存放當前選了的數字
藍色數字是調用順序, 其中部分調用數省略
代碼如下: 這裏圖方便省去了sum , 直接在k上進行操作, 即當k減到0時, 也說明當前和是k
# dfs
def dfs_part_sum(arr, k):
res = list()
item = list()
part_sum(arr ,item,res, k , 0)
print(res)
def part_sum(arr , item, res, k ,cur):
if k == 0 :
res.append(item)
return #這裏得到結果也要返回
if k < 0 or cur == len(arr):
return
#爲了代碼方便,這裏先考慮不選這個元素
c_item_0= item.copy()
part_sum(arr, c_item_0 , res, k , cur+1) #item 不變, k不減少, 只是cur++
#選擇這個元素
item.append(arr[cur]) # 選擇, 即加入當前item
c_item = item.copy()
part_sum(arr , c_item , res, k - arr[cur] , cur+1) # 目標k值縮小
arr = [1,2,3,4,5]
dfs_part_sum(arr , 6)
'''res
[[2, 4], [1, 5], [1, 2, 3]]
'''
3. 水窪數目
有一個大小爲N×M的園子,雨後積起了水。
其中: 1代表有水, 0代表沒水
八連通的積水被認爲是連通在一起的。請求出園子裏總共有多少水窪?(八連通指的是下圖中相對w的*部分)
***
*W*
***
例如某園子如圖:
100000000110
011100000111
000011000110
000000000110
000000000100
001000000100
010100000110
101010000010
010100000010
001000000010
輸出3
思路: 尋找8連通的數目. 此題也是一個很經典的題目, 和前面兩題的dfs模式小有不同.
抽理一下題意 :
從一個值爲1的位置出發, 能夠向四周八個方向同樣是1的地方走,
假設有如下, 一個"1" 選擇了自己右下角的路徑:
那麼相類似, 這個1 同樣有如藍色的路徑可以選擇. 但是請注意, 這時剛剛走過的這個1 , 在進入下一步時應當捨棄, 否則就會出現: 左上角的1 進入到右下角的1, 緊接着右下角的1 回到左上角去. 這種循環死局不是我們希望的.
那麼可以怎麼辦? 其實很簡單, 到達一個 “1” 時, 先將該處置爲"0" , 然後再去找八個方向的1. 下面演示一下過程:
現在回到左上角的1 了 , 然而他已經無路可走, 只能繼續返回, 此時, 我們就認爲這個水窪遍歷完成, count++ 即可.
接下來, 在整個二維數組內遍歷尋找下一個 1 , 一個新的故事上演 … 直到整個界面中的1 全部變爲0時, 遍歷結束, count即爲結果
僞代碼如下:
def fun():
對數組arr中的每個位置元素arr[i][j]:
if arr[i][j] == 1:
dfs(arr, i ,j)
count ++
return count
def dfs(arr , i , j):
if 上方有數字且爲1:
dfs(arr ,i - 1 , j )
if 左上方有數字且爲1:
dfs(arr ,i - 1 , j - 1 )
if 右上方有數字且爲1:
dfs(arr ,i - 1 , j + 1 )
if 左方有數字且爲1:
dfs(arr ,i , j - 1 )
...
//8個方向均需考慮
下面是實現代碼 :
def get_water_num(arr):
count = 0
for i in range(0 , len(arr)):
for j in range(0 , len(arr[0])):
if arr[i][j] == 1:
dfs_get_water_num(arr,i,j)
count +=1
return count
def dfs_get_water_num(arr,i,j):
arr[i][j] = 0
# 更快捷地遍歷8個方向
for k in range(-1 , 2): # -1, 0 ,1 也就是向左1, 不動,向右1
for l in range(-1 , 2): #-1 ,0 ,1
if k == 0 and l == 0: #如果沒動
continue
if i+k >= 0 and i+k <= len(arr) -1 and j+l>= 0 and j+l<=len(arr[0]) - 1: #不能移動出邊界
if arr[i+k][j+l] == 1:
dfs_get_water_num(arr , i + k , j + l)
變式: 在此基礎上, 不但要求出水窪數量, 還要求得各個水窪的大小(1的個數)
#變式: 求出水窪數量, 還要求得各個水窪的大小(1的個數)
def get_water_max(arr):
res = [] #結果list
for i in range(0 , len(arr)):
for j in range(0 , len(arr[0])):
if arr[i][j] == 1:
area = 0
t_a = dfs_get_water_max(arr,i,j,area + 1 )
res.append(t_a)
return res
def dfs_get_water_max(arr,i,j ,area): #area 表示目前的面積大小
arr[i][j] = 0
for k in range(-1 , 2): # -1, 0 ,1
for l in range(-1 , 2): # -1 ,0 ,1
if k == 0 and l == 0:
continue
if i+k >= 0 and i+k <= len(arr) -1 and j+l>= 0 and j+l<=len(arr[0]) - 1:
if arr[i+k][j+l] == 1:
return dfs_get_water_max(arr , i + k , j + l ,area +1) # area + 1
return area # area 沒變化,作爲結果返回
print(get_water_max(arr))
在這個問題需要注意的一點就是: area 作爲當前水窪面積的大小, 我是設計成作爲參數進行傳遞的, 每當成功調用的時候(發現周圍有個1了), 傳入area + 1作爲下一次的area. 由於最終需要保存每個水窪的area,故也要作爲返回值返回.
arr=[
[1,0,0,0,0,0,0,0,0,1,1,0],
[0,1,1,1,0,0,0,0,0,1,1,1],
[0,0,0,0,1,1,0,0,0,1,1,0],
[0,0,0,0,0,0,0,0,0,1,1,0],
[0,0,0,0,0,0,0,0,0,1,0,0],
[0,0,1,0,0,0,1,0,0,1,0,0],
[0,1,0,1,0,0,1,0,0,1,1,0],
[1,0,1,0,1,0,0,1,0,0,1,0],
[0,1,0,1,0,0,0,0,0,0,1,0],
[0,0,1,0,0,0,0,0,0,0,1,0]
]
print(get_water_max(arr))
'''res
[6, 16, 9, 3]#4
'''
在下一文章中, 將繼續介紹DFS概念以及一些諸如n皇后等經典題目