A同学前两天在PTA上刷到7-102 喊山这道30分的题,卡了大半天,跑来找我救火。他代码写得不短,样例输出也完全正确,但一提交就飘红。我问他用的什么算法,他理直气壮地说“DFS,深搜到底找最远”。问题一下就找到了——题目里那个“最远”,实际上要的是按传播层数计算的最短距离下的最远层。深搜这种一扎到底的遍历方式,在环和叉路面前根本管不住距离,而你需要的答案是bfs广度优先搜索加vector邻接表,遍历每个节点时记录传播层数,最后一层里挑编号最小的输出。
这道题看起来是一个简单的图遍历模板,但审题不仔细、建图漏边、多组询问不清空状态,都足以让你从30分一路跌到十几分。我把这道题的题意、建模、代码、踩坑点从头到尾拆一遍,附上能直接照抄的C++实现,顺便还原一次完整的问题排查过程,希望对正在刷图论的你有帮助。
1. 喊山的题面与建模:从喊声扩散到无权图bfs
1.1 题目到底在问什么
喊山的题面不长。你站在某个山头,把双手围成喇叭状大喊一声,旁边的山头能同时听到这声喊,接着旁边山头上的人也会继续向他们的邻居传话,就这样一圈一圈往外扩散。题目给你n个山头、m条“相邻可闻”的关系,以及k次询问;每次问你从某个山头开始喊,最终能传到的最远山头是谁。如果存在多个同样远的山头,输出编号最小的那个;如果这个山头喊了之后除了自己谁都听不到,就输出0。
输入格式很明确,第一行是三个整数n、m、k,接下来m行每行两个整数a、b,表示a山头和b山头之间可以直接听到彼此。这m行之后是k个询问,每个询问给一个起始山头编号。输出则是每个询问对应的最远山头编号,换行分隔。
这里有一个非常容易误解的地方:题目要的不是“图上最长的简单路径”,而是“喊声按层传播后能到达的最远那颗点”。喊一次就是往所有邻居传一次,邻居再同时往外传,所以传播距离本质上是无权图里的最短跳数,而“最远山头”就是所有可达节点里最短跳数最大的那些点。这两个概念在带环图里差别很大,深搜错就错在这里。
1.2 为什么正解是bfs而不是dfs或最短路算法
你把喊声扩散的过程画出来,会发现它天然是分层的:
第0层 1 / \ 第1层 2 3 / \ 第2层 4 5起点是第0层,和起点直接相连的山头是第1层,第1层的邻居除去已访问的又是第2层……每一层都比上一层多一次传播。BFS的队列扩展顺序正好就是一层一层往外扫,每个点第一次入队时的层数,就是从起点到这个点的最短传播距离。谁的层数最大,谁就是答案候选。
为什么不能用DFS?深搜讲究“一条路走到黑”,它访问节点的次序受递归分支先后影响,完全不符合“由近及远”的扩散语义。更致命的是,如果某个节点先被一条长路径访问到并打上vis标记,之后一条更短路径经过它时会被直接跳过,这个点的距离就被永久地标错了。
也有人会想,那用Dijkstra单源最短路行不行?行是行,但没必要。Dijkstra处理的是带权图,需要优先队列维护堆,写法重、常数大。这道题所有边权重都是1,用普通的队列bfs就能保证每个点第一次访问时层数最小,代码简单得多,跑得也更快。
2. 用vector建图写bfs的完整实现
2.1 建图方式怎么选
这题的数据规模在PTA的常规区间内,n、m、k一般都在10^4量级,是一个典型的稀疏图。建图有三种常见选择:
| 建图方式 | 空间复杂度 | 码量 | 适用场景 |
|---|---|---|---|
| 邻接矩阵 | O(n^2) | 最低 | n很小、图很密 |
| vector邻接表 | O(n+m) | 低 | 中大规模稀疏图,本题首选 |
| 链式前向星 | O(n+m) | 较高 | 需要边编号、大量删边等特殊场景 |
邻接矩阵在这题不可取,n到1万时二维数组开1e8个布尔值,内存直接爆炸,而且只有m条边,绝大多数格子是浪费的。链式前向星性能不差,但对初学者来说,头插法、next数组这些概念容易写错,调试成本高。vector邻接表是最平衡的方案:写起来直观,内存只有O(n+m),遍历邻居时用范围for一行搞定,非常适合这类图论入门题。
2.2 核心代码与逐行解释
下面是一份可以直接提交的C++17代码,我加了解释:
#include <bits/stdc++.h> using namespace std; vector<vector<int>> g; vector<int> vis; int bfs(int s) { queue<pair<int, int>> q; q.push({s, 0}); vis[s] = 1; int maxDepth = 0; int ans = 0; while (!q.empty()) { auto [u, dep] = q.front(); q.pop(); if (dep != 0) { if (dep > maxDepth) { maxDepth = dep; ans = u; } else if (dep == maxDepth && u < ans) { ans = u; } } for (int v : g[u]) { if (!vis[v]) { vis[v] = 1; q.push({v, dep + 1}); } } } return ans; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m, k; cin >> n >> m >> k; g.resize(n + 1); vis.assign(n + 1, 0); for (int i = 0; i < m; ++i) { int a, b; cin >> a >> b; g[a].push_back(b); g[b].push_back(a); } while (k--) { int x; cin >> x; fill(vis.begin(), vis.end(), 0); cout << bfs(x) << '\n'; } return 0; }为什么队列里存的是pair而不是单独开一个dist数组?因为bfs天然按层扩展,每个点的dep就是它首次入队时的层数。如果开dist数组,每次扩展还要多一次数组访问;用pair直接跟着点走,逻辑更贴近“传播”这个行为,读代码的人一眼就能明白。
为什么答案更新要用dep和maxDepth去比,而不是单纯记一个“最后一次出队的点”?因为最后一次出队的点一定在最大深度,但同一深度可能有多个点,你需要在它们之间再按编号择优。起点本身深度是0,它不参与答案统计,所以代码里用if (dep != 0)包住答案更新逻辑,这样孤立点查询时ans会保持初始值0,正好符合题目要求。
2.3 手动推演一次bfs的完整过程
光看代码可能不够直观,我用手推一遍。假设输入是:
8 6 1 1 2 2 8 1 3 3 7 1 4 4 5 1这张图有三个分支,8、7、5到起点1的距离都是2,所以最远层是节点5、7、8,正确答案应该输出编号最小的5。
队列和答案变量的变化过程如下:
- 初始队列[(1,0)],maxDepth=0,ans=0
- 出队(1,0),邻居2、3、4按顺序入队,队列[(2,1),(3,1),(4,1)]
- 出队(2,1),dep=1大于0,maxDepth变为1,ans=2,扩展出(8,2)
- 出队(3,1),dep等于maxDepth,但3不比2小,不更新,扩展出(7,2)
- 出队(4,1),dep等于maxDepth,4不比2小,不更新,扩展出(5,2)
- 出队(8,2),dep大于maxDepth,maxDepth变为2,ans=8
- 出队(7,2),dep等于maxDepth,7比8小,ans更新为7
- 出队(5,2),dep等于maxDepth,5比7小,ans更新为5
最终输出5。这个推演刚好演示了同层取最小编号的完整逻辑:如果只在dep大于maxDepth时更新,8就会一直被锁在答案上,5和7都进不来,输出就错了。
3. 影响分数的四个隐藏细节
3.1 无向边漏存一条
题目里“相邻可闻”是双向关系,a能听到b,b也一定能听到a,所以存边时必须写两行:
g[a].push_back(b); g[b].push_back(a);很多人在紧张状态下只写第一行,把图变成了有向图,声音就变成“单向传话”了。样例如果构造得巧,恰好输入顺序和传播方向一致,你能跑过样例;但一旦遇到某条边方向反过来,bfs就从那里断掉了。自检方法很简单:构造只有两个节点一条边的图,问一次1,正常答案应该是2;如果你输出0,那八成就是漏存了反向边。
3.2 多次询问的vis数组清理
vis是全局数组,每跑一次bfs都要恢复成未访问状态。最稳妥的做法是在每次询问前fill一遍:
fill(vis.begin(), vis.end(), 0);常见的低级错误是把这个fill写到while循环外面,只清理了第一次,后面每次询问vis里全都残留着之前的访问标记,bfs一开始就把所有点当成已访问,直接跳过,导致后续询问全部输出0。第一次对、后面全错,这种“部分正确”的分数表现非常典型,排查时一定要先看清理语句的位置。
3.3 最深层多个节点时选编号最小
同一层可能有多个山头,比如一个十字路口两头都能传到。题目要求如果距离相同,输出编号最小的那个。这意味着你的答案更新逻辑必须同时维护两个优先级:深度优先,编号次之。
对应到代码就是开头那一段:
if (dep > maxDepth) { maxDepth = dep; ans = u; } else if (dep == maxDepth && u < ans) { ans = u; }如果只写dep大于maxDepth时的更新,同层节点中后访问的小编号点就永远没有机会替换先访问的大编号点。这种错误样例通常很难暴露,因为样例的图往往是链状的,根本不存在同层多点。建议自己额外构造一个星形或菱形图去测。
3.4 孤立点与“自己不算目标”
如果一个山头周边一个相邻山头都没有,bfs只会访问起点自己。起点深度0不参与答案更新,ans保持初始值0,最后输出0,这正好是题目要求的“传不到其他山头就输出0”。
但如果你在实现时不小心让起点的dep=0参与了答案更新,比如把条件写成了dep >= maxDepth,ans就会被更新成起点本身。题目要的是“最远的其他山头”,自己传给自己不算传播,所以这种写法在孤立点用例上必挂。这里的if (dep != 0)判断不是锦上添花,是必要条件的防线。
4. 一次从部分正确到满分的完整排查过程
4.1 深搜版本为什么样例能过、提交却错
A同学最初的版本是DFS加vis,样例过得很轻松。我给他构造了一个反例:
4 4 1 1 2 2 3 3 4 1 4 1这个图里,1和4直接相邻,同时1又通过2、3和4连成一条链。按正确的含义,1到4的距离是1,1到3的距离是2,所以最远点应该输出3。
但深搜的顺序是递归里先走的邻居决定的。假设从1先走向2,递归就会一路扎到1-2-3-4,在第3层就把4的vis标记设置好;等递归回到1,再走到4那条直接边时,vis[4]已经是true,直接跳过。这时候DFS记录的“深度”里,4变成了深度3,3的深度是2,程序会误以为4是最远的,输出4,正确值3反而丢了。
这就是深搜在无权图距离问题上的典型陷阱:它不能保证首次访问某个点的路径是最短路径,一旦先走了一条长路,这个点就被“污染”了。
4.2 逐项排查的思考链
换掉DFS后,A同学从WA变成了部分正确,于是我们开始从头过逻辑。排查的顺序是这样一条链:
先验证建图。我把测试数据改成两个人两个山头1和2,问1,正确输出2。他跑出来正确,说明双向边建了,排除3.1。
再验证vis清理。我连续给出三次询问,第一次1,第二次还是1,第三次还是1。结果第一次对,后两次都输出0,问题立刻暴露:vis在询问间没有被重置,第二次bfs直接跳过所有点。把fill挪到每次询问前,这部分修复。
再验证答案选择。我构造了前面那个三分支图,正确输出5,他输出8。定位到答案更新逻辑只处理了dep大于maxDepth的情况,没有处理dep等于maxDepth时取更小编号。补上else if分支后,输出变正确。
最后验证孤立点。单独构造一个只有节点1且没有边的图,问1,正确输出0。他此时输出也是0,说明起点不参与答案更新这个细节没有踩坑。
四个环节全部过完,提交才从部分正确变成满分。这个例子非常典型,我建议大家以后遇到“样例能过、提交部分正确”的图论题,也按这个顺序排查:建图方向、vis清理、答案选择、边界输入。大多数丢分都藏在这四个点里,而不是藏在算法本身。
5. 复杂度边界与更省事的优化写法
5.1 O(k(n+m))到底行不行
单次bfs会把每个点访问一次,每条无向边扫描两次,复杂度O(n+m)。题目有k次询问,所以整体最坏是O(k(n+m))。以n、m、k都在10^4量级来算,最坏大约2e8次简单操作,C++的队列加vector遍历在PTA的时限下通常能跑过,所以直接写暴力版本的bfs并不是罪过。
但如果你追求稳妥,有两个小优化值得做。第一个是提前剪枝:每次bfs统计访问的节点数,如果某个询问的起点所在连通分量只有一个点,那么访问次数为1,答案一定是0,不需要继续扩展。第二个是减少清空代价,下面单独说。
5.2 用时间戳省掉memset
每次询问前fill一遍vis数组,代价是O(n)。k次询问就是O(kn),在数据接近上限时会额外增加1e8次操作。时间戳是竞赛里非常经典的做法,本质是把vis从“布尔值”改成“int时间标记”,每次bfs先增加一个tag计数器,访问节点时判断vis[v]是否等于当前tag:
vector<int> vis; int tag = 0; int bfs(int s) { ++tag; vis[s] = tag; queue<pair<int,int>> q; q.push({s, 0}); int maxDepth = 0, ans = 0; while (!q.empty()) { auto [u, dep] = q.front(); q.pop(); if (dep != 0) { if (dep > maxDepth) { maxDepth = dep; ans = u; } else if (dep == maxDepth && u < ans) { ans = u; } } for (int v : g[u]) { if (vis[v] != tag) { vis[v] = tag; q.push({v, dep + 1}); } } } return ans; }这样一来,清空vis的操作从O(n)降到了O(1),只是概念上稍微绕了一点,竞赛时间充裕时值得掌握。
另外还有一种“逐层队列”的写法:用两层循环,外层标记当前深度,内层用q.size()控制只处理当前层的节点,这样代码会天然把“层”包装起来,适合教学展示,但实际跑起来需要额外vector暂存当前层点,道理和pair写法完全一致,就不多写了。
5.3 个人刷题经验
我在实际刷这题的过程中,最大的体会是:喊山考的根本不是会不会写bfs模板,而是能不能把一个生活场景准确地翻译成图模型。翻译对了,剩下的都是背过的模板;翻译错了,DFS写得再花哨也是白搭。以后只要遇上“最短”“最远”“传播”“扩散”这类词,先条件反射地联想到按层展开,然后动手画一张小图,把层号标在旁边,再开始写代码。这个习惯帮我避开了很多看似玄学的部分正确,也推荐给你试试。