扫雷是 Windows 时代最经典的智力游戏,规则简单却藏着不少算法:随机布雷、八邻域计数、翻开空格时的大面积扩散、插旗标记。本文用 Python 纯标准库在终端里复刻一版 9x9、10 颗雷的扫雷,读完你就掌握了"洪水填充"这个到处都能用的万能算法。

1. 游戏规则

棋盘是 9x9 的网格,其中随机埋着 10 颗雷。玩家做两件事:翻开和标记。翻开一个格子:如果是雷,游戏结束;如果是数字,显示周围 8 格里藏着几颗雷;如果是 0(周围没有雷),自动扩散翻开周围所有相连的 0 区域,直到碰到数字格才停下——这是扫雷最好玩、也最需要算法的一步。当所有非雷格子都被翻开,你就赢了。

操作设计成命令行,每行一个命令:

命令含义
o 行 列翻开 (open) 该格子,如 o 3 5
f 行 列标记 / 取消标记 (flag) 该格子
q退出游戏

扫雷的乐趣在于推理:数字告诉你周围 8 格里雷的数量,你可以据此判断哪些格子是安全的、哪些必是雷。终端版和图形版唯一的差别是"手感"——图形版能直接点格子,我们只能敲坐标,但背后的规则一模一样。为了让第一次翻开不那么残忍,很多版本会保证"第一步永远翻不到雷",这个我们放到扩展里讲。

2. 模块拆解

扫雷的状态可以拆成两张表:雷区数据表和玩家可见状态表。数据表 nums 里,-1 表示雷,0~8 表示周围雷数;状态表 state 里,0 表示未翻开、1 表示已翻开、2 表示插了旗。为什么要分开?因为"玩家看到什么"和"实际是什么"是两回事,混在一张表里,后期加功能会非常痛苦。

# nums:-1 表示地雷,其余数字是周围 8 格里的雷数
# state:0 未翻开, 1 已翻开, 2 插旗
nums = [[0, -1, 1],
        [1,  1, 0],
        [0,  0, 0]]
state = [[0, 2, 1],
         [0, 0, 1],
         [0, 0, 0]]
print('雷区数据:', nums)
print('可见状态:', state)

函数划分也顺着这两张表走:

3. 布雷与数字计算

先解决"把 10 颗雷随机放到 81 个格子里"。最简单的办法是循环 10 次、每次随机选一个没放过雷的位置,但更干净的做法是用 random.sample 一次性抽 10 个不重复的位置:

import random

ROWS, COLS, MINES = 9, 9, 10

def init():
    nums = [[0] * COLS for _ in range(ROWS)]
    cells = [(r, c) for r in range(ROWS) for c in range(COLS)]
    for r, c in random.sample(cells, MINES):   # 一次性抽出 10 个不同位置
        nums[r][c] = -1
    for r in range(ROWS):
        for c in range(COLS):
            if nums[r][c] == -1:
                continue
            cnt = 0
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    nr, nc = r + dr, c + dc
                    if 0 <= nr < ROWS and 0 <= nc < COLS and nums[nr][nc] == -1:
                        cnt += 1
            nums[r][c] = cnt
    return nums, [[0] * COLS for _ in range(ROWS)]

nums, state = init()
print('雷数:', sum(row.count(-1) for row in nums))
print('第一行:', nums[0])

计算数字用的是"八邻域"遍历:以当前格为中心,dr 和 dc 各取 -1、0、1,一共 9 个组合,其中 (0, 0) 是自己,其余 8 个就是周围格。注意边界检查 0 <= nr < ROWS 要写进条件里,一行就防住了越界——这是二维数组遍历的标配写法。

random.sample(cells, MINES) 的返回值是一组互不相同的坐标,这正是布雷需要的性质:如果两次随机选到同一个格子,雷的数量就不对了。另外注意 init() 最后返回的是 nums 和一张全 0 的 state,两张表在游戏开始时是"分离"的——玩家什么都还没看到,但雷早已埋好。

4. 核心算法:翻开与洪水扩散

翻开 0 格子时,要自动扩散翻开整片"0 区域",这叫洪水填充 (Flood Fill)。最经典的实现是用队列做 BFS:从起点出发,把周围没翻开的格子入队,一层一层往外"淹没",直到队列空为止:

ROWS = COLS = 5

def reveal(nums, state, r, c):
    """翻开一格;0 区域自动扩散。踩雷返回 False"""
    if not (0 <= r < ROWS and 0 <= c < COLS) or state[r][c] != 0:
        return True
    if nums[r][c] == -1:
        state[r][c] = 1
        return False                      # 踩雷!
    queue = [(r, c)]
    while queue:
        cr, cc = queue.pop(0)
        if state[cr][cc] != 0:
            continue
        state[cr][cc] = 1
        if nums[cr][cc] == 0:             # 是 0 才继续扩散
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    nr, nc = cr + dr, cc + dc
                    if 0 <= nr < ROWS and 0 <= nc < COLS and state[nr][nc] == 0:
                        queue.append((nr, nc))
    return True

# 5x5 小棋盘,只有 (1,1) 一颗雷
nums = [[0, 0, 0, 0, 0],
        [0, -1, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0]]
state = [[0] * COLS for _ in range(ROWS)]
reveal(nums, state, 0, 0)
for row in state:
    print(row)

BFS 的骨架就三步:出队 → 处理 → 邻居入队。两个细节值得注意:一是只有数字为 0 的格子才继续扩散,数字格翻开后就停在原地,充当"边界";二是用 state 判重,防止同一个格子被重复入队,否则队列会指数膨胀。用 collections.deque 替代 pop(0) 性能更好,但对 9x9 的棋盘完全无所谓。运行结果里除了 (1,1) 那颗雷,其余格子全部被翻开,这就是扩散的效果。

顺便说一句,洪水填充除了 BFS 队列写法,还有递归写法:翻开 0 格时对 8 个邻居递归调用自身。递归版本代码更短,但 9x9 的小棋盘还好,换成 100x100 的大棋盘就可能栈溢出;BFS 用显式队列,没有这个顾虑。两种写法都值得亲手实现一遍,你会对"递归和循环是同一件事的两种表达"有更深的体会。

5. 标记、胜利判定与渲染

ROWS = COLS = 5
nums = [[0, 1, 1, 1, 0],
        [0, 1, -1, 1, 0],
        [0, 1, 1, 1, 0],
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0]]
state = [[0, 1, 2, 1, 1],
         [1, 1, 0, 1, 1],
         [1, 1, 1, 1, 1],
         [1, 1, 1, 1, 1],
         [1, 1, 1, 1, 1]]

def win(nums, state):
    return all(state[r][c] == 1 for r in range(ROWS) for c in range(COLS)
               if nums[r][c] != -1)

def draw(nums, state):
    import os
    os.system('cls' if os.name == 'nt' else 'clear')
    print('    ' + ' '.join(f'{c:2}' for c in range(COLS)))
    for r in range(ROWS):
        print(f'{r:2}  ', end='')
        for c in range(COLS):
            if state[r][c] == 2:
                print(' F ', end='')
            elif state[r][c] == 0:
                print(' . ', end='')
            elif nums[r][c] == -1:
                print(' * ', end='')
            else:
                print(f' {nums[r][c]} ', end='')
        print()

draw(nums, state)
print('胜利?', win(nums, state))    # (0,0) 还没翻开,所以是 False

胜利条件用一行 all(...) 表达:所有非雷格子的状态都是"已翻开"。渲染时四个符号各司其职:未翻开 .、插旗 F、地雷 *、数字 1~8,行号和列号也打印出来,方便玩家输入坐标。这里演示的状态里 (0,0) 还没翻开,所以 win() 返回 False——把 (0,0) 翻成 1,它就返回 True 了。

渲染时用 print(..., end='') 不换行,把每个格子拼成一行,最后统一 print() 换行——这是终端表格渲染的基本套路。想更好看的话,可以把 . 换成实心方块、把 F 换成旗帜符号(终端支持的话),代码完全不用改。

6. 主循环与完整代码

把前面的函数拼起来,加上命令解析主循环,就是完整的扫雷。保存为 minesweeper.py,运行 python minesweeper.py 开玩:

import os
import random

ROWS, COLS, MINES = 9, 9, 10

def init():
    nums = [[0] * COLS for _ in range(ROWS)]
    cells = [(r, c) for r in range(ROWS) for c in range(COLS)]
    for r, c in random.sample(cells, MINES):
        nums[r][c] = -1
    for r in range(ROWS):
        for c in range(COLS):
            if nums[r][c] == -1:
                continue
            cnt = 0
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    nr, nc = r + dr, c + dc
                    if 0 <= nr < ROWS and 0 <= nc < COLS and nums[nr][nc] == -1:
                        cnt += 1
            nums[r][c] = cnt
    return nums, [[0] * COLS for _ in range(ROWS)]

def reveal(nums, state, r, c):
    if not (0 <= r < ROWS and 0 <= c < COLS) or state[r][c] != 0:
        return True
    if nums[r][c] == -1:
        state[r][c] = 1
        return False
    queue = [(r, c)]
    while queue:
        cr, cc = queue.pop(0)
        if state[cr][cc] != 0:
            continue
        state[cr][cc] = 1
        if nums[cr][cc] == 0:
            for dr in (-1, 0, 1):
                for dc in (-1, 0, 1):
                    nr, nc = cr + dr, cc + dc
                    if 0 <= nr < ROWS and 0 <= nc < COLS and state[nr][nc] == 0:
                        queue.append((nr, nc))
    return True

def win(nums, state):
    return all(state[r][c] == 1 for r in range(ROWS) for c in range(COLS)
               if nums[r][c] != -1)

def draw(nums, state):
    os.system('cls' if os.name == 'nt' else 'clear')
    print('    ' + ' '.join(f'{c:2}' for c in range(COLS)))
    for r in range(ROWS):
        print(f'{r:2}  ', end='')
        for c in range(COLS):
            if state[r][c] == 2:
                print(' F ', end='')
            elif state[r][c] == 0:
                print(' . ', end='')
            elif nums[r][c] == -1:
                print(' * ', end='')
            else:
                print(f' {nums[r][c]} ', end='')
        print()

def main():
    nums, state = init()
    while True:
        draw(nums, state)
        if win(nums, state):
            print('恭喜,全部排雷成功!')
            break
        try:
            cmd = input('o 行 列 翻开 / f 行 列 标记 / q 退出 > ').split()
        except EOFError:
            break
        if not cmd:
            continue
        if cmd[0] == 'q':
            break
        if len(cmd) != 3 or cmd[0] not in 'of':
            print('命令格式:o 3 5 或 f 3 5')
            continue
        try:
            op, r, c = cmd[0], int(cmd[1]), int(cmd[2])
        except ValueError:
            print('行列必须是数字')
            continue
        if op == 'f':
            if state[r][c] == 2:
                state[r][c] = 0
            elif state[r][c] == 0:
                state[r][c] = 2
        elif op == 'o':
            if not reveal(nums, state, r, c):
                draw(nums, state)
                print('踩雷了!游戏结束。')
                break

if __name__ == '__main__':
    main()

主循环读入一行命令,split() 拆成 ['o', '3', '5'] 这样的三段再分发。op, r, c = cmd[0], int(cmd[1]), int(cmd[2]) 一行同时完成拆包和类型转换,如果玩家输入了字母,int() 抛出的 ValueError 会被捕获并提示重输,程序不会崩溃。标记是"开关":插过旗的格子再按一次就取消。翻开时如果 reveal 返回 False,说明踩雷,先画一遍完整棋盘让玩家看清所有雷的位置,再结束游戏。

运行后第一件事,先输入 o 0 0 翻开左上角试试水——如果运气不好第一脚就踩雷,别灰心,重开一局就好。想提高胜率,记住一个原则:从数字小的区域开始推理,数字 0 周围 8 格一定安全,优先翻开它们总能打开一大片。

7. 扩展想法

8. 总结与练习

这篇的核心是三个算法点:随机布雷(random.sample 去重)、八邻域计数(双重偏移循环)、洪水填充(BFS 扩散)。扫雷是最适合练 BFS 的小项目——它比迷宫直观,又比纯算法题有成就感,做完还能真玩。

练习建议:

再补充一个测试技巧:调试扫雷时,可以在 init() 里临时把雷数改成 1、把棋盘改成 5x5,配合打印 nums,几分钟就能验证扩散逻辑对不对。小规模复现 bug,是调试二维数组程序的通用方法。

💡 洪水填充算法不止扫雷能用:画图软件的油漆桶、迷宫寻路、围棋提子、连通域分析,全是它的变体,值得彻底吃透。