从 0 到 1 理解 DFS 迷宫生成代码:一篇由浅入深的解析

作者:高玉涵
时间:2025.09.09 11:10
博客:blog.csdn.net/cg_i
语言:Python

引子

如果你曾好奇游戏或解谜场景中的迷宫是如何生成的,那么这篇文章将带你通过一段 Python 代码,揭开基于深度优先搜索(DFS)的迷宫生成原理。我们会从代码的整体功能入手,逐步拆解每个模块的作用,最终让你不仅能看懂代码,还能理解其背后的算法逻辑。

一、先睹为快:代码到底做了什么?

在深入代码前,我们先看最终效果(图 1-1):运行代码后,会生成一个 60 列、40 行的随机迷宫,左上角有绿色的 “START” 标记(起点),右下角有红色的 “END” 标记(出口)。这个迷宫没有回路,只有一条唯一路径能从起点走到出口,这就是游戏中常见的 “完美迷宫”。

要实现这个效果,我们需要两个核心工具:

  1. matplotlib 库:负责在电脑屏幕上绘制迷宫的墙壁、文字和标记;

  2. DFS 算法(深度优先搜索):负责随机 “打通” 墙壁,生成无回路的迷宫路径。

在这里插入图片描述

图 1-1 迷宫效果

二、基础准备:看懂开头的 “铺垫” 代码

任何程序都需要先做 “准备工作”,这份代码的开头主要是导入工具、设定迷宫大小,这些是后续所有功能的基础。

1.导入必要的库
import sys
import matplotlib.pyplot as plt
from random import randint
  • sys 库:用来调整 Python 的 “递归深度”(后面会解释为什么需要);

  • matplotlib.pyplot:简称 “plt”,是画图的核心工具 —— 迷宫的墙壁、“START” 文字都靠它绘制;

  • random.randint:用来生成随机数,让每次生成的迷宫路径都不一样。

2.设定迷宫大小和递归限制
WIDTH  = 60  # 迷宫宽度(一共60列小方格)
HEIGHT = 40  # 迷宫高度(一共40行小方格)
sys.setrecursionlimit(WIDTH * HEIGHT)
  • WIDTH 和 HEIGHT:决定迷宫的大小,数字越大,迷宫越复杂(60x40 的迷宫有 2400 个小方格);

  • 递归限制:DFS 算法会用到 “递归”(函数自己调用自己),Python 默认的递归次数有限(1000次),这里把限制设为 “60*40=2400”,避免程序报错。

三、核心工具函数:迷宫的 “基础零件”

就像盖房子需要砖和水泥,生成迷宫也需要几个 “基础工具函数”,负责记录状态、绘制墙壁等基础操作。我们逐个拆解,每个函数都搭配实例说明。

1.记录单元格是否被访问:initVisitedList ()

迷宫是由一个个 “小方格”(称为 “单元格”)组成的(图 3-1),生成迷宫时需要知道哪些单元格已经 “探索过”。这个函数就是创建一个 “访问记录表”:
在这里插入图片描述

图 3-1 迷宫由小方格组成
def initVisitedList():
    """初始化访问记录列表(行优先:visited[y][x])"""
    visited = []
    for y in range(HEIGHT):  	# 先遍历每一行(y从0到39)
        line = [False] * WIDTH  # 每一行有60个False(未访问)
        visited.append(line)
    return visited
  • 实例理解:生成的visited是一个 40 行、60 列的 “表格”,visited[0][0]代表 “第 0 行第 0 列” 的单元格(左下角),初始值为False(未访问);当我们探索过这个单元格后,会把它改成True

  • 关键提醒:这里是 “行优先” 存储,即visited[y][x]—— 先写行号y,再写列号x,不要搞反!

2.绘制和删除墙壁:drawLine () & removeLine ()

迷宫的 “墙壁” 就是一条一条的直线,这两个函数分别负责 “画墙” 和 “拆墙”:

def drawLine(x1, y1, x2, y2):
    """绘制墙壁(黑色线条,加粗2px)"""
    plt.plot([x1, x2], [y1, y2], color="black", linewidth=2)

def removeLine(x1, y1, x2, y2):
    """删除墙壁(用白色线条覆盖)"""
    plt.plot([x1, x2], [y1, y2], color="white")
  • 实例理解:调用drawLine(0,0,0,1)会画一条从(0,0)到(0,1)的黑色竖线,这就是 “第 0 列第 0 行” 单元格的左边墙壁;如果调用removeLine(0,0,0,1),会用白色覆盖这条线,看起来就像 “墙壁被拆了”(打通了路径)。

小细节:在 matplotlib 中,默认的坐标系是:原点在左下角x 轴向右为正方向,y 轴向上为正方向。

3.获取单元格的 4 条边:get_edges ()

每个单元格都有 4 条边(上、下、左、右),这个函数用来计算每条边的两个端点坐标:

def get_edges(x, y):
    """获取单元格(x,y)的4条边(x=列,y=行)"""
    return [
        (x, y, x, y+1),       # 左边:竖线(x固定,y从y到y+1)
        (x+1, y, x+1, y+1),   # 右边:竖线(x+1固定)
        (x, y, x+1, y),       # 下边:横线(y固定)
        (x, y+1, x+1, y+1)    # 上边:横线(y+1固定)
    ]
  • 核心前提:单元格(x,y)占据的区域是 “从 x 到 x+1、y 到 y+1” 的正方形(例如单元格(0,0)占据 x=0~1 、y=0~1 的区域)。

  • 实例理解:以单元格(0,0)为例,调用get_edges(0,0)会返回 4 条边的坐标:

    • 左边:(0,0,0,1)——x=0 固定,y 从 0 到 1 的竖线;

    • 右边:(1,0,1,1)——x=1 固定,y 从 0 到 1 的竖线;

    • 下边:(0,0,1,0)——y=0 固定,x 从 0 到 1 的横线;

    • 上边:(0,1,1,1)——y=1 固定,x 从 0 到 1 的横线。

4.找两个单元格的公共边:getCommonEdge ()

要 “打通” 两个相邻的单元格,首先要找到它们之间的 “公共边”(也就是共用的那面墙)。这个函数就是干这个的:

def getCommonEdge(cell1_x, cell1_y, cell2_x, cell2_y):
    """获取两个相邻单元格的公共边"""
    edges1 = set(get_edges(cell1_x, cell1_y))  # 第一个单元格的边(转成集合)
    edges2 = set(get_edges(cell2_x, cell2_y))  # 第二个单元格的边(转成集合)
    common = edges1 & edges2	# 集合交集:找出两边都有的边(公共边)
    return common.pop() if common else None
  • 实例理解:单元格(0,0)和(1,0)是左右相邻,它们的公共边是(1,0,1,1)—— 也就是(0,0)的右边、(1,0)的左边。找到这条边后,删除它就能打通两个单元格(图 3-2)。
  • 为什么用集合? 集合的 “交集” 操作能快速找到共同元素,比遍历列表效率更高。

在这里插入图片描述

图 3-2 单元格(0,0)和(1,0)是左右相邻,它们的公共边是(1,0,1,1)
5.初始化所有墙壁:initEdgeList ()

生成迷宫前,我们需要先画好 “完整的网格”(所有单元格的所有边),之后再通过 “拆墙” 形成迷宫。这个函数用来创建所有边的集合:

def initEdgeList():
    """初始化所有边的集合(去重)"""
    edges = set()
    for x in range(WIDTH):
        for y in range(HEIGHT):
            edges.update(get_edges(x, y))  # 把每个单元格的边添加到集合
    return edges
  • 关键作用:用set存储边能自动去重。例如(0,0)的右边和(1,0)的左边是同一条边,添加到集合后只会保留一次,避免重复画墙
6.验证单元格是否有效:isValidPosition ()

确保我们不会探索迷宫之外的单元格(比如 x=-1 或 x=60,这些都是无效位置):

def isValidPosition(x, y):
    """验证单元格(x,y)是否在迷宫范围内"""
    return 0 <= x < WIDTH and 0 <= y < HEIGHT
  • 实例理解isValidPosition(59,39)返回True(60 列的 x 最大是 59,40 行的 y 最大是 39);isValidPosition(60,0)返回False(x=60 超出了宽度)。
7.随机打乱方向:shuffle ()

为了让迷宫路径更 “随机”,每次探索前都要打乱 4 个方向(上、下、左、右)的顺序:

def shuffle(dX, dY):
    """随机打乱4个方向的顺序"""
    for _ in range(4):
        i, j = randint(0, 3), randint(0, 3)
        dX[i], dX[j] = dX[j], dX[i]
        dY[i], dY[j] = dY[j], dY[i]
  • 作用:如果不打乱方向,每次都会按 “上→下→左→右” 的固定顺序探索,生成的迷宫会很规律、不好玩;打乱后,每次的探索路径都不同,迷宫更随机。

四、迷宫生成的 “大脑”:DFS 核心函数详解

DFS(深度优先搜索)是生成迷宫的核心,它的思路就像 “蒙眼走迷宫”:从起点出发,随机选一个没去过的方向走,走不通就回头换方向,直到所有单元格都走过。

1.DFS 函数完整代码
def DFS(X, Y, edgeList, visited):
    """DFS核心函数:从单元格(X,Y)开始探索"""
    # 方向偏移量:上(y+1)、下(y-1)、左(x-1)、右(x+1)
    dX = [0,  0, -1, 1]
    dY = [1, -1,  0, 0]
    shuffle(dX, dY)  # 随机打乱方向
    
    for i in range(4):
        nextX = X + dX[i]  # 计算下一个单元格的x坐标
        nextY = Y + dY[i]  # 计算下一个单元格的y坐标
        # 验证下一个单元格有效且未访问
        if isValidPosition(nextX, nextY) and not visited[nextY][nextX]:
            visited[nextY][nextX] = True  # 标记为已访问
            # 找到并删除公共边(打通墙壁)
            common_edge = getCommonEdge(X, Y, nextX, nextY)
            if common_edge in edgeList:
                edgeList.remove(common_edge)
            # 递归调用自己,以上一个单元格为起点继续探索
            DFS(nextX, nextY, edgeList, visited)
2.逐行拆解 DFS 逻辑(结合实例)

我们以 “起点(0,39)”(左上角,x=0,y=39)为例,一步步看 DFS 是如何工作的:

步骤 1:定义方向偏移量
dX = [0,  0, -1, 1]
dY = [1, -1,  0, 0]
  • 偏移量含义:每个索引对应一个方向,通过 “当前坐标 + 偏移量” 得到下一个单元格的坐标:
索引dXdY方向
001向上
10-1向下
2-10向左
310向右
  • 实例:当前在(0,39),若选择 “向右”(索引 3),则 nextX=0+1=1,nextY=39+0=39,即下一个单元格是(1,39)。
步骤 2:打乱方向顺序
shuffle(dX, dY)
  • 假设打乱后 dX 变成 [1,0,-1,0],dY 变成 [0,1,0,-1],则方向顺序变成 “右→上→左→下”—— 每次运行顺序都不同,保证迷宫随机性。
步骤 3:遍历每个方向,尝试探索
for i in range(4):
    nextX = X + dX[i]
    nextY = Y + dY[i]
  • 循环遍历 4 个方向,计算每个方向的下一个单元格坐标。
步骤 4:验证下一个单元格是否可探索
if isValidPosition(nextX, nextY) and not visited[nextY][nextX]:
  • 两个条件都满足才能探索:

    a.isValidPosition(nextX, nextY):下一个单元格在迷宫范围内;

    b.not visited[nextY][nextX]:下一个单元格未被访问过。

  • 实例:若下一个单元格是(1,39),验证结果为True(在范围内且未访问),则继续;若下一个单元格是(-1,39),验证失败,跳过该方向。

步骤 5:标记为已访问并打通墙壁
visited[nextY][nextX] = True  # 标记下一个单元格为已访问
common_edge = getCommonEdge(X, Y, nextX, nextY)  # 找公共边
if common_edge in edgeList:
    edgeList.remove(common_edge)  # 删除公共边(打通墙壁)
  • 实例:标记(1,39)为True,找到(0,39)和(1,39)的公共边(1,39,1,40),从edgeList中删除这条边 —— 此时两个单元格之间的墙壁被打通。
步骤 6:递归探索下一个单元格
DFS(nextX, nextY, edgeList, visited)
  • 核心逻辑:调用自己(DFS 函数),以上一个单元格(nextX, nextY)为新起点,重复上述探索过程 —— 这就是 “递归” 的魔力,自动实现 “深入探索” 和 “回溯”。
3.用 “小故事” 理解 DFS 的探索与回溯

假设迷宫是一个 “房间群”,每个房间对应一个单元格:

  1. 你从 “起点房间 A”(0,39)出发,随机打开一扇门进入 “房间 B”(1,39),并锁上身后的门(标记为已访问);
  2. 在 “房间 B” 里,随机打开一扇未锁的门进入 “房间 C”(1,38),继续深入;
  3. 直到走到 “房间 Z”,发现所有门都已锁上(死胡同),就原路返回 “房间 Y”,尝试其他未锁的门;
  4. 重复这个过程,直到所有房间都走过 —— 此时迷宫就生成好了,没有重复路径,也没有回路。

五、最后一步:绘制迷宫并添加标记

DFS 生成迷宫后,我们需要绘制剩余的墙壁,再添加 “START” 和 “END” 标记,让迷宫更清晰易懂。这部分代码在if __name__ == "__main__":中,是程序的 “启动入口”,我们逐段拆解。

1.初始化绘图参数

首先设置绘图窗口的基本属性,确保迷宫显示效果清晰:

# 1. 初始化绘图参数
plt.figure(figsize=(12, 8))  # 设置窗口大小为12x8英寸,适配60x40迷宫
plt.axis('equal')  # 保证x、y轴刻度比例一致,避免迷宫变形(如正方形变长方形)
plt.title('Maze (START: Top-Left | END: Bottom-Right)', fontsize=14, pad=15)  # 设置标题,注明起点终点位置
  • figsize=(12,8):根据迷宫尺寸(60x40)设置窗口大小,避免迷宫显示过小或过大。
  • plt.axis('equal'):核心参数 —— 如果不设置,x 轴和 y 轴的刻度比例可能不同,导致单元格变成 “长方形”,迷宫视觉上失真。
  • pad=15:增加标题与迷宫之间的距离,避免拥挤。
2.初始化迷宫数据

调用前面定义的工具函数,创建 “完整网格” 和 “访问记录表”:

# 2. 初始化迷宫数据
edgeList = initEdgeList()  # 创建所有墙壁的集合(完整网格)
visited = initVisitedList()  # 创建访问记录列表(初始全为False)
3.设定起点和终点

根据matplotlib的坐标系(原点在左下角),明确迷宫的起点和终点位置:

# 3. 设定起点和终点
start_x, start_y = 0, HEIGHT-1  # 起点:左上角(x=0最左列,y=39最上行)
end_x, end_y = WIDTH-1, 0       # 终点:右下角(x=59最右列,y=0最下行)
  • 关键理解:由于 y 轴向上递增,“最上行” 的 y 值是HEIGHT-1(40 行的话就是 39),“最下行” 的 y 值是 0,这和我们直观的 “上高下低” 认知一致。
4.从起点开始执行 DFS 生成迷宫

标记起点为已访问,然后调用 DFS 函数开始探索和打通迷宫:

# 4. 从起点开始DFS生成迷宫
visited[start_y][start_x] = True  # 标记起点为已访问(避免重复探索)
DFS(start_x, start_y, edgeList, visited)  # 以起点为中心开始DFS探索
  • 这里必须先标记visited[start_y][start_x] = True,否则 DFS 可能会重复判断起点是否 “未访问”,导致逻辑混乱。
5.设置入口和出口(删除边界墙壁)

迷宫需要 “能进能出”,因此要删除起点和终点的边界墙壁,形成入口和出口:

# 5. 设置入口和出口(删除边界墙壁,与标记匹配!)
# 起点入口:删除左上角单元格的左边墙壁
start_left_edge = (start_x, start_y, start_x, start_y+1)
edgeList.discard(start_left_edge)  # 用discard避免边不存在时报错
# 终点出口:删除右下角单元格的右边墙壁
end_right_edge = (end_x+1, end_y, end_x+1, end_y+1)
edgeList.discard(end_right_edge)
  • 为什么用discard而不是remove 如果由于某种原因(如 DFS 已删除)边不在edgeList中,remove会报错,而discard会静默忽略,程序更稳健。
  • 入口逻辑:起点(0, 39)的左边墙壁是(0, 39, 0, 40),删除它后,外部可以通过这个缺口进入迷宫。
  • 出口逻辑:终点(59, 0)的右边墙壁是(60, 0, 60, 1),删除它后,迷宫内部可以通过这个缺口到达外部。
6.绘制迷宫墙壁

遍历edgeList中剩余的边,用黑色线条绘制出来 —— 这些边就是迷宫的 “墙壁”:

# 6. 绘制迷宫墙壁
for edge in edgeList:
    drawLine(edge[0], edge[1], edge[2], edge[3])
  • 此时edgeList中只保留了未被 DFS 删除的边,绘制后就形成了迷宫的路径和墙壁。
7.标记 “START” 和 “END”(核心可视化优化)

为了让迷宫的起点和终点一目了然,我们添加文字标注和彩色圆点标记:

(1)标记起点 “START”(左上角)
# (1)起点标记:左上角(start_x=0, start_y=39)
# 文字标注:迷宫左侧,绿色加粗(绿色代表“开始”“安全”)
plt.text(
    x=-2,  # 位置在迷宫左边2个单位,避免遮挡迷宫
    y=start_y + 0.5,  # 与起点单元格垂直居中对齐(单元格中心y坐标=start_y+0.5)
    s='START',
    ha='center',  # 文字水平居中
    va='center',  # 文字垂直居中
    fontsize=8,
    color='darkgreen',
    fontweight='bold'
)
# 绿色圆点:标记在起点单元格中心(增强视觉识别)
plt.scatter(
    x=start_x + 0.5,  # 单元格水平中心(x坐标=start_x+0.5)
    y=start_y + 0.5,  # 单元格垂直中心(y坐标=start_y+0.5)
    color='green',
    s=80,  # 圆点大小(数值越大,圆点越大)
    alpha=0.8,  # 透明度(0.8既醒目又不遮挡墙壁)
    zorder=5  # 层级优先级(5确保圆点在墙壁上方显示,不会被遮挡)
)

(2)标记终点 “END”(右下角)
# (2)出口标记:右下角(end_x=59, end_y=0)
# 文字标注:迷宫右侧,红色加粗(红色代表“终点”“目标”)
plt.text(
    x=WIDTH + 1,  # 位置在迷宫右边1个单位
    y=end_y + 0.5,  # 与出口单元格垂直居中对齐
    s='END',
    ha='center',
    va='center',
    fontsize=8,
    color='darkred',
    fontweight='bold'
)
# 红色圆点:标记在出口单元格中心
plt.scatter(
    x=end_x + 0.5,
    y=end_y + 0.5,
    color='red',
    s=80,
    alpha=0.8,
    zorder=5
)
  • 单元格中心计算:任何单元格(x, y)的中心坐标都是(x + 0.5, y + 0.5),这是确保圆点精准落在单元格中心的关键。
  • zorder=5matplotlib默认按绘制顺序确定层级,设置zorder可以强制让圆点在墙壁上方显示,避免被黑色墙壁遮挡。
8.隐藏坐标轴,优化视觉效果

删除 x 轴和 y 轴的刻度与线条,让迷宫更整洁:

# 8. 隐藏坐标轴,让迷宫更整洁
plt.axis('off')
9.显示迷宫

完成所有绘制后,弹出窗口显示最终的迷宫:

# 9. 显示迷宫
plt.tight_layout()  # 自动调整布局,避免文字标注被窗口边缘截断
plt.show()
  • plt.tight_layout():解决文字标注(如 “START”“END”)可能被窗口边缘遮挡的问题,确保所有元素都能完整显示。

六、总结

DFS 迷宫生成的本质是 “先建全网格,再通过随机探索拆墙”:通过工具函数搭建基础框架,DFS 算法负责随机生成无回路路径,可视化模块将逻辑转化为直观图形。整个过程既体现了算法的严谨性,也通过可视化降低了理解门槛,是学习递归、DFS 算法及可视化编程的经典案例。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值