1. 问题背景与题目解析
洛谷P1265《公路修建》是一道经典的图论与最小生成树算法练习题。题目描述了一个需要连接多个城市的高速公路网络建设场景:给定N个城市的坐标位置,要求设计一个总长度最短的公路网络,使得任意两个城市之间都可以直接或间接到达。
这道题的核心在于将实际问题抽象为图论模型:
- 每个城市代表图中的一个顶点
- 每两个城市之间潜在的公路连接代表图中的一条边
- 边的权值就是两个城市之间的欧几里得距离
- 目标就是找到一个边的子集,使得所有顶点连通且边的总权值最小
这正符合最小生成树(MST, Minimum Spanning Tree)的定义。在实际工程中,类似的场景非常常见,比如通信基站布线、电网架设、输油管道规划等,都需要解决这种"用最小成本实现全局连通"的问题。
2. 算法选型与复杂度分析
对于最小生成树问题,主要有两种经典算法:
2.1 Prim算法
Prim算法从一个顶点开始,逐步扩展生成树。每次选择连接树与非树顶点的最小权边加入生成树。对于稠密图(边数接近完全图),Prim算法更为高效。
在本题中:
- 城市数量N ≤ 5000
- 每对城市之间都存在潜在连接,属于完全图
- 边数E = N(N-1)/2 ≈ 1.25×10⁷(当N=5000时)
使用邻接矩阵存储的Prim算法时间复杂度为O(N²),空间复杂度也是O(N²)。对于N=5000,N²=25×10⁶,这在现代计算机上是可以接受的。
2.2 Kruskal算法
Kruskal算法按边权从小到大排序,依次选择不形成环的边加入生成树。通常使用并查集来高效判断环的存在。
对于完全图:
- 排序阶段复杂度O(E log E) = O(N² log N²) = O(N² log N)
- 并查集操作近似O(α(N)),总体O(N² log N)
当N=5000时,N² log N ≈ 2.5×10⁸,明显比Prim算法慢。
2.3 决策依据
基于上述分析:
- 本题是完全图,边数远大于顶点数
- Prim算法在稠密图中表现更优
- 题目N≤5000,Prim的O(N²)复杂度可接受
- 不需要存储所有边,节省空间
因此选择Prim算法更为合适。特别是可以使用堆优化的Prim算法,但在完全图中优势不明显。
3. 具体实现细节
3.1 距离计算
城市坐标(x,y)给出,两点a(x₁,y₁)和b(x₂,y₂)之间的距离d为: d = √[(x₂-x₁)² + (y₂-y₁)²]
注意:
- 实际计算时不需要开平方,直接比较距离平方即可
- 但最终总长度需要真实距离而非平方值
3.2 Prim算法实现步骤
初始化:
- 选定起始城市(任意)
- 维护一个数组dist,记录各城市到当前生成树的最小距离
- 初始化dist[0] = 0,其他dist[i] = ∞
- 维护一个数组visited标记是否已加入生成树
主循环:
- 每次选择dist最小的未访问城市u
- 将u标记为已访问
- 总长度累加dist[u]
- 更新u的所有邻接城市v的dist值: dist[v] = min(dist[v], distance(u,v))
终止条件:
- 所有城市都被访问
- 累计的总长度即为答案
3.3 优化技巧
距离缓存:
- 不需要预先计算所有城市对的距离
- 在Prim过程中按需计算,节省空间
堆优化:
- 使用优先队列快速获取最小dist城市
- 但在完全图中,简单的线性查找可能更优(常数因子小)
精度处理:
- 使用double类型存储距离
- 输出时保留两位小数
4. 完整代码实现(C++)
#include <iostream> #include <vector> #include <cmath> #include <climits> #include <iomanip> using namespace std; double calculateDistance(pair<int, int> a, pair<int, int> b) { long long dx = a.first - b.first; long long dy = a.second - b.second; return sqrt(dx*dx + dy*dy); } double primMST(const vector<pair<int, int>>& cities) { int n = cities.size(); vector<double> dist(n, 1e18); vector<bool> visited(n, false); double total = 0.0; dist[0] = 0.0; for (int i = 0; i < n; ++i) { int u = -1; // 线性查找最小dist for (int j = 0; j < n; ++j) { if (!visited[j] && (u == -1 || dist[j] < dist[u])) { u = j; } } if (dist[u] == 1e18) return -1; // 不连通(本题保证连通) visited[u] = true; total += dist[u]; // 更新邻接城市 for (int v = 0; v < n; ++v) { if (!visited[v]) { double d = calculateDistance(cities[u], cities[v]); if (d < dist[v]) { dist[v] = d; } } } } return total; } int main() { int n; cin >> n; vector<pair<int, int>> cities(n); for (int i = 0; i < n; ++i) { cin >> cities[i].first >> cities[i].second; } double totalLength = primMST(cities); cout << fixed << setprecision(2) << totalLength << endl; return 0; }5. 算法正确性证明
Prim算法能得到最小生成树的证明基于贪心选择性质:
安全边:每次选择的dist最小的边(u,v)(u在树中,v不在)必然是某棵最小生成树的边。
- 假设存在不包含(u,v)的MST T
- 将(u,v)加入T会形成环,环中必有另一条边(u',v')连接树与非树
- 由于(u,v)是dist最小的边,w(u,v) ≤ w(u',v')
- 用(u,v)替换(u',v')得到T',总权值不增加
- 因此(u,v)属于某棵MST
归纳法:
- 初始时,空集是某棵MST的子集
- 每次加入安全边后,仍保持是某棵MST的子集
- 最终得到完整的MST
6. 性能优化与边界情况
6.1 进一步优化方向
堆优化Prim:
- 使用优先队列存储(dist, city)对
- 每次取最小dist城市时间复杂度降为O(log N)
- 总体复杂度O(N log N)
- 但完全图中更新邻接点仍需O(N),实际可能更慢
并行计算:
- 距离计算可以并行化
- 但算法本身有顺序依赖,难以完全并行
6.2 边界情况处理
单城市情况:
- 总长度应为0
- 算法中不进入主循环,直接返回0
浮点精度:
- 坐标范围未给出,但距离计算可能溢出
- 使用long long存储中间结果
- 最终用double存储总长度
重复城市:
- 两个城市坐标相同,距离为0
- 不影响算法正确性
7. 实际应用与扩展
最小生成树在实际中有广泛应用:
网络设计:
- 通信网络布线
- 电网架设
- 交通网络规划
聚类分析:
- 基于MST的层次聚类
- 图像分割
近似算法:
- 旅行商问题(TSP)的近似解
- Steiner树问题
对于本题的扩展方向:
部分城市已有连接:
- 预处理时将这些边加入生成树
- 剩余部分继续使用Prim
地形因素:
- 距离计算考虑地形阻碍
- 可能需要A*等更复杂的路径规划
多目标优化:
- 同时考虑建设成本、环境影响等
- 转化为多目标优化问题