2026/10/13 2:53:20

用Python实现扫雷:从数据结构到洪水填充递归展开

用Python实现扫雷:从数据结构到洪水填充递归展开 1. 为什么拿扫雷当练手项目一个游戏里藏了三块硬骨头扫雷这游戏我前前后后写过三版。第一版是刚学编程不久想找个“看起来不简单但又不至于做不出来”的项目练手第二版是给朋友做网页版第三版是把逻辑重新梳理成干净的命令行版用来带新人入门。每次写完都有新收获这也是我为什么一直推荐别人拿扫雷当第一个完整项目——它看起来是个小游戏但拆开之后里面有随机生成、网格遍历、递归展开、交互状态管理这些恰好是以后写任何复杂程序都会反复用到的基础能力。很多人以为做扫雷就是把雷埋进去、点一下炸不炸其实真正的难点不在“布雷”而在“翻开空格之后怎么自动展开一大片”。这个展开逻辑背后是经典的洪水填充算法也就是深度优先搜索或者广度优先搜索的简化应用。你只要把这一个动作想明白以后处理迷宫寻路、连连看消除、地图连通域识别思路就是一路的。这个项目还特别适合练“怎么把规则翻译成代码”。扫雷规则很简单但“简单”恰恰意味着你可以把注意力全部集中在如何设计数据结构、如何组织函数、如何判断边界条件上而不是被复杂的业务逻辑淹没。等你把命令行版本跑通再想升级图形界面、加计时器、做难度分级都是在同一个骨架上加肉扩展感非常明显。技术选型上我这个版本用 Python 写命令行交互。理由很实际Python 语法直观不需要安装额外依赖一段脚本就能跑逻辑完全暴露在眼前最适合学习和复现。如果你后面想做成网页版换成 JavaScript 也很快核心算法几乎可以原样搬过去改动的只是输入输出那一层。下面所有章节的代码我都会按“先讲原理再给代码再解释为什么这么写”的顺序展开保证你能直接照着敲出来。2. 棋盘建模与布雷数据结构先想清楚代码才写得顺2.1 三种状态分离存储而不是揉在一起我第一次写扫雷时犯过一个典型错误用一个二维数组存格子数字和地雷混在一起同时还要用字符去表示“没翻开”结果代码里到处都是判断逻辑越写越乱。后来我才领悟游戏里每个格子其实有三类信息它本身是什么地雷还是数字、玩家是否看到了它翻开与否、玩家是否给它插了旗。这三类信息必须分开存否则任何一步操作都得做一堆字符串解析。这个版本的实现里我用了三个二维列表各自只负责一件事board保存格子的真实内容。-1表示地雷0到8表示周围八格中的地雷数量。visible布尔值True 表示该格已经被翻开。flagged布尔值True 表示该格被玩家插了旗。棋盘大小默认是 9 行 9 列、10 颗雷这也是我说“经典难度”的由来。如果你想让新手玩可以把雷降到 8 颗想快点结束就改成 12 颗。这里建议把行数、列数、雷数全部抽成构造参数不要写死在代码里后面做难度分级就不用大改了。为什么要这样拆分我打个比方你玩桌面游戏每个人头顶上其实挂了三个牌子分别是“真实身份”“当前显示状态”“玩家打上的标记”如果你把一个牌子上的内容全部贴在同一个人脸上别人想改标记时就会连身份一起改坏。程序里同理职责分离越清楚逻辑就越不容易互相干扰。后面所有判断都可以直接问“这个格子 visible 了吗”“是不是 flagged”而不用去翻字符串读代码的人也会轻松很多。2.2 随机布雷去重用 while 计数而不是先造列表再抽布雷的逻辑非常简单在给定坐标范围内随机选行、列如果该位置已经放过雷就重选直到放满指定数量为止。常见错误是用了两层循环去生成随机数结果时不时出现雷数不够的情况或者干脆发生死循环。我用的是计数循环代码很朴素import random class Game: def __init__(self, rows9, cols9, mines10): self.rows rows self.cols cols self.mines mines self.board [[0] * cols for _ in range(rows)] self.visible [[False] * cols for _ in range(rows)] self.flagged [[False] * cols for _ in range(rows)] self.over False self.won False self._place_mines() self._calc_numbers() def _place_mines(self): placed 0 while placed self.mines: r random.randrange(self.rows) c random.randrange(self.cols) if self.board[r][c] -1: continue self.board[r][c] -1 placed 1注意这里用random.randrange而不是random.randint因为前者和 Python 的range一样左闭右开写起来不容易越界。还有一点很重要在init里board初始全部填 0等_place_mines把地雷位置标记成 -1之后马上调用_calc_numbers去计算数字。这样布雷和计算数字就是两个独立方法任何一个出问题都容易定位。有些新手喜欢先构造一个包含所有坐标的列表然后用random.sample一次性抽雷。这个做法当然也可以但当棋盘很大、雷数接近格子总数的时候构造大列表会浪费内存。while 循环的好处是不论棋盘多大、雷多到离谱你都只需要在极少数冲突时重抽代价非常小。实际使用时9×9 的棋盘抽 10 颗雷冲突概率不算低但由于每次冲突都是 O(1) 的判断整体性能完全没问题。2.3 数字生成八方向遍历的“方向数组”技巧布雷完成后每个非雷格子里要填一个数字表示它周围八个格子中有多少颗雷。这个计算没有什么高深技巧但写起来最容易出现重复代码。这个版本我只写了一个双重循环配合方向偏移量一次搞定def _calc_numbers(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] -1: continue cnt 0 for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr 0 and dc 0: continue nr, nc r dr, c dc if 0 nr self.rows and 0 nc self.cols and self.board[nr][nc] -1: cnt 1 self.board[r][c] cnt这里的关键是“不要怕遍历八个方向”。很多人会想能不能只遍历四个方向答案是不行因为斜对角的地雷也算相邻。所以八个方向必须全部检查。再一个容易漏掉的是越界判断。9×9 的棋盘里边角上的格子根本没有八个邻居如果不去检查0 nr rows这样的条件直接访问board[nr][nc]就会抛异常。我把方向偏移和越界判断放在同一个嵌套循环里代码看起来不长但已经把边界全部挡掉了。以后你写任何网格遍历类程序都可以直接把这个“方向数组 越界判断”的模式抄过去。比如贪吃蛇的下一步移动、二维地图的寻路、连连看的连通检测本质上都是“从一个格子出发访问邻居格子”。所以这个小小的_calc_numbers其实是整个扫雷里最值得反复咀嚼的函数之一。3. 点击翻开与连锁展开扫雷的“灵魂算法”是洪水填充3.1 为什么点一个空格会瞬间翻开一大片玩过扫雷的都体验过点到一个周围没有地雷的格子程序会“自动”把它周围一片全部翻开直到数字边界才停下来。很多人第一次玩会觉得这是个魔法其实这个魔法就叫洪水填充。展开逻辑可以这样理解把每个格子想象成一个房间数字代表房间里写了“附近有雷”的提示。当你推开一扇门翻开一个格子发现房间里什么提示都没有说明这一片都是安全的你就应该继续把隔壁的门也推开。一直推直到走进一个写着数字的房间这时你就停在这个房间不再继续往深处推。于是所有连通的空格子都会被翻开而数字格子则像围墙一样挡住扩展趋势。在图的语言里这就是从起点出发的深度优先搜索。扫雷网格就是一张图每个格子是一个节点相邻格子之间有一条边。我选择的实现方式是递归因为它最贴合人类的直觉翻开当前格如果它是空格就对八个邻居依次执行同样的“翻开”操作。代码看着短但信息量很足。3.2 递归实现展开并用 flag 防止重复访问下面这段reveal是整个扫雷里最核心的方法。理解它你就掌握了扫雷算法的一半def reveal(self, r, c): if self.over or self.won: return if not (0 r self.rows and 0 c self.cols): return if self.flagged[r][c] or self.visible[r][c]: return self.visible[r][c] True if self.board[r][c] -1: self.over True return if self.board[r][c] 0: for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr 0 and dc 0: continue nr, nc r dr, c dc if 0 nr self.rows and 0 nc self.cols: self.reveal(nr, nc)几个细节我要专门拎出来讲。第一函数开头绝对要做越界检查。这不是可有可无的防御而是递归的“逃生门”。没有它边角格子会在递归时直接崩溃。每次递归进入新格子都先把坐标范围检查一遍越界就立刻返回相当于告诉递归“这里到边界了别再往下走”。第二visible[r][c]的判断是整个算法的核心防重复机制。想想看一个空格被翻开后它会同时向八个邻居发起递归而它的邻居也会向各自的八个邻居发起递归如果中间不设置“已翻开”标记A格子会被邻居B翻开邻居B又会被邻居C翻开最终每个格子被递归访问很多次既可能陷入无限递归也可能让程序做大量无用功。所以翻开一个格子的第一件事就是立刻把它标记为可见后续递归看到这个标记就直接返回不再处理。第三flagged[r][c]的判断意味着插了旗子的格子不会被“自动翻开”。这是玩家保护自己标注地雷位置的方式如果展开时硬要把它翻开那扫雷的插旗玩法就彻底没意义了。这里我直接在最前面拦住而不是到数字判断之后才干预。如果你担心递归在超大地图上会爆栈比如 100×100 的棋盘全是一片空那么可以把递归改成显式栈来实现深度优先或者用队列实现广度优先。原理一样只是把“函数自己调用自己”改成“把待处理坐标压入栈/队列循环弹出处理”。我推荐先把递归版本写明白因为它更容易理解和调试真遇到超大棋盘再用栈版本优化不迟。3.3 踩到地雷时怎么处理reveal里有一段特殊逻辑如果翻开的是地雷self.over True并立刻返回不再继续展开。这样玩家第一次踩雷就会立即触发游戏结束后续点击全部被if self.over or self.won挡住。这里有个设计取舍传统扫雷在你踩雷时会显示所有地雷的位置然后游戏结束。我们的命令行版本在结束时会打印完整棋盘把地雷显示成*效果类似。不过我没有在踩雷后主动遍历显示全部地雷而是直接把最终棋盘打印出来这样代码更简洁。胜利时展开逻辑也要正确处理如果翻开最后一个安全格子游戏应该判定胜利而不是继续等待。这个判断我们放在主循环里用单独的方法去检查我下一节会详细说。4. 操作循环与胜负判定怎么从程序变成能玩的游戏4.1 主循环就是游戏世界的“心跳”现在棋盘有了展开算法也有了但玩家还没有办法操作。这一步就是把底层逻辑接到交互上让程序每走一轮都打印棋盘、接收输入、执行操作、判断状态。这个循环我习惯叫作“游戏心跳”因为整个游戏世界的生命周期就在这里。主循环的伪代码只有四步显示棋盘 → 读玩家输入 → 执行操作 → 检查是否输了或赢了。代码写法如下def main(): g Game(9, 9, 10) while not g.over and not g.won: g.display() try: raw input(\n输入操作r 行 列翻开f 行 列插旗/拔旗).strip().split() if len(raw) ! 3: print(格式错误需要三个字段例如 r 3 4 表示翻开第3行第4列) continue op, r_str, c_str raw[0], raw[1], raw[2] r, c int(r_str), int(c_str) if not (0 r g.rows and 0 c g.cols): print(坐标越界行和列必须在 0 到 8 之间) continue if op r: g.reveal(r, c) elif op f: if not g.toggle_flag(r, c): print(该格子已翻开不能插旗) else: print(未知操作只能输入 r 或 f) g.check_win() except ValueError: print(请输入数字坐标) g.display() if g.won: print(恭喜所有安全格子都已翻开你赢了) else: print(踩到地雷游戏结束)这里用try...except ValueError是个容易被忽略但很必要的细节。玩家是会用手指头敲键盘的他们大概率会输入r a b、r 3、甚至什么都不输。如果不捕获int()的异常程序会直接崩溃。很多新手写的游戏“测试时好好的给别人一玩就崩”基本都是这个原因。你永远不要假设用户输入是合法的防御性编程在交互程序里必须成为习惯。4.2 插旗操作翻与不翻之间还有第三个状态扫雷里玩家往往不会马上翻开一个格子而是先给它插旗标明“我猜这里是雷”。这个状态必须和“翻开”“未翻开”分开。我在核心逻辑里提供了toggle_flag它的特点是可以反复切换第一次插旗第二次拔旗方便玩家纠正误判。def toggle_flag(self, r, c): if self.visible[r][c]: return False self.flagged[r][c] not self.flagged[r][c] return True这段逻辑很短但我特意把“已翻开的格子不能插旗”的限制写在了方法里而不是让主循环去判断。为什么因为调用方不一定只有一个。以后你扩展图形界面按钮点击事件可能会直接调用这个方法如果逻辑只写在主循环里界面那边就会漏掉限制。把业务规则收进核心类的公开方法是封装的基本功。另外提醒一下插旗的数量并不限制地雷的数量。也就是说玩家可以把整片棋盘都插上旗程序不会报错。真实的扫雷游戏一般会显示剩余地雷数作为提示这个版本里可以之后再加一个mines - flagged_count的计算我放在扩展方向一节再讲。4.3 胜利判定的写法别把条件设反了胜利条件听上去很简单所有非地雷格子都被翻开就算赢。但很多人的第一版实现会把它写成“翻开格子数等于总数减去雷数”这在极少数情况下会漏掉一些状态。我这版的做法是直接遍历检查代码直白不容易出错def check_win(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] ! -1 and not self.visible[r][c]: return False self.won True return True思路是只要还存在一个不是地雷但没被翻开的格子就说明还没赢。全部找完都没找到这样的格子那游戏胜利。这个检查放在每次操作之后一般不会对性能造成什么问题因为棋盘撑死也就几十上百个格子。注意check_win和游戏结束不是同一个状态。over表示踩雷失败won表示胜利。我把它们分开存原因是后续你可能想加“失败时显示所有地雷”“胜利时统计用时”这些不同的处理逻辑两个状态混在一起会让展示层写得很别扭。4.4 打印棋盘把二维数组变成人能读懂的界面命令行的棋盘打印方案是列号打印在第一行行号排在每行的最左边未翻开的格子显示为.插旗显示为F地雷显示为*数字就显示数字本身。这样玩家一眼就能分辨哪些已经翻开、哪些还没处理。def display(self): print( .join(str(i) for i in range(self.cols))) for r in range(self.rows): row [str(r).rjust(2)] for c in range(self.cols): if self.flagged[r][c]: row.append(F) elif not self.visible[r][c]: row.append(.) elif self.board[r][c] -1: row.append(*) else: row.append(str(self.board[r][c])) print( .join(row))打印的顺序值得注意flagged的判断必须放在visible之前否则插了旗的未翻开格子会被打印成.玩家就看不到自己的标记了。这种“状态优先级”的问题是交互程序里最容易踩的坑我在实测部分还会提。5. 完整代码清单Python 命令行版扫雷下面的代码把所有类方法完整组合在一起并加上可运行的main()入口。你可以直接复制保存成minesweeper.py然后运行python minesweeper.py。import random class Game: def __init__(self, rows9, cols9, mines10): self.rows rows self.cols cols self.mines mines self.board [[0] * cols for _ in range(rows)] self.visible [[False] * cols for _ in range(rows)] self.flagged [[False] * cols for _ in range(rows)] self.over False self.won False self._place_mines() self._calc_numbers() def _place_mines(self): placed 0 while placed self.mines: r random.randrange(self.rows) c random.randrange(self.cols) if self.board[r][c] -1: continue self.board[r][c] -1 placed 1 def _calc_numbers(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] -1: continue cnt 0 for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr 0 and dc 0: continue nr, nc r dr, c dc if 0 nr self.rows and 0 nc self.cols and self.board[nr][nc] -1: cnt 1 self.board[r][c] cnt def reveal(self, r, c): if self.over or self.won: return if not (0 r self.rows and 0 c self.cols): return if self.flagged[r][c] or self.visible[r][c]: return self.visible[r][c] True if self.board[r][c] -1: self.over True return if self.board[r][c] 0: for dr in (-1, 0, 1): for dc in (-1, 0, 1): if dr 0 and dc 0: continue nr, nc r dr, c dc if 0 nr self.rows and 0 nc self.cols: self.reveal(nr, nc) def toggle_flag(self, r, c): if self.visible[r][c]: return False self.flagged[r][c] not self.flagged[r][c] return True def check_win(self): for r in range(self.rows): for c in range(self.cols): if self.board[r][c] ! -1 and not self.visible[r][c]: return False self.won True return True def display(self): print( .join(str(i) for i in range(self.cols))) for r in range(self.rows): row [str(r).rjust(2)] for c in range(self.cols): if self.flagged[r][c]: row.append(F) elif not self.visible[r][c]: row.append(.) elif self.board[r][c] -1: row.append(*) else: row.append(str(self.board[r][c])) print( .join(row)) def main(): g Game(9, 9, 10) while not g.over and not g.won: g.display() try: raw input(\n输入操作r 行 列翻开f 行 列插旗/拔旗).strip().split() if len(raw) ! 3: print(格式错误需要三个字段例如 r 3 4 表示翻开第3行第4列) continue op, r_str, c_str raw[0], raw[1], raw[2] r, c int(r_str), int(c_str) if not (0 r g.rows and 0 c g.cols): print(坐标越界行和列必须在 0 到 8 之间) continue if op r: g.reveal(r, c) elif op f: if not g.toggle_flag(r, c): print(该格子已翻开不能插旗) else: print(未知操作只能输入 r 或 f) g.check_win() except ValueError: print(请输入数字坐标) g.display() if g.won: print(恭喜所有安全格子都已翻开你赢了) else: print(踩到地雷游戏结束) if __name__ __main__: main()运行之后的操作方式很简单每次程序先打印当前棋盘然后你输入r 3 4就翻开第 3 行第 4 列输入f 5 6就给第 5 行第 6 列插旗或拔旗。一遍玩下来你会非常直观地感受到“空格自动展开”“踩雷立刻结束”“插旗挡住展开”这几个核心体验是怎么由代码控制的。6. 实测中遇到的坑和想继续做的方向6.1 坑一递归展开里忘记查 visible导致重复访问我第一次写reveal时把visible[r][c]的检查放在了翻开之后才做。结果是一个空格已经被翻开后它的邻居仍然会再次调用reveal而每次调用又会对八个邻居发起递归整个展开过程像是无头苍蝇一样在已经打开的区域里打转。棋盘小的时候还能勉强跑完棋盘一大就直接递归爆栈了。后来我养成了一个习惯凡是涉及递归遍历的代码先把“当前节点是否已处理”的检查放在函数最前面。这个检查看似只是少了几行代码实际上是保证算法复杂度从指数级降回 O(格点数) 的关键。你可以自己试一下删掉if self.visible[r][c]: return看看程序会不会卡死亲眼见过现象之后印象会特别深。6.2 坑二打印棋盘状态顺序不对玩家看不到旗子插旗功能做完以后我发现玩家插了旗从棋盘上看不出来。排查了半天原因是display里我先把visible判断放在了flagged前面。一个插了旗的格子因为visible还是 False直接走到了.分支旗子标记被活活吞掉了。这个例子很好地说明打印这种看似无聊的代码其实也需要明确“状态优先级”先判断插旗再判断是否翻开最后才判断格子的真实内容。顺序反了用户体验就差一个量级。6.3 坑三只要顺序不对雷永远只出现在后面几行还有个随机性问题也值得说。我最早是用两层循环嵌套random.randint来布雷由于重置棋盘时没有先清空board导致程序开始后雷的分布完全偏向后半段棋盘看起来一点都不随机。任何随机类程序都要保证“随机性在每轮初始化时重新生效”。正确的做法是在init里把board全部重置为 0然后再调用布雷函数。现在代码里self.board [[0] * cols for _ in range(rows)]这一行就是稳稳的随机前置条件。6.4 扩展方向加计时、加难度、加图形界面跑通命令行版之后想把它变成真正的成品游戏有几个很顺手的扩展方向。加剩余地雷数显示是最简单的在打印棋盘时用self.mines - sum(sum(row) for row in self.flagged)统计当前旗子数量然后打印在棋盘顶部。这行改成计数器维护会更高效但对小程序来说直接统计完全够用。加计时功能也不复杂在main()开头记录start time.time()每次循环打印时计算elapsed int(time.time() - start)胜利或失败时再把这个数字打出来。如果你想让计时精确到秒可以跳过开始前的等待时间不过 9×9 的棋局一般几十秒就结束误差完全可以接受。加难度分级更是顺水推舟把Game(9, 9, 10)改成Game(16, 16, 40)就是高级难度只要在main()里让用户选一个难度把参数传给构造方法即可。雷数比例记得调整16×16 的棋盘放 40 颗雷密度和 9×9 放 10 颗差不多玩起来节奏正好。加图形界面的方案有两个方向一是 Python 的界面库把每个按钮绑定一个坐标点击时调用reveal或toggle_flag二是迁到网页版用 HTML 生成格子JavaScript 实现核心算法。老实说把命令行版的逻辑扩展到图形界面后你会体会到“界面归界面、逻辑归逻辑”的真正含义——所有核心方法一个字都不用改只要把输入输出层换掉就行。这就是一开始把数据结构设计清楚的回报。做完这个项目后我最大的体会是扫雷不是一个“小游戏”而是一堂浓缩的编程课。随机生成、递归遍历、状态管理、用户输入防御这些平时分散在各种教科书里的知识点被一个简单的游戏串成了一条完整的经验链。如果你照着代码敲了一遍建议再自己从头写一版不要看代码写完再对比收获会大得多。如果在这个基础上还想去挑战可以试试把洪水填充改成迭代版本或者给棋盘加一个“首击必安全”的规则那又是两个很有意思的小问题了。