ARTICLE DETAIL

资讯详情

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

C语言连连看课程设计:数据结构与路径搜索算法实战解析

C语言连连看课程设计:数据结构与路径搜索算法实战解析 期末拿到“数据结构综合实验——连连看”这个题的时候我第一反应是这不就是个消消乐吗真正动手写代码才发现把棋盘、路径、队列、栈这些数据结构全部串起来之后这门课才算是真的入门了。这篇文章就记录我完整做完这个项目的过程包括数据结构选型、连通判定算法推导、代码组织方式以及调试过程中踩过的那些坑。如果你是正在做类似课程设计的同学这篇文章可以直接当参考文档用如果你是想把数据结构知识落地的人连连看这个项目也非常适合拿来练手。先说明一下项目背景这是我在数据结构课程的综合实验环节完成的一个项目开发语言是C语言运行环境是无图形界面的控制台输入输出全部通过标准输入输出完成。之后我基于同一套核心算法又补充了一个基于图形库的扩展版本把界面换成了鼠标点击操作。文章里的所有代码和思路都经过实际编译运行验证可以直接抄作业也能在此基础上二次扩展。1. 实验需求拆解连连看背后到底在考哪几个数据结构知识点很多同学看到“连连看”三个字第一反应是“图形界面”“鼠标操作”“动画特效”但这些其实都不是这门课的重点。作为数据结构综合实验老师真正要考察的是你能否用合理的数据结构来抽象游戏世界并且用算法解决核心逻辑问题。所以我拿到题之后做的第一件事不是写界面而是把需求拆成几个独立的数据结构问题。连连看游戏的核心规则可以浓缩为四条棋盘上有若干种不同花色的方块玩家每次选择两个相同花色的方块如果它们之间能用不超过两个拐点的路径连通且路径上的所有格子均为空则这两个方块被消除棋盘数据随之更新如果所有方块被消除则游戏胜利如果棋盘上还存在方块但已经没有任何一对可消除的方块则游戏进入死局需要重新洗牌或结束。这四条规则听起来简单但每一条背后都对应着具体的数据结构知识点。第一条对应的是“棋盘存储”本质是一个二维数组或动态二维表需要对格子进行增删改查操作。第二条对应的是“连通性检测”本质是在网格图上做路径搜索核心数据结构是队列或栈核心算法是广度优先搜索的变体同时要用到路径回溯技术。第三条对应的是“状态更新与胜负判定”涉及对线性表遍历和条件统计。第四条对应的是“死局检测”最直白的实现就是把所有相同花色的方块两两配对全部调用一次连通判定函数。我还额外做了一层设计为了做到“路径上所有格子均为空”这个判断又不至于频繁越界我在棋盘的四条边外面各加了一圈“虚拟空白格子”让棋盘从逻辑上变成一个更大的二维数组这样路径检测时不需要写一堆判断边界的if语句。这个设计在后面的代码实现里帮我省了很多事强烈建议你也这么做。1.1 开发环境与总体模块划分我的开发环境是VS Code GCC编译器项目代码全部是C语言写的没有任何平台相关的第三方库所以代码在Windows、Linux、macOS下都能编译运行。如果你的课程设计对图形界面有硬性要求可以看我后面写的图形版扩展部分我用的是EasyX图形库代码思路和核心判定函数完全复用只是把输入输出从scanf改成了鼠标消息。整个项目的模块划分我按照“数据层—算法层—逻辑层—交互层”分开这样一是方便调试二是答辩时老师问你模块划分你能讲出个所以然。数据层棋盘二维数组的定义、初始化、随机填充、洗牌。算法层两点之间合法通路的判定、拐点计数、路径回溯。逻辑层鼠标/键盘输入坐标转换、消除判定、胜负判定、死局判定、洗牌。交互层控制台的棋盘打印、提示信息输出图形版的棋盘绘制和鼠标点击响应。这四层之间通过函数接口互相调用数据层不依赖算法层算法层不依赖交互层整个结构是单向依赖的。答辩的时候老师问“你这个模块划分优点在哪里”你直接说“算法层可以独立测试不依赖界面”这就是一个很到位的回答。1.2 数据结构知识点对照表为了在实验报告里把“综合实践”这个标题落到实处我做了一个知识点对照把项目的每个功能点对应到教材里的数据结构章节。这个对照表在写实验报告和答辩PPT的时候可以直接用功能点使用的数据结构对应教材章节棋盘存储二维指针数组第2章 线性表随机填充与洗牌一维数组 随机交换第2章 线性表连通路径搜索队列链队/循环队第3章 栈和队列路径回溯输出栈第3章 栈和队列拐点计数与剪枝状态数组辅助表第6章 图的应用最短路径思想死局检测双层循环 判定函数树/图的遍历思想提示功能遍历所有候选对线性表的查找这张表的好处是一目了然老师扫一眼就知道你这个实验覆盖了哪些知识点不会出现答辩时老师问“你这个实验用了什么数据结构”你只回答一句“用了数组”的尴尬局面。2. 棋盘建模与存储方案二维数组、动态数组和邻接表的取舍棋盘是整个连连看游戏的“地基”地基没打好的话后面所有算法都白搭。我最初用了一个最土的办法固定写死一个10×14的二维数组内层循环直接用数组下标访问。这个方案在能编译通过的基础上确实简单但很快暴露出问题——一旦要调整棋盘尺寸或者从控制台版转向图形版这个写死的数组就变得非常难受而且答辩老师一看就知道你没有认真考虑过扩展性。所以我重新设计了棋盘存储方案。我的最终选择是动态二维数组也就是用二级指针来分配一块连续的、逻辑上二维的内存空间。这样棋盘的长宽可以在程序运行时传入做关卡扩展的时候特别方便。实际分配代码如下// 申请棋盘内存board[row][col]表示坐标(row, col)处的方块类型 // 0表示空白1~N表示不同花色 int **board (int **)malloc(sizeof(int *) * total_rows); for (int i 0; i total_rows; i) { board[i] (int *)malloc(sizeof(int) * total_cols); // 初始化时先把所有格子置为0 memset(board[i], 0, sizeof(int) * total_cols); }注意这里我用的是逐行动态分配。如果你对内存布局有更高要求还可以一次性分配total_rows * total_cols个大块内存再用手工索引访问但作为课程设计逐行分配已经足够清晰而且释放内存时也方便一个for循环挨个free掉就行。为什么不用邻接表我解释一下邻接表适合存储稀疏图比如社交网络里的好友关系边的数量远小于n²用邻接表可以省空间。但连连看的棋盘本质上是一个稠密网格图每个格子和上下左右四个邻居天然相连用二维数组反而是最自然最高效的存储方式访问某个格子是O(1)时间完全没必要引入链表带来的指针开销。这个点答辩老师如果问你就从“时间复杂度和空间复杂度对比”的角度去答非常稳。2.1 棋盘外扩一圈空白的边界处理技巧这是我这个项目里最满意的一个设计在棋盘四条边外面各自“模拟出一圈空白格子”。具体做法是逻辑棋盘的行列数分别为original_rows和original_cols但实际申请的数组是(original_rows 2) × (original_cols 2)数组下标从0到original_rows1其中0行、最后一行、0列、最后一列全部置为0实际摆放方块的区域是从(1,1)到(original_rows, original_cols)。这个设计解决了一个经典问题判断两个边角上的方块是否连通。比如棋盘左上角(1,1)和它右边隔一个空格的位置(1,3)之间可能是直接一条直线连通的但如果(1,1)旁边还有别的东西围着玩家需要绕到棋盘外面走一条“外圈路径”才能连上。如果没有外扩空白这种路径检测要么写一堆边界判断要么直接漏判游戏体验很差。有了外扩空圈之后我的路径检测函数可以放心地从任意格子向四个方向“走出去”遇到数组边界也不用担心越界访问因为最外圈永远是0空相当于整个棋盘之外还有一层无限大的逻辑空白区。这种处理方式在写BFS时尤其好用。2.2 随机填色与洗牌算法的正确姿势棋盘的方块花色填充如果完全随机会导致一种情况某一种花色的方块数量是奇数最后必然剩一个方块永远无法消除游戏必败。所以正确做法是先统计每种花色的方块数保证每种花色出现偶数次再执行随机打乱。我采用的是“先填满再洗牌”的策略假设棋盘一共有cell_count个方块位置扣除空白外圈后的内层区域每种花色要出现的次数为kk必须是偶数。我先把每种花色的k个副本依次放入一个一维数组然后对这个数组执行Fisher-Yates洗牌算法最后把洗牌后的数组按照行优先顺序填入棋盘二维数组。Fisher-Yates洗牌是正态且无偏的它保证每种排列等概率出现。洗牌过程的核心是从当前未处理的位置中随机选一个与当前末尾交换代码写起来很简洁// 对数组arr中前n个元素进行洗牌 for (int i n - 1; i 0; i--) { int j rand() % (i 1); // 随机选下标范围[0, i] int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; }注意初始化随机种子时不要用srand(time(0))之外的东西有的同学用了srand(NULL)导致每次重开游戏棋盘一模一样玩起来相当无聊。时间种子虽然简单但对课程设计来说足够了。2.3 动态内存管理的释放顺序使用动态二维数组一定要记得释放内存释放顺序和分配顺序相反先释放内层再释放外层。如for (int i 0; i total_rows; i) { free(board[i]); } free(board);如果不按这个顺序free会报错或者内存泄漏。还有一个小细节如果实验环境是Windows下的DevCmalloc和free的头文件要写#include stdlib.h不要只写#include malloc.h否则有些编译器会警告。你提交代码之前可以开启编译器的-Wall -Wextra参数把所有警告清零再交这在实验报告里能写一笔“代码通过严格编译检查”是加分项。3. 连通判定算法从BFS到两折线路径剪枝的完整推导连通判定是整个连连看项目里最核心的算法没有之一。它的任务是给定棋盘上两个坐标点start和end如果这两个点的方块类型相同并且存在一条路径路径从start出发到end结束全程只经过空白格子可以经过棋盘外层的虚拟空白路径上的拐点即方向发生改变的点数量不超过2那么判定为可消除并输出整条路径否则判定为不可消除。这里的关键不是“能不能走到”而是“拐点不能超过2个”。普通的BFS找的是最短路径但最短路径不等于拐弯最少用最短路径去跑连连看经常会刚愎自用地绕出7、8个弯然后判定为不可消除但实际上明明有一条2个弯以内的路。所以我的连通判定基于一个改良版BFS每个格子在队列中不仅记录坐标还记录当前方向以及累计拐点数用状态数组dist[x][y][dir]记录到达该格时以某个方向进入的最小拐点数。状态转移规则是这样的从起点出发起点可以向四个方向走此时方向就是移动方向拐点数记为0。从当前格子向某个新方向移动时如果新方向和当前方向相同则拐点数不变如果新方向和当前方向不同则拐点数加1。当拐点数超过2时这条路径直接剪枝不再扩展。访问格子时如果以相同方向进入该格子时拐点数更少就更新状态并入队否则跳过。BFS天然保证按拐点数的非递减顺序进行扩展所以第一次从终点以某个方向出队时就能确认是否存在合法路径。为了把路径打印出来我给每个状态维护了一个pre指针指向扩展出它的前驱状态找到终点后从终点一路回溯到起点再把路径逆序输出这个过程刚好可以用到栈的数据结构。这里我给出一个可以运行的C语言版本核心代码代码中坐标从(1,1)开始棋盘最外圈为空白#include stdio.h #include stdlib.h #include string.h #include stdbool.h #define MAX_ROW 20 #define MAX_COL 20 // 方向数组顺序无所谓只要包含上下左右四个方向 int dx[4] {-1, 1, 0, 0}; int dy[4] {0, 0, -1, 1}; // 棋盘数据0表示空 int board[MAX_ROW][MAX_COL]; // 状态访问标记[x][y][dir]表示从dir方向进入(x,y)时的最小拐点数 int vis[MAX_ROW][MAX_COL][4]; const int INF 0x3f3f3f3f; // 队列节点记录当前坐标、进入方向、累计拐点数 typedef struct { int x, y; int dir; // 0上 1下 2左 3右用-1表示起点初始状态 int turns; } Node; Node queue[MAX_ROW * MAX_COL * 4]; int head, tail; // 判断某个点是否在棋盘范围内棋盘已外扩一圈所以0和max-1是空白边界 bool in_board(int x, int y, int rows, int cols) { return x 0 x rows y 0 y cols; } // 核心函数判断(sx, sy)到(ex, ey)是否存在拐点不超过2的路径 bool can_connect(int sx, int sy, int ex, int ey, int rows, int cols) { // 起点和终点不能是同一个点 if (sx ex sy ey) return false; // 两个格子方块类型必须相同且非空由调用方保证 tail 0; head 0; memset(vis, 0x3f, sizeof(vis)); // 起点向四个方向扩展每个方向的初始拐点数都是0 for (int i 0; i 4; i) { int nx sx dx[i]; int ny sy dy[i]; if (!in_board(nx, ny, rows, cols)) continue; if (board[nx][ny] ! 0 !(nx ex ny ey)) continue; vis[nx][ny][i] 0; queue[tail] (Node){nx, ny, i, 0}; } while (head tail) { Node cur queue[head]; int x cur.x, y cur.y, dir cur.dir, turns cur.turns; if (x ex y ey) { return true; // 到达终点即存在合法通路 } for (int ndir 0; ndir 4; ndir) { int nx x dx[ndir]; int ny y dy[ndir]; if (!in_board(nx, ny, rows, cols)) continue; // 终点必须可达其他格子必须为空 if (board[nx][ny] ! 0 !(nx ex ny ey)) continue; int new_turns turns; if (dir ! ndir) new_turns; if (new_turns 2) continue; // 超过2个拐点直接剪枝 if (new_turns vis[nx][ny][ndir]) { vis[nx][ny][ndir] new_turns; queue[tail] (Node){nx, ny, ndir, new_turns}; } } } return false; }这段代码我在自己机器上对不同棋盘尺寸测试过逻辑靠得住。它的时间复杂度在最坏情况下是O(rows × cols × 4)空间复杂度也是O(rows × cols × 4)对于连连看这种小棋盘来说性能绰绰有余一次判定在微秒级别。3.1 为什么不直接用普通BFS或A*这是我实际调试过程中遇到的最典型的问题。我第一版代码就是纯BFS把终点可达作为判定条件结果怎么测怎么不对——把两个明明可以连通的方块放在棋盘上程序返回不可消除。后来我打印路径才发现BFS找出来的路径绕了很远拐了十几个弯而连连看的规则只允许2个拐点。有人会想那我用A算法加入一个启发函数让路径拐弯少一点。但A本质上是找最短路径的你可以在代价函数里给“拐弯”加一个很大的惩罚值让它倾向于走直线。这个思路是可以的但对于课程设计来说实现复杂度比状态BFS高不少而且调参要调半天。状态BFS的思想更简单直接把拐点数作为状态当拐点数超过2就剪枝这就是为这个题目量身定制的算法。你用A*最后写出来的代码能跑但老师一问为什么这么写你得解释一堆启发函数的设计反而容易暴露对算法理解不深的问题。3.2 用“折点枚举”代替搜索的另一种实现如果你觉得BFS状态数组理解起来有点绕还有一个非常直观的“折点枚举法”。连连看的路径最多两个折点那么路径形态只可能是这三种直线0个折点、一个折点L形、两个折点S形/Z形/C形。直线检测判断起点和终点是否在同一行或同一列并检查中间所有格子是否为空。一个折点检测折点有两种选择一种是起点的横坐标和终点的纵坐标组合另一种是起点的纵坐标和终点的横坐标组合。对每种组合的折点坐标检查起点到折点、折点到终点两段直线是否都畅通。两个折点检测枚举第一段直线所在的行或列。比如先从起点向上、向下、向左、向右延伸记录每个能直达的空格坐标再看这些空格是否能以一个折点到达终点反过来也做一遍。这个方法的代码写起来比状态BFS还要直白一点而且天然可以输出路径的完整拐点坐标。缺点是代码量略大三个检测函数加起来大约150行。BFS和折点枚举在这个特定题目里是等价的你可以选择自己更习惯的那一个。我在项目里用的是状态BFS因为它的扩展性更好——如果老师把规则改成“最多允许3个拐点”我只需要把if (new_turns 2)改成if (new_turns 3)一行代码搞定如果题目改成“路径长度最短优先”我也可以在BFS里加一个距离字段。但如果你目测自己的需求就是固定的2拐点折点枚举法更不易出错。3.3 路径回溯和栈的配合设计到这一步我本来想偷懒不打印路径但实验报告里“输出完整路径”是硬指标。找到可消除方块后除了给出“可以消除”的判定最好把路径上的所有关键点也打印出来让玩家和老师都能看到路径长什么样。路径回溯这里用栈刚刚好终点的pre指针一路回溯到起点得到的是反向路径依次push到栈里再全部pop出来就是正向路径。我在BFS状态里增加了一个指针域记录前驱节点在数组中的下标。这样回溯的时候只需要不停跳前驱下标即可不需要真的动态申请链表节点省内存也省代码。typedef struct { int x, y; int dir; int turns; int pre; // 前驱在队列数组中的下标用-1表示无前驱 } PathNode;回溯时从终点对应的节点下标开始循环把坐标塞进一个栈数组直到pre为-1为止然后逆序打印。如果你不想用数组模拟栈也可以用C标准库的链表结构但那就需要自己写节点分配和释放课设代码反而更复杂。数组模拟栈在工程上完全够用而且答辩时你能说清楚“先进后出刚好用于倒置路径”这是一个非常流畅的知识点串联。4. 游戏主流程与界面实现控制台版如何跑通一局完整游戏算法层搞定以后游戏主流程就是把逻辑组织起来。一个完整的连连看控制台版游戏主循环大致如下初始化棋盘、填充方块、打印初始棋盘。玩家输入两个坐标格式为“行1 列1 行2 列2”。程序检查输入坐标是否合法是否在棋盘内、两个格子是否相同、是否都不为空白。调用连通判定函数如果可消除就把两个点置为0打印棋盘和消除路径如果不可消除打印错误提示并要求重新输入。每次消除后检查棋盘是否还有剩余方块没有则宣布胜利如果剩余方块存在但没有可消除对则自动洗牌。循环直到胜利退出。控制台版里最影响体验的是棋盘打印格式。我的棋盘内层是10行14列每个方块用一个两位数字表示花色空白格子用“ .”表示。注意留好列间距保证玩家在控制台里能清楚分辨行列号不然输入坐标总是输错非常影响心情。我还加入了坐标标注第一行和第一列打印数字坐标这样玩家不用自己数格子。4.1 输入坐标的合法性与语义设计输入的坐标我用的是“行列”语义而不是“xy语义”。有的同学用x表示列、y表示行结果打印棋盘和输入坐标的时候经常错乱越写越乱。我建议统一成“行在前、列在后”和二维数组的下标顺序保持一致这样函数接收参数时不需要做任何转换心智负担小很多。合法性检查要放在调用连通判定之前而且要写得严格一点四个数值都能正确读入如果scanf返回值不等于4说明输入无效清空输入缓冲区后重新让玩家输入。行列坐标是否在有效范围内即1 row rows1 col cols。两个坐标是否指向同一个格子。两个格子的方块值是否相同是否都为0空。这些都通过了才进入连通判定。讲个小细节scanf读到非法字符时会停留在输入缓冲区导致下次循环立刻读到一个无效值陷入死循环。所以在scanf返回值不等于4的时候要调用一个清理函数把所有剩余字符读走直到读到换行符为止。void clear_input_buffer() { int c; while ((c getchar()) ! \n c ! EOF) { // 逐个读走剩余字符 } }这个函数虽然不起眼但少了它你的程序会在输入了非数字字符后像中了邪一样不停地提示“无效输入”。我调试的时候被这个小坑折磨了快一个小时后来才发现缓冲区问题。4.2 死局检测与自动洗牌策略每次消除完成之后程序要判断当前棋盘是否已经无法再消除任何一对方块。最直接的实现是遍历所有非空格子对每对相同花色的格子调用can_connect函数。一旦发现有一对可以连通立即返回“非死局”如果遍历完整个棋盘都没有一对能连那么判定为死局。复杂度分析最坏情况下棋盘有m个非空格子其中花色分为若干种相同花色两两配对。假设某种花色有k个方块那么最多需要检测k*(k-1)/2次。如果棋盘是10×14140格每种花色平均10个那每轮死局检测也就几十到几百次判定每次判定是O(rows×cols)总体开销在毫秒级完全不用担心性能问题。死局之后我采用了“重新洗牌”策略把所有非零方块的值收集到一个一维数组重新调用Fisher-Yates洗牌再填回棋盘。注意洗牌不能改变花色总数否则又会出现奇数个方块无法消除的问题。重新洗牌后要重新检测一次死局如果依然死局就再洗一般最多洗两三次就能跳出死局。虽然理论上可能存在一直洗不出来的情况但实际测下来概率极低作为课程设计可以接受。如果你想彻底杜绝可以洗牌后加一步检查连续洗10次仍然死局就自动缩小棋盘尺寸或者直接判负。4.3 图形版的改造成本与控制台版对比如果你需要带图形界面我强烈建议不要推到重来。我的图形版是在控制台版基础上改的核心数据结构、连通判定、死局检测这些函数一行没改只改了输入输出层把scanf替换成鼠标消息的坐标解析把printf棋盘替换成绘制填充矩形。我用的是EasyX库安装后要加一行#include graphics.h。在鼠标回调函数里接收到左键点击消息时把窗口坐标转换成棋盘行列号换算公式是int row (mouse_y - board_origin_y) / cell_size; int col (mouse_x - board_origin_x) / cell_size;其中board_origin_x和board_origin_y是棋盘在窗口中的左上角坐标cell_size是每个格子的像素边长。玩家第一次点击记录当前坐标第二次点击时直接把两次坐标交给同一套连通判定函数消除成功就重绘这两个格子为空白再更新背景。全程不需要改动数据层和算法层。图形版还有个隐藏好处路径可视化非常直观。你可以在绘制函数里把连通路径的关键折点用连线段画出来玩家能清楚看到两根折线是怎么绕过其他方块连上的这对课程设计答辩演示加分非常明显。我建议你在答辩前把路径绘制功能加上画一个绿色折线路径老师一眼就能看到你算法的输出结果。5. 课程设计里的典型翻车现场完整排查链路与修复过程这部分我想写细一点因为每一条都是我真实踩过的坑不是网上抄来的。做课程设计最怕的不是不会写而是程序行为奇怪但找不到原因。下面记录的是我在测试过程中遇到的4个最有代表性的问题每个都包含现象、定位过程和修复方案。5.1 队列爆了——状态数组为什么始终要带上“方向”维第一版连通判定我写了一个“简化版”访问标记只记录vis[x][y]是否访问过而不区分方向。结果运行到第30个测试用例的时候程序偶尔会返回错误判定有时明明有通路却说没有有时没有通路却消除成功。为了定位问题我在can_connect函数里加了大量printf调试输出打印每次入队时的坐标、方向和拐点数。输出结果暴露了问题假设有两条路径都能到达同一个中间格子第一条路径从上方进入拐了2个弯第二条路径从左边进入拐了1个弯。如果先访问的是第一条路径那么vis[中间格] 2之后第二条路径想更新vis[中间格] 1时因为我只用了一维的“全局最小拐点数”它发现2 1为假就直接丢弃了这条更优路径导致最终漏判。问题根因清楚了在拐点受限的路径搜索中进入同一个格子的方向不同后续可用的转折次数也不同所以访问状态必须按方向拆分成四份。把vis[x][y][dir]改成四维标记之后这个问题彻底消失。这个教训让我理解了为什么状态搜索和普通图搜索的访问标记不一样——因为这里的“状态”不是“位置”而是“位置方向”的组合。5.2 边界崩溃——数组越界为什么会随机出现有一次测试时我把棋盘缩小成4行4列结果一运行程序就崩溃。我第一反应是代码哪里数组越界了。用调试器一步步跟发现程序在BFS扩展时访问了board[-1][3]这个下标。原因是我的边界判断只检查了x和y是否大于等于0但因为棋盘尺寸变小某个坐标已经合法下一步扩展就直接越界到了负下标。修复方法是把我前面提到的“外扩一圈空白”和“越界检查”结合起来BFS扩展邻居时不仅要检查棋盘边界还要注意只有位于棋盘最外圈的虚拟空白格才允许被扩展真实棋盘的空白格和被消除后的空格都是普通空位但不能再往棋盘外的“无限空白”扩展——否则BFS可能无限延伸永远搜不到终点。我在in_board函数里其实已经把范围限制在了0到rows-1rows和cols是包含了外扩空圈的总行数和总列数所以只要传入正确就不会越界。这个坑的核心教训是不要相信某一次运行没有崩溃就代表安全C语言的数组越界是未定义行为有时它不报错只是因为它碰巧没有破坏关键内存。所以从一开始写的时候就要对每一个数组下标做逻辑推演自己在纸上把起始状态、终止状态、边界状态全部列一遍比事后调试省时间。这个习惯从课程设计开始养起对以后做工程是大有裨益的。5.3 scanf死循环——输入非数字字符导致的诡异问题这个问题的现象是玩家输入了两个字母比如“a b”程序没有提示重新输入而是疯狂输出“无效输入请重新输入”刷屏。我第一次遇到时以为是判断逻辑写错了检查半天没发现问题。后来才意识到是scanf把非数字字符留在缓冲区printf提示后循环再次调用scanf读到缓冲区残留的字符又失败无限循环。解决方式是清空缓冲区也就是前面写的clear_input_buffer函数。这里还要注意一个细节如果音乐输入时用了“行1 空格 列1 空格 行2 空格 列2”的格式玩家输入错了某个字符那么scanf返回的值可能是1、2或3而不是4你必须根据返回值判断读入了几个有效数字。我的处理是只要返回值不等于4就调用clear_input_buffer把行尾剩余内容全部吞掉然后重新提示输入。这个处理让程序在各种乱输的情况下都能稳定运行不会崩溃不会卡死。5.4 路径穿过“目标点”之后继续延伸——终点判定顺序的讲究最后一个坑是在写路径打印时遇到的BFS在扩展时如果相邻格子是终点那无论它的方块值是否为空都应该允许进入并直接返回成功。但我的代码把board[nx][ny] ! 0的判断放在了“(nx, ny)是否为终点”判断之前导致终点虽然是两个相同花色方块之一却被当成了不可穿越的障碍物最终结果当然是永远判定失败。修复方式很直白先判断nx和ny是否等于终点的行列如果是直接加入队列并返回true如果不是再判断该位置是否为空。逻辑顺序调换后一切都对了。这个坑本身不复杂但没有调试经验的人很容易在写if条件时随手写串顺序然后被各种怪异的判定结果搞得满头雾水。6. 提升用户体验的三个附加功能提示、反悔和解说基础功能做完后我又加了三个提升体验的小功能。它们都不复杂但能让整个项目在实验报告和答辩中显得更有层次也能展示你对数据结构的灵活掌握。6.1 提示功能复用连通判定函数做全局扫描提示功能的实现思路和白痴级简单遍历棋盘上所有非空格子对每对相同花色的格子调用can_connect函数找到第一对能消除的格子后输出它的坐标作为提示。如果全盘扫描后没有任何可消除对则输出“当前没有可消除的方块请洗牌”。这个功能本质上和死局检测是同一个算法只是输出不同。我在报告里专门写了一段“死局检测与提示功能共用核心判定模块体现了模块复用的思想”。这个复用能体现你的代码设计不是堆砌功能而是有抽象思维的。实际代码就是两层循环加一个判定函数总共15行。6.2 反悔功能基于栈的历史记录连连看支持一定步数内的反悔撤销上一步消除。实现方法是维护一个“历史动作栈”每次成功消除时把两个被消除格子的坐标和方块值压入栈玩家执行反悔时从栈顶弹出上次动作把两个格子的方块值恢复到棋盘上。注意反悔后可能改变死局状态所以恢复完成后要重新检测一次死局如果恢复了之后棋盘反而变成死局那我就提示“当前棋盘已死局请洗牌”让玩家自己决定。这是个非常自然的状态恢复策略。用栈来存储历史动作是再合适不过了——因为反悔的语义就是“撤销最近的一次操作”天然具有“后进先出”的栈特性。这里你不仅可以用栈还能顺便把数据结构课程里栈的应用场景给老师讲得明明白白。6.3 游戏过程日志用顺序表记录整局对局我还实现了一个对局日志功能把每一步消除的坐标、路径拐点数、时间戳记录到一个顺序表动态数组里。游戏结束时可以把日志导出到文本文件方便复盘和写实验报告的数据分析。这个功能的数据结构选型是动态数组顺序表因为对局步数总体是几十到几百的数量级顺序表随机访问和遍历都很高效内存占用也可控。而且日志记录的是顺序追加操作插入删除需求少用线性表非常合适。这又是一个“刻意使用多种数据结构”的点。7. 从课程设计到代码仓库我的一些个人体会如果你正在做类似的综合实验我最后想分享几条实实在在的个人体会。第一动手写代码之前先用文字把游戏规则翻译成算法描述写清楚输入是什么、输出是什么、中间有哪些限制条件。很多同学的代码写不下去问题出在刚开始就没想清楚规则。第二数据结构实验的核心不是界面多么花哨而是你如何用合适的数据结构组织数据、用高效的算法处理逻辑。我见过有人花了两天去研究窗口动画效果结果连通判定函数写得一塌糊涂答辩被老师问得说不出话。第三代码一定要分模块哪怕只是简单的函数拆分也要让核心算法函数独立出来这样出了问题才能单点调试。做完这个项目我最大的感受是数据结构这门课的知识点只有在实际项目中才能串起来。数组平时学过、栈平时学过、队列平时学过但在连连看里它们是协作共生的数组存储棋盘状态队列支撑路径延展栈实现路径回溯与反悔线性表记录对局历史。做完之后你再去看严蔚敏教材里的每一章会发现它们都在真实世界里找到了自己的位置。这个项目我还有不少扩展方向没来得及做比如关卡系统不同棋盘尺寸、不同方块种类、计时模式用队列模拟倒计时事件、联机对战需要自定义协议序列化棋盘状态。如果你在课程设计之外还想继续深挖这些方向都能做而且每一个都能把数据结构的某些知识点挖得更深。尤其是计时模式还可以顺便练一下优先队列或时间轮算法。但我建议先把这个基础版本做扎实再把一个附加功能做得特别完整就足以在答辩中拿到不错的成绩了。希望这篇记录能帮你少走一些弯路也欢迎你在评论区聊聊你在这个项目里的设计选择。
返回列表