使用 C++ 解决一个简单的图论问题 ------ 最小生成树(以 Prim 算法为例),并且使用 Graphviz 库来生成结果图。
在图论中,"边权之和最小" 是最小生成树(MST)的核心目标,其含义和背景可以从以下几个方面解释:
一、基础定义:什么是 "边权之和"?
- 边权:图中每条边的权重(Weight),可以代表实际问题中的成本、距离、时间、容量等量化指标。
- 边权之和:对于一个子图(如生成树),将其中所有边的权重相加得到的总和。
二、"最小" 的具体含义:为什么要让边权之和最小?
在 无向连通图 中,生成树的定义是:
- 包含图中 所有顶点(必须覆盖所有节点)。
- 是一棵 树(即无环,且边数为 \(|V| - 1\),V 为顶点数)。
最小生成树(MST) 是所有可能的生成树中,边权之和最小的那一棵。 例如,假设有一个包含 4 个顶点的图,可能有多种生成树(如下图示),其中边权和最小的即为 MST:
顶点A ─(2)─ 顶点B 顶点A ─(1)─ 顶点C
│(3) │(1)
顶点C ─(1)─ 顶点D 顶点B ─(3)─ 顶点D
边权和:2+3+1=6 边权和:1+1+3=5(MST)
三、数学表达与约束条件
设图 \(G = (V, E)\),其中顶点集合 \(V = \{v_1, v_2, \dots, v_n\}\),边集合 \(E = \{e_1, e_2, \dots, e_m\}\),每条边 \(e_i\) 的权重为 \(w(e_i)\)。 生成树 \(T = (V, E_T)\) 需满足:
- \(E_T \subseteq E\),且 \(|E_T| = |V| - 1\)(无环,连通所有顶点)。
- 目标是最小化 \(\sum_{e \in E_T} w(e)\),即:\(\min \sum_{e \in E_T} w(e)\)
四、实际意义:为什么需要 "边权之和最小"?
"边权之和最小" 的应用场景通常与 优化问题 相关,例如:
- 网络建设 :
- 若边权代表铺设电缆的成本,最小生成树表示用最小成本连接所有节点的方案。
- 电路设计 :
- 边权代表导线长度,MST 可最小化电路中导线的总长度。
- 物流规划 :
- 边权代表运输距离,MST 可找到连接所有地点的最短路径网络(无环,避免冗余)。
五、与 "生成树" 的本质区别
- 生成树:只要满足 "包含所有顶点且无环" 即可,不考虑边权和。
- 最小生成树 :在所有生成树中,额外要求边权和 全局最小,是一种带优化目标的生成树。 (注:若图不连通,则不存在生成树;若边权可能为负,Prim 算法仍适用,但 Kruskal 算法需调整)
六、如何求解 "边权之和最小"?
通过 贪心算法 (如 Prim 算法、Kruskal 算法)或 优先队列优化 来高效找到 MST,核心思想是:
- Prim 算法:从一个顶点出发,每次选择当前生成树到未访问顶点的最小边("加点法")。
- Kruskal 算法:按边权从小到大排序,每次选择不构成环的最小边("加边法")。
总结
"边权之和最小" 是最小生成树的核心目标,它要求在连通所有顶点的无环子图中,选择边权总和最小的方案。这一概念在实际问题中对应 "最小成本连接所有节点",通过经典算法可高效求解,是图论中优化问题的基础应用之一。
实现思路
- Prim 算法:用于在加权连通图中找到最小生成树。
- Graphviz:一个开源的图形可视化工具,通过生成 DOT 语言描述的图形文件,然后转换为图片格式(如 PNG)。

代码解释
- Prim 算法:通过优先队列来选择当前最小权值的边,逐步构建最小生成树。
- 生成 DOT 文件:将图的信息和最小生成树的边信息写入 DOT 文件,方便后续使用 Graphviz 工具将其转换为图片。
使用方法
-
编译并运行上述代码,会生成一个名为
mst.dot
的文件。 -
安装 Graphviz 工具,然后在命令行中运行以下命令将 DOT 文件转换为 PNG 图片:
dot -Tpng mst.dot -o mst.png
这样就可以得到一个可视化的最小生成树图片。
Prim 算法与最小生成树问题
1. 什么是 Prim 算法?
Prim 算法是一种用于求解 ** 加权无向图中最小生成树(Minimum Spanning Tree, MST)** 的贪心算法。 最小生成树 的定义:包含图中所有顶点,且边权之和最小的无环子图(树结构)。 Prim 算法的核心思想是:从任意一个顶点出发,逐步扩展生成树,每次选择当前生成树到其他顶点的最小边,直到所有顶点都被包含。
2. Prim 算法的核心步骤(以邻接表 + 优先队列实现为例)
假设图为 \(G = (V, E)\),顶点集合 \(V = \{0, 1, 2, \dots, n-1\}\),边权非负。
步骤 1:初始化
- 选择一个起始顶点(如顶点 0),标记为 "已访问",加入生成树。
- 维护三个数组:
key[]
:记录每个顶点到当前生成树的最小边权,初始化为无穷大(起始顶点的key
设为 0)。parent[]
:记录生成树中每个顶点的父节点,用于重构生成树。visited[]
:标记顶点是否已加入生成树。
步骤 2:扩展生成树(贪心选择)
- 使用 ** 优先队列(最小堆)** 存储顶点,按
key
值排序,每次取出key
最小的顶点 u。 - 遍历 u 的所有邻接顶点 v:
- 如果 v 未被访问,且 u 到 v 的边权小于 v 的当前
key
值:- 更新 v 的
key
值为该边权。 - 记录 v 的父节点为 u。
- 将 v 加入优先队列。
- 更新 v 的
- 如果 v 未被访问,且 u 到 v 的边权小于 v 的当前
步骤 3:重复直到所有顶点加入
- 重复步骤 2,直到所有顶点被标记为 "已访问"。此时,
parent[]
数组存储了最小生成树的边关系。
- 初始化 :起始顶点 0,
key[0]=0
,其他顶点key
为无穷大。 - 第一次迭代 :取出顶点 0,遍历邻接顶点 1(边权 2)、3(边权 6)。更新它们的
key
为 2 和 6,父节点为 0。 - 第二次迭代 :优先队列中最小
key
是顶点 1(key=2),取出后遍历其邻接顶点 0(已访问)、2(边权 3)、3(边权 8)、4(边权 5)。更新顶点 2 的key
为 3,顶点 4 的key
为 5,父节点分别为 1。 - 第三次迭代 :最小
key
是顶点 2(key=3),取出后遍历邻接顶点 1(已访问)、4(边权 7)。顶点 4 的当前key
是 5,7 大于 5,不更新。 - 第四次迭代 :最小
key
是顶点 4(key=5),取出后遍历邻接顶点 1(已访问)、2(已访问)、3(边权 9)。顶点 3 的当前key
是 6,9 大于 6,不更新。 - 第五次迭代:最后取出顶点 3(key=6),所有顶点已访问,算法结束。
最终生成树的边为: 0-1(2)、1-2(3)、1-4(5)、0-3(6),总权值 2+3+5+6=16。

4. 算法复杂度
- 邻接矩阵 + 数组实现 (适合稠密图):时间复杂度 \(O(V^2)\),无需优先队列,每次遍历所有未访问顶点找最小
key
。 - 邻接表 + 优先队列实现(适合稀疏图):时间复杂度 \(O(E \log V)\),每次更新优先队列的时间为 \(O(\log V)\)。
5. 与 Kruskal 算法的区别
- Prim 算法 :加点法 ,从顶点出发,每次扩展生成树的顶点,适合边稠密或顶点少的图。
- Kruskal 算法 :加边法 ,按边权从小到大排序,每次选不构成环的最小边,适合边稀疏的图。
6. 应用场景
- 网络设计(如最小成本连接所有节点)。
- 电路设计(最小化导线长度)。
- 聚类问题(构建最小生成树后按边权切割)。
总结
Prim 算法通过贪心策略,每次选择当前生成树到未访问顶点的最小边,逐步构建最小生成树。其核心是局部最优选择(最小边权)推导出全局最优解,适用于边权非负的无向图,是解决最小生成树问题的经典算法之一。
cpp
#include <iostream>
#include <vector>
#include <queue>
#include <fstream>
#include <limits>
using namespace std;
// 定义边的结构体
struct Edge {
int to;
int weight;
Edge(int t, int w) : to(t), weight(w) {}
};
// 定义图的邻接表表示
using Graph = vector<vector<Edge>>;
// Prim 算法求最小生成树
vector<Edge> prim(const Graph& graph) {
int n = graph.size();
vector<bool> visited(n, false);
vector<Edge> mst;
priority_queue<pair<int, pair<int, int>>, vector<pair<int, pair<int, int>>>, greater<pair<int, pair<int, int>>>> pq;
// 从顶点 0 开始
visited[0] = true;
for (const Edge& edge : graph[0]) {
pq.push({ edge.weight, {0, edge.to} });
}
while (!pq.empty()) {
auto [weight, nodes] = pq.top();
pq.pop();
int u = nodes.first;
int v = nodes.second;
if (visited[v]) continue;
visited[v] = true;
mst.emplace_back(v, weight);
for (const Edge& edge : graph[v]) {
if (!visited[edge.to]) {
pq.push({ edge.weight, {v, edge.to} });
}
}
}
return mst;
}
// 生成 DOT 文件
void generateDotFile(const Graph& graph, const vector<Edge>& mst, const string& filename) {
ofstream dotFile(filename);
if (!dotFile.is_open()) {
cerr << "无法打开文件: " << filename << endl;
return;
}
dotFile << "graph G {" << endl;
// 绘制所有边
for (int u = 0; u < graph.size(); ++u) {
for (const Edge& edge : graph[u]) {
if (u < edge.to) {
dotFile << " " << u << " -- " << edge.to << " [label=\"" << edge.weight << "\"];" << endl;
}
}
}
// 突出显示最小生成树的边
for (const Edge& edge : mst) {
int u = -1; // 这里需要根据 mst 找到对应的 u,假设第一个节点是 0
for (int i = 0; i < graph.size(); ++i) {
for (const Edge& e : graph[i]) {
if (e.to == edge.to && e.weight == edge.weight) {
u = i;
break;
}
}
if (u != -1) break;
}
dotFile << " " << u << " -- " << edge.to << " [color=red, penwidth=3];" << endl;
}
dotFile << "}" << endl;
dotFile.close();
}
int main() {
// 示例图
Graph graph = {
{{1, 2}, {3, 6}},
{{0, 2}, {2, 3}, {3, 8}, {4, 5}},
{{1, 3}, {4, 7}},
{{0, 6}, {1, 8}, {4, 9}},
{{1, 5}, {2, 7}, {3, 9}}
};
// 计算最小生成树
vector<Edge> mst = prim(graph);
// 生成 DOT 文件
generateDotFile(graph, mst, "mst.dot");
cout << "最小生成树的边已计算,DOT 文件已生成。" << endl;
return 0;
}