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

发布时间:2026/10/11 7:09:04
C语言扫雷实战:从二维数组到递归展开的完整实现 扫雷这东西几乎每个学C语言的人都会在某个阶段碰一次。你刚啃完指针、数组、函数这些基础语法手里攒了一堆知识点正愁没地方串联——扫雷游戏实现就是最合适的那道坎。它麻雀虽小五脏俱全二维数组当棋盘随机函数埋地雷递归展开了结一片空白循环判断收尾胜负一个项目把所有基础语法全串起来了。更妙的是写完之后你能真正“玩”自己写的游戏那种成就感是刷一百道控制台练习题都给不了的。这篇文章我会从设计思路开始把扫雷拆成几个明确模块一步步带你实现重点讲清楚为什么要这么做以及我在实际写代码和调试过程中踩过的坑。如果你正处于学完C语言基础、想找项目练手的阶段或者你已经在写了但卡在某一块逻辑尤其是递归展开那一块这篇内容应该能帮上忙。1. 整体方案选型为什么扫雷适合做练手项目先聊点实际的。很多初学者喜欢一上来就搞“图书管理系统”“学生成绩系统”这些项目本质上就是CRUD写起来全是重复的增删改查练不到什么核心算法也感受不到编程的趣味。扫雷不一样它天然带有游戏属性可以立即运行、立即玩、立即发现问题这个即时反馈对手感养成太重要了。1.1 从需求倒推技术点扫雷的核心玩法说穿了就三件事翻开格子、数字提示、判断胜负。但从这三件事反向推导你需要的技术正好覆盖了C语言入门的大部分重点二维数组用来表示棋盘这是整个游戏的数据基石随机数生成负责埋雷涉及rand()和srand()配合time()做种子递归算法解决“点到空白格子就展开一片”的经典需求这也是最容易搞出bug的地方循环与分支负责玩家输入处理、游戏状态切换、胜负判定函数的模块化拆分让你体会到“好代码是组织出来的”而不是一大坨写在main里你写扫雷的时候表面上是在做一个游戏实际上是在练“把一个复杂问题拆成小问题再逐个击破”的能力。这个能力比语法本身值钱得多。1.2 版本选择控制台版是正道我见过有人一上来就直接用EasyX图形库做扫雷界面确实好看但这不是我现在要推荐的方向。图形库会引入大量和游戏逻辑无关的概念——坐标体系、图片加载、事件循环、资源管理——这些东西会稀释你对C语言本身的关注。做入门项目第一原则是抓住核心矛盾。控制台版本用printf打印字符画界面完全够用。你的注意力应该放在数据结构设计和算法实现上这才是扫雷的灵魂。界面难看一点没关系逻辑对了才是真本事。等以后你学了带界面的GUI开发把核心逻辑迁移过去是分分钟的事。1.3 功能边界第一版要克制我给初学者的建议是第一版做这四个功能就够了地图初始化并随机埋雷玩家输入坐标翻格子踩雷即失败翻开所有安全格即胜利支持标记地雷锦上添花但不建议第一版砍掉因为逻辑很简单不要想着一口气把“计时”“难度选择”“扫雷精灵辅助”全做了。项目不是功能堆得越多越好而是能用最少的功能把核心逻辑表达清楚。做完一版跑通了再扩展这个节奏才健康。2. 数据结构和地图初始化一切的基础写扫雷之前先把数据结构想透。这一步做不好后面所有逻辑都会到处打补丁。2.1 两张表比一张表强新手最常犯的错是用一个二维数组同时记录“地雷位置”和“玩家已翻开状态”。我一开始也是这么干的结果发现逻辑越写越乱——你要么在招式上修改数据要么在翻开判断时畏手畏脚生怕改了不该改的值。正确的做法是准备两张表数组作用取值范围mineMap[ROWS][COLS]地图事实埋雷和数字计算的结果-1表示地雷0~8表示周围地雷数showMap[ROWS][COLS]玩家视角控制显示什么0未翻开1已翻开2标记为雷mineMap负责“真相”showMap负责“呈现”。游戏过程中玩家看到的永远是showMap而逻辑判断依赖的是mineMap。两张表各司其职耦合降到最低。这个设计思路不只是扫雷适用以后你做任何带“信息隐藏”需求的项目——比如打牌游戏、地图探索类游戏——都会用得上。2.2 静态数组加宏定义地图大小我建议用宏定义#define ROWS 9 #define COLS 9 #define MINE_COUNT 10做入门项目不要用动态内存分配没必要。9×9的经典配置固定写死全局数组直接开int mineMap[ROWS][COLS]; int showMap[ROWS][COLS];为什么推荐用宏而不是直接写数字因为测试的时候你经常想调参数——比如把行列改小一点看边界情况或者把地雷数量调成1个验证胜负逻辑。宏改一行全盘皆变这个习惯从第一个项目就要养成。2.3 随机埋雷的坑埋雷看起来简单循环MINE_COUNT次每次随机选一个格子放雷。但真正实践过你就会发现两个隐藏问题。第一个问题是重复埋雷。随机选格子可能选到已经放过雷的位置如果你不做判断地雷数量就会少于预期。最常见也最简单的解法int placed 0; while (placed MINE_COUNT) { int x rand() % ROWS; int y rand() % COLS; if (mineMap[x][y] ! -1) { mineMap[x][y] -1; placed; } }用while循环代替for用placed做计数器确保真正放下了才算数。这个套路我在写贪吃蛇生成食物、抽卡模拟器的时候也都用过通用性很强。第二个问题是随机种子。如果你不设置随机种子每次运行生成的“随机”地图其实完全一样因为rand()默认种子是1。必须在main开头加一句srand((unsigned)time(NULL));记得包含time.h和stdlib.h。这个问题我在刚学的时候根本不知道直到有一次演示给朋友看连续跑了三次地图一模一样场面一度非常尴尬。2.4 计算周围地雷数量雷埋完之后需要为每个非雷格子计算出周边地雷数量。这一步最容易被忽略但其实是有套路可循的。最朴素的想法是外层遍历所有格子内层遍历周围八个格子做累加for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (mineMap[i][j] -1) continue; int cnt 0; for (int di -1; di 1; di) { for (int dj -1; dj 1; dj) { if (di 0 dj 0) continue; int ni i di; int nj j dj; if (ni 0 ni ROWS nj 0 nj COLS mineMap[ni][nj] -1) { cnt; } } } mineMap[i][j] cnt; } }这里的关键是边界判断不能少。数组下标越界是C语言的经典未定义行为跑起来可能没事可能数据错乱可能直接崩溃——一切皆有可能。很多人写到这里图省事不判断边界地图边缘的格子在后续递归展开时就会出现各种莫名其妙的结果。我自己的习惯是单独写一个小函数int isValid(int x, int y) { return x 0 x ROWS y 0 y COLS; }判断边界的时候直接调用代码清爽不少。3. 核心逻辑详解翻开、展开、胜负判定数据结构铺好之后就到了整个项目中技术含量最高、也最容易出bug的部分——游戏逻辑。3.1 翻开一个格子的完整流程玩家输入坐标后程序要处理的情况有两种翻开的是地雷游戏直接结束玩家失败翻开的是数字或空白显示信息继续游戏如果翻开的是数字格子比如周边有3颗雷那很简单直接把showMap标记为已翻开显示出数字3就行。但如果翻开的是空白格子周边雷数为0事情就变得复杂了——真正的扫雷游戏里点开一个空白会“哗”地一下展开一大片直到碰到数字格子才停下来。这个效果是怎么实现的答案就是递归。3.2 递归展开的经典写法递归展开的逻辑其实非常符合直觉如果一个格子是空白那么它周围一圈格子的信息对它来说都是“安全”的可以一并翻开。而这些新翻开的格子中如果又有空白格继续重复这个动作——这就形成了递归。void reveal(int x, int y) { if (!isValid(x, y) || showMap[x][y] 1 || mineMap[x][y] -1) { return; } showMap[x][y] 1; if (mineMap[x][y] 0) { for (int di -1; di 1; di) { for (int dj -1; dj 1; dj) { if (di 0 dj 0) continue; reveal(x di, y dj); } } } }这短短十几行函数是整个扫雷项目中最漂亮、也最值得你反复琢磨的代码。它同时处理了三件事越界检查、防止重复翻开、决定是否继续传播展开。注意一个细节函数开头的三个退出条件顺序是有讲究的。先判断showMap[x][y] 1再进入递归检查这个顺序保证了已经翻开的格子不会重复处理避免了无限递归。如果顺序颠倒你可能会因为多次处理同一个格子导致递归深度暴增。但这里有一个隐患也是面试真题——如果地图很大比如50×50空白连成一大片递归的深度会非常深有栈溢出的风险。控制台入门版的9×9网格不会有这个问题但你心里要清楚这个局限。想规避这个风险可以用队列模拟“广度优先展开”用while配合一个数组模拟队列来实现这个思路你以后学数据结构的时候会再遇到。3.3 胜负判断什么时候算赢赢的条件很明确所有非地雷格子全部被翻开。注意不需要判断“是否把所有地雷都标出来”——游戏的目标是翻开所有安全格不是标出所有雷。所以最简洁的判断方式int isWin() { int opened 0; for (int i 0; i ROWS; i) { for (int j 0; j COLS; j) { if (showMap[i][j] 1 mineMap[i][j] ! -1) { opened; } } } return opened ROWS * COLS - MINE_COUNT; }这个函数在玩家每次成功翻格后调用一次。还有一种优化思路是维护一个计数器safeRemain翻开一个安全格就减一减到0就胜利不必每次都做全地图遍历。对于入门项目全遍历的写法更直观性能也完全够。3.4 第一次翻开不能踩雷这是我从实际游玩体验里发现的重要细节如果玩家第一次点击就直接踩雷这个游戏就显得非常不友好——一眼没看就没了挫败感极强。很多商业扫雷都有“首次保护”机制我们做项目也应该加上。实现思路很巧妙在玩家第一次输入坐标后先检查这个格子是不是雷。如果是把这个雷挪走到其他空位然后重新计算雷区数字。代码逻辑if (isFirstMove) { if (mineMap[x][y] -1) { mineMap[x][y] 0; // 先把这个位置设为非雷 // 找一个非雷的位置放雷 int px, py; do { px rand() % ROWS; py rand() % COLS; } while (mineMap[px][py] -1 || (px x py y)); mineMap[px][py] -1; // 注意重新计算所有格子的数字 calcMineNumbers(); } isFirstMove 0; }注意静态变量isFirstMove要初始化为1每次游戏只能触发一次保护。这个功能的本质是“动态修改地图”玩起来感觉非常稳。4. 玩家交互与输入处理游戏逻辑再漂亮输入处理做得不行体验照样崩。控制台程序的输入处理往往是新手最容易忽视的环节——其中最大的坑就是scanf的换行符残留。4.1 经典输入问题解析如果你在程序里用scanf(%d %d, x, y)读两个整数之后还有一个scanf(%c, cmd)之类的字符输入就会出现换行符残留的问题——字符读到的可能是上次输入末尾的回车而不是玩家真正输入的操作命令。解决方案也有不少我习惯的做法是统一用字符串接收再解析或者干脆在读字符前加一个getchar()把缓冲区的换行吃掉。但最省心的方案是设计输入流程时尽量避免混用scanf读数字和读字符。比如用两个数字坐标表示操作用单独的数字选项表示“翻开还是标记”printf(请输入操作1翻开 2标记 0退出: ); scanf(%d, op); printf(请输入坐标行 列: ); scanf(%d %d, x, y);全程都是数字输入完全避开字符缓冲区的麻烦对于入门项目来说体验清爽很多。虽然这不符合真实扫雷的交互习惯通常是左键翻开右键标记但控制台版能做到逻辑优先就够了。4.2 输入合法性校验玩家输入坐标时可能输入负数、超出地图范围、甚至输入字母——你必须对每一项进行校验并给出明确提示而不是让程序直接崩溃或者悄然越界毁掉数据。if (x 0 || x ROWS || y 0 || y COLS) { printf(坐标超出范围请重新输入行0-%d 列0-%d\n, ROWS - 1, COLS - 1); continue; } if (showMap[x][y] 1) { printf(该格子已翻开请重新选择\n); continue; } if (showMap[x][y] 2) { printf(该格子已被标记如需取消标记请使用操作2\n); continue; }每一个continue跳回去重新输入保证无效输入不会污染游戏状态。这里我踩过一个坑是一开始只是为了处理“越界”做了校验忘了处理“已翻开”结果玩家重复点同一个格子计数逻辑就会出错胜负判断也不准确。把输入校验当成一道完整的防御工事来做不要只做一半。4.3 显示设计控制台输出的设计方案会直接影响玩家体验。我的做法是用不同的字符代表不同状态#表示未翻开的格子F表示被标记为地雷的格子数字字符显示对应的周边雷数*表示地雷只在游戏结束后显示我封装了一个打印函数每次都刷新整个地图void printMap(int showMines) { printf(\n ); for (int j 0; j COLS; j) { printf(%d , j); } printf(\n); for (int i 0; i ROWS; i) { printf(%d , i); for (int j 0; j COLS; j) { if (showMines mineMap[i][j] -1) { printf(* ); } else if (showMap[i][j] 0) { printf(# ); } else if (showMap[i][j] 2) { printf(F ); } else if (mineMap[i][j] 0) { printf( ); } else { printf(%d , mineMap[i][j]); } } printf(\n); } }有一个小细节是当数字为0时我显示两个空格而不是数字这样地图看起来更清爽也更符真实扫雷的视觉习惯。数字0本身不用显示因为空白区域已经通过展开逻辑撑起了一片。showMines参数是用来在游戏结束后展示真相的——玩家失败或者胜利时把地雷全部打出来让玩家看看自己错在哪这一步虽然不起眼但收尾体验差很多。5. 游戏主循环与整体结构当核心函数都拆好之后主循环反而变得非常简单——游戏可以看作一个状态机进行中、胜利、失败。状态变了循环就退出。5.1 主循环设计int main() { srand((unsigned)time(NULL)); int gameState 0; // 0进行中 1胜利 2失败 int isFirstMove 1; initMineMap(); while (gameState 0) { printMap(0); int op, x, y; printf(请输入操作1翻开 2标记 0退出: ); scanf(%d, op); if (op 0) break; printf(请输入坐标行 列: ); scanf(%d %d, x, y); // 输入合法性校验 if (!isValid(x, y)) { printf(坐标不合法请重新输入\n); continue; } if (op 1) { if (isFirstMove) { protectFirstMove(x, y); isFirstMove 0; } if (mineMap[x][y] -1) { gameState 2; } else { reveal(x, y); if (isWin()) { gameState 1; } } } else if (op 2) { toggleFlag(x, y); } else { printf(无效操作\n); } } if (gameState 1) { printf(恭喜你通关了\n); } else if (gameState 2) { printf(踩雷了游戏结束\n); printMap(1); } return 0; }这个主循环的节奏感是很好的——显示、输入、处理、判定、再显示标准的 game loop 思维。虽然控制台程序谈不上什么帧率但其实你已经在上手“事件循环”这个概念了。5.2 函数拆分原则我强烈建议不要把所有逻辑都塞进main即使你的程序很小。把功能拆成独立函数每个函数只做一件事并且函数命名清晰——这会让你的程序变得像阅读文章一样顺畅。比如initMineMap负责初始化和埋雷calcMineNumbers负责计算数字reveal负责递归展开isWin负责胜负判断printMap负责打印地图。每个函数的职责边界清楚出bug的时候定位也快。一个实实在在的体验我帮一个学弟调过他的扫雷代码他所有逻辑全部写在main里面的一个大while循环中将近三百行。当递归展开和输入校验交织在一起稍微动一下就会引发连串问题。后来我帮他拆了几个函数问题立刻明朗了——指向了一个坐标越界。项目小的时候你也许觉得拆分是多余的但等代码规模长到一定程度组织能力就是生产力。5.3 标记功能的实现标记功能就是showMap[x][y]在0和2之间切换。这个逻辑简单到只有几行void toggleFlag(int x, int y) { if (showMap[x][y] 0) { showMap[x][y] 2; printf(已标记该格子为地雷\n); } else if (showMap[x][y] 2) { showMap[x][y] 0; printf(已取消标记\n); } else { printf(该格子已翻开无法标记\n); } }注意标记者了以后这个格子就不能被普通翻开了——你需要在翻开的输入校验里拦截已标记的格子前面输入校验那段已经写到了。小功能也有细节处理干净才能让后面的交互顺畅。6. 常见问题与调试经验总结写扫雷的过程中有几个问题出现频率奇高我特地整理了一份速查表你对照来看能省下大量排查时间。问题现象根本原因解决方案每次运行地图一模一样没有给rand()设置随机种子在main开头调用srand((unsigned)time(NULL))地雷数量不对少于预期随机埋雷时重复选了同一块地用while循环成功放置才计数边缘格子数字计算错误遍历周围格子时未做边界判断写isValid()函数统一判断边界点开一个格子一大片数字乱跳mineMap和showMap共用了一个数组拆成两张表一张存事实一张存显示输入字符后程序疯狂循环scanf遗留的换行符干扰后续读取全部用整数输入避免字符缓冲区混用递归展开时程序崩溃递归边界条件不完整存在无限递归确保已经翻开的格子直接return第一次点就踩雷体验差缺少首次保护机制第一次翻开时动态挪雷胜利判定不准确翻开的格子可能包含雷胜利条件改为所有非雷格都翻开6.1 一个典型的排查思路举一个我记忆很深的例子。有个时候我点开中心区域的一个空白格左边一大片能展开右边却展开不了。看代码逻辑reveal函数似乎完全没问题边界也检查了递归方向也写了。后来我把打印加到了reveal函数里面每次进入都打印坐标。跑了几次之后发现一个问题某几个坐标明明应该被展开但函数根本没执行到。继续往上层排查原来是我在递归调用的地方犯了个很低级的错误——内层循环写的是di和dj从-1到1但调用时却写成了reveal(xdi, ydj)之外还额外加了一步判断恰好把某些方向过滤掉了。这类问题的共性是你很容易怀疑算法本身但实际上算法没问题问题出在前置条件的处理上。调试的时候不妨把每一层输入都打印出来看看数据是如何流转的。打印是接触最多也最直接的调试手段一定不要嫌麻烦。6.2 关于学习节奏的建议最后多说一句。扫雷这个项目看起来简单但如果你不看任何参考代码纯靠自己写第一次跑通可能得花上三到五个小时。这个时间投入是完全值得的——因为在这几个小时里你会在反复试错中把“数组越界”“递归边界”“状态管理”这些概念真正刻进脑子里。如果你卡住了我建议先思考上半小时真的想不通再去网上搜别人的实现。搜的时候重点看数据结构怎么设计、递归怎么终止而不是直接抄代码。抄代码是让别人的思路替代你的思考这行项目就算白写了。写完之后你的后续扩展方向也很多加个计时器、做大中小三个难度档位、把递归展开改成队列实现的广度优先版本、甚至后续学了文件操作之后做一个排行榜。这些扩展每做一步你对框架的理解就更深一层。我个人在实际操作中的体会是扫雷这个项目最迷人的地方在于代码量不大却几乎涵盖了你今后写业务程序会用到的所有基础思维——模块划分、状态流转、防御式编程。认认真真写完这一版你会发现自己看其他代码的视角都不一样了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询