拓冰建站拓冰建站
首页 / 资讯中心 / 正文

深度优先搜索(DFS)与广度优先搜索(BFS)在连通块计数中的核心原理与实现

1. 项目概述与问题引入“连通块”这个概念对于任何一个接触过图论或者搜索算法的朋友来说都像是一道绕不开的坎。它本身并不复杂但却是理解图结构、掌握深度优先搜索DFS和广度优先搜索BFS等基础算法的绝佳练兵场。题目“信息学奥赛一本通 1335【例2-4】连通块”就是一个非常经典的入门例题它要求我们统计一个二维矩阵中由相邻的‘1’所组成的连通区域的数量。这听起来简单但新手在实际编码时往往会卡在递归的边界条件、访问标记的时机甚至是输入输出的细节上。我自己在带学生刷题时发现至少有七成的同学第一次无法一次性AC通过所有测试用例问题就出在对“连通”的理解和搜索过程的细节把控上。这个题目的核心价值在于它剥离了复杂的应用场景直指算法核心如何系统性地遍历一个图的所有节点并正确地区分不同的连通分量。无论是之后要学的岛屿问题、图像处理中的区域填充还是社交网络中的社群发现其底层逻辑都与此一脉相承。因此吃透这道题不仅仅是解决一个OJ在线评测问题更是为后续更复杂的图论和搜索问题打下坚实的基础。接下来我将以一个过来人的视角拆解这道题的每一个关键环节分享从思路构建到代码实现再到调试优化的完整心路历程并提供一些教材和标准题解里通常不会提及的“坑点”和技巧。2. 核心思路与算法选型分析2.1 问题本质抽象首先我们需要把题目描述从具体的“矩阵”、“单元格”中抽象出来。给定一个n * m的矩阵矩阵中的元素是0或1。我们把值为1的单元格视为一个“节点”。如果两个1单元格在上下左右四个方向之一上是相邻的那么我们就认为这两个节点之间存在一条“边”。这样整个矩阵就转化成了一个无向图。我们的任务就是计算这个无向图中连通分量的个数。所谓连通分量就是指图中任意两个节点之间都存在路径的最大子图。在这个问题里一个由相邻1组成的独立区域就是一个连通分量。2.2 算法对比DFS vs BFS解决连通块计数最主流、最直观的两种方法就是深度优先搜索DFS和广度优先搜索BFS。两者在这个问题上都能完美解决但在实现细节和适用场景上略有不同。深度优先搜索DFS的思路是“一条路走到黑再回头”。从某个未被访问的1出发尽可能深地探索它的邻居直到无路可走然后回溯继续探索其他分支。这个过程天然适合用递归来实现代码非常简洁优雅几乎是对问题定义的直接翻译。对于新手理解递归栈的调用过程是掌握DFS的关键。广度优先搜索BFS的思路是“层层推进”。从某个未被访问的1出发先访问它所有的直接邻居然后再访问这些邻居的邻居以此类推。这需要借助一个队列Queue数据结构来实现。BFS的优点是它按照距离起点的层次进行遍历在某些需要最短路径信息的变体问题中更有优势。为什么我推荐初学者先用DFS代码简洁递归实现的DFS通常比手动维护队列的BFS代码量更少逻辑更集中。思维直接“探索一个块”的过程用递归描述非常自然“我在这里我去看看上面是不是‘1’且没看过如果是我就‘跳过去’做同样的事情...”。栈空间考量对于本题常规的数据范围比如n, m 100递归深度最多可能达到10000这在大多数评测环境栈空间通常8MB或更大下是安全的。但这是一个重要的注意点后面会详细讨论。BFS的用武之地当矩阵非常大如1000*1000递归深度可能导致栈溢出时使用基于队列的BFS或迭代DFS是更安全的选择。因为它使用的队列是在堆内存上空间限制更宽松。注意无论是DFS还是BFS核心都是在访问一个节点后立即将其标记为“已访问”这是避免重复访问和无限循环的重中之重。很多同学的错误都源于标记时机不对。2.3 方向数组让代码更整洁在遍历上下左右四个方向时硬编码四种情况(x-1, y),(x1, y),(x, y-1),(x, y1)会使代码冗长且容易出错。标准的技巧是使用一个方向数组。// 常用的四个方向上、下、左、右 int dx[4] {-1, 1, 0, 0}; int dy[4] {0, 0, -1, 1};这样在搜索函数中只需要一个循环就可以尝试所有方向for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; // 检查(nx, ny)是否合法且为未访问的‘1’ }这种方法极大提高了代码的可读性和可维护性也是竞赛编程中的通用惯例。3. 详细实现步骤与代码解析接下来我们以C为例采用递归DFS来实现并逐行解析。我会假设输入格式为第一行两个整数n和m接下来n行每行m个字符‘0’或‘1’字符间无空格。3.1 全局变量与数据结构定义#include iostream #include cstring // 用于memset using namespace std; const int MAXN 105; // 根据题目数据范围设定稍大一些避免边界问题 char grid[MAXN][MAXN]; // 存储地图 bool visited[MAXN][MAXN]; // 访问标记数组 int n, m; // 地图的行数和列数 // 方向数组 int dx[4] {-1, 1, 0, 0}; int dy[4] {0, 0, -1, 1};定义解析MAXN常量定义数组大小。通常设为题目最大范围5防止因粗心导致的数组越界。grid字符型二维数组存储输入的地图。这里用char而不是int是因为输入可能没有空格用cin grid[i]可以一次性读入一行字符串非常方便。visited布尔型二维数组初始化为false。记录每个单元格是否已经被探索过是避免重复计数的关键。dx, dy方向数组如前所述。3.2 深度优先搜索DFS函数实现这是整个程序的核心。void dfs(int x, int y) { // 1. 标记当前节点为已访问 visited[x][y] true; // 2. 遍历四个方向 for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; // 3. 判断新位置(nx, ny)是否有效 // 条件1) 在地图范围内 2) 是陆地(1) 3) 未被访问过 if (nx 0 nx n ny 0 ny m grid[nx][ny] 1 !visited[nx][ny]) { dfs(nx, ny); // 递归探索 } } // 4. 函数结束自动回溯到上一层调用 }关键点解析标记时机进入dfs函数后第一件事就是标记visited[x][y] true。绝对不能在递归调用dfs之后再标记否则在多个路径指向同一个节点时会导致该节点被重复访问可能引发栈溢出或逻辑错误。边界检查if (nx 0 nx n ny 0 ny m ...)这是保证数组访问不越界的生命线。务必注意是 n和 m而不是。递归调用只有当新位置(nx, ny)完全满足所有条件时才进行递归调用。递归就像“分身”当前函数的状态变量x, y, i会被系统保存直到递归返回。3.3 主函数逻辑与连通块计数主函数负责组织整个流程读入数据、遍历地图、启动搜索、计数。int main() { // 读入地图规模 cin n m; // 读入地图数据 for (int i 0; i n; i) { cin grid[i]; // 一次性读入第i行字符串 } // 初始化访问数组虽然全局变量默认初始化为false但显式初始化是好习惯 memset(visited, false, sizeof(visited)); int componentCount 0; // 连通块计数器 // 遍历地图的每一个格子 for (int i 0; i n; i) { for (int j 0; j m; j) { // 如果找到一个未被访问的1说明发现了一个新的连通块 if (grid[i][j] 1 !visited[i][j]) { componentCount; // 块数加一 dfs(i, j); // 调用DFS把这个块的所有‘1’都标记为已访问 } } } // 输出结果 cout componentCount endl; return 0; }主函数逻辑拆解双重循环遍历这是寻找“种子”的过程。我们检查每一个单元格。发现新块的判断条件grid[i][j] 1 !visited[i][j]。这个条件至关重要。它是我们计数的基础。只有当一个1单元格尚未被任何之前的DFS过程访问过时它才属于一个新的连通块。计数与搜索的联动每当发现一个新块的“种子”我们先增加计数器componentCount然后再调用dfs(i, j)。这个顺序不能反。DFS的任务不是计数而是“染色”或“标记”把这个连通块内所有的1都通过visited数组标记出来这样外层循环在遍历到这些位置时条件!visited[i][j]就不满足了不会被重复计数。输入输出这里使用了cin grid[i]来读入一行前提是输入中数字之间没有空格。如果题目输入是带空格的数字则需要用cin grid[i][j]此时grid应定义为int型。4. 关键细节、易错点与性能分析4.1 访问标记的陷阱这是最常见的错误。错误做法示例void dfs_wrong(int x, int y) { // 错误先递归再标记 for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; if (nx 0 nx n ny 0 ny m grid[nx][ny] 1 !visited[nx][ny]) { dfs_wrong(nx, ny); // 递归进去了但当前(x,y)还没标记 } } visited[x][y] true; // 标记得太晚了 }假设从A点开始A连接到B。错误的流程是dfs_wrong(A)进入发现B未访问调用dfs_wrong(B)。在dfs_wrong(B)中它又会检查四个邻居包括A。此时A在visited中仍然是false因为标记在递归之后于是dfs_wrong(B)可能又会尝试调用dfs_wrong(A)。这会导致相互递归调用最终栈溢出Stack Overflow。正确做法牢记一脚踩进新格子立刻插上“我已来过”的旗子标记visited。4.2 递归深度与栈溢出对于n100, m100的全1矩阵最坏情况下递归深度可能达到10000。每次函数调用都需要在栈上分配空间保存返回地址、参数和局部变量。10000层的递归对栈空间是一个考验。大多数在线评测系统如一本通配套的OJ的栈空间足够大通常8MB或更多可以安全应对。但在某些环境或极端数据下这可能导致栈溢出。如果遇到Runtime Error (SIGSEGV)且怀疑是栈溢出可以尝试以下方法改用BFS将递归DFS改为用队列实现的BFS彻底避免深递归。手动扩大栈空间编译器选项在本地调试时对于G可以在编译时加入-Wl,--stack268435456来设置栈大小为256MB。但这在OJ上通常不可行。迭代DFS用自己定义的栈Stack来模拟递归过程将堆内存作为栈使用。BFS版本代码片段供参考void bfs(int startX, int startY) { queuepairint, int q; q.push({startX, startY}); visited[startX][startY] true; while (!q.empty()) { auto [x, y] q.front(); q.pop(); for (int i 0; i 4; i) { int nx x dx[i]; int ny y dy[i]; if (nx 0 nx n ny 0 ny m grid[nx][ny] 1 !visited[nx][ny]) { visited[nx][ny] true; q.push({nx, ny}); } } } }在主函数中调用bfs(i, j)代替dfs(i, j)即可。4.3 输入格式的适应性题目没有明确说明输入数字间是否有空格。上述代码按无空格的字符矩阵处理。如果输入是带空格的0/1整数矩阵读入部分需要修改// 将 grid 定义为 int 类型 int grid[MAXN][MAXN]; for (int i 0; i n; i) { for (int j 0; j m; j) { cin grid[i][j]; // 直接读入整数 0 或 1 } } // 判断条件也要相应修改 if (grid[nx][ny] 1 !visited[nx][ny]) { ... }经验之谈在竞赛中仔细阅读输入输出格式描述是第一要务。如果不确定可以观察样例输入的形式。像“连通块”这类经典题字符型无空格输入更为常见因为它更节省输入体积且符合矩阵的视觉直观。4.4 方向数组的扩展八连通问题本题是“四连通”上下左右。还有一种常见变体是“八连通”包括对角线方向。此时方向数组需要扩展为8个方向// 八连通方向数组上、下、左、右、左上、右上、左下、右下 int dx8[8] {-1, 1, 0, 0, -1, -1, 1, 1}; int dy8[8] {0, 0, -1, 1, -1, 1, -1, 1};算法逻辑完全不变只需在DFS/BFS的循环中将4改为8并使用对应的方向数组即可。这体现了方向数组设计模式的优越性核心算法不变只需修改数据。5. 调试技巧与常见问题排查即使思路清晰代码也可能因为各种细节问题无法AC。下面是一个基于我个人经验的调试清单。5.1 常见错误类型与解决方法错误现象可能原因排查与解决方法输出结果比预期少1.visited数组未初始化或初始化错误。2. 判断新位置(nx, ny)时边界条件写错如ny m。3. 读入数据时行列n, m顺序弄反或使用了错误的索引。1. 使用memset(visited, 0, sizeof(visited))或循环初始化。2. 仔细检查if条件确保是nx n和ny m。3. 打印出读入后的grid矩阵前几行确认数据是否正确加载。输出结果比预期多1. 访问标记visited[x][y] true的时机太晚如在递归之后。2. 在DFS函数中错误地多次标记了同一个起点如在循环前标记了循环内又标记了邻居。1.确保dfs函数入口第一句就是标记。2. 检查标记语句确保每个节点只在被发现时标记一次。运行时错误RE1.栈溢出递归深度太深。2.数组越界dx/dy数组访问越界或nx, ny计算后未检查边界就直接作为数组下标。3. 输入数据规模超出定义的MAXN。1. 尝试BFS解法。2.严格检查所有数组下标的有效性特别是nx, ny。3. 确认MAXN常量是否大于题目给出的最大n和m。超时TLE1. 算法时间复杂度本身是O(n*m)对于本题范围不可能超时。如果超时极可能是死循环。2. 死循环的原因访问标记逻辑错误导致两个点互相递归调用或BFS中同一个节点被重复加入队列。1. 在小数据上模拟运行使用IDE调试器或打印关键变量如x, y, nx, ny观察程序流向。2. 再次审视visited标记的时机和条件。5.2 实用的调试方法小数据测试自己设计一个小的测试用例比如3x3的矩阵用纸和笔模拟程序的运行一步步跟踪visited数组的变化和计数器的增加。打印中间状态在DFS函数开头或主函数循环中加入临时打印语句。void dfs(int x, int y) { cout Visiting: ( x , y ) endl; // 调试语句 visited[x][y] true; // ... }观察访问顺序是否符合预期。可视化visited数组在每次发现新连通块并完成DFS后打印出整个visited数组用1/0表示。可以清晰看到哪些区域被“染色”了。使用静态分析工具在本地编译时开启所有警告选项如-Wall -Wextra编译器可能会发现一些潜在问题如未使用的变量、有符号无符号比较等。5.3 关于“一本通”在线评测系统的特别提示“信息学奥赛一本通”的在线评测系统有时对格式要求非常严格。确保没有多余输出你的程序应该只输出一个整数连通块数不要输出任何提示性语句如cout 答案是 componentCount endl;。注意换行通常cout componentCount endl;即可。处理多组数据仔细看题目描述本题“【例2-4】”通常是单组数据。但如果题目要求处理到文件结束EOF则需要用while(cin n m)包裹主逻辑。这是很多初学者容易忽略而WA答案错误的地方。6. 算法扩展与变体思考掌握基础连通块计数后我们可以思考一些常见的变体问题这有助于深化理解。6.1 求最大连通块的面积问题不仅统计个数还要找出最大的连通块包含多少个1。解法在DFS或BFS过程中增加一个计数器。每次从一个新种子开始搜索时将计数器清零。在搜索函数中每访问一个有效的新节点计数器加1。搜索结束后用这个计数器的值更新全局最大面积变量。int currentArea 0; void dfs_area(int x, int y) { visited[x][y] true; currentArea; // 面积加1 for (int i 0; i 4; i) { int nx x dx[i], ny y dy[i]; if (nx 0 nx n ny 0 ny m grid[nx][ny] 1 !visited[nx][ny]) { dfs_area(nx, ny); } } } // 在主函数中 int maxArea 0; for (int i 0; i n; i) { for (int j 0; j m; j) { if (grid[i][j] 1 !visited[i][j]) { currentArea 0; // 重置当前块面积计数器 dfs_area(i, j); maxArea max(maxArea, currentArea); // 更新最大面积 } } } cout maxArea endl;6.2 标记不同的连通块问题给矩阵中不同的连通块标上不同的数字如2, 3, 4...。解法准备一个和地图一样大的int型id数组初始化为0。在遍历地图时如果遇到未访问的1则赋予一个新的编号从2开始在DFS过程中不仅标记visited还将这个编号写入id数组的对应位置。int componentId[MAXN][MAXN] {0}; // 连通块编号数组 int currentId 2; // 从2开始编号0和1已被地图占用 void dfs_id(int x, int y, int id) { visited[x][y] true; componentId[x][y] id; // 标记编号 for (int i 0; i 4; i) { int nx x dx[i], ny y dy[i]; if (nx 0 nx n ny 0 ny m grid[nx][ny] 1 !visited[nx][ny]) { dfs_id(nx, ny, id); // 传递相同的id } } } // 在主函数中 for (int i 0; i n; i) { for (int j 0; j m; j) { if (grid[i][j] 1 !visited[i][j]) { dfs_id(i, j, currentId); currentId; // 准备下一个编号 } } } // 输出 componentId 即可看到不同块被标上了不同数字6.3 基于并查集Union-Find的解法连通块问题本质是动态连通性问题也可以用并查集来解决。思路是将每个1的单元格视为一个独立的集合。遍历每个1单元格查看其右方和下方的邻居避免重复如果邻居也是1则将这两个单元格所在的集合合并。最后统计有多少个集合的根节点代表元对应的是1单元格即为连通块数。 这种方法的优点是在处理动态加边或需要持续查询连通性的场景下效率更高但在此静态问题中其代码复杂度高于DFS/BFS且不易直接输出每个连通块。作为初学者理解DFS/BFS是更优先的目标但知道有并查集这种替代方案可以拓宽视野。7. 从“连通块”到更广阔的应用这道题虽然简单但它蕴含的思想是许多复杂问题的基石。图像处理在二值图像中寻找所有的白色区域或黑色区域就是连通块标记问题。八连通更符合视觉上“区域”的定义。游戏开发在地图编辑器中检测所有可通行区域是否连通判断角色是否能从A点走到B点。网络分析在社交网络中每个用户是一个节点关注关系是边寻找连通分量就是在发现不同的社群。迷宫求解迷宫可以看作一个矩阵通路是1墙是0。判断起点和终点是否连通等价于判断它们是否在同一个连通块中。当你再遇到诸如“岛屿数量”、“被围绕的区域”、“朋友圈”等问题时你会发现它们都是“连通块”问题的“换皮”或升级。核心的搜索与标记框架是不变的变化的只是具体的条件和处理细节。因此花时间彻底理解并熟练实现这个基础模型性价比极高。在编写代码时养成好习惯清晰的定义、严谨的边界检查、即时的状态标记。这些习惯会让你在应对更复杂的问题时依然能保持代码的清晰和正确性。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门