1. 二刷C语言,为什么我选了扫雷当突破口
如果你正在经历C语言的“第二次学习”,你大概率已经过了那个“指针是什么、结构体怎么用”的懵懂期,也写过链表、字符串反转、九九乘法表这类作业题。这时候最尴尬的状态是:语法都认识,代码能跑通,但一遇到稍大一点的程序就不知道怎么组织;或者说,你开始觉得练习题太碎,没法形成体系。
我的建议一直很直接:二刷C语言不要再去刷题了,去做一个完整的小项目,而扫雷就是性价比最高的那个。
为什么是扫雷?因为它的核心逻辑几乎覆盖了C语言的基础主干——二维数组的遍历、随机数的生成与布局、函数的拆分与模块化、循环与条件判断的嵌套、递归展开(如果你愿意做进阶版)、文件读写存档、甚至结构体和链表在某些拓展方案里也能用上。你在一刷时积累的那些零散知识点,会在扫雷这个项目里被强行串成一条线。
这篇文章记录的,是我用大约一周时间“二刷扫雷”的完整过程。它不是一刷时那种“照着书抄一遍代码跑通就完事”的实现,而是带着重构和拓展意识的强化训练。我会按真实的推进顺序来写:先说要做什么、为什么这样做,再给核心实现思路,最后讲我踩过的坑和拓展玩法。代码用C语言编写,适配C99标准,编译器用的 VS Code + GCC 环境。
如果你正准备二刷C语言,或者手里有个扫雷项目想做得更扎实,这篇文章可以直接当参考。如果你只是想看看一个二维数组项目能玩到什么程度,也会有点收获。
2. 动手之前,先把扫雷的需求和数据结构想清楚
很多初学者拿到扫雷题目就急着写代码,写到一半发现逻辑越来越乱,最后强行嵌套一堆 if 把程序拼出来,跑通是跑通了,但根本不敢改动任何一行。这个问题的根源不在于写代码能力,而在于没做需求拆解就开始编码。
我二刷的第一件事,就是把自己当成一个产品经理,把扫雷的需求一条条列出来:
- 棋盘大小可调,扫雷一般有三种难度:9x9带10个雷、16x16带40个雷、16x30带99个雷。为了练习,可以先做一个固定大小的 9x9,然后拓展成可配置的。
- 玩家输入坐标,选择“翻开”或“标记”。
- 翻开某个格子后,如果它是雷,游戏结束。如果不是雷,显示周围8个格子中地雷的数量。如果这个格子周围没有雷,自动展开相邻的空白区域(这个逻辑是整个扫雷的核心,也是递归的最佳练习点)。
- 玩家可以手动标记疑似地雷的格子,防止误触。
- 所有非雷格子都被翻开时,玩家胜利。
把需求列出来之后,再考虑数据结构。这里需要想清楚一个问题:显示给玩家的棋盘和实际存储雷信息的棋盘,需不需要分离?
大部分扫雷教学代码,不管是国内的实验课还是翁恺老师的练习题,都采用双层数组方案。一个数组(我用mineMap)存地雷布局,另一个数组(我用showMap)存玩家看到的翻开状态。这样分离的好处是,地雷位置不会因为玩家操作而被意外修改,逻辑上也非常清晰。
具体的数据结构定义是这样的:
#define ROWS 9 #define COLS 9 #define MINES 10 char mineMap[ROWS][COLS]; // 实际地雷布局,'*'表示雷,'0'~'8'表示周围雷数 char showMap[ROWS][COLS]; // 玩家可见棋盘,'?'表示未翻开,数字/空格表示已翻开那为什么要用 char 数组而不是 int 数组?这是很多初学者会忽略的细节。扫雷棋盘上的信息本质上是字符——数字、星号、问号——用 char 存储更直观,内存也更紧凑。一个 9x9 的棋盘,int 数组要 324 字节,char 数组只要 81 字节,差别不大,但在理解上char这种“字符即数据”的思维方式才是更贴近问题的。真正写代码的时候,你会发现字符串输出、格式化打印时 char 数组反而是最顺手的。
需要额外处理的一个细节是棋盘边界。如果你在遍历时对新旧地图的索引不加边界检查,访问mineMap[r-1][c-1]这种位置时,当 r=0 或 c=0 就会越界,导致读到内存里的随机值。这个问题的常规解法有两个方向:一是每次遍历时都判断坐标是否在边界内,二是给棋盘加一圈“虚拟边界”。我二刷时选择了第二种,因为这本身就是一种很好的工程思维训练:
#define ROWS 9 #define COLS 9 // 实际分配的数组维度 #define MAP_ROWS (ROWS + 2) #define MAP_COLS (COLS + 2)也就是实际数组比可视棋盘大一圈,玩家坐标从 1 开始编号。这样遍历邻居时,mineMap[r + dr][c + dc]里的 dr、dc 在 -1 到 1 之间变化,永远不会出现负数或者超出边界的索引。这个设计思路在后续做生命游戏、迷宫生成等二维数组项目时同样通用,属于那种“一次学会、终身受用”的小技巧。
3. 核心实现一步步拆:初始化、布雷、计算数字、打印
3.1 初始化棋盘的两重循环
数据结构和需求都定了,接下来是初始化。这里有两个细节:
第一,数组必须显式初始化,否则里面存的是栈上的垃圾值。最保险的做法是双重循环全部赋值。
void initMaps(char mineMap[MAP_ROWS][MAP_COLS], char showMap[MAP_ROWS][MAP_COLS]) { for (int i = 0; i < MAP_ROWS; i++) { for (int j = 0; j < MAP_COLS; j++) { mineMap[i][j] = '0'; showMap[i][j] = '?'; } } }初始化完成后,mineMap全部是字符'0',showMap全部是'?'。注意'0'这个字符和数字 0 的区别,后面计算周围雷数时需要小心。
第二,雷区的边界圈(最外圈的虚拟行/列)虽然逻辑上不参与游戏,但如果不初始化,打印时踩到它们就会输出乱码。我踩过一次坑:打印棋盘时遍历的是 1 到 ROWS 的可视区域,但initMaps是整块整个数组全赋值的,所以虚拟边界也是干净的,这一点一开始没意识到,后续排查问题时才反应过来。
3.2 布雷的随机性陷阱
布雷是初学者最容易写错的地方之一,因为随机数的坑非常多。
先看一个常见的错误版本思路:双重循环遍历棋盘每个格子,用一个概率判断是否放雷。这看起来没什么问题,但会导致地雷数量不可控,而且棋盘越大,分布越不均匀。
正确的做法是:精确地往棋盘里放置固定数量的雷。每次随机生成一个坐标,如果这个格子还没有雷,就放一颗,直到总数达到 MINES。
void placeMines(char mineMap[MAP_ROWS][MAP_COLS]) { int placed = 0; srand((unsigned)time(NULL)); while (placed < MINES) { int row = rand() % ROWS + 1; int col = rand() % COLS + 1; if (mineMap[row][col] != '*') { mineMap[row][col] = '*'; placed++; } } }这里有个潜在问题:如果雷的数量非常接近棋盘格子总数,这个 while 循环可能会运行很久,因为越到后面越容易随机到已经有雷的格子。我曾经试过在 5x5 的棋盘中放 20 颗雷,程序几乎卡住。实际应用中,更稳妥的做法是把所有坐标存进一个数组,洗牌后取前 MINES 个,但作为 C 语言练习题,while 循环版本的简洁度更友好,只要雷数不超过棋盘面积的七成,性能基本没问题。
值得专门提一句的是srand((unsigned)time(NULL))的放置位置。很多初学者把srand放进placeMines函数里,这没问题,但每次调用都会被重置。如果后续要在同一个程序里重新开始游戏,重复调用srand可能导致“两次生成的雷布局一模一样”的诡异问题。我二刷的版本把srand放到main函数开头,只调用一次,保证整个程序生命周期内随机序列不会因为重新初始化而重复。
3.3 计算每个格子周围的雷数
布雷完成后,需要统计每个非雷格子周围 8 个格子中雷的数量,并把结果以字符形式存进mineMap。
这个计算逻辑很简单,但也有优化空间。最直接的做法是双重循环遍历所有格子,遇到非雷格子就数周围 8 格的'*'数量:
void calcNumbers(char mineMap[MAP_ROWS][MAP_COLS]) { for (int i = 1; i <= ROWS; i++) { for (int j = 1; j <= COLS; j++) { if (mineMap[i][j] == '*') continue; int count = 0; for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (mineMap[i + dr][j + dc] == '*') count++; } } mineMap[i][j] = '0' + count; } } }由于我们已经预留了虚拟边界,这里不需要任何边界判断,mineMap[i + dr][j + dc]是绝对安全的。如果没有虚拟边界,你就得写一大堆 if (i+dr>=0 && i+dr<ROWS && ...) 之类的代码,又丑又容易漏。
一个值得思考的点是:为什么要“先布雷,再统一算数字”,而不是“边布雷边给周围的格子加计数值”?后者的效率其实更高,一次布雷就能同时更新周边的数字。但前者的代码更简单、更直观,也不容易出现累加错误。二刷扫雷的目的不是写一个性能怪兽,而是把流程理清楚,所以我选择了先布雷后计算,这种“两步走”对代码可读性有好处。
3.4 打印棋盘:隐藏信息与可见信息分离
打印棋盘是扫雷体验的重要部分,但也是很多实现中做得最潦草的部分——直接 printf 一长串字符,完全不管排版。
我二刷时给打印函数做了两个版本:一个是游戏过程中的showMap打印,用'?'隐藏未翻开的格子;一个是调试时用的mineMap打印,方便开发者查看地雷分布。
void printMap(char map[MAP_ROWS][MAP_COLS], int reveal) { printf(" "); for (int c = 1; c <= COLS; c++) { printf("%d ", c); } printf("\n"); for (int r = 1; r <= ROWS; r++) { printf("%d ", r); for (int c = 1; c <= COLS; c++) { if (reveal) { printf("%c ", map[r][c]); } else { printf("%c ", (map[r][c] == '?') ? '?' : map[r][c]); } } printf("\n"); } }这个printMap函数有一个参数reveal,传 1 就显示地雷布局,传 0 就只显示玩家可见信息。调试时非常有用,你可以随时把完全布局打印出来验证逻辑。上面这段代码是“半成品形态”,实际二刷时我还会加上一行一列的坐标边框,让玩家不用数格子也能精准输入坐标。
打印时另一个小技巧是用%2c来格式化输出,保证数字对齐。否则雷数到 8 以上时棋盘会歪。你可能觉得这种细节不重要,但作为“用家”的玩家,一个排版整齐的棋盘,体验上的差距是巨大的。
4. 玩家交互逻辑:输入校验、翻开与标记
游戏的核心交互是:玩家输入坐标,选择翻开或者插旗。这里的坑主要集中在输入处理上,尤其是如何应对玩家输入越界坐标、非数字字符、格式错误等情况。
4.1 输入校验:防御性编程的第一课
新手写扫雷时,默认玩家一定会规规矩矩输入数字坐标。实际上玩家什么都会输:输入负数、输入字母、输入“3 7 5”这种多余数字、甚至直接按回车。如果不加防御,scanf读到不匹配的类型时,输入缓冲区会残留脏数据,后续所有读取都会错乱。
我在二刷时用了一个简单的辅助函数来封装输入逻辑:
int getInput(int *row, int *col, char *op) { char buffer[64]; // 读取一整行输入,避免缓冲区残留 if (fgets(buffer, sizeof(buffer), stdin) == NULL) { return 0; } // 尝试解析三个参数:操作(o/m)、行号、列号 if (sscanf(buffer, "%c %d %d", op, row, col) != 3) { return 0; } if (*row < 1 || *row > ROWS || *col < 1 || *col > COLS) { return 0; } if (*op != 'o' && *op != 'm') { return 0; } return 1; }注意到fgets+sscanf的组合了吗?这是我在实际项目里最爱用的输入方式。scanf直接读取的问题在于:当输入类型不匹配时,数据会残留在缓冲区里,下一次scanf会读到同样的脏数据,形成死循环。而fgets一次性把一行读走,sscanf哪怕解析失败,那行数据也已经从缓冲区被清掉了,不会影响后续输入。
这个技巧在热搜词里有c语言fgets,很多人搜过。属于那种“知道了就能少掉很多头发”的知识点。
4.2 翻开与展开递归
游戏里最关键的两个操作是:翻开(open)和标记(mark)。
标记逻辑很简单,就是让showMap[row][col]在'?'和'F'之间切换。
翻开的逻辑稍微复杂一点。如果翻开的格子是雷,游戏结束;如果是数字格,直接显示数字;如果是空白格(周围没有雷),需要自动展开相连的空白区域,直到遇到数字格为止。
这个展开逻辑有递归和非递归两种实现。递归版本最直观:
void expand(char mineMap[MAP_ROWS][MAP_COLS], char showMap[MAP_ROWS][MAP_COLS], int row, int col) { // 边界保护 if (row < 1 || row > ROWS || col < 1 || col > COLS) return; if (showMap[row][col] != '?') return; // 已经翻开了 if (mineMap[row][col] == '*') return; // 是雷,不处理 showMap[row][col] = mineMap[row][col]; // 翻开当前格子 if (mineMap[row][col] == '0') { // 当前是空白格,递归展开邻居 for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; expand(mineMap, showMap, row + dr, col + dc); } } } }注意这里的终止条件:showMap[row][col] != '?'是关键,它保证了已经翻开的格子不会被重复递归。如果没有这一条,递归会在两个相邻空格之间无限循环,最终栈溢出。这是我见过最多的递归错误之一,也是刷题网站上的经典错法。
但也得说,递归版本有一个潜在的坑——如果棋盘特别大、空白区域特别广,递归深度可能很深,极端情况下会栈溢出。我实测过 9x9 棋盘完全没有问题,但如果你想拓展到 30x30 的巨型棋盘,可能就要换成基于栈的非递归版本了。二刷时可以先写递归版,跑通以后再尝试改迭代版,这也是很好的栈和数据结构练习。
4.3 判赢逻辑:如何判断“所有非雷格子都被翻开”
扫雷的胜利条件是:所有非雷格子都已经被翻开。实现方式有两种思路:
第一种是每次翻开格子时,把“已翻开的非雷格子数”累加,当这个数等于总格子数减雷数时,玩家获胜。
int revealedCount = 0; // 每次翻开非雷格子时,revealedCount++ // 胜利条件:revealedCount == ROWS * COLS - MINES第二种是遍历showMap,检查是否所有非雷格子都已翻开。这个方法逻辑简单但效率低,每次判定都要扫一遍全棋盘。9x9 规模无所谓,但作为工程练习,我推荐第一种——用计数器维护状态,比每次重新计算要优雅得多。
不过要注意一个细节:玩家翻开的格子数要排除雷格。如果你在所有雷都标记为'F'的情况下,不小心翻开了雷,游戏就该结束,这时候无论已翻开多少格子都不重要。判赢必须在所有非雷格子翻开且未踩雷的前提下进行。
5. 强化阶段:从“能玩”到“像样”的重构之路
如果你把上面的代码全部写完,扫雷就已经能玩了。但如果你真的是在“二刷强化”,那此时才刚热身。二刷和一刷最大的区别,就是你必须带着工程意识去审视自己的代码。下面这三件事,是我二刷时重点做的重构,也是我认为从“交作业”到“像作品”之间最关键的几步。
5.1 函数越短越好,一屏放得下才叫健康
一刷扫雷时,最常见的坏味道是:main函数里塞了上百行逻辑,然后配两个大函数,一个负责游戏循环,一个负责逻辑判断。这种代码改一个功能,就要小心翼翼地在多个地方同时修改,不然就会出 bug。
二刷我给自己定了一条硬指标:每个函数的主体逻辑不能超过 20 行。为此我把原本的逻辑拆成了十几个小函数,比如:
initMaps:初始化数组placeMines:布雷calcNumbers:计算数字printMap:打印棋盘getInput:读取并校验玩家输入processOpen:处理翻开操作processMark:处理标记操作checkWin:检查胜利checkLose:检查是否踩雷
每个函数只做一件事,函数名就是注释。调试的时候,哪一步出了问题就直接定位到对应的函数,不用再从上到下通读一整个文件。这是二刷受益最大的一步。
这段重构需要你重新审视函数间的依赖关系。比如processOpen需要调用expand,而expand又需要读mineMap写showMap。把这些依赖画成一张清晰的图(哪怕只是在脑子里),你写代码时就会更有条理。
5.2 魔法数字清零,用宏定义替代裸数字
打开你以前的扫雷代码,肯定能看到大量裸写的 9、10、0 这种数字。二刷时我做的第一件事,就是把这些数字全部替换成宏定义:
#define ROWS 9 #define COLS 9 #define MINES 10 #define MAP_ROWS (ROWS + 2) #define MAP_COLS (COLS + 2)那些表示操作的'o'和'm',也可以定义成常量或枚举:
typedef enum { OP_OPEN = 'o', OP_MARK = 'm' } Operation;这样做的好处是:第一,如果你想从 9x9 扩展到 16x16,只需要修改 ROWS、COLS、MINES 三个宏,所有函数自动适配。第二,代码的可读性会大幅提升——看到if (op == 'o')大概能猜到是“打开”操作,但看到if (op == OP_OPEN)则完全不需要猜。
所谓“魔法数字”,就是那些含义不明、直接写在代码里的数字。它们在代码里到处都是的话,后期维护就是一场灾难。二刷扫雷最值得养成的习惯之一,就是养成给魔法数字命名的意识。
5.3 打印一眼就能看懂:标志位与信息分离
我在前面介绍的printMap函数实际上还留了一个尾巴:用reveal参数控制显示内容。但在真实二刷时,我觉得这种方式不够清晰。你想,调用printMap(mineMap, 1)和printMap(showMap, 0),参数 1 和 0 本身就属于魔法数字,阅读者还得去查函数定义才知道这是什么意思。
更好的做法是定义两个标志位,或者干脆写两个函数:
void printDisplayMap(char showMap[MAP_ROWS][MAP_COLS]) { // 打印玩家可见棋盘,'?' 表示未翻开 } void printDebugMap(char mineMap[MAP_ROWS][MAP_COLS]) { // 打印地雷布局,调试用 }虽然代码多了一点点重复,但调用处的意思一目了然。二刷扫雷本来就是为了强化“代码可读性”这个意识,这一点点“冗余”完全值回票价。
6. 踩坑实录与排查链路:我二刷遇到的三个真问题
6.1 随机数种子引发的“同一个棋盘”陷阱
我的二刷代码写完后,测试时发现一个诡异的问题:每次程序启动后,布雷结果完全一样——第一颗雷永远在 (4, 7),第二颗永远在 (6, 2),位置一模一样。
排查链路是这样的:
- 第一步,怀疑随机函数本身。我在
placeMines里加入了printf("%d", rand());做了简单测试,发现 rand 每次确实在变化。 - 第二步,怀疑
srand的参数。我检查代码,发现我把srand((unsigned)time(NULL));放在了placeMines函数内部,而且这个函数在游戏循环里被调用了多次。每次调用时time(NULL)返回的秒数变化不大,所以生成的随机序列几乎一样。如果玩家连续两次开始游戏,两次布雷布局完全相同。 - 第三步,修复:把
srand移动到main函数开头,只调用一次。这样整个程序生命周期内只会产生一个随机序列,后续每次调用 rand 都会从序列中取不同的值。
这个坑的本质是对rand和srand关系的理解不够。rand是伪随机数生成器,srand是种子。种子的作用是在程序开始时初始化伪随机序列,而不是在每次需要随机数时重置它。
提示:C 语言的
rand()生成的随机数质量并不高,尤其是低几位。但在扫雷这种项目里完全够用。如果你以后做蒙特卡洛模拟或者加密相关的工作,才需要考虑更高质量的随机数生成算法。
6.2 展开递归边界条件:难忘的栈溢出瞬间
我第一次测试expand函数时,用了一个中间是空格、周围全是雷的角落场景。结果程序直接卡死,不断输出棋盘,最后崩溃。用调试器一查,才发现是无限递归。
排查链路:
- 第一步,加了一行
printf("expand: %d %d\n", row, col);,发现 row 和 col 在两个相邻格子之间反复横跳,永远跳不出来。 - 第二步,检查
expand的终止条件。我写的是if (showMap[row][col] != '?') return;——理论上翻开过的格子不会再递归。但问题是:我的expand函数里先调用了printf调试输出,再检查边界条件,导致即使格子已经翻开,它还是会输出、还是继续递归。 - 第三步,修复:把“是否已翻开”的检查放在函数最开头,放在所有输出之前。
这是递归函数最常见的错误之一:没有把终止条件放在最前面。递归函数第一行就应该是终止判断,之后才能做具体操作。如果你把处理逻辑放在终止条件前面,递归就可能无限继续下去。
6.3 格式化输出导致列对齐失败:一个经典问题
棋盘打印是我最后调试的,但也是最折磨人的一个。问题描述是:当雷数为 8 或 9 的时候,棋盘某些行会错位,看起来参差不齐。
排查链路:
- 第一步,考虑到可能是
printf的格式问题。检查后发现我用了printf("%c ", map[i][j]),这里字符和空格混排,当数字为 10 时就不会对齐。但扫雷的雷数最多是 8,所以这不是根因。 - 第二步,检查变量类型。我把
mineMap定义为char数组,当格子里的数字从'0'到'8'时,直接以字符打印没问题。但我发现showMap里保存的是showMap[row][col] = mineMap[row][col],打印时调用了printDisplayMap,它内部用%2c格式化,应该不会错位。反复测试后发现:不是数字导致的错位,而是我打印列坐标时用了printf("%d ", c),没有设置宽度,导致两位数的列坐标(比如 10、11)挤压了后面的列。 - 第三步,修复:坐标格式化改为
printf("%2d ", c),每个格子固定占两列,列坐标和棋盘内容就对齐了。
这个问题看似很小,但非常典型:当坐标从一位数变成两位数时,所有对齐都会崩掉。以后你打印任何表格型数据,都应该养成“用固定宽度格式化”的习惯,否则等到数据量变大,排版一定出问题。
7. 拓展方向:从扫雷到“真正的程序”
二刷扫雷的价值不只在扫雷本身。如果你把基础版写透了,下面这几个拓展方向,每一个都能让技术能力再上一个台阶。
7.1 存档与继续游戏:文件读写的完整演练
用文件读写实现“保存游戏进度”,是很多读书列表里的热门练习。在扫雷项目里,你需要保存地雷布局、可见棋盘、已翻开的格子数、游戏状态(进行中/结束),以及当前难度等级。
核心代码思路:在玩家退出或游戏结束时,用fprintf按固定格式把这些内容写到文件里。下次启动时,用fscanf读回来并重建棋盘。
void saveGame(char mineMap[MAP_ROWS][MAP_COLS], char showMap[MAP_ROWS][MAP_COLS], int revealed) { FILE *fp = fopen("save.dat", "w"); if (fp == NULL) return; fprintf(fp, "%d %d %d\n", ROWS, COLS, revealed); for (int i = 1; i <= ROWS; i++) { for (int j = 1; j <= COLS; j++) { fprintf(fp, "%c", mineMap[i][j]); } fprintf(fp, "\n"); } // 同理写 showMap fclose(fp); }这里要注意的是:保存的是mineMap和showMap这两个核心数组,而不是打印出来的棋盘文本。棋盘文本只是为了让人看的,存档数据必须是最原始的信息结构。这个思路在真实项目里也适用——存储数据而不是存储表现。
7.2 难度选择与动态棋盘:函数式思考和宏替换的实战
如果你用宏定义控制了 ROWS、COLS、MINES,那么实现难度选择其实很自然。可以在main函数开头让玩家输入难度,然后根据难度给这三个变量赋值。但这里有个函数式编程的坑:宏是编译期常量,不能在运行时改变。
解决思路有两种:
第一种,把所有函数都改成接受 ROWS、COLS 作为参数,动态使用malloc分配二维数组。这需要你重新实现一遍数组访问,但锻炼价值极高——二维指针、内存分配、函数参数传递,全部都会涉及。
第二种,利用数组长度在编译期固定的特性,直接把所有棋盘定义为最大尺寸,然后用 ROWS、COLS 宏配合循环只访问有效区域。缺点是浪费内存,但写起来最省事。
我在二刷时两种都试了。第一种写完,我彻底理解了int**这个类型的本质——二维数组在堆上的组织方式;第二种则让我更清楚地看到了宏在编译期控制的优势。强烈建议你去试试第一种,这个练习比写三百行链表对指针理解的促进作用还要强。
7.3 排雷助手与概率推理:把算法引进来
扫雷玩到后期,很多时候需要根据“数字+周边未翻开格子”推理出哪些格子一定安全、哪些一定有雷。如果你愿意更进一步,可以写一个“提示”功能——让程序帮你推理出确定安全的格子或确定的雷。
这不是 AI,只是一个基于当前可见棋盘的确定性推理。核心思路是:遍历每个已经翻开的数字格,统计它周围未翻开的格子数量,以及已被标记为雷的格子数量。如果两者相等,说明所有未翻开的格子都是雷;如果未翻开的格子数等于该数字,再排除掉已经标记为雷的格子,剩下的就是确定安全的格子。
这个逻辑不难,但它逼着你去做更高层次的抽象——遍历所有格子、归类标记、推演结论。写出来的代码可能比扫雷本身还要长,但这个过程会把你从“写功能”提升到“写算法”的层次。
7.4 无递归展开:用栈重写 Flood Fill
我在前面提到递归展开有栈溢出风险。如果你用过文件读写的存档功能,再结合无递归展开,就能实现一个“超大型棋盘扫雷”——比如 80x40 的棋盘,几百个雷。这种规模下,递归展开几乎必然爆栈。
无递归版本的expand需要自己显式管理一个栈结构,可以借助数组模拟:
int stackRow[4096], stackCol[4096]; int top = -1; // push 坐标 // while (top >= 0) pop,处理格子,把邻居 push 进去这个过程本质上是把系统调用栈转换成你自己的数据栈。写一遍这个版本,你对“递归不过是一种隐式使用栈”的理解会深入很多。这也是 C 语言二刷里最有含金量的练习之一。
8. 从扫雷项目往回看,二刷到底刷到了什么
最后聊一点个人感受,可能对正在二刷的你更有帮助。
一刷的时候,我写扫雷是照着教程一行行敲,跑通了就觉得自己会了。二刷的时候不查教程,纯粹靠思路和记忆去实现,遇到不会的再去翻文档,整个过程最大的收获不是“我会写扫雷了”,而是我终于理解了:程序不是靠背代码写出来的,而是靠把需求拆成逻辑、再把逻辑翻译成代码写出来的。
具体来说,二刷让我牢牢掌握了几个以前始终模棱两可的东西:
第一,二维数组和指针之间的关系。动态棋盘版本里,我用malloc分配行指针数组再为每行分配内存,彻底明白了int**的本质是“指向指针的指针”,而不是扁平的矩阵。这个理解直接帮我跨过了 C 语言那堵最著名的墙。
第二,递归展开的边界条件思维。以前写递归就是照着模板套,现在我知道递归三要素——终止条件、递归过程、返回值——每个都必须仔细设计。写expand那种多分支递归,每个分支都可能引入 bug,只有把终止条件放在最前面才能保证正确性。
第三,输入防御的重要性。scanf直接读用户输入在练习项目里很常见,但在真实程序里就是灾难。我现在的习惯是任何用户输入都用fgets+sscanf组合处理,收到一次教训后就再也不想走回头路。
第四,打印和调试技巧。加调试输出、用断言验证条件、设置打印开关,这些看起来不起眼的习惯,在排查 bug 时就是救命稻草。特别是那个因格式化对齐而反复测试的老问题,让我记住了“固定宽度输出”这个原则。
如果你现在正处于“C语言好像都会了,但不知道自己能不能独立写个像样的项目”的状态,我建议你也拿扫雷当跳板。把基础版写完,再选一个拓展方向(我推荐你先做存档或动态难度)深入进去,你会发现,这一套流程下来,你对 C 语言的理解会发生质变,不只是记得语法,而是真的能“用来做东西”了。