最近要给俱乐部的成员培训,所以专门写了一些代码方便初学者理解。代码注释很详细,同时还有一些独特见解,希望大家看完后会有所收获。

# -*- 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的全过程。
"""