1. 题目背景与理解
这道GESP七级真题"城市规划"考察的是图论中的最小生成树算法应用。题目描述了一个典型的城市道路连通问题:A国有n座城市,通过m条双向道路连接,要求我们找到一种最优的修路方案。
在实际工程中,这类问题非常常见。比如市政部门需要规划城市间的道路网络,既要保证所有城市互相可达,又要尽可能降低建设成本。这正是最小生成树算法的经典应用场景。
注意:题目中明确所有城市原本就是连通的(即任意两城市间已有路径),这与现实中的某些情况不同。这意味着我们不需要考虑图不连通的情况。
2. 算法选择与思路分析
2.1 为什么选择Kruskal算法
对于这类问题,通常有两种主流解法:
- Prim算法 - 适合稠密图(边数接近完全图)
- Kruskal算法 - 适合稀疏图(边数远少于完全图)
在本题中,城市数量n和道路数量m的关系没有明确说明,但考虑到:
- 城市间道路通常不会太多(现实中的城市连接数有限)
- Kruskal算法实现更简单直观
- 题目要求输出具体选择的道路,Kruskal更容易记录边
因此我选择使用Kruskal算法来解决这个问题。
2.2 Kruskal算法核心思想
Kruskal算法的核心步骤是:
- 将所有边按权值从小到大排序
- 初始化一个空集合用于存放选中的边
- 按顺序考虑每条边,如果不形成环就加入集合
- 重复直到选中n-1条边
这里的关键是"不形成环"的判断,这可以通过并查集(Disjoint Set)数据结构高效实现。
3. 代码实现详解
3.1 数据结构设计
首先我们需要定义合适的数据结构:
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Edge {
int u, v, w;
Edge(int _u, int _v, int _w): u(_u), v(_v), w(_w) {}
};
vector<Edge> edges; // 存储所有边
vector<int> parent; // 并查集父节点数组
vector<Edge> selected; // 选中的边
3.2 并查集实现
并查集是Kruskal算法的核心组件,用于高效判断环的存在:
cpp复制int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
void unionSet(int x, int y) {
int fx = find(x), fy = find(y);
if (fx != fy) {
parent[fy] = fx;
}
}
3.3 主算法流程
完整的Kruskal算法实现:
cpp复制int kruskal(int n) {
// 初始化并查集
parent.resize(n+1);
for (int i = 1; i <= n; ++i) {
parent[i] = i;
}
// 按边权排序
sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) {
return a.w < b.w;
});
int total = 0, count = 0;
for (const auto& e : edges) {
if (find(e.u) != find(e.v)) {
unionSet(e.u, e.v);
total += e.w;
selected.push_back(e);
if (++count == n-1) break;
}
}
return total;
}
4. 完整题解代码
结合题目要求的输入输出格式,完整解决方案如下:
cpp复制#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
struct Edge {
int u, v, w;
Edge(int _u, int _v, int _w): u(_u), v(_v), w(_w) {}
};
vector<Edge> edges;
vector<int> parent;
vector<Edge> selected;
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
void unionSet(int x, int y) {
int fx = find(x), fy = find(y);
if (fx != fy) {
parent[fy] = fx;
}
}
int kruskal(int n) {
parent.resize(n+1);
for (int i = 1; i <= n; ++i) parent[i] = i;
sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) {
return a.w < b.w;
});
int total = 0, count = 0;
for (const auto& e : edges) {
if (find(e.u) != find(e.v)) {
unionSet(e.u, e.v);
total += e.w;
selected.push_back(e);
if (++count == n-1) break;
}
}
return total;
}
int main() {
int n, m;
cin >> n >> m;
for (int i = 0; i < m; ++i) {
int u, v, w;
cin >> u >> v >> w;
edges.emplace_back(u, v, w);
}
int total = kruskal(n);
cout << total << endl;
for (const auto& e : selected) {
cout << e.u << " " << e.v << endl;
}
return 0;
}
5. 算法复杂度分析
让我们分析这个解决方案的时间和空间复杂度:
-
时间复杂度:
- 排序边:O(m log m)
- 并查集操作:每次find/union近似O(α(n)),其中α是反阿克曼函数,可以认为是常数
- 总复杂度:O(m log m) 主导
-
空间复杂度:
- 存储边:O(m)
- 并查集:O(n)
- 总复杂度:O(m + n)
对于典型竞赛题目,n和m的范围通常在1e5以内,这个复杂度是完全可接受的。
6. 常见问题与调试技巧
6.1 为什么我的程序输出结果不正确?
常见错误原因:
- 忘记对边进行排序
- 并查集初始化不正确(应该初始化每个节点为自己的父节点)
- 没有正确处理边的输入顺序(题目可能有特殊要求)
- 循环终止条件错误(应该选n-1条边)
调试建议:
- 打印中间结果,特别是排序后的边列表
- 检查每次union前后的集合状态
- 使用小样例手动验证
6.2 如何优化程序性能?
对于大规模数据:
- 使用更快的IO方式(如关闭cin同步)
- 考虑使用裸数组代替vector(在确定大小的情况下)
- 使用更高效的排序算法(如内建的sort通常已经足够)
cpp复制// 优化IO示例
ios::sync_with_stdio(false);
cin.tie(nullptr);
6.3 如何处理特殊情况?
虽然题目保证图连通,但在实际应用中可能需要:
- 检查图是否连通(最终选中的边数是否为n-1)
- 处理边权相同的情况(按题目要求的顺序选择)
- 处理自环和重边(通常在输入时过滤)
7. 算法扩展与应用
最小生成树问题有许多变种和应用场景:
7.1 变种问题
- 次小生成树
- 度限制最小生成树
- 有向图的最小树形图
7.2 实际应用
- 网络设计(电信、交通等)
- 聚类分析
- 图像分割
- 近似算法解决旅行商问题
7.3 相关算法学习建议
- 掌握Prim算法及其堆优化版本
- 学习Borůvka算法(并行性更好)
- 了解动态最小生成树维护方法
在实际编程竞赛中,最小生成树常与其他算法结合考察,比如:
- 与最短路算法结合
- 与二分查找结合(如判断某边是否在MST中)
- 与数据结构结合(如线段树维护边权)
8. 个人实现心得
在实现这个算法的过程中,有几个关键点值得注意:
-
并查集优化:路径压缩对性能提升非常明显,在大型数据集上可能带来数倍的性能差异。我测试过一个1e5规模的图,没有路径压缩的版本超时,而优化后的版本轻松通过。
-
边存储方式:使用结构体数组比单独维护三个数组更清晰,也更容易排序。结构体实现比较函数时,使用lambda表达式比重载运算符更灵活。
-
输入处理:题目没有说明城市编号是否从0开始,保险起见我按照1-based处理。在实际比赛中,一定要仔细阅读输入说明,避免因此失分。
-
测试用例设计:除了题目给的样例,我还设计了几个边界用例:
- 最小情况(2个城市1条边)
- 完全图情况
- 所有边权相同的情况
- 存在重边的情况
-
输出格式:题目要求先输出总权值,再输出具体边。注意边输出顺序要与选择顺序一致,这在某些变种问题中可能影响得分。