到了训练营第六十二天,卡码网KamaCoder53这道“寻宝”题,我前后提交了四次才算彻底跑通。不是题本身有多难,而是我一开始把它当成最短路去想了,绕了好大一圈才意识到:题目里说的“打通所有藏宝点之间的道路,让任意两点都能相互到达,并且总花费最小”,这正是教科书级的最小生成树问题。如果你也正卡在这道题上,或者刚做完想回头捋一捋Kruskal和Prim的差异,那这篇文章应该能给你一些实在的参考。
先说结论:KamaCoder53“寻宝”本质上是一道无向带权图的最小生成树题。图里有N个节点,M条边,每条边有一个花费;你需要选择其中一部分边,让所有节点连通,且选出的边的总花费最小。约束条件没有特别刁钻的地方,数据范围也适合用常规的Kruskal或堆优化Prim去做。真正值得琢磨的是:为什么这个问题不能用最短路思路,为什么最终答案一定是一棵树,以及两种主流解法各自适合什么场景。这些内容我会在后面逐段展开,顺手把可直接提交的C++代码和调试样例也贴出来。
1. 题面拆解:为什么“寻宝”是所有点连通的最小花费问题
1.1 从一个朴素的连通需求说起
这道题的场景包装很有意思:你有一张藏宝图,图上散布着若干个宝藏点,点与点之间有一些已经存在的道路,每条道路的修建或通行成本不同。你的目标是让任意一个宝藏点都能通过若干条道路到达其他所有宝藏点,求满足条件的最小总成本。
这个描述里藏着两个容易忽略的关键词。第一个是“任意两点都能到达”,这意味着我们的目标不是从A点到B点的某一条路径最短,而是整个图作为一个整体必须连通。第二个是“总成本最小”,也就是说我们要在保证连通的前提下,把选中的边权之和压到最低。这两个条件组合在一起,定义了一个和“最短路”完全不同的优化目标。
打个比方,最短路问题像你在城市里叫网约车,只想从家到公司距离最短,一路上经过哪些路口根本不重要;而最小生成树问题像电信运营商在几个城市之间拉骨干光缆,目标是让所有城市都接入这张网络,至于某两个城市之间的绕行距离是多少,反而不是首要考虑。这两类问题表面上都涉及“选边”和“权值”,但优化的对象完全不同。
1.2 生成树:为什么最优方案一定没有环
明确了目标是“全连通”之后,一个自然的追问是:最终选出的边会是什么形态?假设图里有N个节点,如果选择的结果包含了一个环,那么去掉环上的任意一条边,原来连通的节点依然连通,而总成本却变小了。既然我们要的是最小成本,那么任何带环的方案都必然不是最优解。所以最优方案一定是一个无环连通图,也就是一棵生成树。
连通N个节点至少需要N-1条边,而一棵树恰好有N-1条边。所以这道题的答案必然是:从M条边中选出N-1条边,构成一棵生成树,并且让这棵生成树的总权值最小。这就是“最小生成树”这个名称的由来。
有了这个前提,解题思路就变得清晰了。我们要解决的无非是“如何高效地从M条边里挑出那N-1条边”,而两个最经典的选择策略,一个是优先从权值最小的边开始看,另一个是从某个节点开始不断向外扩展。这两条路分别对应Kruskal算法和Prim算法。
1.3 两种贪心直觉,恰好对应两个算法
我在第一次做这道题的时候,脑子里冒出来的第一个想法是:先把所有边按照花费从低到高排个序,然后从最小的边开始逐个尝试,只要这条边能让当前图变得更连通,就留下它。这个思路非常自然,它就是Kruskal算法。
第二种想法是:随便选一个宝藏点作为起点,先把通往最近邻点的路建上,然后把已经连通的区域看成一个整体,再从这片区域向外找最小花费的边,不断“吞噬”新的点。这是Prim算法的思路。
两种方式最终都能得到最优解,但它们在实现层面依赖的数据结构完全不同。Kruskal依赖并查集来快速判断两个点是否已经连通,Prim依赖优先队列(最小堆)来高效获取“当前可以扩展的最小边”。我在训练营前面的章节里学过并查集,也刷过不少堆相关的题目,但直到做这道“寻宝”题,才真正体会到这两个工具的配合有多巧妙。
2. Kruskal路线:把边按权值排序,用并查集完成连通块合并
2.1 完整的算法主流程
Kruskal算法的思想可以用一句话概括:把所有边按权值从小到大排序,然后依次尝试每条边,如果这条边连接的两个节点已经连通,就跳过;否则就把它们连通,并累加这条边的权值。选够N-1条边后,算法结束。
具体步骤拆开来看是这样:
- 读入全部N个节点和M条边,把每条边存储为三元组(权值w,起点u,终点v)。
- 对M条边按w从小到大排序。
- 初始化并查集,让每个节点独立成为一个集合。
- 从小到大遍历每条边,用并查集查询u和v是否在同一个集合中。
- 如果不在同一个集合,说明这条边可以安全地加入生成树,执行合并操作,并把w累加到答案里;如果在同一个集合,说明加入这条边会形成环,直接丢弃。
- 当已经选出N-1条边时,提前停止遍历,输出答案。
这个流程里最关键的一步是第5步的“会形成环”判断。举个例子,一个三角形有三条边,权值分别是1、2、3。排序后先看权值1的边,两个端点原来不连通,加入;再看权值2的边,两个端点也不连通,加入;最后看权值3的边,这时两个端点已经通过前两条边连通了,加入就会形成环,所以必须跳过。
2.2 为什么“能要就要”的贪心不会出错
很多人第一次学Kruskal时都会有个疑问:只因为某条边权值最小就先选它,万一这一步的局部最优破坏了全局最优怎么办?这里需要一点理论支撑,但不需要严格证明到数学论文的程度,只需要理解交换论证的思想。
假设存在一棵最优生成树T,它没有选择某条权值较小的边e。把e加入T,一定会形成一个环。在这个环上,至少有一条边f的权值大于或等于e的权值。如果我们用e替换掉f,得到的仍然是一棵生成树,并且总权值不会增加,甚至可能变小。这意味着任何“跳过这条最小边”的最优解,都可以被换成“包含这条最小边”的最优解。逐条边做这样的替换,就说明Kruskal的贪心选择不会错失最优解。
这个论证不需要背下来,但理解了它之后,你会对贪心算法产生更踏实的信任感,而不是“碰巧能过”的侥幸感。
2.3 并查集的实现要点(训练营知识点的实战检验)
Kruskal的代码主体并不长,但并查集写得好不好直接影响提交成绩。我在训练营早期刷并查集专题时,觉得路径压缩已经够了,按秩合并无所谓。结果在“寻宝”这题的数据量下,不写按秩合并也能过,但如果数据再大一些,退化风险就会暴露出来。稳妥起见,我建议这里直接写完整版。
int find(int x) { return parent[x] == x ? x : parent[x] = find(parent[x]); } void unite(int a, int b) { a = find(a); b = find(b); if (a == b) return; if (rank[a] < rank[b]) swap(a, b); parent[b] = a; if (rank[a] == rank[b]) rank[a]++; }路径压缩保证了find操作近似O(1),按秩合并保证了树的深度不会失控。两个一起用,并查集的操作时间可以看作常数级别,整个Kruskal算法的瓶颈只剩排序本身的O(E log E)。
2.4 Kruskal的时间复杂度与应用场景
Kruskal的复杂度主要由排序决定,为O(E log E),其中E是边数。并查集部分可以近似看作O(E α(N)),α(N)是反阿克曼函数,增长极为缓慢。所以整体来讲,Kruskal在边数较少时表现非常好,适合稀疏图。
在“寻宝”这道题里,如果M和N的规模在同一数量级,甚至M更小,那无脑选Kruskal就可以了。它的另一个优点是实现直观,不容易写错;你只需要把边排序,然后维护一个并查集,逻辑链条非常短。
3. Prim路线:从任意点出发,用优先队列贪心扩展连通块
3.1 算法主流程与朴素的选点思路
如果说Kruskal是从“边”的视角入手,那么Prim就是从“点”的视角入手。它维护一个“已经加入生成树”的点集合,初始时可以任选一个点加入。接下来反复执行这样的操作:在连接树内点和树外点的所有边中,找出权值最小的那条边,把对应树外点加入树中,并累加边权。重复N-1次之后,所有点就都在树里了。
最直白的实现方式是维护一个数组dist,表示每个树外点连接到当前树的最小边权。每次扫描所有树外点,找dist最小的那个加入树中,然后更新它的邻接点。这个做法的时间复杂度是O(V^2),在稠密图里(比如V只有几千但M很大时)表现很好。
但对于“寻宝”这类边数和点数都可能上万的情况,我更倾向于用优先队列优化。它的做法是:把“当前树可以向外扩展的所有边”都丢进一个小顶堆,每次从堆顶取一条边,如果边的另一端已经在树内,就跳过;否则就把这个新点加入树中,并把这个新点的所有邻边也都丢进堆里。
3.2 为什么任选起点都能得到最优解
Prim算法给人最不直观的地方在于:我随便选一个起点,怎么保证最后得到的就是全局最优?其实这个性质和Kruskal的证明思路殊途同归。无论你从哪个点开始,当前树和剩余点之间的“割”上,权值最小的那条边一定是某棵最小生成树中的边。因为如果有更优方案不使用这条跨割最小边,就可以用交换论证把它替换进去而不会变差。
正因为每次Prim都选择“当前割上的最小边”,所以无论起始点是谁,最终形成的生成树权值都一样大。这个“割性质”是理解Prim的关键,也是后续学习次小生成树、瓶颈生成树时反复用到的基础。
3.3 堆优化Prim的完整代码骨架
用C++实现堆优化Prim时,我习惯用pair存储候选边,第一个元素是权值,第二个元素是端点编号。这样优先队列默认的小顶堆可以自动按权值排序。完整代码骨架如下:
#include <bits/stdc++.h> using namespace std; using PII = pair<int, int>; int main() { int n, m; cin >> n >> m; vector<vector<PII>> g(n + 1); for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; g[u].push_back({v, w}); g[v].push_back({u, w}); } vector<int> vis(n + 1, 0); priority_queue<PII, vector<PII>, greater<PII>> pq; pq.push({0, 1}); long long ans = 0; int cnt = 0; while (!pq.empty()) { auto [w, u] = pq.top(); pq.pop(); if (vis[u]) continue; vis[u] = 1; ans += w; cnt++; for (auto [v, ww] : g[u]) { if (!vis[v]) { pq.push({ww, v}); } } } if (cnt != n) { cout << "图不连通" << endl; } else { cout << ans << endl; } return 0; }有几个细节需要特别注意。第一,无向图建邻接表时必须双向存储,我只存一边的话,后面更新会漏边。第二,优先队列里允许出现重复边,因为一个点可能通过不同路径被推进堆多次,但vis数组保证只有第一次弹出时才真正生效。第三,答案要用long long,如果边权和可能超过int范围,用int会造成溢出,这种错误在样例上很难发现,提交后才会暴露。
3.4 Prim的时间复杂度与适用场景
堆优化Prim的复杂度是O((V+E) log V),在大多数竞赛数据下都能轻松通过。它和Kruskal的取舍在于:稠密图(E接近V^2)时,朴素Prim的O(V^2)可能更快;稀疏图时Kruskal更简单。对于“寻宝”这种没有明确给出稠密还是稀疏的题,我更推荐先读题看数据范围:如果N很小而M很大,用Prim;如果M和N同阶,甚至M比N还小,用Kruskal。这两者在“寻宝”的数据范围内都够用,选择标准更多是个人编码习惯和是否能一遍写对。
4. 可直接提交的C++题解:Kruskal与Prim的完整实现对照
4.1 Kruskal版本的完整代码
把前面的并查集和排序流程组合起来,就是一份能直接跑的Kruskal题解。我用tuple存边,排序时按权值优先排序,这样代码读起来最舒服。
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; int parent[MAXN], rk[MAXN]; int find(int x) { return parent[x] == x ? x : parent[x] = find(parent[x]); } void unite(int a, int b) { a = find(a); b = find(b); if (a == b) return; if (rk[a] < rk[b]) swap(a, b); parent[b] = a; if (rk[a] == rk[b]) rk[a]++; } int main() { int n, m; cin >> n >> m; vector<tuple<int, int, int>> edges; for (int i = 0; i < m; i++) { int u, v, w; cin >> u >> v >> w; edges.push_back({w, u, v}); } sort(edges.begin(), edges.end()); for (int i = 1; i <= n; i++) { parent[i] = i; rk[i] = 0; } long long ans = 0; int cnt = 0; for (auto [w, u, v] : edges) { if (find(u) != find(v)) { unite(u, v); ans += w; cnt++; if (cnt == n - 1) break; } } if (cnt == n - 1) { cout << ans << endl; } else { cout << "图不连通" << endl; } return 0; }这份代码我把“图不连通”的情况也处理了。最初我只写else输出ans,乍一看没问题,但如果在“寻宝”的测试数据里混入森林(不连通图),cnt到不了n-1,程序会输出一个不完整的结果。虽然标准题意应该保证连通,但养成判断连通性的习惯,对你的面试代码和竞赛代码都有好处。
4.2 两版代码的对比与选型建议
我给出的Prim版本代码和Kruskal版本代码各有一些优势。Prim的代码里不需要并查集,也不需要排序,只是优先队列的语法要稍微熟悉一些;Kruskal则几乎没有思维难度,难点全在并查集是否扎实。
如果让新手二选一,我会建议第一遍写Kruskal,因为它的步骤更线性,不容易出现堆使用上的逻辑漏洞。但如果图特别稠密,或者你确定数据规模大、内存紧,那Prim的堆优化版本会更稳。这两份代码都是O(E log V)级别的复杂度,在“寻宝”的数据范围内跑得飞快。
4.3 必测的三个小样例
我每次写完最小生成树代码,都会先测下面三个小样例,跑通了再提交。这几个样例能覆盖绝大部分常见错误。
第一个是链式图:4个点,3条边,分别是1-2权值1、2-3权值1、3-4权值1。答案显然是3,此时Kruskal会依次选3条边,Prim从任意点开始也恰好选3条边。这个样例主要验证基本流程。
第二个是三角形图:3个点,3条边,1-2权值1、2-3权值2、1-3权值3。答案应该是3(选1-2和2-3)。Kruskal排序后会先选1、2,再选2、3,然后跳过1-3;Prim从1开始选1-2,再从堆中弹出2-3。这个样例能检查你是否正确处理了“成环跳过”和“去重访问”。
第三个是带自环和重边的图:比如3个点之间有三条1-2权值2的边,再加一条1-1权值100的自环。正确输出应该为4(选一条1-2权值2,一条2-3权值2,自环忽略)。这个样例能暴露两个问题:自环是否会导致Prim误算,重边是否会干扰Kruskal的合并判断。实际上只要用了vis数组和并查集,自环会被天然跳过,重边只会让堆中多几个候选,但不影响最终结果。
4.4 提交前的一处关键自查
我犯过的一个很低级的错误是:忘了把答案累加部分放在正确的位置。Prim里我曾在if (vis[u]) continue之前就执行ans += w,导致同一个点被访问两次时权值被重复计算。这类问题的排查方法很简单,就是在每一个样例里手工模拟一次,把每一步弹出的边写下来,对照程序输出是否一致。如果样例太小不够,完全可以构造一个5点、6边、带3条重边的输入,手工算一遍答案,再用程序跑一遍。这个过程虽然花时间,但能避免提交后反复WA的挫败感。
5. 训练营第六十二天的位置:MST是图论、并查集、贪心的汇合点
5.1 走到这一天时你回头看会看到什么
代码随想录训练营的图论部分不是孤立存在的。到第六十二天时,前面已经铺垫了大量基础:DFS、BFS、深搜去重、拓扑排序、最短路径(Dijkstra、Bellman-Ford、Floyd),还有独立的并查集专题。最小生成树恰好是把这些知识拧成一股绳的题目类型。
Kruskal用到的是并查集加贪心,Prim用到的是图存储加堆,两种解法都在反复考验一个核心能力:把题目里的条件抽象成图模型,再把图模型对应到一块熟悉的数据结构。这正好呼应了卡码网这道“寻宝”题的命名——表面上是在寻宝,实际上是在寻找“如何用最少的代价把所有节点连成一张网”的建模直觉。
5.2 对“寻宝”这类应用题的建模建议
还有一点值得多说一句:训练营的题目经常把算法藏在生活化场景里。“寻宝”是把修路问题包装成藏宝图,“修水管”“架电线”“建通信基站”也都是同一类包装。你在读题时应当训练自己快速剥离场景,找到“N个点、M条带权边、求最小连通代价”这个骨架。这种抽象能力不只是为了这道题,更是为了应对真实面试里的系统设计题和算法题——很多问题最终都会归结到一个经典模型上。
5.3 做完这题之后可以继续想的几个方向
如果你把“寻宝”AC之后还有余力,我建议思考三个问题。
第一个问题:如果题目中某些边是单向的,还能直接用最小生成树算法吗?答案是不能,因为MST的定义建立在无向图上,有向图的对应问题是“最小树形图”,需要用朱刘算法,这已经是另一个知识块了。
第二个问题:如果题目不保证图连通,要求输出“无法连通”,代码应该怎么改?这个我在上面的代码里已经顺手写了,核心就是cnt是否等于n或n-1的判断。
第三个问题:如果不仅要求最小总花费,还要求输出一种具体的选边方案,Kruskal应该怎么记录答案?方法很简单,在每次成功合并时,把当前边存入一个vector,最后输出即可。这在实际工程中比只输出一个数字更有意义,因为它能告诉你“路到底该怎么修”。
5.4 我个人的练习建议
第六十二天这道题,我的建议是你至少亲手把Kruskal和Prim各写一遍。不是说考试会考两遍,而是这两套代码的思维模式完全不同。Kruskal强迫你理解“边排序、并查集合并”的离线思考方式,Prim强迫你熟悉“visited数组、优先队列、邻接表更新”的在线扩展方式。两种都写过了,你对最小生成树的理解才会真正立体起来,而不是停留在“背模板”的层面。
我最终提交“寻宝”的时候用的是堆优化Prim,因为自己写Kruskal时总觉得并查集已经练过太多遍,想换个姿势提升一下对堆的熟练度。但改天遇到数据量很大的稠密图我可能会切回Kruskal或者朴素Prim,毕竟“能用且不复杂”才是竞赛和工作中更重要的原则。
最后分享一个小技巧:不管用哪个算法,提交之前一定要伪造几个极限数据,比如N=100000、M=100000,权值给到1e9。看看自己的代码会不会因为vector频繁扩容而超时,或者因为long long用法不统一而出现编译警告。这种极限测试我在训练营里养成习惯之后,后面的图论题基本都是一次AC的概率更大,省下的时间远比写测试代码的时间多。