演算法小課堂:走迷宮之DFS vs BFS
技術標籤:演算法小課堂演算法pythondfsbfsalgorithm
演算法小課堂:走迷宮之DFS vs BFS
前言
上期我們說到DFS的經典入門題目——Fibonaci數列的第n項,這次我們來說下DFS或者BFS較為經典的走迷宮題目解法,很多關於迷宮類題目的母題都是這個了,而且也很容易理解,沖沖衝!
我們約定迷宮裡出口用S表示,出口用T表示,可以通行(不是一方通行)的格子用點表示,障礙物(即不能通行)的格子用*表示。
類似如下的輸入形式:
....s*
.*****
....*.
***..*
.T....
今天我們利用兩種思路,分別是DFS和BFS來解決這個問題。
DFS走迷宮
演算法思路
DFS方法來解決的思路類似於我們上次說的老鼠走迷宮,真就無限套娃唄……從一條路一直走到底A,如果碰到障礙物,就退回一步到B,在B處嘗試完所有的可能方向,如果還不行,繼續回退到C,迴圈往復,直到找到可行的通路。如果沒有,則表示沒有通路。
這裡有幾個點需要注意:
- 怎麼表示走的過程,實際上就是你在二維陣列操作的過程
- 走的過程中座標移動時不應該超過地圖範圍
- 迷宮可能有多條通路,應該選擇最近的一條
- 回退過程中怎麼標記已經檢視過的節點避免迴圈bug
- 最後一個是我們整體的邏輯思路:即上面一段話的程式實現
因為是開始講解DFS的演算法題解,我會盡量詳細,讓大家沒有太多理解負擔,本身也是為了通俗易懂的講解演算法知識。
程式碼實現細節
首先我們來讀取迷宮資料並標記入口和出口,以及各種障礙物
# 讀取maze資料的行列數
row,col = list(map(int,input().split()))
# 儲存maze資料的二維陣列
maze_data = []
for i in range(row):
# maze_data.append([s for s in input()])
In = input()
maze_data.append([s for s in In])
#starting point
s = (0, 0)
for r in maze_data:
if 's' in r:
# 獲取起點的行列座標,從0開始計數
s = (maze_data.index(r), r.index('s'))
接下來我們定義走的方向,由於我們只是上下左右移動(四連通),其實就是行列座標的加一減一:
# 行走方向,四連通方式
direction = [[-1, 0], [0, -1], [1, 0], [0, 1]]
還有走的時候不應該超過maze的範圍,即超過範圍的行列座標pass
# check valid coordinates
def check(x,y):
nonlocal row,col
return 0 <= x < row and 0 <= y < col
這裡nonlocal關鍵字修飾變數後標識該變數是上一級函式中的區域性變數,且只能用於巢狀函式中,因為我們是在一個函式裡定義的check函式,所以適用。一般這麼用主要是為了避免使用global這種比較不安全和暴力的全域性變數宣告方式。
接下來我們來看看一些其他變數的定義:
# 初始化最遠路徑長度
distance = 100000
# 初始化標記陣列,用來避免重複進入節點
max_r_c = max(row, col)
vis = [[0 for _ in range(max_r_c)] for _ in range(max_r_c)]
# 可行通路數量counter
num_paths = 0
vis陣列這裡的作用類似於給一個節點能不能走打標籤,當我們進入節點A,之後進入節點B,那麼我們要將vis裡對應A的位置標記,否則再遍歷B的可行方向時又會回到A,A又會進入B,導致迴圈bug。
而當我們退出一個節點C的所有子狀態時,應該取消vis裡對應的C的標記,因為這僅僅表示這條通路經過C無法通走,不表示其他節點不能通過C組成通路。
有了上面的準備,我們可以來進行DFS核心函式的編寫:
def dfs(x, y, steps):
"""
maze core function
Args:
x (int): row index start from 0
y (int): col index start from 0
steps (int): current steps from starting point
"""
nonlocal vis, distance, maze_data, num_paths
# if reach the end point
if maze_data[x][y] == 'T':
# get shortest one
distance = min(distance, steps)
# counter fresh
num_paths += 1
# mark when enter node
vis[x][y] = 1
# find possible direction
for i in range(4):
tx = x + direction[i][0]
ty = y + direction[i][1]
if check(tx, ty) and maze_data[tx][ty] != '*' and not vis[tx][ty]:
dfs(tx, ty, steps + 1)
# remove marker when exit node
vis[x][y] = 0
其中下面這個判斷就是我們的核心思路:
if check(tx, ty) and maze_data[tx][ty] != '*' and not vis[tx][ty]:
dfs(tx, ty, steps+1)
當下一步的格子在maze範圍內,而且不是障礙物*,並且還沒有被標記過表示可以通行,則遞迴到該點。
再來點收尾工作:
# dfs from starting point with step 0
dfs(s[0], s[1], 0)
print("Get {} practical paths".format(num_paths))
print("Shortest distance:{}".format(distance))
樣例測試
我們用剛才的例子來進行測試:
>>> dfs_maze()
5 6
....s*
.***.*
......
***..*
.T.*.*
No Solution
Using time: 0.00400s
>>> dfs_maze()
5 6
....s*
.*****
....*.
***..*
.T....
Get 2 practical paths
Shortest distance:13
Using time: 0.00700s
如果你在dfs的過程中加入路徑記錄的操作就可以顯示最短的路徑啦,這裡我直接給出最短路徑的標識(用m標記):
mmmmS*
m*****
mmmm*.
***m.*
.Tmm..
BFS走迷宮
演算法原理
DFS可以尋找最短路徑,其實BFS也可以,它們兩者最大的區別在於搜尋方式的不同。BFS即廣度優先搜尋,以走迷宮為例形象的說就是當你在一個節點時,不是一條路走到底,而是先上下左右的格子都走一遍,在其中把可行的子節點加到佇列裡,慢慢地擴大你的搜尋半徑。就好像把水倒在棋盤格慢慢地從中心格點浸沒到周圍的點。
注意BFS常用的資料結構就是佇列,先進先出FIFO。(關於佇列的知識小夥伴自行百度學習喔~)
BFS自帶最短特性,在起始點已知的情況下,每一步都是以起始點為中心向外半徑不斷增大輻射的,所以一旦找到可行的通路,就是最短的路徑,是不是很nice!
程式碼實現細節
maze的讀入和一些常用變數和方向的定義跟DFS其實是一樣,這裡我們在BFS中加入儲存最短路徑的操作。
from queue import Queue
# 儲存最短路徑上每個節點的上一個節點位置
Parent =[[0 for _ in range(max_r_c)] for _ in range(max_r_c)]
# 列印最短路徑
def print_result_path(x, y, flag = 'm'):
"""
print maze data with shortest pathway
Args:
x (int): row index start from 0
y (int): col index start from 0
flag (str, optional): shortest pathway marker. Defaults to 'm'.
"""
nonlocal Parent,maze_data,row
while Parent[x][y]:
maze_data[x][y] = flag
# fresh coordinates
x, y = Parent[x][y]
# show maze
for i in range(row):
print(''.join(str(s) for s in maze_data[i]))
其中,Parent是儲存最短路徑上每一個節點父節點位置的一個數組,通過它我們可以標記出整條道路。
我們來看看BFS的核心邏輯:
# initial Queue
Q = Queue()
# add starting point
Q.put((s[0], s[1], 0))
# current step initial
step = 0
# when queue is not empty
while Q.qsize() != 0:
# get next node
x, y, step = Q.get()
# end point check
if maze_data[x][y] == 'T':
print("Shortest distance:{}".format(step))
print_result_path(x, y)
return
# mark node
vis[x][y] = 1
for d in direction:
nx, ny = x+d[0], y+d[1]
if check(nx, ny) and maze_data[nx][ny] != '*' and not vis[nx][ny]:
# add next possible node in queue
Q.put((nx, ny, step+1))
# record parent node coordinates
Parent[nx][ny] = [x, y]
其中,Python裡隊Queue的用法請自行百度,code裡也只是展示了最基本的用法~
由於BFS的自帶最短特性,所以我們只需要進入節點時標記,而不需要去掉標記的步驟,因為不存在回溯的過程,一旦找到出口那就是最短路徑啦~
樣例測試
我們來看看測試結果:
>>> bfs_maze()
5 6
....s*
.***.*
......
***..*
.T.*.*
No Solution
Using time: 0.00300s
>>> bfs_maze()
5 6
....s*
.*****
....*.
***..*
.T....
Shortest distance:13
mmmms*
m*****
mmmm*.
***m.*
.mmm..
Using time: 0.02200s
兩種方法的對比
這裡BFS的執行時間是0.02s左右,而DFS是0.007s左右,小夥子你有問題 不講code德。 原理上BFS是最短特性搜尋,應該比DFS全域性搜尋更快。
其實BFS相比DFS在結構上由於使用了佇列,在開闢記憶體塊和存取上操作更多,問題規模數不大的情況下自然也就相對來說耗費了更多的時間。簡單問題上不能顯示出BFS的優勢,我們這裡來測試一個複雜一些的maze。
如以下的一個maze:
**********************
*...*...**.**.....*..T
*.*...*.......***.*.**
*.*.*..**..****...*.**
***.******....***.*..*
*..........**..**....*
*****.******...*****.*
*.....*...*******..*.*
*.*******......S.*...*
*................*.***
******************.***
來看看各自的測試結果(省略輸入的列印):
>>> dfs_maze()
……
Get 768 practical paths
Shortest distance:51
Using time: 0.10801s
>>> bfs_maze()
……
Shortest distance:51
**********************
*...*...**.**mmmmm*mmT
*.*...*...mmmm***m*m**
*.*.*..**.m****..m*m**
***.******m...***m*m.*
*....mmmmmm**..**mmm.*
*****m******...*****.*
*mmmmm*...*******..*.*
*m*******......S.*...*
*mmmmmmmmmmmmmmm.*.***
******************.***
Using time: 0.04600s
可以看到BFS的優勢已經顯示出來,DFS找到了所有的可能通路,一共768條,然後選出最少的一條。在只需要得到最短路徑及其長度的情況下,顯然BFS的方法更優
總結
相信通過本次的DFS和BFS迷宮經典問題講解,大家可以對利用DFS來解決問題有一個更好的理解,關於DFS和BFS的相愛相殺我們後續還會繼續嗑瓜~