关于文中的标记
【正】 = 我第一次口述这段时讲错了,这里是更正和原因
【补】 = 整理时补的一层
本系列是上下两篇,本篇是上篇:
篇 讲什么 一句话 上篇(本篇) 最小生成树:Kruskal / Prim 把所有点 用最小的代价串起来 → 得到一棵树 下篇 最短路径:Dijkstra / Bellman-Ford / Floyd 求两个点之间 最近的走法 → 得到一条路 为什么必须分两篇 :这两个问题经常被混着记,但它们不连通时的判据、对负权的态度,恰好相反 。
两篇的完整对照表放在下篇,本篇只在"边界"里各提一句。
一、专题介绍
Q:最小生成树到底在求什么?
A:用最少的代价,把整个连通图"串起来"。
| 关键词 | 含义 |
|---|---|
| 生成树 | 包含全部 n 个顶点、恰好 n-1 条边、无环的连通子图 |
| 最小 | 这 n-1 条边的权值总和最小 |
重点! 注意是"总和最小",不是"每一条都最小"。
【正】我口述时说的是"包含 N 个顶点和 N-1 条边,且每条边的权值最小"------这句是错的。一条边最小不代表总和最小,这是两件事。
Q:那"最小生成树"和"最短路径"有什么关系?
A:几乎没有关系,除了都叫"最小/最短",还都用贪心。
| 最小生成树(本篇) | 最短路径(下篇) | |
|---|---|---|
| 要什么 | 一棵树(覆盖所有点) | 一条路 (从 s 到 t) |
| 优化对象 | n-1 条边的总和 |
单条路径的长度 |
| 图不连通 | ❌ 做不出来 | ✅ dist = ∞ 就是答案 |
| 负权边 | ✅ 无害 | ❌ Dijkstra 直接失效 |
| 负环 | ✅ 无关 | ❌ 题目无解 |
这张表是这两篇的核心,也是最容易记混的地方------所以我把完整版放在下篇第二节,那里有五个算法的横向对照。
本篇只需要记住一句话:
MST 问的是"把所有点连起来要多少钱";最短路问的是"从这里到那里有多远"。
两个问题长得很像,但答案的形状完全不同------一个是树,一个是路径。
二、三条准则
构造最小生成树只有三条约束,两个算法的每一步都在满足它们:
准则 1:只能使用图中的边来构造
准则 2:只能使用恰好 n-1 条边来连接 n 个顶点
准则 3:选用的 n-1 条边不能构成回路
这三条同时也是"为什么两个算法能对"的骨架:
- Kruskal 靠并查集 满足准则 3,靠边数计数满足准则 2
- Prim 靠集合划分天然满足准则 3(见 4.2)
所以"判环"这件事,两个算法的做法完全不同------这是它们的第一处分岔路口。
三、Kruskal:按边扩张
3.1 一句话思想
每次从【全图所有边】里挑最小的那条,只要它不成环就收下。
它不关心这两个点在哪、离已选的部分有多远,只看边的权值。
选边图示:

3.2 为什么必须判环
因为它是"按边挑",两条边的端点可能已经在同一个连通块里了。
判"是否在同一连通块",用并查集:
cpp
if (ufs.Union(mine->_srcIndex, mine->_dstIndex)) // 返回 false = 本来就一块
{
mintree.addEdgeByIndex(mine->_srcIndex, mine->_dstIndex, mine->_w);
total += mine->_w;
++chosen;
}
并查集的 Union 返回 false,就是"这两个点已经连着了"。
关于并查集的介绍,可以跳转作者本篇文章:
3.3 注意点:无向图的边只能入一次
cpp
for (size_t i = 0; i < _linkTable.size(); ++i)
for (Edge* e = _linkTable[i]; e; e = e->_next)
{
if (!Direction && e->_srcIndex > e->_dstIndex) continue; // ← 去重
heap.push(e);
}
无向图的每条边在邻接表里存了两次 (a→b 和 b→a)。如果两条都入堆,Kruskal 会把它当成两条不同的边处理------虽然并查集能挡住第二次,但白白多占一倍空间。
有向图则相反:两个方向是两条不同的边,都得入。
3.4 什么时候结束
cpp
const size_t need = _vertexs.size() > 0 ? _vertexs.size() - 1 : 0;
while (chosen < need && !heap.empty()) { ... }
两个退出条件 :选够 n-1 条,或者边用完了。
3.5 注意点:判环用并查集,判连通用边数------是两件事
【正】我口述时说:"在最后检查最小生成树是否连通时,若存在孤立点或带环则说明最小生成树不合法"------这句把两件事缠在一起了。
判环 → 并查集(Union 返回 false)
判连通 → 最后数一下 chosen == n-1 ?
"有环"和"不连通"不可能同时用来判断 :并查集已经保证无环了,最后检查只可能是"边数不够"(即图不连通)。
cpp
if (chosen != need) // 选不满 n-1 条 → 图本来就不连通
{
// 清空已经建了一半的树
return false;
}
而且这个返回值是最小生成树独有的------最短路径不会因为它"走不到"而失败(见第六节)。
四、Prim:按点扩张
4.1 一句话思想
只在"已选集合 → 未选集合"这一刀上挑最小的边。
这一刀叫割。Prim 从来不看"两个端点都在集合里"或"都不在"的边。
选边图示:

4.2 为什么 Prim 不用判环
因为它根本不会选出成环的边。
集合 S(已选) | 集合 V−S(未选)
↑
只挑跨过这条线的边
一条边如果两端都在 S 里,它根本不在候选范围 ;一端在 S 一端在 V−S 的边,加上它只会把"未选那边的点"拉进来,不可能闭合出环。
所以"集合划分"这个动作,本身就满足准则 3。
Kruskal 需要显式判环(并查集),Prim 不需要------这是两者最本质的差别。
4.3 注意点:Prim 不能用 Kruskal 那种"一次全入堆"
我口述时讲到了这一点:
如果一口气把所有边放入小根堆,可能选到两端顶点都已存在或都不存在的边。
【补】具体坏在哪:
堆里全是边 → 弹出堆顶 → 发现这条边两端都在 S 里 → 丢掉
→ 再弹 → 还是不行 → 再弹...
因为 priority_queue 不能"修改堆内元素",也不能"只留符合条件的"------你只能弹出来看,不合适就扔。
Prim 的做法是"边走边加":
cpp
// 1. 起点的边先入堆
for (Edge* e=_linkTable[topi]; e ; e = e->_next)
{
heap.push(e); //这里不需要比较srci和dsti!
}
// 2. 每加入一个新点,才把它发出去的边入堆
for (Edge* e=_linkTable[tope->_dstIndex]; e ; e = e->_next)
{
if (!setv[e->_dstIndex]) //跨割的边(注意这里不能让e->_srcIndex < e->_dstIndex!出发点不一定是按顺序的!
{
heap.push(e);
}
}
这样堆里永远只有"跨割的边"。
4.4 为什么 Prim 一定要传起点
Kruskal 不用传,Prim(pminTree, 'a') 要传。
原因不是"随便指定一个",而是:Prim 需要一个"种子"来定义割。
没有起点 → S 是空集 → "跨割的边"不存在 → 无从下手
任意选哪个点当起点都行 (最小生成树的总和一样),但必须有一个。
边界 :如果图不连通,从某个点出发可能只能覆盖一部分,最后 chosen < n-1,返回 false。
4.5 注意点:去重不能比下标大小
我口述时点出了这一条:
cpp
// 错:以为起点下标一定小于终点
if (e->_srcIndex < e->_dstIndex) heap.push(e);
// 对:直接看终点在不在集合里
if (!setv[e->_dstIndex]) heap.push(e);
边的存储顺序和顶点编号没有必然关系 ------c→a 这种"大下标指向小下标"的边完全存在。
不过严格说,Prim 里这一步的主要作用不是"去重",而是"只加跨割的边"。 用 setv 判断,正好一举两得。
五、对比两个算法
| Kruskal | Prim | |
|---|---|---|
| 一句话 | 按边扩张 | 按点扩张(切割) |
| 每步挑什么 | 全图最小的边 | 跨割的最小的边 |
| 判环 | 要(并查集) | 不要(集合天然避免) |
| 需要起点 | ❌ | ✅ |
| 主要数据结构 | 边集 + 排序/堆 + 并查集 | 邻接表 + 堆 + setv 数组 |
| 复杂度 | O(E log E) |
O(E log V) |
| 适合 | 稀疏图(边少) | 稠密图(边多) |
5.1 关于"全局贪心 / 局部贪心"
【正】我口述时用了这两个词,然后自己补了一句:
"我确实有点背概念了"
我发现自己在用术语解释术语,而不是在解释机制。
更准确的叫法:
| 我口述的说法 | 换成 |
|---|---|
| 全局贪心 | 按边扩张(候选是全图所有边) |
| 局部贪心 | 按点扩张 / 割(候选只有跨割的边) |
"全局 / 局部贪心"不是标准术语,而且容易和"全局最优 / 局部最优"混淆------后者说的是"这个贪心能不能得到整体最优解",是另一个话题。
六、边界
6.1 MST 要求连通------这是和最短路径最大的差别
| 图不连通时 | |
|---|---|
| Kruskal / Prim | ❌ 返回 false。"生成树"要求所有点连成一片 |
| Dijkstra / Bellman-Ford | ✅ 到不了的点 dist = ∞,那就是答案 |
(完整对照见下篇)
原因很清楚:生成树的定义里就含"连通"二字;而"从 s 到 t 有多远",答案可以就是"没有路"。
连通性是最小生成树的门槛,不是最短路径的门槛。
代码里对应的是两处 if (chosen != need) return false;------这个返回值本身就是 MST 独有的语义。
边界:不连通时只能得到"生成森林"(每个连通块一棵树),标准 MST 算法不处理这个。
6.2 反直觉:负权边对 MST 无害
最短路径遇到负权会出问题(Dijkstra 直接失效、负环导致无解)。
但最小生成树不怕负权:
Kruskal:只看边的相对大小排序 → 有负数也照样排
Prim :每步挑跨割的最小边 → 负数也照样比
为什么? 因为 MST 优化的是"n-1 条边的和",没有"路径越绕越短"这种问题 ------它本来就固定选 n-1 条边,不存在"多选一条更划算"的情况。
最短路径怕负权,是因为"多走一段可能更短";MST 不怕,因为边数是固定的。
6.3 最小生成树不唯一
当有多条边权值相同时,可能构造出不同的树,但它们的权值和一定相同。
所以"最小"指的是权值和最小,不是"这棵树唯一"。
6.4 稠密图上,邻接矩阵版 Prim 更快
我在实现Kruskal算法和Prim算法时使用了邻接表,但是:
| 实现 | 复杂度 |
|---|---|
| 邻接表 + 堆 | O(E log V) |
| 邻接矩阵 + 数组扫描 | O(V²),不建堆 |
稠密图里 E ≈ V²,E log V 比 V² 还差。 所以"用堆"不是无条件更优------又是"看图的稠密度"。
七、代码与测试
7.1 代码实现
Kruskal算法:
cpp
struct Cmp
{
bool operator()(Edge* e1, Edge* e2) const
{
return e1->_w > e2->_w;
}
};
//Kruskal算法:找最小生成树
bool Kruskal(Self& mintree, W& total) const
{
//清空mintree
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
}
//初始化最小生成树
mintree._vertexs = _vertexs;
mintree._vertexmap = _vertexmap;
mintree._linkTable.assign(_linkTable.size(), nullptr);
//1.小根堆,边入堆,无向图只入一次
priority_queue<Edge*, vector<Edge*>, Cmp> heap;
for (size_t i = 0; i < _linkTable.size(); ++i) //遍历边
{
for (Edge* e = _linkTable[i]; e; e = e->_next)
{
if (!Direction && e->_srcIndex > e->_dstIndex) continue;
heap.push(e);
}
}
//贪心选边,用并查集避免带环问题
UnionFindSet ufs(_vertexs.size()); //注意是顶点的数量
total = W();
//需要的边数
const size_t need = _vertexs.size() > 0 ? _vertexs.size() - 1 : 0;
//选入的边数
size_t chosen = 0;
while (chosen < need && !heap.empty())
{
//2.每次选最小的边
Edge* mine = heap.top();
heap.pop();
//3.检查并查集,若不成环,加入最小生成树
if (ufs.Union(mine->_srcIndex, mine->_dstIndex))
{
//cout << mine->_srcIndex << "-" << mine->_dstIndex << endl; //测试用
mintree.addEdgeByIndex(mine->_srcIndex, mine->_dstIndex, mine->_w);
total += mine->_w;
++chosen;
}
}
//检查是否是连通图
if (chosen != need)
{
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
head = nullptr;
}
//cout << "Kruskal failed!" << endl;
return false;
}
//cout << "Kruskal success!" << endl;
return true;
}
Prim算法:
cpp
//Prim算法:找最小生成树(集合选顶点)
bool Prim(Self& mintree, V& v) const
{
//清空mintree
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
}
//初始化最小生成树
mintree._vertexs = _vertexs;
mintree._vertexmap = _vertexmap;
mintree._linkTable.assign(_linkTable.size(), nullptr);
//1.新建集合:判断是否已经入最小生成树
size_t need = _vertexs.size() - 1;
size_t n = _vertexs.size();
vector<bool> setv(n, false); //true为已包含的点
size_t topi = GetVertexIndex(v);
setv[topi] = true;
//2.建小根堆,入边
int chosen = 0;
priority_queue<Edge*, vector<Edge*>, Cmp> heap;
for (Edge* e=_linkTable[topi]; e ; e = e->_next)
{
heap.push(e); //这里不需要比较srci和dsti!
}
//3.循环,每次挑最小的边加入最小生成树
while (!heap.empty() && chosen < need)
{
Edge* tope = heap.top();
heap.pop();
while (!heap.empty() && setv[tope->_dstIndex]) //确保终点是新的顶点
{
tope = heap.top();
heap.pop();
}
if (setv[tope->_dstIndex]) break;
mintree.addEdgeByIndex(tope->_srcIndex, tope->_dstIndex, tope->_w);
//cout << _vertexs[tope->_srcIndex] << "->" << _vertexs[tope->_dstIndex] << ":" << tope->_w << endl;
++chosen;
//4.更新集合,更新堆
setv[tope->_dstIndex] = true;
//5.从该边指向的点出发的边入堆(去重)
for (Edge* e=_linkTable[tope->_dstIndex]; e ; e = e->_next)
{
if (!setv[e->_dstIndex]) //新节点,且边不重复(注意这里不能让e->_srcIndex < e->_dstIndex!出发点不一定是按顺序的!
{
heap.push(e);
}
}
}
//5.判断并返回
if (chosen != need)
{
//清空mintree
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
head = nullptr;
}
// cout << "Prim failed!" << endl;
return false;
}
// cout << "Prim success!" << endl;
return true;
}
LinkGraph.h:
cpp
#pragma once
#include<iostream>
#include<vector>
#include<map>
#include<climits>
#include<queue>
#include<cstdlib>
using namespace std;
//并查集
class UnionFindSet
{
private:
vector<int> _ufs;
public:
UnionFindSet(size_t size)
:_ufs(size, -1)
{
}
//查找元素的根
int FindRoot(int x)
{
int root = x;
while (_ufs[root] >= 0)
{
root = _ufs[root];
}
//优化:将该路径上的节点优化为与根直接相连,降低树高度
while (_ufs[x] >= 0) //bug点,注意是大于等于0,否则最后一个父节点不会拼到根下面
{
int parent = _ufs[x];
_ufs[x] = root;
x = parent;
}
return root;
}
//合并两个元素所在的集合/建立关系
bool Union(int x1, int x2)
{
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
//两个元素属于同一个集合
if (root1 == root2) return false;
//优化:数据量小的向数据量大的合并
if (abs(_ufs[root1]) < abs(_ufs[root2]))
{
swap(root1, root2); //if-else的另一种写法
}
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
return true;
}
//集合个数
size_t Count() const
{
size_t ret = 0;
for (size_t i = 0; i < _ufs.size(); ++i)
{
if (_ufs[i] < 0) ++ret;
}
return ret;
}
//打印(测试用)
void Print() const
{
for (int i = 0; i < _ufs.size(); ++i)
{
cout << i << ":" << _ufs[i] << endl;
}
}
};
//边结构体
template<class W>
struct LinkEdge
{
int _srcIndex; //起点下标
int _dstIndex; //终点下标
W _w; //权值
LinkEdge<W>* _next; //next指针(指向同起点下一条边)
LinkEdge(const W& w)
:_srcIndex(-1)
, _dstIndex(-1)
, _w(w)
, _next(nullptr)
{
}
~LinkEdge() {} //不要写delete!
};
//value(vertex), weight(edge)
//邻接表
template<class V, class W, W W_MAX = INT_MAX, bool Direction = false>
class LinkGraph
{
typedef LinkEdge<W> Edge;
typedef LinkGraph<V, W, W_MAX, Direction> Self;
private:
vector<V> _vertexs; //顶点
map<V, size_t> _vertexmap; //顶点和数组下标的映射关系
vector<Edge*> _linkTable; //边
public:
LinkGraph() = default; //默认生成
LinkGraph(const V* vertexs, size_t n) //为什么传指针?-> 更泛用,传入一段连续的内存(依赖地址而不是容器)
{
//初始化顶点
_vertexs.reserve(n); //开出n个顶点的大小
for (size_t i = 0; i < n; ++i)
{
_vertexs.push_back(vertexs[i]);
_vertexmap[vertexs[i]] = i;
}
//初始化边
_linkTable.resize(n, nullptr);
}
//禁用拷贝
//LinkGraph(const Self&) = delete;
LinkGraph(const LinkGraph&) = delete; //类内可以省略模板只写类型名(注入类名机制)
//禁用赋值重载
LinkGraph& operator=(const LinkGraph&) = delete;
//析构邻接表
~LinkGraph()
{
cout << "####################~LinkGraph()####################" << endl;
for (int i = 0; i < _linkTable.size(); ++i)
{
Edge* head = _linkTable[i];
while (head)
{
cout << "删除边:" << _vertexs[head->_srcIndex] << "-" << _vertexs[head->_dstIndex] << endl;
Edge* tmp = head->_next;
delete head;
head = tmp;
}
}
cout << "#####################################################" << endl;
cout << endl;
}
//获取顶点对应下标
size_t GetVertexIndex(const V& v) const
{
auto it = _vertexmap.find(v);
//找到
if (it != _vertexmap.end())
{
return it->second; //返回下标
}
//优化:抛异常
throw invalid_argument("不存在的顶点");
//return -1;
}
//根据下标添加边
void addEdgeByIndex(size_t srci, size_t dsti, const W& w)
{
//添加边(头插法)
Edge* newedge = new Edge(w);
newedge->_srcIndex = srci;
newedge->_dstIndex = dsti;
newedge->_next = _linkTable[srci];
_linkTable[srci] = newedge;
if (dsti == srci) return; //自环时不重复添加(不要放在无向图的new后面!会内存泄漏!)
//无向图:添加另一条边
if (!Direction)
{
Edge* newedge = new Edge(w);
newedge->_srcIndex = dsti;
newedge->_dstIndex = srci;
newedge->_next = _linkTable[dsti];
_linkTable[dsti] = newedge;
}
}
//根据顶点添加边
void addEdgeByValue(const V& src, const V& dst, const W& w)
{
//获取顶点
size_t srci = GetVertexIndex(src);
size_t dsti = GetVertexIndex(dst);
//添加边
addEdgeByIndex(srci, dsti, w);
}
//打印图测试
void Print()
{
//输出相连的边和权值
if (!Direction) //无向图只打印一次
{
for (int i = 0; i < _vertexs.size(); ++i)
{
cout << i << " -> " << _vertexs[i] << " : ";
Edge* tmp = _linkTable[i];
if (!tmp) cout << "nullptr";
while (tmp)
{
if (tmp->_srcIndex <= tmp->_dstIndex) //注意要统一逻辑为小于等于(自环时=)
{
cout << _vertexs[tmp->_dstIndex] << "(" << tmp->_w << ") ";
}
tmp = tmp->_next;
}
cout << endl;
}
return;
}
//有向图
for (int i = 0; i < _vertexs.size(); ++i)
{
cout << i << " -> " << _vertexs[i] << " : ";
Edge* tmp = _linkTable[i];
if (!tmp) cout << "nullptr";
while (tmp)
{
cout << _vertexs[tmp->_dstIndex] << "(" << tmp->_w << ") ";
tmp = tmp->_next;
}
cout << endl;
}
cout << endl;
}
//测试用:返回树/图的权值和(无向图每条边存两份,只算一次)
//只统计 src<dst 的那一份;自环不在生成树里,天然被排除
W TotalWeight() const
{
W sum = W();
for (size_t i = 0; i < _linkTable.size(); ++i)
{
for (Edge* e = _linkTable[i]; e; e = e->_next)
{
if (Direction || e->_srcIndex < e->_dstIndex) sum += e->_w;
}
}
return sum;
}
void _dfs(size_t index, vector<bool>& visit)
{
if (!visit[index])
{
//当前节点
cout << index << "->" << _vertexs[index] << endl;
visit[index] = true;
//相连节点
Edge* it = _linkTable[index];
while(it)
{
_dfs(it->_dstIndex, visit);
it = it->_next;
}
}
}
//dfs深度优先遍历
void dfs(const V& v)
{
size_t n = _vertexs.size();
size_t index = GetVertexIndex(v);
vector<bool> visit(n, 0);
//当前节点
_dfs(index, visit);
//重新遍历找剩下非相连节点
for (size_t i = 0; i < n; ++i)
{
_dfs(i, visit);
}
}
struct Cmp
{
bool operator()(Edge* e1, Edge* e2) const
{
return e1->_w > e2->_w;
}
};
//Kruskal算法:找最小生成树
bool Kruskal(Self& mintree, W& total) const
{
//清空mintree
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
}
//初始化最小生成树
mintree._vertexs = _vertexs;
mintree._vertexmap = _vertexmap;
mintree._linkTable.assign(_linkTable.size(), nullptr);
//1.小根堆,边入堆,无向图只入一次
priority_queue<Edge*, vector<Edge*>, Cmp> heap;
for (size_t i = 0; i < _linkTable.size(); ++i) //遍历边
{
for (Edge* e = _linkTable[i]; e; e = e->_next)
{
if (!Direction && e->_srcIndex > e->_dstIndex) continue;
heap.push(e);
}
}
//贪心选边,用并查集避免带环问题
UnionFindSet ufs(_vertexs.size()); //注意是顶点的数量
total = W();
//需要的边数
const size_t need = _vertexs.size() > 0 ? _vertexs.size() - 1 : 0;
//选入的边数
size_t chosen = 0;
while (chosen < need && !heap.empty())
{
//2.每次选最小的边
Edge* mine = heap.top();
heap.pop();
//3.检查并查集,若不成环,加入最小生成树
if (ufs.Union(mine->_srcIndex, mine->_dstIndex))
{
//cout << mine->_srcIndex << "-" << mine->_dstIndex << endl; //测试用
mintree.addEdgeByIndex(mine->_srcIndex, mine->_dstIndex, mine->_w);
total += mine->_w;
++chosen;
}
}
//检查是否是连通图
if (chosen != need)
{
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
head = nullptr;
}
//cout << "Kruskal failed!" << endl;
return false;
}
//cout << "Kruskal success!" << endl;
return true;
}
//Prim算法:找最小生成树(集合选顶点)
bool Prim(Self& mintree, V& v) const
{
//清空mintree
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
}
//初始化最小生成树
mintree._vertexs = _vertexs;
mintree._vertexmap = _vertexmap;
mintree._linkTable.assign(_linkTable.size(), nullptr);
//1.新建集合:判断是否已经入最小生成树
size_t need = _vertexs.size() - 1;
size_t n = _vertexs.size();
vector<bool> setv(n, false); //true为已包含的点
size_t topi = GetVertexIndex(v);
setv[topi] = true;
//2.建小根堆,入边
int chosen = 0;
priority_queue<Edge*, vector<Edge*>, Cmp> heap;
for (Edge* e=_linkTable[topi]; e ; e = e->_next)
{
heap.push(e); //这里不需要比较srci和dsti!
}
//3.循环,每次挑最小的边加入最小生成树
while (!heap.empty() && chosen < need)
{
Edge* tope = heap.top();
heap.pop();
while (!heap.empty() && setv[tope->_dstIndex]) //确保终点是新的顶点
{
tope = heap.top();
heap.pop();
}
if (setv[tope->_dstIndex]) break;
mintree.addEdgeByIndex(tope->_srcIndex, tope->_dstIndex, tope->_w);
//cout << _vertexs[tope->_srcIndex] << "->" << _vertexs[tope->_dstIndex] << ":" << tope->_w << endl;
++chosen;
//4.更新集合,更新堆
setv[tope->_dstIndex] = true;
//5.从该边指向的点出发的边入堆(去重)
for (Edge* e=_linkTable[tope->_dstIndex]; e ; e = e->_next)
{
if (!setv[e->_dstIndex]) //跨割的边(注意这里不能让e->_srcIndex < e->_dstIndex!出发点不一定是按顺序的!
{
heap.push(e);
}
}
}
//5.判断并返回
if (chosen != need)
{
//清空mintree
for (auto& head : mintree._linkTable)
{
while (head)
{
Edge* tmp = head->_next;
delete head;
head = tmp;
}
head = nullptr;
}
//cout << "Prim failed!" << endl;
return false;
}
//cout << "Prim success!" << endl;
return true;
}
};
7.2 测试示例
八组定向用例 + 随机对拍的完整代码,覆盖:
| 用例 | 考察点 |
|---|---|
| A | 连通全正权 |
| B | 不连通 → 两个都应 false |
| C | 含负权边 → 都应成功(验证 6.2"负权对 MST 无害") |
| D | 权值全相同 → MST 不唯一,但 total 必须相同(验证 6.3) |
| E | n = 1 退化 → success,total = 0 |
| F | 自环边 → 被并查集挡住,不进树 |
| G | 链状图 → 答案唯一 |
| H | 最小边会成环 → 验证判环真的生效 |
| 对拍 | 200 张随机图,Kruskal / Prim / 独立参考三方比 |
测试代码:
cpp
// 最小生成树测试:Kruskal + Prim
#include "LinkGraph.h"
#include <sstream>
#include <random>
#include <string>
#include <algorithm>
#include <climits>
using namespace std;
using Graph = LinkGraph<char, int>;
// 吞掉算法内部和析构函数里的 cout,避免刷屏
struct Silence {
ostringstream oss;
streambuf* old;
Silence() { old = cout.rdbuf(oss.rdbuf()); }
~Silence() { cout.rdbuf(old); }
};
static void banner(const string& s) { cout << "\n==== " << s << " ====\n"; }
// 一条无向边(只存一份)
struct RE { int u, v, w; };
// ---------- 参考实现 1:独立 Kruskal(用 sort,不用 priority_queue,不用 UnionFindSet对象) ----------
static bool refKruskal(int n, const vector<RE>& es, long long& out)
{
vector<RE> e = es;
sort(e.begin(), e.end(), [](const RE& a, const RE& b) { return a.w < b.w; });
vector<int> p(n);
for (int i = 0; i < n; ++i) p[i] = i;
auto find = [&](auto&& self, int x) -> int { return p[x] == x ? x : self(self, p[x]); };
long long s = 0; int cnt = 0;
for (auto& ed : e) {
int a = find(find, ed.u), b = find(find, ed.v);
if (a == b) continue; // 成环,丢弃
p[a] = b; s += ed.w; ++cnt;
}
out = s;
return cnt == n - 1; // 不够 n-1 条 → 不连通
}
// ---------- 参考实现 2:暴力枚举所有 n-1 条边的子集,取最小 ----------
static void dfsCombo(int start, int depth, int n, const vector<RE>& es,
vector<int>& pick, long long& best, bool& found)
{
if (depth == n - 1) {
vector<int> p(n);
for (int i = 0; i < n; ++i) p[i] = i;
auto find = [&](auto&& self, int x) -> int { return p[x] == x ? x : self(self, p[x]); };
long long s = 0; bool bad = false;
for (int idx : pick) {
int a = find(find, es[idx].u), b = find(find, es[idx].v);
if (a == b) { bad = true; break; } // 成环
p[a] = b; s += es[idx].w;
}
if (!bad) { found = true; if (s < best) best = s; }
return;
}
for (int i = start; i < (int)es.size(); ++i) {
pick.push_back(i);
dfsCombo(i + 1, depth + 1, n, es, pick, best, found);
pick.pop_back();
}
}
// 返回:<是否连通, 最优权值和>
static pair<bool, long long> refBrute(int n, const vector<RE>& es)
{
long long best = LLONG_MAX; bool found = false;
vector<int> pick;
dfsCombo(0, 0, n, es, pick, best, found);
return { found, found ? best : 0 };
}
// ---------- 自己实现的两个算法 ----------
struct Res { bool okK, okP; long long tK, tP; };
static Res runBoth(const vector<char>& vs, const vector<RE>& es)
{
Res r{ false, false, 0, 0 };
{
Silence s; // 连析构函数一起吞掉
Graph g(vs.data(), vs.size());
for (auto& e : es) g.addEdgeByValue(vs[e.u], vs[e.v], e.w);
{
Graph mtK; int total = 0;
r.okK = g.Kruskal(mtK, total);
r.tK = r.okK ? total : 0;
}
{
Graph mtP; char st = vs[0]; // Prim 要传起点(非 const 引用)
r.okP = g.Prim(mtP, st);
r.tP = r.okP ? mtP.TotalWeight() : 0;
}
}
return r;
}
// 统一的判定输出:两个算法之间 + 和参考实现之间
static int g_fail = 0;
static void check(const string& name, const vector<char>& vs, const vector<RE>& es, bool expectOk)
{
Res r = runBoth(vs, es);
long long ref = 0; bool refOk = false;
// 边数不多就上暴力,否则退回独立 Kruskal
if ((int)es.size() <= 18) {
auto b = refBrute((int)vs.size(), es);
refOk = b.first; ref = b.second;
}
else {
refOk = refKruskal((int)vs.size(), es, ref);
}
bool bad = false;
string why;
if (r.okK != expectOk) { bad = true; why += "Kruskal返回=" + string(r.okK ? "true" : "false") + " "; }
if (r.okP != expectOk) { bad = true; why += "Prim返回=" + string(r.okP ? "true" : "false") + " "; }
if (r.okK != refOk) { bad = true; why += "Kruskal与参考判定不一致 "; }
if (r.okP != refOk) { bad = true; why += "Prim与参考判定不一致 "; }
if (refOk && r.okK && r.tK != ref) { bad = true; why += "Kruskal权值=" + to_string(r.tK) + "≠参考" + to_string(ref) + " "; }
if (refOk && r.okP && r.tP != ref) { bad = true; why += "Prim权值=" + to_string(r.tP) + "≠参考" + to_string(ref) + " "; }
if (r.okK && r.okP && r.tK != r.tP) { bad = true; why += "两算法权值和不等 "; }
cout << name << "\n"
<< " Kruskal: " << (r.okK ? "success" : "failed ") << " total=" << r.tK << "\n"
<< " Prim : " << (r.okP ? "success" : "failed ") << " total=" << r.tP << "\n"
<< " 参考 : " << (refOk ? "连通" : "不连通") << " total=" << ref << "\n"
<< " → " << (bad ? ("【不一致】" + why) : "一致 ✓") << "\n";
if (bad) ++g_fail;
}
// =========================================================
// 定向用例
// =========================================================
// A 连通、全正权
void TA()
{
banner("A 连通、全正权 expect 两算法 success 且 total 相等");
// 下标:a=0 b=1 c=2 d=3 e=4 f=5 g=6 h=7 i=8
const vector<char> vs = { 'a','b','c','d','e','f','g','h','i' };
vector<RE> es = {
{0,1,4},{0,7,8}, // a-b 4, a-h 8 ← 这两条别漏,漏了 a 就是孤点
{1,2,8},{1,7,11},{2,8,2},{2,5,4},{2,3,7},
{3,5,14},{3,4,9},{4,5,10},{5,6,2},{6,7,1},{6,8,6},{7,8,7}
};
check("A", vs, es, true);
}
// B 不连通:{a,b,c} 和 {d,e} 两块
void TB()
{
banner("B 不连通图(两块) expect 两算法都 failed");
const vector<char> vs = { 'a','b','c','d','e' };
vector<RE> es = { {0,1,1},{1,2,2},{3,4,3} };
check("B", vs, es, false);
}
// C 含负权边:MST 不怕负权(对应上篇 6.2)
void TC()
{
banner("C 含负权边 expect 两算法 success,total 相同(负权对 MST 无害)");
const vector<char> vs = { 'a','b','c','d' };
vector<RE> es = { {0,1,-5},{1,2,-3},{2,3,4},{0,3,10},{0,2,2} };
check("C", vs, es, true);
}
// D 权值大量重复:MST 不唯一,但权值和必须相同
void TD()
{
banner("D 权值全为 1(MST 不唯一) expect total = n-1");
const vector<char> vs = { 'a','b','c','d','e' };
vector<RE> es = { {0,1,1},{0,2,1},{0,3,1},{0,4,1},{1,2,1},{1,3,1},{2,4,1},{3,4,1} };
check("D", vs, es, true);
}
// E 退化:只有一个点
void TE()
{
banner("E n=1(退化) expect success,total=0");
const vector<char> vs = { 'a' };
vector<RE> es = {};
check("E", vs, es, true);
}
// F 自环:应该被并查集挡住,不进树
void TF()
{
banner("F 含自环边 expect success,自环不进树,total 不受影响");
const vector<char> vs = { 'a','b','c' };
vector<RE> es = { {0,1,3},{1,2,4},{0,0,99},{2,2,99} };
check("F", vs, es, true);
}
// G 一条链:答案唯一
void TG()
{
banner("G 链状图(答案唯一) expect success");
const vector<char> vs = { 'a','b','c','d','e' };
vector<RE> es = { {0,1,2},{1,2,3},{2,3,4},{3,4,5} };
check("G", vs, es, true);
}
// H 必须"跳过一条最小边"的图:最小边成环,逼 Kruskal 真的判环
void TH()
{
banner("H 最小边会成环 expect success(验证判环真的生效)");
// a-b-c 三角形:三条边里最小的两条 (1,2) 直接构成树
const vector<char> vs = { 'a','b','c' };
vector<RE> es = { {0,1,1},{1,2,2},{0,2,100} };
check("H", vs, es, true);
}
// =========================================================
// 对拍:随机图,Kruskal vs Prim vs 独立参考
// =========================================================
void Stress()
{
banner("对拍:200 张随机图(3~7 点,边权 -8~15)");
mt19937 rng(20261102);
int totalGraph = 200, failBoth = 0, failK = 0, failP = 0, failJudge = 0;
int negGraph = 0, disconnected = 0;
string first;
for (int iter = 0; iter < totalGraph; ++iter)
{
int n = 3 + (int)(rng() % 5);
int pct = 35 + (int)(rng() % 50); // 边的密度
bool withNeg = (iter % 3 == 0);
vector<char> vs(n);
for (int i = 0; i < n; ++i) vs[i] = (char)('A' + i);
vector<RE> es;
uniform_int_distribution<int> pick(0, 99);
uniform_int_distribution<int> wgen(withNeg ? -8 : 1, 15);
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
if (pick(rng) < pct) {
int w = wgen(rng);
if (w == 0) w = 1; // 避免 0 权干扰观察
es.push_back({ i, j, w });
}
if (withNeg) ++negGraph;
Res r = runBoth(vs, es);
// 参考:边数少用暴力,否则用独立 Kruskal
long long ref = 0; bool refOk = false;
if ((int)es.size() <= 18) {
auto b = refBrute(n, es);
refOk = b.first; ref = b.second;
}
else {
refOk = refKruskal(n, es, ref);
}
if (!refOk) ++disconnected;
bool bad = false;
string why;
if (r.okK != refOk) { bad = true; ++failK; why += "Kruskal判定 "; ++failJudge; }
if (r.okP != refOk) { bad = true; ++failP; why += "Prim判定 "; ++failJudge; }
if (refOk && r.okK && r.tK != ref) { bad = true; ++failK; why += "Kruskal权值 "; }
if (refOk && r.okP && r.tP != ref) { bad = true; ++failP; why += "Prim权值 "; }
if (r.okK && r.okP && r.tK != r.tP) { bad = true; ++failBoth; why += "两算法不等 "; }
if (bad && first.empty()) {
ostringstream os;
os << "图#" << iter << " n=" << n << " 边数=" << es.size()
<< " 你的 K=" << (r.okK ? "ok" : "fail") << "/" << r.tK
<< " P=" << (r.okP ? "ok" : "fail") << "/" << r.tP
<< " 参考=" << (refOk ? "连通" : "不连通") << "/" << ref
<< " [" << why << "]";
first = os.str();
}
}
cout << "随机图 " << totalGraph << " 张 | 含负权 " << negGraph
<< " 张 | 其中不连通 " << disconnected << " 张\n\n";
cout << "Kruskal vs 参考 不一致: " << failK << "\n";
cout << "Prim vs 参考 不一致: " << failP << "\n";
cout << "两算法之间权值不等 : " << failBoth << "\n";
if (!first.empty()) cout << "\n[首个失败] " << first << "\n";
if (failK == 0 && failP == 0 && failBoth == 0) cout << "\n>>> 全部通过\n";
}
int main()
{
TA(); TB(); TC(); TD(); TE(); TF(); TG(); TH();
Stress();
cout << "\n============================\n";
cout << "定向用例不一致数: " << g_fail << (g_fail ? " ← 有失败" : " ✓ 全过") << "\n";
return 0;
}
7.3 打印结果
==== A 连通、全正权 expect 两算法 success 且 total 相等 ====
A
Kruskal: success total=37
Prim : success total=37
参考 : 连通 total=37
→ 一致 ✓
==== B 不连通图(两块) expect 两算法都 failed ====
B
Kruskal: failed total=0
Prim : failed total=0
参考 : 不连通 total=0
→ 一致 ✓
==== C 含负权边 expect 两算法 success,total 相同(负权对 MST 无害) ====
C
Kruskal: success total=-4
Prim : success total=-4
参考 : 连通 total=-4
→ 一致 ✓
==== D 权值全为 1(MST 不唯一) expect total = n-1 ====
D
Kruskal: success total=4
Prim : success total=4
参考 : 连通 total=4
→ 一致 ✓
==== E n=1(退化) expect success,total=0 ====
E
Kruskal: success total=0
Prim : success total=0
参考 : 连通 total=0
→ 一致 ✓
==== F 含自环边 expect success,自环不进树,total 不受影响 ====
F
Kruskal: success total=7
Prim : success total=7
参考 : 连通 total=7
→ 一致 ✓
==== G 链状图(答案唯一) expect success ====
G
Kruskal: success total=14
Prim : success total=14
参考 : 连通 total=14
→ 一致 ✓
==== H 最小边会成环 expect success(验证判环真的生效) ====
H
Kruskal: success total=3
Prim : success total=3
参考 : 连通 total=3
→ 一致 ✓
==== 对拍:200 张随机图(3~7 点,边权 -8~15) ====
随机图 200 张 | 含负权 67 张 | 其中不连通 38 张
Kruskal vs 参考 不一致: 0
Prim vs 参考 不一致: 0
两算法之间权值不等 : 0
>>> 全部通过
============================
定向用例不一致数: 0 ✓ 全过
八、坑点以及口述的错误点
| # | 说法 / 坑 | 实际情况 |
|---|---|---|
| 1 | "每条边的权值最小" | 是权值和最小,不是每条边最小 |
| 2 | "最后检查时,若存在孤立点或带环则不合法" | 两件事要分开 :判环用并查集,判连通用 chosen != n-1。并查集之后不可能有环 |
| 3 | "全局贪心 / 局部贪心" | 不是标准术语。改成按边扩张 / 按点扩张(割),名字自带解释 |
| 4 | Prim 去重按 srcIndex < dstIndex |
错 。边方向和小标大小无关,要按 setv[dst] 判断 |
一开始我没有注意无向图的边重复入堆了,还以为是Print函数双向打印问题,后来在调试的时候发现,是无向图的每条边都是双向的,因此入堆了两次,需要在入堆前进行去重。于是:
改前:

改后:

九、总结
两个算法,两条扩张路线:
Kruskal :按【边】扩张 ------ 全图挑最小,用并查集挡环
Prim :按【点】扩张 ------ 只挑跨割的边,集合天然无环
三条准则:
| 准则 | Kruskal 怎么满足 | Prim 怎么满足 |
|---|---|---|
| 只能用图中的边 | 从边集里挑 | 从邻接表里挑 |
恰好 n-1 条 |
chosen < need 计数 |
同 |
| 不能成环 | 并查集 | 集合划分(天然) |
复习重点:
- "最小"指的是权值和,不是每条边
- Kruskal 判环靠并查集,Prim 不判环(切割保证)
- 两个算法最后都用
chosen != n-1判连通,如果成立则清空树并返回 false - MST 要求连通,最短路径不要求------这是上下两篇最该分清的一条(完整对照在下篇第二节)
- 负权对 MST 无害,对最短路径有害(边数固定 vs 路径可延长)
- 无向图边去重:
srcIndex > dstIndex跳过;不能用下标大小判断 Prim 的去重
十、引子:最短路径
本篇到此结束。下篇会从本篇留下的第一个问题开始:
本篇里,"挑最小的边收下"这件事从来不需要证明 ------因为 MST 的边数是固定的,收满
n-1条就完事。下篇里,同样一句"挑最小的收下",却要赌一个前提:绕远路只会更远。
同一个动作,为什么一个不用证、一个必须证? 这就是下篇要讲的东西。