前言
上期我们说到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的相爱相杀我们后续还会继续嗑瓜~