数据结构实战:从连连看游戏解析BFS算法与棋盘状态管理
2026/9/5 14:25:22 网站建设 项目流程

简介:本资源是武汉理工大学数据结构课程的综合性实践项目——“欢乐连连看”游戏实现,面向计算机类专业本科生及数据结构初学者,旨在通过真实游戏开发场景深化对线性表、图、哈希查找、DFS/BFS遍历、回溯与随机化算法等核心知识的理解与应用。压缩包共103个文件,含10个关键cpp源码与13个头文件(h)构成完整逻辑框架,7幅bmp背景及元素图、1段wav音效支撑多媒体交互,另有sln工程文件与vcxproj配置确保VS环境一键编译,整体大小为190.99MB。已有734人学习下载,资源提供可直接运行的exe程序、清晰分层的代码结构(如GameDlg.cpp主控逻辑、LLKDlg.cpp界面响应)、原创美术资源与完整构建产物(obj/pdb等),便于读者逆向分析数据存储设计(如二维数组建模棋盘、链表管理待消元素)、调试搜索算法实现细节,并迁移应用于其他消除类游戏开发。

1. 项目概述:从游戏到数据结构实战

“欢乐连连看”这个项目,对于计算机专业的学生来说,绝不仅仅是一个简单的游戏复刻。它本质上是一个绝佳的、综合性极强的数据结构与算法实战沙盒。当你拿到“武汉理工大学数据结构综合实验-欢乐连连看”这个标题时,其核心价值已经超越了“做一个能玩的游戏”本身。它要求你将《数据结构》课本上那些抽象的概念——栈、队列、图、递归、搜索算法——在一个具体、有趣且可视化的场景中融会贯通。

这个实验的深层目标,是考察你如何运用数据结构作为“工具”,去解决一个复杂的、多步骤的工程问题。你需要设计高效的数据模型来存储棋盘状态,实现核心的连通性判定算法,管理用户操作与游戏逻辑的流程,并最终呈现出一个交互流畅的程序。整个过程,是对你分析问题、设计解决方案、编码实现和调试优化能力的一次全面检验。无论你是正在备战数据结构课程设计,还是希望找一个项目来巩固算法基础,这个实验都能提供一条清晰、有趣且富有挑战性的实践路径。

2. 核心需求与设计思路拆解

2.1 需求功能全景图

一个完整的“欢乐连连看”程序,需要拆解为以下几个核心模块,每个模块都对应着特定的数据结构与算法需求:

  1. 游戏地图生成与初始化:这是所有逻辑的基石。需要创建一个 M x N 的二维棋盘,并随机或按规则填充若干种图案。关键在于,填充后的棋盘必须保证至少存在一对可以消除的图案,否则游戏开局即“死局”。这涉及到图的连通性预判或回溯生成算法。

  2. 核心连通性判定算法:这是项目的“灵魂”。给定两个坐标的图案,判断它们能否通过不超过两次(或三次,根据规则)的直线拐弯相连,且路径不被其他图案阻挡。这是对广度优先搜索(BFS)或深度优先搜索(DFS)算法的经典应用场景,路径搜索的空间就是棋盘网格。

  3. 用户交互与状态管理:处理鼠标点击或触摸事件,记录玩家选中的两个图案。需要管理游戏状态(等待选择、已选一个、成功消除、无解提示等)。这里通常使用变量和标志位来管理,但背后的操作记录(如撤销功能)可能会用到栈。

  4. 消除与刷新逻辑:当一对图案被判定为可消除时,需要将其从棋盘数据中移除,并可能触发上方图案的下落填充,以及从顶部补充新图案。这个过程模拟了“重力”效果,是对数组或链表操作的集中体现。

  5. 胜负判定与辅助功能:包括计时、计分、提示(自动寻找一对可消除图案)、洗牌(重新排列剩余图案)等功能。提示功能需要遍历当前棋盘所有可能的图案对,调用连通性判定算法,这直接考验算法的效率。

2.2 数据结构选型背后的逻辑

为什么选择这些数据结构?这取决于它们各自的特性和我们要解决的问题:

  • 二维数组(或向量)作为棋盘模型:这是最直观的选择。board[i][j]可以直接表示第 i 行、第 j 列的图案编号(0表示空格)。其随机访问(O(1)时间复杂度)的特性,对于频繁查询任意位置状态的操作至关重要。
  • 队列(BFS)用于路径搜索:在实现连通性判定时,BFS 比 DFS 更适合寻找最短路径(拐弯最少)。我们将搜索的“当前位置”和“已拐弯次数”封装成一个节点,放入队列。BFS 能保证首先找到的可行路径就是拐弯最少的,这符合游戏规则和玩家直觉。
  • 栈用于实现撤销功能:这是一个加分项。每次成功的消除操作,可以将操作信息(消除的两个坐标、原来的图案类型、得分等)压入栈中。当玩家点击“撤销”时,从栈顶弹出信息,恢复棋盘状态和分数。栈的“后进先出”特性完美匹配操作回退的顺序。
  • 图的思想用于分析整体连通性:在实现“提示”或判断“死局”时,我们可以将棋盘抽象为一个图。每个有图案的格子是一个顶点,如果两个格子满足直接相邻(可通过0拐弯连接),则在它们之间连一条边。通过遍历这个隐式图,可以更高效地分析全局的可消除对,虽然在本项目中不一定显式构建邻接矩阵,但运用的思想是相通的。

3. 核心算法深度解析:连通性判定的三种实现

连通性判定是核心中的核心。规则通常约定:两个相同的图案,如果能用不超过3条直线段连接(即最多拐两个弯),且线段经过的格子均为空格或端点本身,则可以被消除。

3.1 基础方案:分类讨论法(适合入门理解)

这是最直观的方法,将连接方式分为三类:

  1. 0拐弯(直线连接):检查两个点是否在同一行或同一列,且中间所有格子均为空。
  2. 1个拐弯(L型连接):假设拐点为C。那么A到C必须直线连通且C点为空,同时C到B也必须直线连通。只需遍历所有可能的C点(A的行、B的列 与 A的列、B的行的交点)进行判断。
  3. 2个拐弯(Z型或U型连接):可以理解为存在两个拐点C和D,使得A->C(直),C->D(直),D->B(直)均连通,且C、D均为空。这需要两次遍历寻找可能的中间线。

注意:这种方法逻辑清晰,但代码实现会包含大量的重复性方向检查和条件判断,在棋盘较大时效率不是最优,且扩展到更多拐弯规则时不易维护。

3.2 标准方案:广度优先搜索(BFS)法

这是更通用和优雅的解决方案。我们将搜索状态定义为(x, y, direction, turnCount),表示当前搜索到格子(x, y),是从某个方向direction过来的,已经拐弯turnCount次。

算法步骤:

  1. 将起点A的四个方向(上、下、左、右)的相邻且为空或为终点B的格子作为初始状态,加入队列。此时turnCount=0
  2. 从队列中取出一个状态。
  3. 如果该状态的位置就是终点B,则找到路径,返回成功。
  4. 否则,从该位置向四个方向扩展:
    • 如果新方向与当前状态的方向不同,则意味着拐弯,newTurnCount = turnCount + 1。如果newTurnCount > 2(最大允许拐弯数),则放弃此方向。
    • 如果方向相同,newTurnCount不变。
    • 检查新位置(nx, ny)是否在棋盘内,且是否为空格(或终点B)。
    • 如果满足条件,则将新状态(nx, ny, newDirection, newTurnCount)加入队列,并标记该位置已访问(避免重复循环)。
  5. 重复步骤2-4,直到队列为空。若队列空仍未找到B,则判定为不可连通。

BFS的优势:它能系统性地探索所有可能路径,并且首次到达终点B的路径一定是拐弯最少的(最短)路径,完全符合游戏规则。代码结构统一,易于理解和调试。

3.3 优化方案:预存空格连通性

这是一种空间换时间的策略,适合对性能要求极高的场景(如超大棋盘或需要实时频繁提示)。

核心思想:在棋盘状态不变时,预先计算出一个“空位连通图”。对于每个空格子,我们知道它向上、下、左、右四个方向能直接延伸到的边界(直到遇到图案或棋盘边)。这样,判断A和B能否连接时:

  • 检查A和B是否在同一行或同一列且直接连通(0拐弯)。
  • 检查是否存在一个空格子C,使得A和C在同一行直接连通,且C和B在同一列直接连通(1个拐弯)。这个检查可以通过查询A的行连通区域和B的列连通区域是否有交集快速完成。
  • 2个拐弯的情况类似,寻找两个空格子C和D,使得A->C(直),C->D(直),D->B(直)。这可以通过预存的连通区域进行快速交集判断。

这种方法将路径搜索的复杂度大幅降低,但代价是每次棋盘发生变化(消除、下落、填充)后,都需要更新这个预存信息,增加了数据维护的复杂性。

4. 详细实现步骤与关键代码剖析

我们以使用C++语言和BFS算法为例,勾勒核心实现框架。

4.1 数据结构定义与棋盘初始化

#include <iostream> #include <vector> #include <queue> #include <cstdlib> #include <ctime> using namespace std; const int ROWS = 10; // 棋盘行数 const int COLS = 12; // 棋盘列数 const int EMPTY = 0; // 空格子标识 const int PATTERN_TYPES = 8; // 图案种类数 vector<vector<int>> board(ROWS, vector<int>(COLS, EMPTY)); // 方向数组:上、下、左、右 const int dirs[4][2] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}; struct SearchNode { int x, y; // 当前坐标 int dir; // 来自的方向 (0,1,2,3 对应 dirs索引,-1表示起点) int turnCount; // 已拐弯次数 SearchNode(int _x, int _y, int _dir, int _tc) : x(_x), y(_y), dir(_dir), turnCount(_tc) {} }; // 初始化棋盘,保证有解 void initBoard() { srand(time(0)); // 首先,保证图案成对出现 vector<int> patterns; for(int i = 0; i < ROWS * COLS / 2; ++i) { int pattern = rand() % PATTERN_TYPES + 1; // 图案编号从1开始 patterns.push_back(pattern); patterns.push_back(pattern); // 放入一对 } // 如果格子数是奇数,随机再加一个图案(会有一个无法配对的,游戏后期处理) if((ROWS * COLS) % 2 != 0) { patterns.push_back(rand() % PATTERN_TYPES + 1); } // 随机打乱并填入棋盘 random_shuffle(patterns.begin(), patterns.end()); int idx = 0; for(int i = 0; i < ROWS; ++i) { for(int j = 0; j < COLS; ++j) { if(idx < patterns.size()) { board[i][j] = patterns[idx++]; } } } // 简易有解性检查:这里可以加入一个回溯算法,确保初始棋盘至少有一对可消。 // 为简化,此处省略,实践中强烈建议实现。 }

4.2 BFS连通性判定函数实现

bool canConnect(int x1, int y1, int x2, int y2) { if (board[x1][y1] != board[x2][y2] || board[x1][y1] == EMPTY) { return false; // 图案不同或为空,直接失败 } if (x1 == x2 && y1 == y2) { return false; // 同一个点 } // 访问标记数组,记录以某种状态(位置+拐弯数)是否被访问过,避免重复搜索 // visited[x][y][k] 表示在位置(x,y)且已拐弯k次的状态是否被访问 vector<vector<vector<bool>>> visited(ROWS, vector<vector<bool>>(COLS, vector<bool>(3, false))); queue<SearchNode> q; // 将起点四周可直达的格子作为初始状态入队 for(int i = 0; i < 4; ++i) { int nx = x1 + dirs[i][0]; int ny = y1 + dirs[i][1]; // 检查是否可以向该方向走一步(位置合法且为空格或终点) while(nx >= 0 && nx < ROWS && ny >=0 && ny < COLS && board[nx][ny] == EMPTY) { // 如果是空格,可以作为路径的一部分,继续朝这个方向探索更远 // 这里BFS的精妙之处在于,我们把从起点直线可达的所有空格都作为“一步”状态入队 // 但实际上,对于连通性,我们更关心拐点。一个更标准的做法是: // 将起点自身作为一个状态入队,dir=-1, turnCount=0。 // 下面我们采用更标准的写法: } } // 更清晰标准的BFS初始化: q.push(SearchNode(x1, y1, -1, 0)); // 起点,方向为-1,拐弯0次 visited[x1][y1][0] = true; while(!q.empty()) { SearchNode cur = q.front(); q.pop(); // 向四个方向探索 for(int i = 0; i < 4; ++i) { int nx = cur.x + dirs[i][0]; int ny = cur.y + dirs[i][1]; int newTurnCount = cur.turnCount; // 判断是否拐弯:当前方向与上一方向不同且不是起点 if(cur.dir != -1 && cur.dir != i) { newTurnCount = cur.turnCount + 1; } // 拐弯次数超限,跳过 if(newTurnCount > 2) { continue; } // 检查新位置是否合法 while(nx >= 0 && nx < ROWS && ny >=0 && ny < COLS) { // 如果新位置是终点 if(nx == x2 && ny == y2) { return true; } // 如果新位置不是空格,则这个方向被阻挡,中断while循环 if(board[nx][ny] != EMPTY) { break; } // 新位置是空格,且该状态未被访问,则入队 if(!visited[nx][ny][newTurnCount]) { visited[nx][ny][newTurnCount] = true; q.push(SearchNode(nx, ny, i, newTurnCount)); } // 继续沿该方向直线前进 nx += dirs[i][0]; ny += dirs[i][1]; } } } return false; // 队列空,未找到路径 }

实操心得:BFS实现中的关键点是“状态”的定义和“访问标记”。必须将(x, y, turnCount)作为一个复合状态来标记已访问,而不是仅仅标记(x, y)。因为从不同路径、以不同拐弯数到达同一个格子,其后续的搜索潜力是不同的。如果只标记位置,可能会错误地剪掉一条拐弯更少但后续能到达终点的路径。

4.3 消除与棋盘刷新逻辑

当判定两个格子(x1, y1)(x2, y2)可连通后,执行消除:

void eliminateAndRefresh(int x1, int y1, int x2, int y2) { // 1. 消除图案 board[x1][y1] = EMPTY; board[x2][y2] = EMPTY; // 2. 处理每一列,让上面的图案下落(模拟重力) for (int j = 0; j < COLS; ++j) { int writeIdx = ROWS - 1; // 从该列底部开始写入 // 从下往上遍历,将非空格子向下移动 for (int i = ROWS - 1; i >= 0; --i) { if (board[i][j] != EMPTY) { board[writeIdx][j] = board[i][j]; if (writeIdx != i) { // 如果不是原地,则清空原位置 board[i][j] = EMPTY; } writeIdx--; } } // 此时,writeIdx指向的是从下往上数第一个待填充的空格上方。 // 3. 从顶部补充新图案(可选规则,也可以不从顶部补充) // 假设我们从顶部随机生成新图案填充空白区域 for (int i = writeIdx; i >= 0; --i) { board[i][j] = rand() % PATTERN_TYPES + 1; // 生成1~PATTERN_TYPES的图案 } } }

5. 功能扩展与高级实现技巧

5.1 实现智能提示(Hint)功能

提示功能需要遍历当前棋盘上所有非空格子,找出任意一对可连通的相同图案。

朴素实现(效率较低但直观):

bool findHint(int &x1, int &y1, int &x2, int &y2) { // 收集所有有图案的格子 vector<pair<int, int>> cells; for(int i = 0; i < ROWS; ++i) { for(int j = 0; j < COLS; ++j) { if(board[i][j] != EMPTY) { cells.push_back({i, j}); } } } // 双重循环遍历所有格子对 for(int i = 0; i < cells.size(); ++i) { for(int j = i + 1; j < cells.size(); ++j) { int r1 = cells[i].first, c1 = cells[i].second; int r2 = cells[j].first, c2 = cells[j].second; if(board[r1][c1] == board[r2][c2] && canConnect(r1, c1, r2, c2)) { x1 = r1; y1 = c1; x2 = r2; y2 = c2; return true; } } } return false; // 未找到可消除对 }

优化思路:可以按图案类型将坐标分组,只对同类型图案进行两两判断。对于大型棋盘,提示功能调用频繁,canConnect函数本身的效率至关重要,因此BFS的优化或采用预存连通性方法就很有价值。

5.2 实现洗牌(Shuffle)功能

当玩家点击洗牌时,需要将棋盘上剩余的图案随机打乱,但必须保证打乱后至少存在一对可消除(否则游戏卡死)。

安全洗牌算法:

  1. 收集当前棋盘所有图案到一个一维数组。
  2. 使用std::random_shufflestd::shuffle打乱数组。
  3. 将打乱后的数组按行优先顺序填回棋盘。
  4. 关键步骤:调用一次findHint或专门的“有解性判定”函数,检查新棋盘是否有解。如果无解,则回到步骤2重新打乱。为了避免死循环,可以设置最大重试次数(例如100次),若仍无解,则可以考虑主动生成一对相邻的可消除图案插入棋盘。

5.3 实现撤销(Undo)功能

使用栈来记录操作历史。

struct Operation { int x1, y1, x2, y2; int pattern1, pattern2; int scoreDelta; }; stack<Operation> historyStack; // 执行消除时,记录操作 void performElimination(int x1, int y1, int x2, int y2) { Operation op; op.x1 = x1; op.y1 = y1; op.pattern1 = board[x1][y1]; op.x2 = x2; op.y2 = y2; op.pattern2 = board[x2][y2]; op.scoreDelta = calculateScore(); // 计算本次得分 historyStack.push(op); // ... 执行实际的消除和刷新逻辑 } // 撤销操作 void undo() { if(historyStack.empty()) return; Operation op = historyStack.top(); historyStack.pop(); // 恢复棋盘状态(这里需要逆操作,比较复杂) // 1. 恢复两个位置的图案 board[op.x1][op.y1] = op.pattern1; board[op.x2][op.y2] = op.pattern2; // 2. 撤销因“重力下落”和“顶部填充”带来的影响。 // 这需要记录更复杂的状态(如整个列的变化),或采用“操作命令”模式保存每一步的逆操作。 // 实现完整的撤销是挑战性较高的部分。 }

注意事项:撤销功能的完整实现复杂度高,因为简单的消除会影响整列的布局。一种简化方案是只允许撤销上一次操作,并且在撤销时,将棋盘恢复到消除前的精确快照(这需要保存整个棋盘的副本),但这会消耗较多内存。另一种方案是记录导致棋盘变化的每一个原子操作(如某个格子从图案A变为空,或从空变为图案B),撤销时反向执行这些原子操作。

6. 常见问题、调试技巧与性能优化

6.1 开发与调试中的常见“坑”

  1. 数组越界:在BFS中向四个方向探索时,务必先检查新坐标(nx, ny)是否在[0, ROWS-1][0, COLS-1]范围内,这是最常见的崩溃原因。
  2. 死循环或无限递归:如果使用DFS且未设置正确的访问标记或递归基线,容易导致栈溢出。BFS中如果忘记标记已访问状态,也会导致队列无限膨胀。
  3. 连通性判定逻辑错误:特别是对于“拐弯”的定义和计数。确保你的算法在路径是直线时拐弯数为0,在改变方向时拐弯数+1。仔细检查边界条件,例如起点和终点相邻的情况。
  4. 棋盘状态不一致:消除后,下落和填充逻辑有bug,可能导致某些格子未被正确清空或填充,出现图案错乱。在每次棋盘变动后,打印整个棋盘状态进行可视化调试,是非常有效的手段。
  5. 有解性判断缺失:随机生成的初始棋盘或洗牌后的棋盘可能无解。必须在这些操作后加入有解性检查,否则游戏可能无法进行下去。

6.2 性能优化点

  1. BFS的访问标记:使用三维数组visited[ROW][COL][MAX_TURN+1]是标准的,但可能会占用较大内存。如果棋盘很大,可以考虑使用unordered_set来存储复合状态的哈希值,但查询会稍慢。
  2. canConnect函数的提前剪枝:在BFS开始前,可以先进行快速失败判断:
    • 如果两个点中有一个是空格,直接返回false
    • 如果两个点图案不同,直接返回false
    • 可以计算两个点的曼哈顿距离(|x1-x2| + |y1-y2|),如果这个距离所需的直线段数量(拐弯数+1)已经超过允许的最大拐弯数+1,也可以提前返回false。但这只是一个粗略估计。
  3. 提示功能的优化:不要每次请求提示都全盘扫描。可以:
    • 在每次棋盘变化后,异步计算或更新一个“可消除对”的列表。
    • 按图案类型分组检查。
    • canConnect函数设计得尽可能快,这是根本。
  4. 绘图与逻辑分离:如果你使用了图形库(如EasyX, SDL等),确保将游戏逻辑计算(棋盘数据、连通性判断)与渲染绘制分离。避免在渲染循环中进行复杂的计算,导致界面卡顿。通常是在事件触发(如点击)或独立的逻辑更新线程中进行计算。

6.3 测试用例设计

设计全面的测试用例是保证程序健壮性的关键:

测试场景输入预期结果检查点
基本连通水平相邻相同图案可消除0拐弯判断
基本连通垂直相邻相同图案可消除0拐弯判断
一拐弯连接形成L型的两个相同图案可消除1拐弯判断
二拐弯连接形成Z型或U型的两个相同图案可消除2拐弯判断
超过拐弯限制需要3个拐弯才能连不可消除拐弯次数限制
路径被阻中间有其它图案挡路不可消除路径空格检查
自我消除点击同一个图案两次不可消除坐标相同处理
消除空格点击空白格子无反应/提示空格处理逻辑
边界测试点击棋盘最边角的图案连通性正常数组越界预防
消除后下落消除中间一行的两个图案上方图案正确下落重力模拟逻辑
填充新图案消除后顶部出现空位空位被新图案填充填充逻辑
胜负判定棋盘被清空游戏胜利,结束状态机切换
胜负判定棋盘无任何可消除对游戏结束/触发洗牌死局检测

我个人在实现这个项目时,最大的体会是“分而治之”和“可视化调试”。不要试图一口气写完所有功能。先搭建一个静态的棋盘并显示出来,然后实现最简单的直线消除,再逐步增加拐弯判断、下落效果、提示功能。每完成一个步骤,都通过打印棋盘或简单的图形输出进行验证。遇到复杂的Bug时,在关键函数入口打印参数,在循环中打印中间状态,这些看似“笨”的方法往往比单纯盯着代码思考更有效。最后,当你看到自己编写的程序能够流畅地运行,完成一个个图案的消除时,那种将理论知识转化为实际成果的成就感,正是这个综合实验最宝贵的收获。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询