【数据结构】【学习笔记】最小生成树
笔者偷懒中。。。最小生成树的实现方法有两种,稀疏图时用Kruskal算法,用并查集和排序来获得;稠密图时用Prim算法,带权的广度优先搜索。
最小生成树(Minimum Spanning Tree, MST)是在一个带权的无向连通图中,用总权重最小的边把所有节点连接起来且不形成环的树状子图。
现实类比:铺设光纤
假设要把 5 个城市用光纤连接起来,两两之间铺设的成本不同。最省钱且能保证任意两座城市都能相互通信的铺设方案,就是这 5 个城市构成的最小生成树。
⚙️定义
- 生成树(Spanning Tree):在一个包含 \(V\) 个节点的无向连通图中,任意一个包含全部 \(V\) 个节点、且仅用 \(V - 1\) 条边使所有节点相连的无环连通子图。
- 生成树的权值:生成树中所有被选中的边的权重加和(\(\sum w_i\))。
- 最小生成树(MST):在原图的所有可能生成的树中,边的权值和最小的那棵(或几棵)生成树。
🔎特性
- 覆盖全节点:必须包含原图中的所有节点(假设为 \(V\) 个)。
- 边数固定:刚好包含 \(V - 1\) 条边(少一条会导致节点不连通,多一条必然形成环)。
- 无环结构:是一棵树,节点之间存在唯一路径,绝对不包含回路。
- 权重和最小:所有被选中的边的权重之和,在所有可行的连通方案中是最小的(注:最小生成树可能不唯一,但最小权重和是唯一的)。
🤓实现
两大经典求解算法
| 算法名称 | 核心策略 | 适合场景 | 关键数据结构 |
|---|---|---|---|
| Kruskal 算法 | 加边法:按边权从小到大排序,优先选小边,只要不形成环就加入树中 | 稀疏图(边数较少) | 并查集(Union-Find) |
| Prim 算法 | 加点法:从一个初始节点开始,每次挑一条连接“树内”和“树外”的最小边,把新节点拉入树中 | 稠密图(边数极多) | 优先队列 / 最小堆 |
1. Kruskal 算法
Kruskal 的精髓在于:贪心(按边权排序) + 并查集(高效检测回路)。
核心实现 (C++)
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 1. 定义图的边
struct Edge {
int u, v, weight;
// 重载小于号,按边权从小到大排序
bool operator<(const Edge& other) const {
return weight < other.weight;
}
};
// 2. 并查集结构(用于判断加入某条边后是否会形成环)
class DSU {
private:
vector<int> parent;
public:
DSU(int n) {
parent.resize(n + 1);
for (int i = 0; i <= n; ++i) parent[i] = i;
}
int find(int i) {
if (parent[i] == i) return i;
return parent[i] = find(parent[i]); // 路径压缩
}
bool unite(int i, int j) {
int rootI = find(i);
int rootJ = find(j);
if (rootI != rootJ) {
parent[rootI] = rootJ; // 合并两个集合
return true;
}
return false; // 两个节点已连通,若再连边会形成环!
}
};
// 3. Kruskal 求解最小生成树
int kruskal(int n, vector<Edge>& edges) {
// 步骤 A:按边权从小到大排序
sort(edges.begin(), edges.end());
DSU dsu(n);
int mstWeight = 0; // 累计最小生成树的总权重
int edgesCount = 0; // 统计已选中的边数
// 步骤 B:贪心遍历每一条边
for (const auto& edge : edges) {
// 如果 u 和 v 不在同一个连通块中,说明加入此边不会产生环
if (dsu.unite(edge.u, edge.v)) {
mstWeight += edge.weight;
edgesCount++;
// 只要收集到了 V - 1 条边,最小生成树构建完成,提前退出
if (edgesCount == n - 1) break;
}
}
// 若边数不足 n - 1,说明原图不连通,无法构成生成树
return (edgesCount == n - 1) ? mstWeight : -1;
}
2. Prim 算法
核心:
感觉有点像选择排序和广度优先搜索。
Prim 算法本质上就是一个“加权版的 BFS”。
在人工智能和图论中,这种基于优先队列的广度优先搜索也被称为 均等代价搜索(Uniform Cost Search)。
- 起点初始化:任意选择一个起始节点放入“已建好的树内”,其余节点为“树外”。
- 切分选择(贪心):维护所有连接“树内”与“树外”的切分边,借由小顶堆(优先队列)每次取出权值最小且目标节点未访问过的边。
- 扩展树体:将该边的目标节点拉入“树内”,同时将其引出的所有连接“树外”的邻边推入优先队列。
- 循环终止:重复上述步骤,直到所有 \(V\) 个节点均已加入树内。
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
// 邻接表边节点定义:{weight, toNode}
using pii = pair<int, int>;
/**
* Prim 算法求解最小生成树
* @param n 节点总数 (节点编号 1 ~ n)
* @param adj 邻接表,adj[u] 存储 {weight, v}
* @param startNode 起始节点编号,默认为 1
* @return 最小生成树的总权重,若图不连通则返回 -1
*/
int prim(int n, const vector<vector<pii>>& adj, int startNode = 1) {
vector<bool> visited(n + 1, false); // 标记节点是否已在“树内”
// 小顶堆:自动按边权从小到大排序,元素格式为 {weight, node}
priority_queue<pii, vector<pii>, greater<pii>> pq;
int mstWeight = 0; // 累加最小生成树总边权
int visitedCount = 0; // 记录已拉入树内的节点数量
// 1. 将起始节点入堆(到起始节点自身的边权设为 0)
pq.push({0, startNode});
while (!pq.empty()) {
auto [weight, u] = pq.top();
pq.pop();
// 若当前节点已在树内,忽略该冗余边(防止重复加入/形成环)
if (visited[u]) continue;
// 2. 将节点 u 正式划入树内
visited[u] = true;
mstWeight += weight;
visitedCount++;
// 3. 遍历节点 u 的所有邻边,将连接到“树外”的边压入小顶堆
for (const auto& edge : adj[u]) {
int edgeWeight = edge.first;
int neighbor = edge.second;
if (!visited[neighbor]) {
pq.push({edgeWeight, neighbor});
}
}
}
// 若连通节点数等于总节点数,说明 MST 构建成功
return (visitedCount == n) ? mstWeight : -1;
}
❔题目
1. 找到最小生成树里的关键边和伪关键边
解题思路
分析边种类定义:
- 关键边:存在于所有最小生成树中的边。一旦把它从图中删掉,算出来的 MST 总权重必然增加,或者图直接断开无法连通。
- 伪关键边:存在于至少一个最小生成树中、但并非所有 MST 都有的边。这意味着它有等价的边,用它能凑出最小生成树,不用它也能用别的边凑出来。
- 无关边:任何最小生成树都绝对用不上的边。
解法——“删边法” 与 “强选法” 测试
核心:先构建最小生成树,获得最小权值,然后从头查看边是否可被替换。
因为 \(n\) 和边数 \(m\) 的范围都不大,最标准且最不容易出错的解法是对过程进行测试:
- 基准线先对原图跑一遍 Kruskal 算法,算出标准的最小生成树总权重 stdWeight。
- 逐条测试每一条边 edges[i]
- 第一步:测是不是【关键边】
- 在图中彻底删掉 edges[i],对剩下的边跑 Kruskal 算法。
- 如果算出的新权重 newWeight > stdWeight,或者图断开无法连通 ➔ edges[i] 是关键边。
- 第二步:测是不是【伪关键边】
- 如果它不是关键边,我们强制第 1 步就选中 edges[i],然后再对其余边跑 Kruskal 算法。
- 如果算出的新权重 forceWeight == stdWeight,说明把它强行塞进生成树里依然能拿到最优解 ➔ edges[i] 是伪关键边。
- 第一步:测是不是【关键边】
实现
class DSU {
private parent: number[];
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
}
find(i: number): number {
if (this.parent[i] === i) return i;
return (this.parent[i] = this.find(this.parent[i])); // 路径压缩
}
unite(i: number, j: number): boolean {
const rootI = this.find(i);
const rootJ = this.find(j);
if (rootI !== rootJ) {
this.parent[rootI] = rootJ;
return true;
}
return false;
}
}
function findCriticalAndPseudoCriticalEdges(n: number, edges: number[][]): number[][] {
const m = edges.length;
// 带有原始下标的边列表:[from, to, weight, originalId]
const sortedEdges: number[][] = [];
for (let i = 0; i < m; i++) {
sortedEdges.push([edges[i][0], edges[i][1], edges[i][2], i]);
}
// 按边权升序排序
sortedEdges.sort((a, b) => a[2] - b[2]);
// 计算 MST 权重的辅助函数
function getMSTWeight(ignoreId: number, forceId: number): number {
const dsu = new DSU(n);
let weight = 0;
let count = 0;
// 优先强制选择 forceId 对应的边
if (forceId !== -1) {
for (const edge of sortedEdges) {
if (edge[3] === forceId) {
dsu.unite(edge[0], edge[1]);
weight += edge[2];
count++;
break;
}
}
}
// 按顺序选择其余边
for (const edge of sortedEdges) {
if (edge[3] === ignoreId || edge[3] === forceId) continue;
if (dsu.unite(edge[0], edge[1])) {
weight += edge[2];
count++;
}
}
return count === n - 1 ? weight : Infinity;
}
// 1. 获取基准 MST 权重
const stdWeight = getMSTWeight(-1, -1);
const critical: number[] = [];
const pseudoCritical: number[] = [];
// 2. 对每条原始边做“删边”和“强选”实验
for (let i = 0; i < m; i++) {
// 忽略该边后权重增大/断联 ➔ 关键边
if (getMSTWeight(i, -1) > stdWeight) {
critical.push(i);
}
// 强制选用该边后仍可构成最优 MST ➔ 伪关键边
else if (getMSTWeight(-1, i) === stdWeight) {
pseudoCritical.push(i);
}
}
return [critical, pseudoCritical];
}
using System;
using System.Collections.Generic;
public class DSU {
private int[] parent;
public DSU(int n) {
parent = new int[n];
for (int i = 0; i < n; i++) parent[i] = i;
}
public int Find(int i) {
if (parent[i] == i) return i;
return parent[i] = Find(parent[i]); // 路径压缩
}
public bool Unite(int i, int j) {
int rootI = Find(i);
int rootJ = Find(j);
if (rootI != rootJ) {
parent[rootI] = rootJ;
return true;
}
return false;
}
}
public class Solution {
private int GetMSTWeight(int n, List<int[]> sortedEdges, int ignoreId, int forceId) {
DSU dsu = new DSU(n);
int weight = 0;
int count = 0;
// 优先强行选择指定边
if (forceId != -1) {
foreach (var edge in sortedEdges) {
if (edge[3] == forceId) {
dsu.Unite(edge[0], edge[1]);
weight += edge[2];
count++;
break;
}
}
}
// 正常按权值跑 Kruskal
foreach (var edge in sortedEdges) {
if (edge[3] == ignoreId || edge[3] == forceId) continue;
if (dsu.Unite(edge[0], edge[1])) {
weight += edge[2];
count++;
}
}
return count == n - 1 ? weight : int.MaxValue;
}
public IList<IList<int>> FindCriticalAndPseudoCriticalEdges(int n, int[][] edges) {
int m = edges.Length;
List<int[]> sortedEdges = new List<int[]>();
// 保存原始下标:[from, to, weight, original_id]
for (int i = 0; i < m; i++) {
sortedEdges.Add(new int[] { edges[i][0], edges[i][1], edges[i][2], i });
}
// 按边权升序排列
sortedEdges.Sort((a, b) => a[2].CompareTo(b[2]));
// 1. 基准 MST 权重
int stdWeight = GetMSTWeight(n, sortedEdges, -1, -1);
List<int> critical = new List<int>();
List<int> pseudoCritical = new List<int>();
// 2. 依次测试每条原始边
for (int i = 0; i < m; i++) {
// 删边测试:权重增加则为关键边
if (GetMSTWeight(n, sortedEdges, i, -1) > stdWeight) {
critical.Add(i);
}
// 强选测试:仍能构成最优生成树则为伪关键边
else if (GetMSTWeight(n, sortedEdges, -1, i) == stdWeight) {
pseudoCritical.Add(i);
}
}
return new List<IList<int>> { critical, pseudoCritical };
}
}
#include <vector>
#include <algorithm>
#include <climits>
using namespace std;
// 1. 并查集 (DSU) 数据结构
class DSU {
vector<int> parent;
public:
DSU(int n) {
parent.resize(n);
for (int i = 0; i < n; ++i) parent[i] = i;
}
int find(int i) {
if (parent[i] == i) return i;
return parent[i] = find(parent[i]); // 路径压缩
}
bool unite(int i, int j) {
int rootI = find(i);
int rootJ = find(j);
if (rootI != rootJ) {
parent[rootI] = rootJ;
return true;
}
return false; // 已在同一连通块中,跳过以防产生环
}
};
class Solution {
private:
// 计算 MST 权重的通用辅助函数
// ignoreId: 需要强制忽略(删除)的原始边 ID
// forceId: 需要强制首先加入树中的原始边 ID
int getMSTWeight(int n, const vector<vector<int>>& sortedEdges, int ignoreId, int forceId) {
DSU dsu(n);
int weight = 0;
int count = 0;
// 【强选逻辑】:若指定了 forceId,优先将该边压入 MST
if (forceId != -1) {
for (const auto& edge : sortedEdges) {
if (edge[3] == forceId) {
dsu.unite(edge[0], edge[1]);
weight += edge[2];
count++;
break;
}
}
}
// 【正常贪心】:按权值升序遍历其余边
for (const auto& edge : sortedEdges) {
// 跳过已被删去或已被强制选中的边
if (edge[3] == ignoreId || edge[3] == forceId) continue;
if (dsu.unite(edge[0], edge[1])) {
weight += edge[2];
count++;
}
}
// 若选中的边数不足 n - 1,说明无法构成连通生成树,返回极大值
return count == n - 1 ? weight : INT_MAX;
}
public:
vector<vector<int>> findCriticalAndPseudoCriticalEdges(int n, vector<vector<int>>& edges) {
int m = edges.size();
vector<vector<int>> sortedEdges(m);
// 绑定原始下标:[from, to, weight, original_id]
for (int i = 0; i < m; ++i) {
sortedEdges[i] = {edges[i][0], edges[i][1], edges[i][2], i};
}
// 按边权从小到大排序
sort(sortedEdges.begin(), sortedEdges.end(), [](const vector<int>& a, const vector<int>& b) {
return a[2] < b[2];
});
// 1. 求解不加干涉时的标准最小生成树权重
int stdWeight = getMSTWeight(n, sortedEdges, -1, -1);
vector<int> critical;
vector<int> pseudoCritical;
// 2. 依次测试每一条原始边
for (int i = 0; i < m; ++i) {
// 测试 A:删去边 i 后,MST 权重变大或无法构成树 ➔ 关键边
if (getMSTWeight(n, sortedEdges, i, -1) > stdWeight) {
critical.push_back(i);
}
// 测试 B:强选边 i 后,仍能达到与最优解相同的总权重 ➔ 伪关键边
else if (getMSTWeight(n, sortedEdges, -1, i) == stdWeight) {
pseudoCritical.push_back(i);
}
}
return {critical, pseudoCritical};
}
};
复杂度分析
- 时间复杂度:\(O(M^2 \cdot \alpha(N))\),其中 \(M\) 为边数。
排序耗时 \(O(M \log M)\),后续共进行了 \(2M + 1\) 次 Kruskal 运行,每次运行为 \(O(M \cdot \alpha(N))\)。由于题目数据规模通常满足 \(M \le 200\),该算法可在数十毫秒内快速通过。 - 空间复杂度:\(O(N + M)\)
主要用于保存附带原始 ID 的边列表以及并查集的数组空间。
2. 连接所有点的最小费用
解题思路
因为每个点都有边,所以使用Prim算法,不过,对于完全图,有一个经典优化技巧:放弃优先队列,改用普通的 minDist 数组做线性扫描(即“朴素 Prim”)。这样可以进一步提升性能。
实现
function minCostConnectPoints(points: number[][]): number {
const n = points.length;
const minDist = new Array(n).fill(Infinity);
const visited = new Array(n).fill(false);
minDist[0] = 0; // 从 0 号节点开始构建生成树
let mstWeight = 0;
for (let i = 0; i < n; i++) {
// 1. 寻找当前不在树内、且距离树最近的节点 u
let u = -1;
for (let j = 0; j < n; j++) {
if (!visited[j] && (u === -1 || minDist[j] < minDist[u])) {
u = j;
}
}
// 2. 将 u 正式划入树内,并累加边权
visited[u] = true;
mstWeight += minDist[u];
// 3. 用节点 u 更新所有其余“树外”节点到树的最短距离
for (let v = 0; v < n; v++) {
if (!visited[v]) {
const dist = Math.abs(points[u][0] - points[v][0]) + Math.abs(points[u][1] - points[v][1]);
if (dist < minDist[v]) {
minDist[v] = dist;
}
}
}
}
return mstWeight;
}
public class Solution {
public int MinCostConnectPoints(int[][] points) {
int n = points.Length;
int[] minDist = new int[n];
Array.Fill(minDist, int.MaxValue);
bool[] visited = new bool[n];
minDist[0] = 0; // 从 0 号节点开始构建生成树
int mstWeight = 0;
for (int i = 0; i < n; i++) {
// 1. 寻找当前不在树内、且距离树最近的节点 u
int u = -1;
for (int j = 0; j < n; j++) {
if (!visited[j] && (u == -1 || minDist[j] < minDist[u])) {
u = j;
}
}
// 2. 将 u 正式划入树内,并累加边权
visited[u] = true;
mstWeight += minDist[u];
// 3. 用节点 u 更新所有其余“树外”节点到树的最短距离
for (int v = 0; v < n; v++) {
if (!visited[v]) {
int dist = Math.Abs(points[u][0] - points[v][0]) + Math.Abs(points[u][1] - points[v][1]);
minDist[v] = Math.Min(minDist[v], dist);
}
}
}
return mstWeight;
}
}
using pii = pair<int, int>;
class Solution {
public:
int minCostConnectPoints(vector<vector<int>>& points) {
int n = points.size();
vector<bool> visited(n+1, false); // 标记节点是否已在树内
priority_queue<pii, vector<pii>, greater<pii>> pq;
int mstWeight = 0;
int visitedCount = 0;
pq.push({0, 0});
while(!pq.empty()){
auto [weight, u] = pq.top();
pq.pop();
if(visited[u]) continue;
visited[u] = true;
mstWeight += weight;
visitedCount++;
for(int i = 0; i < n; i++){
if(i != u && !visited[i]){
int edgeWeight = abs(points[i][0] - points[u][0]) + abs(points[i][1] - points[u][1]);
pq.push({edgeWeight, i});
}
}
}
return (visitedCount == n) ? mstWeight : -1;
}
};
class Solution {
public:
int minCostConnectPoints(vector<vector<int>>& points) {
int n = points.size();
// minDist[i] 表示节点 i 到当前“已建好的树”的最短距离
vector<int> minDist(n, INT_MAX);
vector<bool> visited(n, false); // 标记节点是否已加入树中
minDist[0] = 0; // 从 0 号点开始建树
int mstWeight = 0;
for (int i = 0; i < n; ++i) {
// 1. O(N) 寻找当前不在树内、且距离树最近的节点 u
int u = -1;
for (int j = 0; j < n; ++j) {
if (!visited[j] && (u == -1 || minDist[j] < minDist[u])) {
u = j;
}
}
// 2. 将 u 拉入树内,并累加边权
visited[u] = true;
mstWeight += minDist[u];
// 3. 用节点 u 更新所有其余“树外”节点到树的最短距离
for (int v = 0; v < n; ++v) {
if (!visited[v]) {
int dist = abs(points[u][0] - points[v][0]) + abs(points[u][1] - points[v][1]);
minDist[v] = min(minDist[v], dist);
}
}
}
return mstWeight;
}
};
复杂度分析
- 通用Prim
- 时间复杂度:\(O(N^2 \log N)\)
- 空间复杂度:\(O(N^2)\)
- 朴素Prim
- 时间复杂度:\(O(N^2)\)
- 空间复杂度:\(O(N)\)
引用
[1] 力扣探索模式
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号