[算法系列]搞懂DFS(1)——經典例題(數獨遊戲, 部分和, 水窪數目)圖文詳解

[算法系列] 搞懂DFS(1)——經典例題(數獨遊戲, 部分和, 水窪數目)圖文詳解

本文是遞歸系列的四篇文章, 往期回顧:

  1. [算法系列] 搞懂遞歸, 看這篇就夠了 !! 遞歸設計思路 + 經典例題層層遞進
  2. [算法系列] 遞歸應用: 快速排序+歸併排序算法及其核心思想與拓展 … 附贈 堆排序算法
  3. [算法系列] 深入遞歸本質+經典例題解析——如何逐步生成, 以此類推,步步爲營

在前面的遞歸相關的設計思路, 例題介紹的基礎上, 本文通過圖文並茂的方式詳細介紹三道比較經典的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皇后等經典題目

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