[C++高阶数据结构·图论上] 最小生成树:为什么“选边”和“选点”是两条路?

关于文中的标记

【正】 = 我第一次口述这段时讲错了,这里是更正和原因

【补】 = 整理时补的一层
本系列是上下两篇,本篇是上篇:

篇 讲什么 一句话
上篇(本篇) 最小生成树: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,就是"这两个点已经连着了"。

关于并查集的介绍,可以跳转作者本篇文章:

C++高阶数据结构 并查集原理 | 模拟实现 | 力扣算法题实战

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 计数 同
不能成环 并查集 集合划分(天然)

复习重点:

  1. "最小"指的是权值和,不是每条边
  2. Kruskal 判环靠并查集,Prim 不判环(切割保证)
  3. 两个算法最后都用 chosen != n-1 判连通,如果成立则清空树并返回 false
  4. MST 要求连通,最短路径不要求------这是上下两篇最该分清的一条(完整对照在下篇第二节)
  5. 负权对 MST 无害,对最短路径有害(边数固定 vs 路径可延长)
  6. 无向图边去重:srcIndex > dstIndex 跳过;不能用下标大小判断 Prim 的去重

十、引子:最短路径

本篇到此结束。下篇会从本篇留下的第一个问题开始:

本篇里,"挑最小的边收下"这件事从来不需要证明 ------因为 MST 的边数是固定的,收满 n-1 条就完事。

下篇里,同样一句"挑最小的收下",却要赌一个前提:绕远路只会更远。

同一个动作,为什么一个不用证、一个必须证? 这就是下篇要讲的东西。

相关推荐
Bczheng12 小时前
VS2022 安装MFC和QT
c++·qt·mfc
dialect_2 小时前
CODEFORCES ROUND 1127 DIV2
开发语言·c++·算法
悠仁さん10 小时前
【C++】AVL 树
c++
wuminyu10 小时前
JVM规范学习总结:解释器与JIT编译器如何处理JVM字节码
java·linux·c语言·jvm·c++
小徐不徐说11 小时前
序列化与反序列化:把 C++ 对象可靠地保存下来
开发语言·数据库·c++·php
ragnwang11 小时前
Notes: Python pybind11 import C++ module
开发语言·c++·python
充值内卷12 小时前
我给桌面加了个鱼缸:C++ 写后端,WebView2 里养一座 Three.js 小岛
开发语言·javascript·c++
樱花树下的数学喵15 小时前
手写简易 STL string|C++ 字符串底层实现
开发语言·c++
AI吃大瓜15 小时前
指针压力表读数3:C/C++实现指针压力表读数(可实时检测)
c++·指针读数·指针压力表·压力表自动读数·压力表读数