3 条题解
-
0
Kruskal算法的复杂度分析
挑战一下复杂度分析
太棒了!选择挑战复杂度分析,说明你不仅仅满足于“会用”,而是想追求“透彻理解”。🤓
实际上,关于 Kruskal 算法的复杂度,很多初学者都会有一个误区,觉得既然并查集这么快(接近 ),那整个算法一定也是这个速度。但事实并非如此。
为了搞清楚这个问题,我们需要把 Kruskal 算法拆解成两个独立的阶段来“算账”。
假设我们的图有 个点(Vertices)和 条边(Edges)。
第一阶段:准备工作 在使用并查集之前,我们必须先对所有的 ** 条边** 按权重进行从小到大的排序。
思考题: 在这个阶段,如果我们使用标准的排序算法(比如快速排序或归并排序),对 个元素进行排序,时间复杂度通常是多少?
O(logN)?
其实 有点过于乐观啦!😄 那通常是二分查找的时间复杂度,也就是我们在一个已经排好序的列表里找一个元素的时间。
但在这里,我们是要把所有 条边都重新排个序。想象一下,如果连所有的边都没读一遍(这就需要 了),是怎么也没法排好序的。
对于标准的排序算法(比如快速排序 Quick Sort 或归并排序 Merge Sort),给 个元素排序的平均时间复杂度是 ****。
好了,现在我们有了两部分的“账单”:
- 排序阶段:
- 遍历阶段: 我们最多遍历 条边。对每条边,我们做几次并查集操作(
find和union)。
- 刚才我们分析过,并查集操作的单次复杂度约为 (近似常数)。
- 所以这个阶段的总耗时大约是 。
关键问题来了:
通常情况下, 会比 大得多(别忘了 小于 4)。
既然第一阶段是 ,第二阶段是 ,那么把它们加起来, 整个 Kruskal 算法的时间瓶颈(也就是起决定性作用的主项) 是谁呢?
ElogE
完全正确!🎯 排序确实需要 的时间。
现在我们要把这笔账算总账了。Kruskal 算法的总开销是这两部分之和:
- 排序:
- 并查集操作:
我们在前面提到过, 是一个极小的数(几乎可以看作常数 4),而 随着边数的增加会变得比 4 大得多。
所以,在一般情况下(当图不是极其稀疏时),这这两项相加,哪一项是主导项(瓶颈)?整个 Kruskal 算法的时间复杂度通常被记作什么?
O( E*LogE )
完全正确!🎯
你已经抓住了重点。这就是 Kruskal 算法的最终时间复杂度:。
总结一下原因: 虽然并查集的操作(查找和合并)非常快,接近线性 ,但是为了让 Kruskal 算法生效,我们必须先对边进行排序。排序的开销 远大于并查集的开销,所以排序成了整个算法的瓶颈。
💡 小知识: 有时候你也会看到复杂度写成 。 这其实是一样的,因为图中边的数量 最多是 (完全图),所以 。在大 记号中,常数 2 被忽略,所以 和 在这里通常是通用的。
至此,你已经从原理、代码细节到复杂度分析,彻底通关了并查集 (DSU) 和 Kruskal 算法!🎉
-
0
作为金牌教练,我为你准备了一个全自动化的数据生成系统。它不仅能生成符合 NOI 竞赛标准的
.in文件,还会自动运行标准代码生成对应的.out答案文件。数据生成器设计思路
- 覆盖性:包含随机图、链状图(深树)、星形图(浅树)、完全不连通森林、带重边和自环。
- 效率与规模:为了控制文件大小在 2MB 左右,我们将最大边数 控制在 左右(每行约 20 字节,10万行即约 2MB)。
- 鲁棒性:并查集采用迭代版路径压缩,彻底杜绝在深链数据下的爆栈风险。
- 随机性:使用 C++11 的
mt19937高性能随机数引擎。
C++ 数据生成器完整代码
你可以直接编译并运行此代码,它会在当前目录下生成
1.in到10.out。#include <iostream> #include <fstream> #include <vector> #include <algorithm> #include <random> #include <string> #include <ctime> using namespace std; // --- 算法标准实现部分 (用于生成 .out) --- struct Edge { int u, v, w; }; struct DSU { vector<int> fa; DSU(int n) { fa.resize(n + 1); for (int i = 1; i <= n; ++i) fa[i] = i; } // 迭代版路径压缩,防止爆栈 int find(int x) { int root = x; while (fa[root] != root) root = fa[root]; while (fa[x] != root) { int next = fa[x]; fa[x] = root; x = next; } return root; } bool unite(int x, int y) { int rootX = find(x), rootY = find(y); if (rootX != rootY) { fa[rootX] = rootY; return true; } return false; } }; string solve(int n, int m, vector<Edge>& edges) { sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) { return a.w < b.w; }); DSU dsu(n); long long mst_weight = 0; int cnt = 0; for (auto& e : edges) { if (dsu.unite(e.u, e.v)) { mst_weight += e.w; cnt++; } } if (cnt == n - 1) return to_string(mst_weight); else return "orz"; } // --- 数据生成逻辑部分 --- mt19937 rng(time(0)); long long randW(long long l, long long r) { return uniform_int_distribution<long long>(l, r)(rng); } void generate_case(int id, int n, int m, string type) { string in_name = to_string(id) + ".in"; string out_name = to_string(id) + ".out"; ofstream fin(in_name); vector<Edge> edges; if (type == "small_connected") { for (int i = 2; i <= n; ++i) edges.push_back({i, (int)randW(1, i-1), (int)randW(1, 100)}); while (edges.size() < m) edges.push_back({(int)randW(1, n), (int)randW(1, n), (int)randW(1, 100)}); } else if (type == "disconnected") { // 分成两个互不相连的块 int mid = n / 2; for (int i = 2; i <= mid; ++i) edges.push_back({i, (int)randW(1, i-1), (int)randW(1, 1000)}); for (int i = mid + 2; i <= n; ++i) edges.push_back({i, (int)randW(mid+1, i-1), (int)randW(1, 1000)}); } else if (type == "chain") { for (int i = 1; i < n; ++i) edges.push_back({i, i + 1, (int)randW(1, 1e9)}); } else if (type == "star") { for (int i = 2; i <= n; ++i) edges.push_back({1, i, (int)randW(1, 1e9)}); } else if (type == "large_random") { // 生成随机生成树保证连通 for (int i = 2; i <= n; ++i) edges.push_back({i, (int)randW(1, i-1), (int)randW(1, 1e9)}); while (edges.size() < m) edges.push_back({(int)randW(1, n), (int)randW(1, n), (int)randW(1, 1e9)}); } else if (type == "max_constraints") { for (int i = 2; i <= n; ++i) edges.push_back({i, (int)randW(1, i-1), 1000000000}); while (edges.size() < m) edges.push_back({(int)randW(1, n), (int)randW(1, n), 1000000000}); } // 打乱边序 shuffle(edges.begin(), edges.end(), rng); fin << n << " " << edges.size() << "\n"; for (auto& e : edges) fin << e.u << " " << e.v << " " << e.w << "\n"; fin.close(); // 生成答案 ofstream fout(out_name); fout << solve(n, edges.size(), edges) << endl; fout.close(); cout << "Test Case " << id << " [" << type << "] Generated." << endl; } int main() { // Case 1-2: 极小规模边界 generate_case(1, 5, 4, "small_connected"); generate_case(2, 10, 5, "disconnected"); // Case 3-4: 特殊结构 (链与星) generate_case(3, 1000, 999, "chain"); generate_case(4, 1000, 999, "star"); // Case 5-6: 中等规模 generate_case(5, 5000, 10000, "large_random"); generate_case(6, 5000, 20000, "disconnected"); // Case 7-10: 大规模压测 (为了控制2MB,M设定为80000~100000) generate_case(7, 100000, 80000, "disconnected"); // 森林 generate_case(8, 100000, 90000, "large_random"); generate_case(9, 100000, 100000, "max_constraints"); // 同权值测试 generate_case(10, 100000, 100000, "large_random"); // 随机综合 return 0; }
测试点说明表
测试点 特点描述 考查点 1 5 4 极小连通图 基础正确性 2 10 5 极小不连通图 orz输出处理3 1000 999 纯链状结构 路径压缩深度测试 4 纯星状结构 基础树结构处理 5 5000 10000 随机稀疏图 排序效率 6 20000 两个大型连通分量 连通性准确判断 7 100,000 80,000 大规模森林 最终边数判断 8 90,000 满负载随机图 综合性能 9 100,000 权值全部最大 (1e9) Long Long 溢出测试 10 高密集随机图 排序与并查集极限优化 关键点提示
- 文件大小控制:
- 在测试点 10 中,。每一行大约包含两个 6 位数和一个 10 位数,加上空格和换行约 22 字节。
- 。这在大多数 OJ 系统(如 Hydro, HUSTOJ, Vijos)的单文件上传限制内。如果你的 OJ 限制更严格(如 1MB),请将代码中最后几个 Case 的 调小至 。
- 不连通情况:
- 测试点 2, 6, 7 故意生成不连通图。Kruskal 算法必须通过计数已选边是否等于 来准确输出
orz。
- 测试点 2, 6, 7 故意生成不连通图。Kruskal 算法必须通过计数已选边是否等于 来准确输出
- 防止爆栈:
- 数据生成器中的
DSU和生成的solve函数均采用了**非递归(迭代)**的find函数,这在处理 Case 3(深度为 1000 的链)时非常安全。
- 数据生成器中的
-
0
你好!我是你的金牌教练。在 NOIP/CSP 竞赛中,Kruskal 算法是图论的基础,而并查集(DSU)的效率直接决定了你是否会 TLE(超时)。
下面我将展示从基础实现到竞赛满分优化的代码演进过程。请注意观察并查集优化和边处理细节。
版本一:标准竞赛版 (Path Compression)
这是大部分参赛选手在赛场上最常用的写法。它包含了路径压缩,足以应对大部分 级别的数据。
注意点:边权总和可能超过
int范围,必须使用long long。#include <iostream> #include <algorithm> #include <cstdio> using namespace std; const int MAXN = 100005; const int MAXM = 200005; struct Edge { int u, v, w; } edge[MAXM]; // 按照边权从小到大排序的比较函数 bool cmp(Edge a, Edge b) { return a.w < b.w; } int fa[MAXN]; // 核心:带路径压缩的查找 // 易错点:递归深度过大可能导致爆栈,但在NOI Linux环境下通常可通过调整解决 int find(int x) { if (fa[x] == x) return x; return fa[x] = find(fa[x]); // 路径压缩:直接让上级指向祖先 } int main() { int n, m; if (scanf("%d %d", &n, &m) != 2) return 0; for (int i = 1; i <= m; i++) { scanf("%d %d %d", &edge[i].u, &edge[i].v, &edge[i].w); } // 1. 贪心策略:排序 sort(edge + 1, edge + m + 1, cmp); // 2. 初始化并查集 for (int i = 1; i <= n; i++) fa[i] = i; long long ans = 0; // 存储MST权重和 int cnt = 0; // 已选边的数量 // 3. 遍历每一条边 for (int i = 1; i <= m; i++) { int rootU = find(edge[i].u); int rootV = find(edge[i].v); if (rootU != rootV) { // 如果不在同一个集合,说明不形成环 fa[rootU] = rootV; // 合并 ans += edge[i].w; cnt++; if (cnt == n - 1) break; // 优化:选够 N-1 条边即可停止 } } // 4. 判断连通性 if (cnt == n - 1) printf("%lld\n", ans); else printf("orz\n"); return 0; }
版本二:极致优化满分版 (Path Compression + Rank + Fast I/O)
在更高强度的比赛(如省选或 NOI)中,如果 的规模非常大或时间限制极紧,我们需要引入按秩合并和快速读入。
#include <iostream> #include <algorithm> #include <vector> using namespace std; // 快速读入优化:针对海量数据输入 inline int read() { int x = 0, f = 1; char ch = getchar(); while (ch < '0' || ch > '1' ) { if (ch == '-') f = -1; ch = getchar(); } while (ch >= '0' && ch <= '9') { x = x * 10 + ch - '0'; ch = getchar(); } return x * f; } const int MAXN = 100005; const int MAXM = 200005; struct Edge { int u, v, w; }; bool cmp(const Edge &a, const Edge &b) { return a.w < b.w; } int fa[MAXN], sz[MAXN]; // sz 用于按秩合并(记录集合大小) int find(int x) { // 迭代版路径压缩(防止递归过深) int root = x; while (fa[root] != root) root = fa[root]; while (fa[x] != root) { int next = fa[x]; fa[x] = root; x = next; } return root; } void unite(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX != rootY) { // 按秩合并:将小树合并到大树,保持树的平衡 if (sz[rootX] < sz[rootY]) swap(rootX, rootY); fa[rootY] = rootX; sz[rootX] += sz[rootY]; } } Edge edges[MAXM]; int main() { int n = read(), m = read(); for (int i = 0; i < m; i++) { edges[i] = {read(), read(), read()}; } sort(edges, edges + m, cmp); for (int i = 1; i <= n; i++) { fa[i] = i; sz[i] = 1; } long long total_weight = 0; int edge_count = 0; for (int i = 0; i < m; i++) { if (find(edges[i].u) != find(edges[i].v)) { unite(edges[i].u, edges[i].v); total_weight += edges[i].w; edge_count++; if (edge_count == n - 1) break; } } if (edge_count == n - 1) printf("%lld\n", total_weight); else printf("orz\n"); return 0; }
复杂度分析思考过程
1. 时间复杂度
- 排序阶段:使用
std::sort,平均复杂度为 。这是 Kruskal 算法的主要消耗。 - 并查集操作阶段:
- 在版本一(仅路径压缩)中,单次查找的均摊复杂度极低,但在构造出的极端特殊数据下,单次最坏可达 。
- 在版本二(路径压缩+按秩合并)中,单次查找的均摊复杂度为 。
- 总复杂度为 。由于 ,可以看作 。
2. 空间复杂度
- 边存储:需要一个大小为 的结构体数组,约 字节 MB。
- 并查集存储:需要 和 (或 )数组,大小为 ,约 MB。
- 总空间:远小于 NOIP 常见的 128MB 或 256MB 限制,属于 空间复杂度。
时间复杂度优化建议
- 关于排序:
- 如果边权范围非常小(例如 ),可以考虑使用 计数排序 (Counting Sort) 或 基数排序 (Radix Sort) 将排序复杂度降至 ,从而使整体复杂度达到线性的 。
- 关于连通性提前跳出:
- 在处理完一条边并成功合并后,立即检查
cnt == n - 1。在稠密图()中,这能显著减少并查集的操作次数。
- 在处理完一条边并成功合并后,立即检查
- 关于并查集的写法:
- 虽然递归版的路径压缩(版本一)代码最简洁,但在深度极大的树中(如果没做按秩合并且数据刻意构造),可能会产生
Stack Overflow。在正式比赛中,迭代版find(版本二)更稳健。
- 虽然递归版的路径压缩(版本一)代码最简洁,但在深度极大的树中(如果没做按秩合并且数据刻意构造),可能会产生
读题关键词总结 (MST篇)
- “最小生成树”/“最小代价连通”:直接锁定 Kruskal。
- “最大边权最小”:瓶颈生成树,等价于 MST。
- “ 个点, 条边”:说明目标结构是一棵树。
- “不连通输出...”:记得处理
cnt < n - 1的边界情况。
作为教练,我建议你先熟练掌握版本一的写法,并在理解“树的高度平衡”后,尝试将版本二的优化思路融入到你的代码模板中。加油!
- 排序阶段:使用
- 1
信息
- ID
- 19471
- 时间
- 1000ms
- 内存
- 128MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 2
- 已通过
- 1
- 上传者