Skip to content

D. Roads not only in Berland - 题解

比赛与标签

比赛: Codeforces Beta Round 25 (Div. 2 Only) 标签: dsu, graphs, trees 难度: *1900

题目大意喵~

在一个由 n 个城市和 n-1 条道路组成的世界里,交通网络可能有点小问题。有些地方可能因为道路太多形成了环路,而有些地区之间却互相无法到达,形成了一个个“孤岛”。我们的任务是成为一名天才交通规划师!

我们的超能力是:每天可以拆除一条已有的道路,并立刻在任意两个城市之间新建一条道路。

目标是用最少的天数(也就是最少的操作次数)来改造路网,使得从任何一个城市出发,都能到达其他所有城市。最后,我们还需要给出一份详细的每日施工计划呐。

简单来说,就是要用最少的“拆一建一”操作,把一个可能包含环路和多个连通分量的图,变成一棵树(一个单一的连通图)。

解题思路的探索之旅喵!

这道题的核心是把一个乱七八糟的图修复成一个完美的连通图,也就是一棵树。一棵由 n 个节点组成的树,有以下两个重要特征:

  1. 整个图是连通的。
  2. 恰好有 n-1 条边,且没有环。

题目给了我们 n 个城市和 n-1 条路,边的数量是刚刚好的!但问题在于,这些边的连接方式可能不对。问题出在哪里呢?

  1. 多余的边(环路): 如果在两个已经能互相到达的城市之间再加一条路,就会形成一个环。这条路对于“连通性”来说是多余的,我们可以叫它“冗余边”。
  2. 缺失的边(不连通): 如果整个图分成了好几个独立的连通块(比如 k 个),那么想把它们全都连起来,我们至少需要 k-1 条新的边。

我们的操作是“拆一建一”,这简直是天作之合呀!我们可以拆掉一条“冗余边”,然后用这个机会去修建一条“缺失的边”,连接两个独立的连通块。这样一次操作就同时解决了两个问题,是不是很棒的说?

那么,需要多少次操作呢? 一个惊人的发现是:冗余边的数量恰好等于 (需要连接的连通块数量 - 1)! (想知道为什么的同学可以看看这个小证明喵:假设有 k 个连通块,第 i 个块有 n_i 个点和 e_i 条边。因为块内是连通的,所以至少需要 n_i-1 条边。如果 e_i > n_i-1,多出来的 e_i - (n_i-1) 条就是冗余边。所有块的冗余边总数是 Σ(e_i - (n_i-1)) = Σe_i - Σ(n_i-1) = (n-1) - (Σn_i - Σ1) = (n-1) - (n - k) = k-1。看吧,不多不少,正好是 k-1 条!)

所以,我们只需要找到所有的冗余边,和所有的独立连通块,然后一一配对就好啦!

这时候,处理连通性问题的神器——**并查集(Disjoint Set Union, DSU)**就该登场了!

算法步骤如下喵:

  1. 初始化: 创建一个并查集,让 n 个城市各自成为一个独立的集合。
  2. 处理道路: 遍历输入的 n-1 条道路 (u, v):
    • find(u)find(v) 检查 uv 是否已经在同一个连通块里。
    • 如果 find(u) == find(v),说明它们已经连通了,这条路 (u, v) 就是一条冗余边!我们把它记下来,因为它需要被拆除。
    • 如果 find(u) != find(v),说明这条路可以连接两个不同的连通块。太好了!我们执行 union(u, v),把这两个块合并成一个。
  3. 制定计划:
    • 处理完所有道路后,我们就得到了最终的连通块情况和一张冗余边列表。操作次数就是冗余边的数量。
    • 接下来,我们需要找到所有独立的连通块。我们可以遍历所有城市,把它们的根节点(find(i))收集起来,就能知道有哪些连通块了。
    • 假设我们有 k 个连通块,它们的代表点(或者说,我们可以随便从每个块里选一个城市作为代表)分别是 c_1, c_2, ..., c_k。同时,我们有 k-1 条冗余边。
    • 现在,我们可以制定一个简单的建设计划:把所有连通块都连接到第一个连通块上。
      • 第1天:拆掉第1条冗余边,新建一条路连接 c_1c_2
      • 第2天:拆掉第2条冗余边,新建一条路连接 c_1c_3
      • ...
      • k-1 天:拆掉第 k-1 条冗余边,新建一条路连接 c_1c_k
    • 这样,k-1 天后,所有城市就都连通啦!任务完成!

代码实现の詳細解説的说

cpp
#include <iostream>
#include <vector>
#include <algorithm>
#include <set>

using namespace std;

const int MAXN = 1005;
int parent[MAXN]; // 存储每个节点的父节点
int rank_[MAXN]; // 用于按秩合并,优化并查集性能
int max_node_in_component[MAXN]; // 一个小技巧,记录每个连通块中节点编号最大的城市,方便后面选代表

// 初始化并查集,让每个节点都自成一派
void make_set(int v) {
    parent[v] = v;
    rank_[v] = 0;
    max_node_in_component[v] = v;
}

// 查找v所属集合的根节点(带路径压缩优化)
int find_set(int v) {
    if (v == parent[v])
        return v;
    return parent[v] = find_set(parent[v]);
}

// 合并a和b所在的集合
void union_set(int a, int b) {
    a = find_set(a);
    b = find_set(b);
    if (a != b) {
        // 按秩合并,总是将rank小的树合并到rank大的树上
        if (rank_[a] < rank_[b])
            swap(a, b);
        parent[b] = a;
        // 合并时,更新新根节点所代表连通块的最大节点编号
        max_node_in_component[a] = max(max_node_in_component[a], max_node_in_component[b]);
        if (rank_[a] == rank_[b])
            rank_[a]++;
    }
}

int main() {
    int n;
    cin >> n;

    // 初始化n个城市
    for (int i = 1; i <= n; i++) {
        make_set(i);
    }

    vector<pair<int, int>> redundant_edges; // 存放需要拆除的冗余边
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        // 如果u和v已经在同一个连通块里,这条边就是冗余的
        if (find_set(u) == find_set(v)) {
            redundant_edges.push_back({u, v});
        } else {
            // 否则,用这条边连接两个不同的连通块
            union_set(u, v);
        }
    }

    // 找出所有独立的连通块的根节点
    set<int> roots;
    for (int i = 1; i <= n; i++) {
        roots.insert(find_set(i));
    }

    // 把连通块的代表点(这里用的是块内最大编号的节点)存起来
    vector<pair<int, int>> components;
    for (int r : roots) {
        components.push_back({max_node_in_component[r], r});
    }
    // 排序是为了有一个固定的连接顺序,不排也可以,但排了更稳妥
    sort(components.begin(), components.end());

    int t = redundant_edges.size();
    cout << t << endl; // 输出需要的天数

    // 输出每天的施工计划
    // 每次拆一条冗余边,然后把一个独立的连通块连接到第一个连通块上
    for (int i = 0; i < t; i++) {
        // 拆除这条冗余边
        cout << redundant_edges[i].first << " " << redundant_edges[i].second << " ";
        // 新建一条边,连接第一个连通块和第 i+2 个连通块
        cout << components[0].first << " " << components[i + 1].first << endl;
    }

    return 0;
}

复杂度分析喵

  • 时间复杂度: O(N * α(N)) 的说。 我们对 N-1 条边进行了处理,每次处理都涉及到并查集的 findunion 操作。使用了路径压缩和按秩合并优化的并查集,单次操作的平均时间复杂度接近常数,记为 α(N)(阿克曼函数的反函数,一个增长极其缓慢的函数)。因此,总的时间复杂度是 O(N * α(N))
  • 空间复杂度: O(N) 的说。 我们需要 parent, rank_, max_node_in_component 等数组来维护并查集,大小都和城市数量 N 成正比。redundant_edgescomponents 两个向量最多也只会存储 O(N) 级别的数据。所以总的空间开销是 O(N)

知识点与总结呐~

这道题真是一次愉快的思维体操!它完美地展示了如何将一个看似复杂的图论问题,通过分析其内在结构,转化为一个可以使用经典数据结构解决的模型。

  1. 核心算法: 并查集 (DSU) 是解决这类动态连通性问题的王牌。它能高效地判断两个元素是否在同一集合,以及合并两个集合。
  2. 图论思想: 理解“树”、“连通分量”和“环”的基本概念是关键。认识到“冗余边”和“缺失的连接”之间的数量关系 (k-1) 是解题的突破口。
  3. 问题转化: 把“修复图”的问题,转化为“找出多余的边和独立的块,然后重新连接它们”的步骤,这是非常重要的抽象能力。
  4. 实现技巧: 代码中用 max_node_in_component 数组来快速获取每个连通块的一个代表节点,是一个很聪明的小技巧,避免了需要时再去遍历查找。

希望这篇题解能帮助大家更好地理解这道题目!只要我们一步步分析,再复杂的问题也能被我们轻松解决的喵~ 加油,未来的算法大师们!

Released under the MIT License.