☰
数据结构玉米地实训:BFS最短路径与队列应用详解
2026/10/7 4:24:13 网站建设 项目流程

第一次在头歌实践教学平台的实训列表里看到“玉米地”这三个字时,我的第一反应是:走错频道了?一个数据结构课程实践,怎么冒出这么田园的名字。等点进去读完题目才发现,名字起得再随意,考点一点不含糊——二维矩阵建模、图的遍历、队列栈的应用,全都塞进这片玉米地了。今天就把我在这道题上的完整思路和踩坑过程拆开讲一遍,包括题目该怎么读、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 本地调试与异常数据测试

本地测试时,别只拿着样例输入跑一遍就完事,至少要自己构造几组特殊数据:

  1. 起点就是玉米:
3 3 1 0 0 0 0 0 0 0 0

期望输出 -1。如果代码输出 1 或 0,说明起手判断缺失。

  1. 完全被玉米包围,到不了终点:
3 3 0 1 1 1 1 1 0 0 0

期望输出 -1。这组验证 BFS 跑完整个连通区域后能正常返回 -1,而不是死循环。

  1. 一步直达:
1 1 0

期望输出 1。边界到只有单个格子的情况,起点即终点,很多人的代码卡死在这里,因为循环里还没入队target就返回了。

  1. 一条直线通路:
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——这才是实训开这道题的真正用意。

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

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

立即咨询