ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言练手项目:用扫雷吃透数组与递归实现

C语言练手项目:用扫雷吃透数组与递归实现 今年上半年带一个学弟做期末课程设计他犹豫了很久最后选了一个图书馆管理系统。我劝他换个题目他不听结果链表删除那一块整整卡了两周。后来我给他出了一个简单得多的题用C语言写一个控制台扫雷。他一开始很不服气觉得扫雷是个小游戏没什么含金量。等他真的动手写才发现自己连二维数组的边界判断都没吃透更别说递归展开了。后来他跟我说写了扫雷之后再去回头看链表很多之前模糊的概念一下子都通了。这个反馈让我挺意外也让我觉得扫雷这个练手项目是真的被很多人低估了。这不是个例。我带过不少C语言学习者见过太多人能把语法概念背得滚瓜烂熟指针、结构体、链表讲得头头是道但一动手写一个完整的小程序就立刻露馅。而扫雷这个项目恰恰是那种把C语言核心知识全部调动起来、又不会难到让你直接放弃的项目。这篇文章就完整记录一遍我用C语言实现扫雷的全过程从数据结构设计到布雷从玩家交互到递归展开再到实际调试时踩过的坑。适合刚学完数组和循环、想找一个正经练手项目的同学也适合感觉自己学过但不会写的人查漏补缺。1. 为什么C语言练手项目偏偏要选扫雷1.1 一个二维数组就把核心语法串完了先说说选型逻辑。C语言的学习路径一般是变量与数据类型、分支与循环、数组、函数、指针、结构体、链表。问题在于这些知识点在教科书里是按章节分的学完数组可能只会打印几个图案学完函数可能只会写一个判断素数。知识是零散的没有一条主线把它们串起来。扫雷正好是那条线。它最基础的一个版本涉及到的知识点包括二维数组整个棋盘就是一个二维数组、嵌套循环遍历所有格子做初始化、统计周围雷数、分支判断每个格子可能是数字、空白、雷、旗子、函数拆分初始化、打印、布雷、计数、递归展开每个功能一个函数、随机数rand和srand、递归展开空白区域。如果再想做得花哨一点指针、函数指针、二维数组传参也都避不开。这七样东西正好对应C语言从基础到进阶的核心语法。而且它不像管理系统那种项目有大量说不清的业务规则。扫雷的规则非常简单所有格子排布成方形找雷不是雷就显示数字点开所有非雷格就赢。规则简单意味着你可以把全部精力放在怎么用C语言把逻辑写对上而不是花一半时间去纠结需求到底是什么。1.2 扫雷的难点不在布雷在展开很多人第一反应是布雷有什么难的确实随机撒几颗雷很简单大部分人第一次写扫雷也能很快完成布雷和踩雷判断。但扫雷真正的分水岭在当玩家点开一个空格周围雷数为0的格子时要把所有连通的空白格子一起展开这一步。这一步没有现成的语法教过你。它需要你理解递归的本质——一个函数调用自己并且必须有一个明确的退出条件。同时它还牵扯到数组越界的问题你展开的时候会不会读到棋盘外面去会不会因为互相调用而无限循环这些问题的排查过程才是这个项目真正的价值。控制台版的扫雷做出来你的感觉不会像写完了九九乘法表那样空洞而是会第一次体会到我能把一个完整的东西从零写出来了。这个体验是刷100道练习题都给不了的。2. 动笔前先想清楚双棋盘与哨兵边界2.1 玩家看到的和程序知道的必须分开这是我认为整个项目里最重要的设计决策但也是很多教程没讲透的地方。我见过一些初学者用一个二维数组一把梭数组里存什么就打印什么布雷后直接把*替换成数字或M。这样做的问题在做游戏逻辑时会立刻暴露——你无法区分这个格子还没被点开和这个格子本来就是空的也无法实现数字只在点开后显示的效果。正确做法是维护两个棋盘char showBoard[ROWS][COLS]; // 玩家看到的棋盘初始全为 * char mineBoard[ROWS][COLS]; // 内部真实雷局1 表示雷0 表示非雷玩家看到的showBoard只存放四种符号*表示未翻开1到8表示周围雷数F表示玩家插旗标记空格表示已翻开的空白格。而mineBoard只负责在游戏开始后记录雷的分布。揭开一个格子时去查mineBoard再把结果写到showBoard上——一切逻辑就都清晰了。为什么用char不用int因为棋盘上的数字范围只有0到8char类型完全够用而且打印的时候可以直接以字符格式输出不用做数字到字符的转换。这个选择算不上什么高超技巧但能让你后面省心不少。2.2 数组开大一圈边界判断省很多事如果你是第一次写扫雷很容易把棋盘定义成和界面一样大的数组比如9行9列就开int board[9][9]。等你写到计算周围八格雷数的时候麻烦就来了board[x-1][y-1]在x0时下标变成-1数组越界。为了处理边界你不得不在每个方向上都加一行if (x-1 0 x-1 ROWS...)的判断四个角落、四条边全要照顾到。代码写出来又臭又长还容易漏。我推荐的方案是实际棋盘是9x9但数组定义为11x11也就是多出一圈哨兵边界。有效区域从下标1到9第0行、第0列、第10行、第10列永远不存放真实数据。这样在统计周围雷数时即使是对角线上的格子也一定落在数组内部不会越界。for (int i -1; i 1; i) { for (int j -1; j 1; j) { if (i 0 j 0) continue; if (mineBoard[x i][y j] 1) count; } }有了外边这一圈坐标计算就可以放心大胆地偏移不需要写一堆边界判断。这个思路在别的领域也经常用到比如图像处理里对图像边缘做卷积运算时很多人会用padding来避免特判。可以把它理解成空间换代码复杂度非常划算。还有一个细节棋盘显示时行号列号从1开始写。这样玩家输入3 5对应的就是第3行第5列和程序内部索引自然对应不用额外做1的转换也就少了一个出错点。3. 布雷的实现从简单循环到洗牌算法3.1 三种布雷方案的取舍布雷的核心需求就是在棋盘上随机选N个位置把位置标记成雷。按照难度不同常见写法有三种。第一种是撞大运式。用一个while循环不停生成随机坐标如果这个位置已经是雷就重新生成直到布满N颗雷int placed 0; while (placed MINECOUNT) { int row rand() % ROWS 1; int col rand() % COLS 1; if (mineBoard[row][col] ! 1) { mineBoard[row][col] 1; placed; } }这个写法最大的优点是直观适合刚学完随机数的同学。缺点是在极端情况下效率低如果棋盘只有10个格子、雷数是9那最后一个格子需要循环很久才能踩中。不过在标准的9x9棋盘、10颗雷配置下这个方案完全没问题。第二种是洗牌式。先把棋盘上所有坐标写进一个一维数组然后随机打乱顺序把前N个位置设为雷。这样做的好处是效率稳定且不会出现超过格子总数的死循环。代价是需要多申请一块一维数组逻辑上稍微绕一点。第三种是Fisher-Yates洗牌算法很多工程上随机抽N个不重复元素的场景都用它。大致做法是先把数组填成0到总格子数-1然后从后往前遍历每一步将这个位置与前面某个随机位置交换。交换完后取前N个位置作为雷的下标。它比第二种方案更规范思路也是直接面向无重复抽样这个本质。三种方案的对比如下方案代码量效率适合场景随机重试少雷少时没问题雷多时可能慢新手入门一维洗牌中稳定理解随机打乱思想Fisher-Yates中稳定且规范想顺手练一下算法我的建议如果只是交作业方案一完全够用。如果想让自己的代码更有工程感写方案三顺带把这套洗牌算法过一遍之后在抽奖、题库随机组卷这类需求里还能复用。3.2 随机数种子为什么雷的位置总是那几颗布雷过程中新手最常碰到的诡异问题就是——每次运行雷的位置几乎一样或者干脆完全一样。这不是随机函数坏了而是你少做了seed这件事。C语言的rand()其实是一个伪随机数生成器它会根据一个种子计算出一串看起来随机的数列。如果种子不改变下一次程序运行生成的数列就会和上一次完全相同。所以要让每次开局不一样必须用srand()设置一个会变化的值最常用的就是当前时间#include time.h srand((unsigned int)time(NULL));注意这句要放在程序最前面、第一次调用rand()之前而且只调用一次。如果你把它放进一个循环里就可能导致每轮循环把随机数重置到同一个状态最后所有格子拿到的随机数都差不多雷的位置呈现某种奇怪的规律。我听过一个更隐蔽的问题有人在每次布雷前都调用srand(time(NULL))因为time()的粒度是秒如果两次布雷发生在同一秒内种子一样生成的雷局也完全一样。解决方法是只在main()里种一次种子或者用更高精度的时钟来作为种子。4. 玩家交互与胜负判断这是游戏的主循环4.1 scanf的缓冲区陷阱与输入校验游戏的主循环非常简单打印棋盘、读玩家输入、根据输入执行操作、判断输赢、再打印。但读输入这一步藏着很多坑。最常见的scanf陷阱是缓冲区残留。比如玩家输入了一个非法值abcscanf(%d %d, x, y)读到a时失败不会消耗掉剩余字符下一次循环scanf重新执行时还是会从缓冲区里读到a于是你的程序进入死循环——反复输出输入无效却无法继续。解决思路有两种。第一种是每次读完后用getchar()清空缓冲区int c; while ((c getchar()) ! \n c ! EOF);第二种更稳用fgets读一行字符串再用sscanf解析char line[32]; fgets(line, sizeof(line), stdin); if (sscanf(line, %d %d, x, y) ! 2) { printf(输入格式错误请重新输入\n); continue; }使用fgets的好处是即使玩家输入了很长的内容你也只用一行不会把脏数据留给下一次循环。这个技巧在处理交互式命令行程序时非常实用。输入校验同样重要。玩家输入0 0或者15 15已经超出了棋盘范围必须拒绝。如果玩家输入了一个已经翻开的格子坐标也应该提示这个格子已经翻开了。这些都是逻辑上的细节但恰恰是使用者最容易注意到的地方。4.2 递归展开的三种边界条件当前面的雷布好了输入循环也通了整个游戏最难的部分就是翻开一个空格时进行的连锁展开。我把它单独拎出来讲是因为它几乎集合了数组越界、递归退出、标志位使用这几个最容易出错的知识点。展开的逻辑可以描述成点开一个格子后如果这个格子的周围雷数是0那么它本身不是数字应该变空白并且它周围的8个格子也要被翻开。如果周围那8个格子中有某个格子周围雷数也是0就继续展开它周围的格子——这就是洪水填充Flood Fill。递归函数雏形void expand(int x, int y) { if (showBoard[x][y] ! *) return; int cnt countMine(x, y); showBoard[x][y] cnt 0 ? : (char)(0 cnt); if (cnt 0) return; for (int i -1; i 1; i) { for (int j -1; j 1; j) { if (i 0 j 0) continue; expand(x i, y j); } } }这里必须说明白上面这段代码看起来简单但隐藏着边界问题。如果玩家在第一行第一列点开一个空格expand(1,1)会调用expand(0,1)、expand(1,0)、expand(0,0)这些坐标已经超出了有效雷区的范围。虽然靠哨兵边界数组不会真的读到非法内存但你仍然会去处理这些没有初始化过的格子。所以严谨一点展开函数要先加一道边界检查或者把哨兵边界里的值预先设成一个特殊字符让它们在展开时直接被return掉。我实际使用的方法是把整个mineBoard初始化为9然后只填充1到9行的有效区域。这样即使expand递归到了第0行也会因为showBoard[0][y]不等于*而快速返回。这里还有一个新手很容易忽略的点展开之前一定要先判断这个格子是不是已经被翻开了。如果不判断两个相邻的空白格子就会互相调用A调用BB又调用A无穷无尽最后栈溢出程序崩溃。这就是递归函数最重要的终止条件。写递归之前先把终止条件想清楚比什么都重要。4.3 胜利判断用一个计数器就够了判断游戏胜利其实不需要每次遍历整个棋盘比较是否还有非雷格没翻开。维护一个全局变量opened初始为0每次成功翻开一个格子即该格子从*变成其他字符就把opened加1。当opened等于总格子数减去雷数时玩家已经把全部非雷格都翻开了游戏胜利。if (opened ROWS * COLS - MINECOUNT) { printf(恭喜你排掉了所有雷\n); break; }这种方式的时间复杂度是O(1)每次只需要比较一个整数。如果用遍历判断虽然9x9棋盘也快得无感但养成用状态变量替代遍历的思维习惯对以后写更大项目非常有帮助。5. 我第一次实现时踩过的三个坑5.1 递归互相调用导致的死循环这是我早期写扫雷时第一次遭遇程序崩溃。当时expand函数里我只检查了当前格子是不是雷没有检查当前格子是否已经翻开。结果就是两个相邻的空白格子互相调用栈的空间被无限吞掉最后画面卡死然后闪退。后来我在纸上画了一个小例子才发现A展开时调用了BB展开时看到A还没被翻开又调用A如此循环往复。修复方式就是在递归开头加一行判断if (showBoard[x][y] ! *) return;。从这个角度看递归确实能让一个马虎的人立刻长记性。5.2 循环里调用srand带来的伪随机感有个同学问我为什么他的雷总是成排出现或者每次运行雷的位置固定。我让他把srand的位置检查一遍果然他把srand(time(NULL))放进了布雷函数里每布一颗雷之前都重新种一下。因为time()返回的是秒级时间戳循环执行极快数十颗雷都在同一秒内种下rand()每次都是重新从同一个种子开始产出的随机位置自然高度相关。修正方法就是把srand放到main()的开头全局只种一次。这个问题的本质是随机数种子影响的是整条随机序列而不是单次随机值。理解了这一点后面所有跟随机相关的代码都不容易再犯错。5.3 清屏闪烁与控制台兼容性用system(cls)清屏是Windows下最简单的方式但它有几个问题。第一在部分终端比如新版Windows Terminal、PowerShell里它会清掉整个缓冲区玩家想往上翻看历史记录也翻不到了。第二频繁清屏重绘会让画面整体闪烁观感很差。如果只是交课程设计这没什么大碍但如果你想在Linux或者Mac上跑system(cls)根本无效因为那些系统用的是clear命令。一个比较干净的跨平台方案是用ANSI转义序列printf(\033[2J\033[H);这段代码的意思是清屏并把光标移回左上角。在大部分现代终端里都有效。我的建议是写一个clearScreen()函数把清屏逻辑封装起来以后换平台只改这一个函数。这个习惯比具体用哪个命令更重要。6. 控制台之外三个值得一试的进阶方向6.1 文件存读档扫雷做完你手里已经有了一份完整的棋盘数据和一套交互逻辑。这时候加一个保存进度和读取进度功能正好把C语言文件读写练一遍。每局开始时把雷区、已翻开状态、胜负情况写入一个文本文件比如minesave.txt下次启动时读取并恢复。文件读写用fprintf和fscanf就能轻松搞定。需要注意的坑是写文件时最好设置一个简单的文件头标记比如第一行写MINESAVEV1防止玩家把别的文件当成存档读入。这算是一个非常经典的防御性编程练习也是把基础语法落到实际场景的好机会。6.2 链表版动态雷区二维数组的棋盘天然是规则的矩形。如果你想让游戏更有挑战性可以试试用链表来组织格子每个格子是一个结构体节点包含坐标、是否雷、周围雷数和四个方向的指针或者八个方向。这样雷区就不一定非得是矩形了甚至可以让玩家自定义一片不规则的扫雷岛。这一做法的好处是你会同时练习结构体、链表、二级指针把C语言后半本书的知识点一次过完。缺点是实现复杂度明显上升建议把数组版跑通了再动这个念头。说实话能把链表版扫雷写通的人C语言基础都不会差这比背十道链表笔试题管用得多。6.3 从控制台到图形界面控制台版的价值在于逻辑清晰但展示效果确实有限。想进一步提升有两个方向一是接入一些轻量的终端界面库让棋盘能根据键盘方向键移动光标、用空格翻格子体验会更接近真正的扫雷二是学着接入图形界面库做出一个带鼠标点击的完整窗口程序。说实话第一次做出可以鼠标点的扫雷时那种成就感是控制台版本完全比不了的。你甚至可以把之前的文件存读档功能一并迁移过去做一个完整的小游戏项目。对一个初学者来说从控制台到图形界面这个过程本身就是一次很好的技术栈迁移锻炼。最后说点题外话。我个人一直觉得判断一个C语言学习者是不是真的入门了不是看他能不能背出指针和数组的关系而是看他能不能独立完成一个像扫雷这样的麻雀虽小五脏俱全的小程序。这个项目里没有复杂的算法、没有高深的设计模式但它逼着你把东西拆开、一步步来、遇到问题老老实实调试。我见过太多人一开始就冲去做图书管理系统学生成绩系统结果被业务逻辑淹没核心编程能力反而没练到。而扫雷这个小东西恰好能让你把精力全部放在程序设计本身。如果你把这个版本写完了我建议你再多做一个改动把棋盘尺寸和雷数做成玩家可输入的参数或者加一个计时器。这些看起来很小的功能加完之后你会发现代码又要重新组织一遍这本身就是一次很好的重构训练。等你亲手点开一大片空白格子的那一刻就会明白为什么这个写了几十年的小游戏到今天依然是最适合C语言入门者的第一道坎。
返回列表