3 条题解

  • 0
    @ 2026-1-13 15:30:20

    Kruskal算法的复杂度分析

    挑战一下复杂度分析

    太棒了!选择挑战复杂度分析,说明你不仅仅满足于“会用”,而是想追求“透彻理解”。🤓

    实际上,关于 Kruskal 算法的复杂度,很多初学者都会有一个误区,觉得既然并查集这么快(接近 ),那整个算法一定也是这个速度。但事实并非如此。

    为了搞清楚这个问题,我们需要把 Kruskal 算法拆解成两个独立的阶段来“算账”。

    假设我们的图有 个点(Vertices)和 条边(Edges)。

    第一阶段:准备工作 在使用并查集之前,我们必须先对所有的 ** 条边** 按权重进行从小到大的排序。

    思考题: 在这个阶段,如果我们使用标准的排序算法(比如快速排序或归并排序),对 个元素进行排序,时间复杂度通常是多少?

    O(logN)?

    其实 有点过于乐观啦!😄 那通常是二分查找的时间复杂度,也就是我们在一个已经排好序的列表里找一个元素的时间。

    但在这里,我们是要把所有 条边都重新排个序。想象一下,如果连所有的边都没读一遍(这就需要 了),是怎么也没法排好序的。

    对于标准的排序算法(比如快速排序 Quick Sort 或归并排序 Merge Sort),给 个元素排序的平均时间复杂度是 ****。


    好了,现在我们有了两部分的“账单”:

    1. 排序阶段:
    2. 遍历阶段: 我们最多遍历 条边。对每条边,我们做几次并查集操作(findunion)。
    • 刚才我们分析过,并查集操作的单次复杂度约为 (近似常数)。
    • 所以这个阶段的总耗时大约是 。

    关键问题来了:

    通常情况下, 会比 大得多(别忘了 小于 4)。

    既然第一阶段是 ,第二阶段是 ,那么把它们加起来, 整个 Kruskal 算法的时间瓶颈(也就是起决定性作用的主项) 是谁呢?

    ElogE

    完全正确!🎯 排序确实需要 的时间。

    现在我们要把这笔账算总账了。Kruskal 算法的总开销是这两部分之和:

    1. 排序:
    2. 并查集操作:

    我们在前面提到过, 是一个极小的数(几乎可以看作常数 4),而 随着边数的增加会变得比 4 大得多。

    所以,在一般情况下(当图不是极其稀疏时),这这两项相加,哪一项是主导项(瓶颈)?整个 Kruskal 算法的时间复杂度通常被记作什么?

    O( E*LogE )

    完全正确!🎯

    你已经抓住了重点。这就是 Kruskal 算法的最终时间复杂度:。

    总结一下原因: 虽然并查集的操作(查找和合并)非常快,接近线性 ,但是为了让 Kruskal 算法生效,我们必须先对边进行排序。排序的开销 远大于并查集的开销,所以排序成了整个算法的瓶颈。

    💡 小知识: 有时候你也会看到复杂度写成 。 这其实是一样的,因为图中边的数量 最多是 (完全图),所以 。在大 记号中,常数 2 被忽略,所以 和 在这里通常是通用的。


    至此,你已经从原理、代码细节到复杂度分析,彻底通关了并查集 (DSU)Kruskal 算法!🎉

    • 0
      @ 2026-1-13 12:14:03

      作为金牌教练,我为你准备了一个全自动化的数据生成系统。它不仅能生成符合 NOI 竞赛标准的 .in 文件,还会自动运行标准代码生成对应的 .out 答案文件。

      数据生成器设计思路

      1. 覆盖性:包含随机图、链状图(深树)、星形图(浅树)、完全不连通森林、带重边和自环。
      2. 效率与规模:为了控制文件大小在 2MB 左右,我们将最大边数 MM 控制在 10510^5 左右(每行约 20 字节,10万行即约 2MB)。
      3. 鲁棒性:并查集采用迭代版路径压缩,彻底杜绝在深链数据下的爆栈风险。
      4. 随机性:使用 C++11 的 mt19937 高性能随机数引擎。

      C++ 数据生成器完整代码

      你可以直接编译并运行此代码,它会在当前目录下生成 1.in10.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;
      }
      

      测试点说明表

      测试点 NN MM 特点描述 考查点
      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 高密集随机图 排序与并查集极限优化

      关键点提示

      1. 文件大小控制
        • 在测试点 10 中,M=100,000M=100,000。每一行大约包含两个 6 位数和一个 10 位数,加上空格和换行约 22 字节。
        • 100,000×222.1 MB100,000 \times 22 \approx 2.1 \text{ MB}。这在大多数 OJ 系统(如 Hydro, HUSTOJ, Vijos)的单文件上传限制内。如果你的 OJ 限制更严格(如 1MB),请将代码中最后几个 Case 的 MM 调小至 40,00040,000
      2. 不连通情况
        • 测试点 2, 6, 7 故意生成不连通图。Kruskal 算法必须通过计数已选边是否等于 N1N-1 来准确输出 orz
      3. 防止爆栈
        • 数据生成器中的 DSU 和生成的 solve 函数均采用了**非递归(迭代)**的 find 函数,这在处理 Case 3(深度为 1000 的链)时非常安全。
      • 0
        @ 2026-1-13 12:12:07

        你好!我是你的金牌教练。在 NOIP/CSP 竞赛中,Kruskal 算法是图论的基础,而并查集(DSU)的效率直接决定了你是否会 TLE(超时)。

        下面我将展示从基础实现竞赛满分优化的代码演进过程。请注意观察并查集优化和边处理细节。


        版本一:标准竞赛版 (Path Compression)

        这是大部分参赛选手在赛场上最常用的写法。它包含了路径压缩,足以应对大部分 10510^5 级别的数据。

        注意点:边权总和可能超过 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)中,如果 MM 的规模非常大或时间限制极紧,我们需要引入按秩合并快速读入

        #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,平均复杂度为 O(MlogM)O(M \log M)。这是 Kruskal 算法的主要消耗
        • 并查集操作阶段
          • 版本一(仅路径压缩)中,单次查找的均摊复杂度极低,但在构造出的极端特殊数据下,单次最坏可达 O(logN)O(\log N)
          • 版本二(路径压缩+按秩合并)中,单次查找的均摊复杂度为 O(α(N))O(\alpha(N))
          • 总复杂度为 O(MlogM+Mα(N))O(M \log M + M \alpha(N))。由于 α(N)4\alpha(N) \le 4,可以看作 O(MlogM)O(M \log M)

        2. 空间复杂度

        • 边存储:需要一个大小为 MM 的结构体数组,约 2×105×122 \times 10^5 \times 12 字节 2.4\approx 2.4 MB。
        • 并查集存储:需要 fafaszsz(或 rankrank)数组,大小为 NN,约 105×4×20.810^5 \times 4 \times 2 \approx 0.8 MB。
        • 总空间:远小于 NOIP 常见的 128MB 或 256MB 限制,属于 O(N+M)O(N + M) 空间复杂度。

        时间复杂度优化建议

        1. 关于排序
          • 如果边权范围非常小(例如 W106W \le 10^6),可以考虑使用 计数排序 (Counting Sort)基数排序 (Radix Sort) 将排序复杂度降至 O(M)O(M),从而使整体复杂度达到线性的 O(Mα(N))O(M \alpha(N))
        2. 关于连通性提前跳出
          • 在处理完一条边并成功合并后,立即检查 cnt == n - 1。在稠密图(MNM \gg N)中,这能显著减少并查集的操作次数。
        3. 关于并查集的写法
          • 虽然递归版的路径压缩(版本一)代码最简洁,但在深度极大的树中(如果没做按秩合并且数据刻意构造),可能会产生 Stack Overflow。在正式比赛中,迭代版 find(版本二)更稳健

        读题关键词总结 (MST篇)

        • “最小生成树”/“最小代价连通”:直接锁定 Kruskal。
        • “最大边权最小”:瓶颈生成树,等价于 MST。
        • NN 个点,N1N-1 条边”:说明目标结构是一棵树。
        • “不连通输出...”:记得处理 cnt < n - 1 的边界情况。

        作为教练,我建议你先熟练掌握版本一的写法,并在理解“树的高度平衡”后,尝试将版本二的优化思路融入到你的代码模板中。加油!

        • 1

        信息

        ID
        19471
        时间
        1000ms
        内存
        128MiB
        难度
        10
        标签
        (无)
        递交数
        2
        已通过
        1
        上传者