# -*- coding : utf-8 -*-
"""
@author: 2022数据赋能俱乐部
@Description: 迷宫的DFS和BFS遍历
@Date: 2023-2-5 17:20
"""
from queue import Queue # 系统内置的队列,用于广度遍历
# 1不可走, 0可走
maze = [[0, 1, 1, 1, 1], # 迷宫
[0, 0, 0, 0, 1],
[1, 1, 1, 0, 1],
[1, 0, 0, 0, 1],
[1, 0, 0, 0, 0]]
m, n = len(maze), len(maze[0]) # m * n 迷宫
start = (0, 0) # 起点
end = (4, 4) # 终点
""" DFS 深度优先搜索 """
# 记录每个单元格的访问情况,防止重复访问。
# visited[x][y] = 1 表示(x,y)位置已经访问过,visited初始全为False。
visited = [[False] * n for _ in range(m)] # m * n 大小
path = [] # 用于临时记录当前走过的路径,全局变量,格式:[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (4, 1), (4, 2), (4, 3), (4, 4)]
result = [] # 用于记录一条可行的路径,全局变量,格式和上面一样
flag = False # 用于标记是否找到一条可行的路径,全局变量
# 只寻找一条路径的DFS
def dfs(maze, start, end):
global visited, path, result, flag # 扩展全局变量作用域到此处(否则默认会当成局部变量)
x, y = start # 当前(递归到此处)的起点,解包出(x, y)方便后面使用
# (对于任何递归函数,在进行下一层递归操作前必定先判断是否能够继续递归。否则会出现无限递归的情况)
# 先判断是否已找到一条可行的路径,再判断是否越界,然后判断是否可走,最后判断是否已经访问过
if not flag and 0 <= x < m and 0 <= y < n and maze[x][y] == 0 and visited[x][y] is False:
# 既然满足了上面的条件,那么就说明当前位置是可走的
visited[x][y] = True # ① 当前位置标记为走过
if start == end: # 如果当前位置(start)就是终点,那么成功找到了一条路径
""" 思考:为什么path.append((x, y))要放在if的后面?放到前面会怎样? """
# 由于此时path中还没有加入end,所以需要在此处加入end
result = path.copy() + [end] # 记录当前路径 (注意:这里必须是path.copy(),不能直接赋值!!!否则后面path.pop()会导致result也被pop())
flag = True # 找到一条可行的路径,将flag置为True
return # 结束递归(第一种情况)
path.append((x, y)) # ② 将当前位置加入到路径中
# 当前位置已走过,准备寻找新的位置
# 分别试探当前位置上下左右四个方向的路径
""" 对于“方向”一词,不同题目中的含义可能不同,这里只是恰巧是物理意义上的上下左右。"""
""" 但是在其他题目中,可能是逻辑意义上的上下左右,或者是其他意义上的上下左右,"""
""" 比如在当前情况下能做出几种选择,就有几个“方向”。当然,“方向”的数量也有可能不是4个。 """
# 以下四行代码的顺序可以随意调换,该顺序会影响到探索策略,即先探索哪个方向的新位置。(此处不懂请看回放)
# 所以可能导致答案不同(但有答案就一定能找到),因为“条条大路通罗马”,而该函数只找其中一条可行的道路。
dfs(maze, (x + 1, y), end) # 传递新的start进去,向下继续搜索
dfs(maze, (x - 1, y), end) # 传递新的start进去,向上继续搜索
dfs(maze, (x, y + 1), end) # 传递新的start进去,向右继续搜索
dfs(maze, (x, y - 1), end) # 传递新的start进去,向左继续搜索
path.pop() # 回溯,将当前位置从路径中移除
""" 什么时候会回溯?可以看到回溯代码前已经遍历了当前位置的所有上下左右方向, """
""" 这说明此处的上下左右都找过了,已无路可走,那么当然要“走回头路”,撤销此步选择。 """
""" 下面注释的代码是结束递归的第二种情况,即当前位置不可走,那么就不用再继续递归了 """
""" 实际不需要这一步,因为上面的条件判断已经包含了这一步。特别说明只是为了更好的理解 """
"""
else:
return
"""
dfs(maze, start, end) # DFS搜索
if result: # 如果有解
print('此迷宫的一条可行路径为:', result)
else:
print('无路可走!')
print('此时path的内容是:', path) # 思考:为什么此时path的内容是空的?
print('此时visited的内容是:') # 思考:如果先往左走,其他方向探索顺序不变,那么visited的内容是什么?
for i in visited:
print(i)
# 记录每个单元格的访问情况,防止重复访问。
# visited[x][y] = 1 表示(x,y)位置已经访问过,visited初始全为False。
visited = [[False] * n for _ in range(m)] # m * n 大小
path = [] # 用于临时记录当前走过的路径,全局变量,格式:[(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (4, 1), (4, 2), (4, 3), (4, 4)]
result = [] # 用于记录所有可行的路径,全局变量
# 寻找所有路径的DFS
def dfs_all(maze, start, end):
global visited, path, result # 扩展全局变量作用域到此处(否则默认会当成局部变量)
x, y = start # 当前(递归到此处)的起点,解包出(x, y)方便后面使用
# (对于任何递归函数,在进行下一层递归操作前必定先判断是否能够继续递归。否则会出现无限递归的情况)
# 先判断是否越界,然后判断是否可走,最后判断是否已经访问过
if 0 <= x < m and 0 <= y < n and maze[x][y] == 0 and visited[x][y] is False:
# 既然满足了上面的条件,那么就说明当前位置是可走的
visited[x][y] = True # ① 当前位置标记为走过
path.append((x, y)) # ② 将当前位置加入到路径中
if start == end: # 如果当前位置(start)就是终点,那么成功找到了一条路径
result.append(path.copy()) # 记录当前路径 (注意:这里必须是path.copy(),不能直接赋值!!!否则后面path.pop()会导致result也被pop())
# 当前位置已走过,准备寻找新的位置
# 分别试探当前位置上下左右四个方向的路径
""" 对于“方向”一词,不同题目中的含义可能不同,这里只是恰巧是物理意义上的上下左右。"""
""" 但是在其他题目中,可能是逻辑意义上的上下左右,或者是其他意义上的上下左右,"""
""" 比如在当前情况下能做出几种选择,就有几个“方向”。当然,“方向”的数量也有可能不是4个。 """
# 以下四行代码的顺序可以随意调换,该顺序会影响到探索策略,即先探索哪个方向的新位置。(此处不懂请看回放)
# 所以可能导致答案不同(但有答案就一定能找到),因为“条条大路通罗马”,而该函数只找其中一条可行的道路。
dfs_all(maze, (x + 1, y), end) # 传递新的start进去,向下继续搜索
dfs_all(maze, (x - 1, y), end) # 传递新的start进去,向上继续搜索
dfs_all(maze, (x, y + 1), end) # 传递新的start进去,向右继续搜索
dfs_all(maze, (x, y - 1), end) # 传递新的start进去,向左继续搜索
visited[x][y] = False # 回溯,将当前位置重新标记为未访问
path.pop() # 回溯,将当前位置从路径中移除
""" 什么时候会回溯?可以看到回溯代码前已经遍历了当前位置的所有上下左右方向, """
""" 这说明此处的上下左右都找过了,已无新路可走,那么当然要“走回头路”,撤销此步选择。 """
dfs_all(maze, start, end)
print('所有可行的路径:') # 思考:仔细观察dfs和dfs_all代码区别,为什么如此改动就可以找到所有路径?
for i in result:
print(i)
""" BFS 广度优先搜索 """
# 记录每个单元格的访问情况,防止重复访问。
# visited[x][y] = 1 表示(x,y)位置已经访问过,visited初始全为False。
visited = [[False] * n for _ in range(m)] # m * n 大小
result = [] # 用于记录一条最短的路径,全局变量,格式和上面一样
# 寻找最短路径的BFS
def bfs(maze, start, end):
global visited, path, result
queue = Queue() # 创建一个队列,用于广度遍历
father = {} # 用于记录每个单元格的上一步单元格
x, y = start # 起点,取出(x, y)方便后面使用
queue.put(start) # 将起点放入队列
visited[x][y] = True # 将起点标记为已访问
father[(x, y)] = None # 起点没有上一步,所以标记为None
while not queue.empty(): # 队列不为空时就循环
cur = queue.get() # 访问出队列中的第一个元素(同时具有读取和删除的功能)
x, y = cur # 解包出(x, y)方便后面使用
visited[x][y] = True # 将当前位置标记为已访问
if cur == end: # 如果当前位置是终点,就结束搜索
# 根据father字典,从终点往回找,找到起点为止,就是一条最短路径
while cur is not None:
result.append(cur)
cur = father[cur]
result = result[::-1] # 反转一下,从起点到终点
return # 返回,结束搜索
for next_pos in [(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)]: # 遍历当前位置的上下左右四个方向
x_next, y_next = next_pos # 解包出(x_next, y_next)方便后面使用
# 判断是否越界,是否是障碍物,是否已经访问过
if 0 <= x_next < m and 0 <= y_next < n and maze[x_next][y_next] == 0 and not visited[x_next][y_next]:
queue.put(next_pos) # 将下一个可行位置加入队列
father[next_pos] = cur # 将下一个可行位置的上一步单元格(下一个的上一个,就是当前单元格)记录下来
bfs(maze, start, end)
if result:
print('最短路径:')
print(result) # 输出最短路径
print('最短路径长度:', len(result) - 1) # 输出最短路径长度,减1是因为两个点之间的距离是两个点之间的边数
else:
print('没有找到路径!')
# 思考:为什么bfs可以找到最短路径?为什么bfs不需要回溯?
# 如果有多条最短路径,bfs能找到所有吗?如果能,如何改动代码?
""" 其实对于BFS来说,while里面的for循环更像是一种分身机制; 假如你现在有一只老鼠(代码里的cur)帮你走迷宫。
当它遇到一个二叉分叉口(代码里的四个方向)的时候,他不会去选择其中一个去走,而是变出两个分身(代码里的next_pos)
替它去走(之后自己保持原地不动,在队列里面它就是出队了,相当于在之后的搜索里面起不到任何作用)。这样的话,
每碰到一个分叉口就变出更多的分身。这里的分叉口其实就是对于我们迷宫中的每一个位置,而分叉口的个数则是当前能走的
方向数(没有走过且不是墙且在地图内)。对于每一时刻的老鼠(其实每一时刻的所有老鼠,都是上一时刻所有老鼠的分身),
我们让它们每次只走一步。这样子就能保证在任意时刻每个老鼠所走的步数是相同的,那么只要在某个时刻有一只老鼠到达终点,
那它的路径一定就是最短的。这就是为什么BFS总能找到最短路径。那如何保存这个最短路径呢?我们知道,每一个老鼠都是由
之前的某个老鼠分身而来的,那么我们只需要记录每个老鼠是由谁分身而来的就可以了。这样子,我们抓到最后一只找到出口
的老鼠,不断地从它身上寻找它的源体,最终就能找到一开始放在入口的那只老鼠。然后我们再把这个过程倒过来,那么就
得到了从入口到出口的一个最短路径了,这就是BFS的全过程。
"""