C++手写扫雷实战:从二维数组到递归展开的完整实现

发布时间:2026/10/16 4:20:53
C++手写扫雷实战:从二维数组到递归展开的完整实现
1. 为什么选择用C手写一个扫雷扫雷这个游戏很多人第一次接触都是在老版本的Windows系统里。它规则简单、上手快但真正自己动手用C把它写出来会发现里面涉及的东西远比想象中多。我之所以拿它当练手项目是因为它几乎覆盖了C入门到进阶的大部分核心知识点二维数组、递归、随机数、控制台输入输出、面向对象封装甚至还能延伸到图形界面和算法优化。先说说这个项目到底能做什么。用C写出来的扫雷最基础的版本是控制台程序运行之后在终端里显示一个方格矩阵玩家通过输入坐标来翻开格子踩到雷就结束翻完所有非雷格子就获胜。进阶一点可以用EasyX或者Qt做成图形界面鼠标点击操作加上计时器、旗子标记、难度选择这些功能。再往深了做还能加入自动求解算法、排行榜、存档读档等功能。适合谁来参考如果你刚学完C的基础语法不知道拿什么项目练手扫雷非常合适。它不像管理系统那样枯燥也不像游戏引擎那样门槛高。代码量可控一个基础版本两三百行就能跑起来但每一个细节都值得推敲。如果你已经有一定基础想练习面向对象设计和代码重构也可以拿它来练把过程式的代码改造成类与类之间职责清晰的架构。我见过不少人学C学到指针和数组就卡住了原因就是缺少一个能把这些知识串起来的项目。扫雷恰好就是那个“串珠子”的线。下面我会从整体设计思路开始一步步拆解核心细节、实操过程、常见问题把我在写这个项目过程中踩过的坑和总结的技巧都分享出来。2. 整体设计思路与核心架构拆解2.1 从需求出发确定功能边界动手写代码之前先要想清楚这个扫雷到底要做成什么样。我把功能分成三个层次核心层、交互层、扩展层。核心层是必须有的一个N×N的格子矩阵随机布置M个雷玩家翻开格子如果翻到雷就失败如果翻到空白格就自动展开周围区域如果翻开的格子周围有雷就显示数字提示。当所有非雷格子都被翻开时游戏胜利。交互层是玩家能感知到的显示当前棋盘状态、接收玩家输入、判断输入合法性、显示剩余雷数或者已用时间。扩展层是可以后续加的难度选择初级9×9十雷、中级16×16四十雷、高级16×30九十九雷、标记旗子、计时器、重开功能、排行榜。为什么要这样分层因为如果一上来就把所有功能混在一起写代码会变成一锅粥。分层之后核心逻辑可以独立测试交互层可以替换控制台换成图形界面扩展层可以按需添加。这是我在实际项目中反复验证过的思路先让核心跑通再考虑包装。2.2 数据结构选型二维数组还是vector最直观的做法是用二维数组int board[16][16]来存棋盘。但二维数组的问题是大小固定如果要做难度选择就得开一个足够大的数组然后只用一部分浪费空间不说代码里到处都要传行列数容易出错。我最终选择用std::vectorstd::vectorint来存棋盘。这样做的好处是大小动态可控创建棋盘时根据难度参数直接初始化成对应尺寸。访问方式也很自然board[row][col]。性能上对于扫雷这种规模的程序来说vector和原生数组的差异完全可以忽略。具体来说我用了两个二维vector一个存真实状态哪里是雷、哪里是数字、哪里是空白另一个存玩家可见状态哪些格子已翻开、哪些被标记了旗子。这种“双棋盘”的设计是扫雷程序的经典做法把逻辑数据和显示数据分开避免在递归展开时误改真实状态。2.3 雷的布置与随机数处理雷的布置看起来简单其实有个容易踩的坑如果直接用rand() % (row * col)来生成雷的位置可能会在同一个位置重复放雷导致实际雷数少于预期。正确的做法是生成一个位置就检查是否已经有雷如果有就重新生成直到放满M个雷为止。另一种更优雅的做法是用一个一维数组存所有格子的编号然后做洗牌Fisher-Yates shuffle取前M个作为雷的位置。这种方法效率更高而且不会出现重复问题。我在代码里用的是洗牌法因为当棋盘较大、雷数较多时重复生成法的效率会明显下降。随机数种子用std::mt19937配合std::random_device比传统的rand()质量高很多。这一点很多人容易忽略用rand()在Windows上每次运行结果都一样因为默认种子是固定的。用现代C的随机数库可以避免这个问题。2.4 递归展开的边界条件设计扫雷最核心的算法就是递归展开。当玩家翻开的格子周围没有雷时需要自动把周围八个方向的格子也翻开如果这些格子周围也没有雷就继续往外扩。这个逻辑用递归实现最自然。但递归有两个必须处理好的边界条件第一不能越界访问每次递归前要检查行列坐标是否在合法范围内第二不能重复访问已经翻开的格子否则会无限递归导致栈溢出。我的做法是在递归函数入口先判断坐标合法性再判断该格子是否已经翻开如果已经翻开就直接返回。还有一个细节如果周围有雷的格子也就是显示数字的格子递归展开时应该把它翻开但不再继续递归。这个判断要放在递归调用之前否则会把不该展开的格子也展开。3. 核心细节解析与实操要点3.1 棋盘初始化如何正确计算周围雷数棋盘初始化分两步先布置雷再计算每个非雷格子周围的雷数。计算周围雷数的时候最容易犯的错误是边界处理不当。比如左上角的格子只有三个邻居如果代码里固定遍历八个方向就会访问到负索引。我的处理方式是写一个辅助函数countAdjacentMines(row, col)在函数内部遍历八个方向每次计算新坐标后判断是否在合法范围内如果在就检查该位置是否是雷是就计数加一。这样不管格子在哪条边上逻辑都是统一的。这里有个性能上的小优化如果棋盘很大每次翻开格子都重新计算周围雷数会很慢。更好的做法是在初始化阶段就把所有格子的周围雷数算好存起来之后直接查表。对于扫雷这种规模的程序两种方式差异不大但从代码清晰度来说预计算更直观。3.2 递归展开的实现细节与栈溢出防范递归展开的代码大概长这样void reveal(int row, int col) { if (row 0 || row rows || col 0 || col cols) return; if (visible[row][col]) return; visible[row][col] true; if (board[row][col] 0) { for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; reveal(row dr, col dc); } } } }这段代码看起来简单但有几个点需要注意。第一visible[row][col] true必须放在递归调用之前否则会无限递归。第二只有当board[row][col] 0时才继续递归如果是有数字的格子就只翻开不递归。第三对于特别大的棋盘比如100×100全空白递归深度可能达到几千层有栈溢出的风险。解决办法是把递归改成用栈或队列的迭代方式或者限制递归深度。我在实际测试中发现16×16的棋盘递归深度最多也就几十层完全没问题。但如果要做超大棋盘建议改成迭代。这是一个典型的“根据实际需求选择方案”的例子不要为了追求理论上的完美而过度设计。3.3 玩家输入处理与坐标映射控制台版本的扫雷玩家输入的是行列号。这里有个常见的坑玩家习惯从1开始数而数组是从0开始的。如果直接拿玩家输入当索引就会越界。我的做法是在输入后统一减一同时做合法性检查如果输入超出范围就提示重新输入。另一个细节是输入缓冲区的问题。如果玩家输入了非数字字符std::cin会进入错误状态后续所有输入都会失败。正确的处理方式是检查std::cin.fail()如果失败了就清除错误状态并忽略缓冲区中的内容if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 输入无效请重新输入。 std::endl; continue; }这个技巧在处理所有控制台输入时都适用不只是扫雷。很多人写控制台程序时忽略这一点导致程序在用户误输入后行为异常。3.4 显示模块的设计如何让棋盘清晰易读控制台显示棋盘最朴素的方式是直接打印数字。但这样可读性很差玩家分不清哪些是翻开的、哪些是未翻开的。我的做法是用不同的字符表示不同状态未翻开的格子显示#翻开的空白格显示空格或者.翻开的数字格显示对应的数字被标记旗子的格子显示F为了让棋盘对齐每个格子占两个字符宽度行号和列号也打印出来方便玩家定位。比如1 2 3 4 5 1 # # # # # 2 # 1 . # # 3 # # # # #这种显示方式虽然简单但足够清晰。如果要做图形界面这部分逻辑可以完全替换掉核心逻辑不需要改动这就是分层设计的好处。4. 完整实操过程与核心环节实现4.1 项目结构搭建与编译环境准备我用的编译环境是g配合CMake操作系统不限。项目文件结构很简单minesweeper/ ├── CMakeLists.txt ├── main.cpp ├── game.h └── game.cpp把游戏逻辑放在game类里main.cpp只负责创建对象和调用游戏循环。这样做的原因是方便后续扩展比如想加图形界面时只需要换掉显示和输入部分游戏逻辑完全复用。CMakeLists.txt的内容也很简单cmake_minimum_required(VERSION 3.10) project(Minesweeper) set(CMAKE_CXX_STANDARD 17) add_executable(minesweeper main.cpp game.cpp)用C17是因为要用到std::mt19937和std::random_device这些在C11就已经支持了但C17的语法更简洁。如果你用的是老版本编译器把标准改成C11也能跑。4.2 游戏类的设计与核心方法实现我把游戏逻辑封装在一个Minesweeper类里主要成员变量包括rows、cols、mineCount棋盘尺寸和雷数board真实棋盘存每个格子的状态-1表示雷0-8表示周围雷数visible玩家可见状态false表示未翻开true表示已翻开flagged标记状态true表示插了旗子gameOver、win游戏状态标志核心方法有init()初始化棋盘布置雷计算周围雷数reveal(row, col)递归展开toggleFlag(row, col)标记或取消标记checkWin()检查是否获胜display()显示当前棋盘play()游戏主循环这种设计的好处是每个方法职责单一测试和调试都很方便。比如我想测试递归展开是否正确只需要构造一个棋盘然后调用reveal检查visible数组的变化是否符合预期。4.3 雷区布置的具体实现与参数计算布置雷的代码如下void Minesweeper::placeMines() { std::vectorint positions(rows * cols); std::iota(positions.begin(), positions.end(), 0); std::mt19937 gen(std::random_device{}()); std::shuffle(positions.begin(), positions.end(), gen); for (int i 0; i mineCount; i) { int pos positions[i]; board[pos / cols][pos % cols] -1; } }这里用std::iota生成0到N-1的序列然后洗牌取前mineCount个作为雷的位置。std::shuffle需要包含algorithm头文件std::mt19937需要random。计算周围雷数的代码void Minesweeper::calculateNumbers() { for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (board[r][c] -1) continue; int count 0; for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; int nr r dr, nc c dc; if (nr 0 nr rows nc 0 nc cols board[nr][nc] -1) { count; } } } board[r][c] count; } } }这段代码的时间复杂度是O(rows × cols × 8)对于扫雷的棋盘规模来说完全够用。如果你要做1000×1000的超大棋盘可以考虑优化但那就偏离扫雷这个项目的本意了。4.4 游戏主循环与胜负判定逻辑游戏主循环的流程是显示棋盘 → 等待玩家输入 → 处理输入 → 检查游戏状态 → 如果没结束就继续循环。void Minesweeper::play() { while (!gameOver !win) { display(); std::cout 请输入行和列如 3 4输入 -1 -1 标记旗子 std::endl; int r, c; std::cin r c; // 输入检查和坐标转换 // ... if (标记操作) { toggleFlag(r, c); } else { if (board[r][c] -1) { gameOver true; std::cout 踩到雷了游戏结束。 std::endl; } else { reveal(r, c); checkWin(); } } } display(); if (win) std::cout 恭喜你排雷成功 std::endl; }胜负判定很简单如果翻开的格子数等于总格子数减去雷数就获胜。我在reveal函数里每次翻开一个格子就把计数器加一这样不用每次都遍历整个棋盘。这里有个细节标记旗子的操作不应该触发翻开逻辑所以我在输入处理里加了一个判断如果玩家输入的是标记指令就只调用toggleFlag不调用reveal。这个设计让操作更清晰也避免了误触。5. 常见问题与排查技巧实录5.1 递归展开导致程序崩溃怎么办这是新手最容易遇到的问题。程序运行到递归展开时突然崩溃大概率是栈溢出或者无限递归。排查步骤第一检查递归函数的入口是否有边界判断和已访问判断。如果缺少任何一个都会导致无限递归。第二检查递归调用的条件。只有当格子周围没有雷时才应该继续递归如果条件写反了就会把所有格子都展开包括有雷的格子。第三如果棋盘特别大考虑改成迭代实现。用std::queue或std::stack来模拟递归可以避免栈溢出。我在第一次写的时候忘记在递归前判断visible状态导致程序直接卡死。后来加上判断就正常了。这个坑很典型建议写完递归函数后先用手动构造的小棋盘测试。5.2 随机数每次运行结果都一样怎么解决如果你用的是rand()而没有调用srand()或者srand()的参数是固定值那么每次运行程序生成的雷区都是一样的。解决办法是用std::random_device作为种子std::mt19937 gen(std::random_device{}());std::random_device会从系统熵池获取真随机数每次运行结果都不同。如果你用的是老式rand()可以用time(nullptr)作为种子但精度只到秒级快速连续运行两次可能得到相同结果。5.3 输入非数字导致程序进入死循环前面提到过std::cin在遇到非数字输入时会进入错误状态后续所有输入操作都会立即失败形成死循环。解决办法是在每次输入后检查std::cin.fail()如果失败了就清除状态并忽略缓冲区。这个问题的排查方法是运行程序故意输入一个字母观察程序行为。如果程序开始疯狂刷屏或者卡住不动就是这个问题。修复后再次测试确保输入字母、符号、超长数字都能正确处理。5.4 棋盘显示错位或数字对不齐控制台显示棋盘时如果每个格子的宽度不一致就会导致列对不齐。比如数字1占一个字符而#也占一个字符但如果你用了中文全角字符或者制表符宽度就会乱。解决办法是统一用半角字符每个格子固定占两个字符宽度。可以用std::setw(2)来格式化输出。另外行号和列号也要用同样的宽度这样整体才能对齐。我在第一次实现时用了\t来分隔格子结果因为数字位数不同导致错位。后来改成固定宽度格式化就整齐了。这个细节虽然小但直接影响玩家的游戏体验。5.5 常见问题速查表问题现象可能原因排查方法解决方案程序崩溃递归无限调用检查递归入口条件加边界判断和已访问判断每次雷区相同随机种子固定检查种子设置用random_device做种子输入后死循环cin错误状态未清除输入字母测试clear加ignore棋盘错位字符宽度不一致观察输出对齐统一用setw格式化翻开数字格也递归递归条件写错检查board值判断只在board为0时递归标记旗子后仍会翻开操作判断缺失检查输入处理逻辑标记操作不调用reveal6. 进阶扩展与个人实操心得6.1 从控制台到图形界面的平滑迁移控制台版本跑通之后如果想做成图形界面最省力的方案是用EasyXWindows平台或者Qt。迁移的关键是保持游戏逻辑不变只替换显示和输入部分。具体做法是把Minesweeper类里的display()和输入处理抽成接口控制台版本用std::cout和std::cin实现图形版本用绘图函数和鼠标事件实现。这样核心逻辑一行都不用改。我用EasyX做过一个版本大概加了200行绘图代码包括画格子、画数字、画旗子、处理鼠标点击。整体难度不大但视觉效果提升明显。如果你只是想练C控制台版本足够了如果想做点能拿出手的东西图形界面值得一试。6.2 自动求解算法的思路与实现要点扫雷的自动求解是一个很有意思的扩展。基本思路是模拟人类的推理过程如果一个已翻开的数字格周围未翻开的格子数等于该数字那么这些未翻开的格子全是雷可以标记如果一个数字格周围已标记的雷数等于该数字那么其余未翻开的格子全是安全的可以翻开。更高级的求解器会用约束满足或者概率推理但那已经超出扫雷项目的范畴了。我实现过一个基础版求解器大概一百多行代码能解决大部分初级和中级局面。高级局面因为需要猜测求解器也会卡住。这个扩展的价值在于它强迫你重新审视游戏的数据结构设计。如果一开始把数据封装得好求解器很容易接入如果数据到处散落写求解器就会很痛苦。这也是我反复强调分层设计的原因。6.3 我在这个项目上踩过的三个坑第一个坑是递归展开时忘记判断格子是否已经翻开导致程序在空白区域无限递归。这个问题在测试小棋盘时不容易发现因为递归深度浅可能只是慢一点。但换成大棋盘就直接崩溃。教训是递归函数的入口条件一定要写全边界判断和状态判断缺一不可。第二个坑是随机数种子用了固定值导致每次运行雷区完全一样。这个问题很隐蔽因为程序逻辑完全正确只是“运气”不好。后来养成习惯凡是涉及随机的地方第一件事就是检查种子。第三个坑是输入处理没有考虑异常情况玩家输入字母后程序直接卡死。这个问题在自测时很容易忽略因为自己测试时总是输入合法数字。后来我养成了一个习惯每写完一个输入模块先故意输入各种非法内容测试一遍。6.4 给不同阶段学习者的建议如果你刚学完C基础语法建议先写一个最简版本固定9×9棋盘、10个雷、只支持翻开操作、不做标记和计时。这个版本大概150行代码能让你把数组、循环、函数、递归这些基础知识点串起来。如果你已经写过一些小程序想提升代码组织能力建议把游戏封装成类把显示、输入、逻辑分开尝试用vector替代原生数组用现代C的随机数库替代rand()。这个阶段的目标是写出“像样”的代码而不是“能跑”的代码。如果你有更多经验想挑战进阶内容可以尝试加图形界面、自动求解、存档读档、排行榜。这些功能每一个都涉及新的知识点比如文件IO、序列化、GUI编程、算法设计。扫雷这个项目的好处就是它的核心足够简单但扩展空间足够大你可以根据自己的水平选择做到哪一步。我个人在实际操作中的体会是扫雷这个项目最大的价值不在于游戏本身而在于它提供了一个“刚刚好”的复杂度。它不像计算器那样过于简单也不像编译器那样过于复杂。你可以在两三天内写出一个能跑的版本然后用几周时间不断打磨和扩展。每一次重构都会让你对C的理解更深一层。如果你正在找练手项目不妨从它开始。