ARTICLE DETAIL

资讯详情

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

数据结构玉米地实训:BFS最短路径与队列应用详解

数据结构玉米地实训:BFS最短路径与队列应用详解 第一次在头歌实践教学平台的实训列表里看到“玉米地”这三个字时我的第一反应是走错频道了一个数据结构课程实践怎么冒出这么田园的名字。等点进去读完题目才发现名字起得再随意考点一点不含糊——二维矩阵建模、图的遍历、队列栈的应用全都塞进这片玉米地了。今天就把我在这道题上的完整思路和踩坑过程拆开讲一遍包括题目该怎么读、BFS怎么选、代码怎么写才能一次过判题脚本以及这片玉米地背后到底藏着哪几类数据结构考点。不管你是刚学到队列的高校生还是准备考研408顺带刷实践题的同学这篇都值得花十分钟看完。1. 拿到题目先拆题玉米地到底在考什么1.1 题目原型的两种常见设定头歌上叫“玉米地”的数据结构实训不同学校、不同学期实际下发的内容会有些差异。我拿到的版本大概是这样的一个 n×m 的网格0 表示可以走的空地1 表示长着玉米的位置不可通行人要绕开玉米从左上角 (0,0) 出发走到右下角 (n-1,m-1)求最少需要多少步走不到就输出 -1。这个设定本质是迷宫寻路。除了这个版本我还见到过另一种设定统计整片玉米地里有几块连通的玉米区域。比如 1 表示有玉米0 表示空地四连通的相邻 1 算同一片最后输出玉米总片数。这两种都叫“玉米地”但解法完全不同前者是BFS最短路径后者是连通块计数DFS 或 BFS 都行。所以拿到题目第一步永远是先确认到底考的是哪种别上来就套模板。如果题目描述里出现“最少步数”“最短路径”那铁定是 BFS如果出现“共有几片”“连通区域”那就是遍历计数。1.2 输入输出规范和第一眼的直觉误区以我做的“迷宫寻路”版本为例输入格式很标准第一行两个整数 n m接下来 n 行每行 m 个整数0 或 1。输出一行一个整数表示从起点到终点的最短步数。第一眼直觉肯定是“这不就是套 BFS 模板吗”。确实算法层面就是模板但实践中很容易在小地方翻车。我归纳了三个最容易踩的点起点或终点本身就是玉米也就是 grid[0][0] 或 grid[n-1][m-1] 等于 1此时应该直接输出 -1。很多人的 BFS 框架里没有这个起手判断等队列跑空了才意识到问题。步数语义不统一。有的关卡把起点算作第 1 步有的按“移动了几次”计数也就是从 (0,0) 到相邻格子算 1 步还是 2 步的差别。我看过不少同学在讨论区里争论答案相差 1 的问题其实根源就是步数定义。我习惯在代码里把起点初始步数置为 1因为多数判题脚本按“走过的格子数”计数但保险做法是看一眼题目示例的输入输出反推一下。题目里有没有“多组输入直到 EOF”这句话。头歌的部分关卡会放多组测试数据循环读 n m 而不是只读一次如果漏了这层本地跑样例没问题一上判题系统就“运行错误”或“答案错误”。1.3 为什么这道题归到“数据结构”而不是“纯算法”很多人学到这里会困惑BFS 不是算法课的内容吗怎么跑到数据结构实训里来了。其实这道题是绝佳的“数据结构综合练习”因为它的底层存储是二维数组这种数组本身就是图的邻接矩阵表示而遍历过程需要队列BFS或栈DFS / 递归系统栈来维系visited 数组的标记本质上解决的是“节点去重”问题。说白了数据结构课真正要练的不是你会不会背 BFS 代码而是你能不能把一个具体问题抽象成“节点 边”的图模型然后选择合适的存储结构和遍历工具。玉米地就是这种能力的练兵场。2. 玉米地建模把二维格子抽象成图2.1 格子即节点邻接关系即边每一种图算法第一步都是建模。玉米地里每个坐标 (x, y) 就是一个节点上下左右四个方向能走到的格子就是它的邻居也就是四条边。整个 n×m 网格就是一张天然的邻接矩阵图——grid 本身就是矩阵边的权重恒为 1因为每走一步的成本就是一步。有一点初学者容易忽略我们并不是真的要为每个格子动态分配邻接表也不需要额外建一个 graph 结构体。二维数组 grid 已经承担了邻接矩阵的角色我们只需要在遍历时通过方向数组去“动态生成邻居”。这是这道题和后续图论题的一个重要区别——节省了建图时间也降低了理解门槛。2.2 方向数组和边界检查的写法遍历邻居的通用写法是准备一个方向数组int dir[4][2] { {-1, 0}, // 上 {1, 0}, // 下 {0, -1}, // 左 {0, 1} // 右 };方向顺序其实无所谓但养成“上下左右”固定排列的习惯后面做打印路径、调试时心智负担小很多。拿到当前节点后循环四个方向计算新坐标int nx cur.x dir[i][0]; int ny cur.y dir[i][1]; if (nx 0 || nx n || ny 0 || ny m) continue; // 越界 if (grid[nx][ny] 1) continue; // 玉米障碍 if (visited[nx][ny]) continue; // 已访问这里本质上处理了三种“非法邻居”越出矩阵网格、撞到玉米障碍、已经走过。很多第一次写的人会把三个判断条件写成一个长长的 if 加上 return其实用 continue 跳过法可读性更好也更贴近后续写多源 BFS 的习惯。另外边界判断里的 n 和 m 是全局变量时要特别小心数组下标别写成从 1 开始——网格的行列是从 0 开始的这是老手也偶尔犯的低级错误。2.3 四邻域和八邻域的取舍玉米地里能不能斜着走这是题目里最容易模糊的地方。如果不加说明默认是四邻域上下左右如果题目写“八个方向都可以走”那就是八邻域方向数组要扩成 8 个int dir[8][2] { {-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {-1, 1}, {1, -1}, {1, 1} };差别很大。八邻域下斜穿一个玉米格子的角是允许的而四邻域不行。举个例子有一条 1 组成的线斜着穿过网格四邻域里这条线是障碍八邻域里可以沿对角空隙穿过。如果题目描述里没说“斜着走”一律当四邻域处理。判题系统不会因为你“觉得可以斜着走”就网开一面老老实实按题意来。我吃过一次亏把八邻域写进题目只有四邻域的版本里结果多走了很多“斜路”最短步数偏小样例过了但评分脚本判错最后逐行核对才发现方向数组的问题。3. 为什么一定要用 BFS最短步数背后的队列逻辑3.1 广度优先的层次性和“首次到达即最短”边权恒为 1 的图上求最短路径BFS 是天然最优解原因是 BFS 按“广度”一层层向外扩展。可以想象成一块石头扔进池塘波纹一圈圈往外推每一圈都对应“走了 k 步能到达的所有位置”。当你第一次碰到目标格子时这一圈就是最短的层数因为更短的圈都已经扩展过了不可能漏掉。DFS 就不一样它是一条路走到黑碰壁了再回头换一条路。它可以找到一条从起点到终点的路径但这条路径完全取决于方向顺序和递归运气很可能不是最短的。强行用 DFS 做最短路径也不是不行那就要走遍所有路径并维护全局最小步数复杂度指数级上升在小矩阵上勉强跑一上大数据就超时。所以遇到“最少步数”四个字直接锁定 BFS。3.2 队列的逐层扩展手写循环队列比 STL 更可控BFS 的骨架就是“队列 出队遍历邻居 入队”。C 语言选手可以直接用 STL 的 queue也可以用数组模拟队列。我个人建议在练题阶段用数组模拟倒不是 STL 不好而是手写队列能让你真正看见 head 和 tail 是怎么移动的对“层”的概念体会更深。而且这类题目的节点总数最多 n×m 个循环队列也不用担心溢出#define MAXN 105 typedef struct { int x, y, step; } Node; Node queue[MAXN * MAXN]; int head 0, tail 0; void push(Node node) { queue[tail] node; } Node pop() { return queue[head]; } int empty() { return head tail; }入队是 tail 动出队是 head 动head 和 tail 相等时队列为空。这个朴素实现里没有做环形缓冲但因为每个节点最多入队一次总入队数有限所以数组开 MAXN×MAXN 一定够用不会出现浪费问题。3.3 visited 标记的时机入队时就要标记不是出队时这是 BFS 新手最容易栽的坑也是最值得详细说的一个点。错误示范是从队列里弹出一个节点时才把它标记为 visited然后遍历邻居如果邻居没被访问就入队。看起来没问题但实际会导致同一个节点被重复入队很多次。考虑两个相邻节点 A 和 B同一轮扩展时 A 发现 B 没被访问把 B 入队紧接着 B 从另一个路径也被发现又入队一次。等 B 真正出队时已经被标记过了却已经在队列里躺了两份浪费空间不说步数逻辑也可能出现混乱。正确做法是在把邻居入队的同时立刻标记 visited。这样任何后续节点再发现这个邻居时看到 visited 已经为真就不会重复入队。从数学上讲一个节点只需要被“第一次遇见”时入队一次这个第一次遇见一定是在最短层上发生的所以标记时机提前完全不影响正确性反而保证了每个节点只入队一次全算法复杂度稳定在 O(n×m)。4. 完整代码与平台提交实战4.1 从伪代码到能过判题的完整 C 实现理清思路后代码其实很紧凑。下面是我在“玉米地迷宫寻路”这一关上最终提交的版本注释我加得比较详细方便直接对照#include stdio.h #include string.h #define MAXN 105 int n, m; int grid[MAXN][MAXN]; int visited[MAXN][MAXN]; int dir[4][2] { {-1, 0}, {1, 0}, {0, -1}, {0, 1} }; typedef struct { int x, y, step; } Node; Node queue[MAXN * MAXN]; int head, tail; void push(Node node) { queue[tail] node; } Node pop() { return queue[head]; } int empty() { return head tail; } int bfs() { if (grid[0][0] 1 || grid[n-1][m-1] 1) { return -1; } memset(visited, 0, sizeof(visited)); head tail 0; push((Node){0, 0, 1}); visited[0][0] 1; while (!empty()) { Node cur pop(); if (cur.x n - 1 cur.y m - 1) { return cur.step; } for (int i 0; i 4; i) { int nx cur.x dir[i][0]; int ny cur.y dir[i][1]; if (nx 0 || nx n || ny 0 || ny m) { continue; } if (grid[nx][ny] 1) { continue; } if (visited[nx][ny]) { continue; } visited[nx][ny] 1; push((Node){nx, ny, cur.step 1}); } } return -1; } int main() { while (scanf(%d %d, n, m) ! EOF) { for (int i 0; i n; i) { for (int j 0; j m; j) { scanf(%d, grid[i][j]); } } printf(%d\n, bfs()); } return 0; }这个版本里我做了一个细节决策把起点判障碍放在 BFS 函数开头。如果你把它放在 main 里读入之后判断也完全可以但放函数里更聚合后面改成别的变体题目时只需要动函数内部逻辑。注意 while (scanf(...) ! EOF) 的写法这是为了兼容“多组输入”的隐藏数据即使题目只做单组也能正常跑不会多输出多余内容。4.2 头歌判题环境的输入输出习惯在头歌平台上提交有一类低级失误最容易让人抓狂题目框架里已经给出了函数比如int solve(int n, int m, int grid[][MAXN])要求你只补全函数体而不是写整个 main。这种情况下如果你照搬完整代码很可能编译不过或运行时报“undefined reference to main”之类的错。所以提交前先花十秒钟看准关卡要求需要补全函数只提交函数体别带 main 脱掉自己写的读入部分直接利用形参需要提交完整程序main 里输入输出格式必须和题目给的样例严格一致有些关卡要求输出后不带多余空格有些无所谓统一用 printf(%d\n, ans) 最稳妥。另一个很容易被忽视的是数组大小。题目给的范围也许只有 n, m ≤ 100但判题数据可能存在边界用例数组开大一两格是很有用的习惯。我把 MAXN 设为 105 就是给 100×100 的输入留出余量。如果是 n, m ≤ 1000 的大数据同样的代码思路也能用但 queue 和 grid 都需要换动态分配或开更大的静态数组否则内存不够或者栈溢出。4.3 本地调试与异常数据测试本地测试时别只拿着样例输入跑一遍就完事至少要自己构造几组特殊数据起点就是玉米3 3 1 0 0 0 0 0 0 0 0期望输出 -1。如果代码输出 1 或 0说明起手判断缺失。完全被玉米包围到不了终点3 3 0 1 1 1 1 1 0 0 0期望输出 -1。这组验证 BFS 跑完整个连通区域后能正常返回 -1而不是死循环。一步直达1 1 0期望输出 1。边界到只有单个格子的情况起点即终点很多人的代码卡死在这里因为循环里还没入队target就返回了。一条直线通路1 5 0 0 0 0 0期望输出 5。验证只往一个方向扩展时方向数组没有把右方向漏掉。我个人的习惯是把这四组数据存成一个 test.txt每次改完代码直接重定向跑一遍gcc corn_field.c -o corn_field ./corn_field test.txt配合同样存好的 expected.txt用 diff 比对结果改代码的效率会高很多。尤其是后来从“迷宫寻路”改成“连通块计数”时这组测试也能快速暴露我是不是把 visited 复用的逻辑写拧了。5. 玉米地的变体一个模板能打多少题5.1 连通块计数换成 DFS 反而更顺手如果题目变成“统计玉米地里有几片连续的玉米区域”核心逻辑就变成遍历整个矩阵遇到一个没访问过的 1 玉米格子说明发现了一片新区域计数加一然后沿着这个格子把所有四连通的相邻 1 全部标记为已访问。这种做法 DFS 写起来非常自然int cnt 0; for (int i 0; i n; i) { for (int j 0; j m; j) { if (grid[i][j] 1 !visited[i][j]) { cnt; dfs(i, j); } } }dfs 内部的递归逻辑就是“标记当前节点然后递归处理四个方向”。这里还有个细节如果用递归写法且网格特别大系统栈会溢出稳妥做法是用显式栈模拟递归或者干脆用前面那套 BFS 队列入队时标记也能完成连通块标记只是从语义上不如 DFS 直观。数据结构课作业阶段用递归没问题但如果你是在准备笔试、机试我强烈建议把这个“显式栈替代递归”的版本也写一遍等哪天遇到超大矩阵你就知道这个备份多值钱了。5.2 多源 BFS、打印最短路径和状态压缩玉米地的寻路模板稍微改一改就能解决一片经典题型多源 BFS如果不止一个起点比如玉米地里有好几个出口要把“从任意出口出发到某点最短距离”求出来。做法是先把所有起点都入队都标记 visited再照常 BFS。第一层多个点同时扩散得出的一定是到最近起点的距离。这在“腐烂的橘子”“多个门口逃生”一类题目里是标配。打印路径在入队时顺便记录每个节点的前驱坐标存一个二维的 prev 数组。BFS 结束后从终点往前回溯就能得到最短路径。注意此时把起点步数设为 1 的语义要统一路径长度包含起点和终点本身。状态压缩如果题目里除了坐标还有额外的状态维度比如“拿没拿到钥匙”可以把 visited 扩展成三维第三维是状态二进制位。玉米地本身通常不需要但这个思路是很多高级搜索题的敲门砖。5.3 和考研 408 及后续算法的衔接其实“玉米地”这类二维网格题在专业上对应的是无向无权图的最短路问题也就是 BFS。它是 408 里“图”这一章的入门级实践。等你做完玉米地再往后学的 Dijkstra、Floyd 都能看出一脉相承的影子同样是在图上找路径只是在边权不为 1 时不能再用“层次扩展”的简单思路了。我在头歌上还见过不少同学卡在玉米地这一关跑到讨论区求助。常见原因不是 BFS 不会写而是题目变体没认出来——比如输入里 1 是空地、0 是玉米和我们的惯例正好相反。这时候如果你死记模板就牛头不对马嘴。真正的做法是写代码前先花三十秒读题确定“能走”的标记是哪个值。数据结构实训考的是建模和应变能力不是默写能力。最后分享一个我在实际做题中很受用的习惯每做完一道类似题就把它的“核心变化点”整理成一个模板注释。玉米地这个模板我至今保留着注释里写明了四邻域方向数组、visited 入队标记、队列容量 n×m、多组输入的 EOF 写法。之后遇到岛屿数量、腐烂的橘子、迷宫最短路我都是复制这个模板然后改几个关键变量的意义。宁可把时间花在分析和变体训练上也别每次从零写一遍 BFS——这才是实训开这道题的真正用意。
返回列表