D. Roads not only in Berland - 题解
比赛与标签
比赛: Codeforces Beta Round 25 (Div. 2 Only) 标签: dsu, graphs, trees 难度: *1900
题目大意喵~
在一个由 n
个城市和 n-1
条道路组成的世界里,交通网络可能有点小问题。有些地方可能因为道路太多形成了环路,而有些地区之间却互相无法到达,形成了一个个“孤岛”。我们的任务是成为一名天才交通规划师!
我们的超能力是:每天可以拆除一条已有的道路,并立刻在任意两个城市之间新建一条道路。
目标是用最少的天数(也就是最少的操作次数)来改造路网,使得从任何一个城市出发,都能到达其他所有城市。最后,我们还需要给出一份详细的每日施工计划呐。
简单来说,就是要用最少的“拆一建一”操作,把一个可能包含环路和多个连通分量的图,变成一棵树(一个单一的连通图)。
解题思路的探索之旅喵!
这道题的核心是把一个乱七八糟的图修复成一个完美的连通图,也就是一棵树。一棵由 n
个节点组成的树,有以下两个重要特征:
- 整个图是连通的。
- 恰好有
n-1
条边,且没有环。
题目给了我们 n
个城市和 n-1
条路,边的数量是刚刚好的!但问题在于,这些边的连接方式可能不对。问题出在哪里呢?
- 多余的边(环路): 如果在两个已经能互相到达的城市之间再加一条路,就会形成一个环。这条路对于“连通性”来说是多余的,我们可以叫它“冗余边”。
- 缺失的边(不连通): 如果整个图分成了好几个独立的连通块(比如
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)**就该登场了!
算法步骤如下喵:
- 初始化: 创建一个并查集,让
n
个城市各自成为一个独立的集合。 - 处理道路: 遍历输入的
n-1
条道路(u, v)
:- 用
find(u)
和find(v)
检查u
和v
是否已经在同一个连通块里。 - 如果
find(u) == find(v)
,说明它们已经连通了,这条路(u, v)
就是一条冗余边!我们把它记下来,因为它需要被拆除。 - 如果
find(u) != find(v)
,说明这条路可以连接两个不同的连通块。太好了!我们执行union(u, v)
,把这两个块合并成一个。
- 用
- 制定计划:
- 处理完所有道路后,我们就得到了最终的连通块情况和一张冗余边列表。操作次数就是冗余边的数量。
- 接下来,我们需要找到所有独立的连通块。我们可以遍历所有城市,把它们的根节点(
find(i)
)收集起来,就能知道有哪些连通块了。 - 假设我们有
k
个连通块,它们的代表点(或者说,我们可以随便从每个块里选一个城市作为代表)分别是c_1, c_2, ..., c_k
。同时,我们有k-1
条冗余边。 - 现在,我们可以制定一个简单的建设计划:把所有连通块都连接到第一个连通块上。
- 第1天:拆掉第1条冗余边,新建一条路连接
c_1
和c_2
。 - 第2天:拆掉第2条冗余边,新建一条路连接
c_1
和c_3
。 - ...
- 第
k-1
天:拆掉第k-1
条冗余边,新建一条路连接c_1
和c_k
。
- 第1天:拆掉第1条冗余边,新建一条路连接
- 这样,
k-1
天后,所有城市就都连通啦!任务完成!
代码实现の詳細解説的说
#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
条边进行了处理,每次处理都涉及到并查集的find
和union
操作。使用了路径压缩和按秩合并优化的并查集,单次操作的平均时间复杂度接近常数,记为α(N)
(阿克曼函数的反函数,一个增长极其缓慢的函数)。因此,总的时间复杂度是O(N * α(N))
。 - 空间复杂度: O(N) 的说。 我们需要
parent
,rank_
,max_node_in_component
等数组来维护并查集,大小都和城市数量N
成正比。redundant_edges
和components
两个向量最多也只会存储O(N)
级别的数据。所以总的空间开销是O(N)
。
知识点与总结呐~
这道题真是一次愉快的思维体操!它完美地展示了如何将一个看似复杂的图论问题,通过分析其内在结构,转化为一个可以使用经典数据结构解决的模型。
- 核心算法: 并查集 (DSU) 是解决这类动态连通性问题的王牌。它能高效地判断两个元素是否在同一集合,以及合并两个集合。
- 图论思想: 理解“树”、“连通分量”和“环”的基本概念是关键。认识到“冗余边”和“缺失的连接”之间的数量关系 (
k-1
) 是解题的突破口。 - 问题转化: 把“修复图”的问题,转化为“找出多余的边和独立的块,然后重新连接它们”的步骤,这是非常重要的抽象能力。
- 实现技巧: 代码中用
max_node_in_component
数组来快速获取每个连通块的一个代表节点,是一个很聪明的小技巧,避免了需要时再去遍历查找。
希望这篇题解能帮助大家更好地理解这道题目!只要我们一步步分析,再复杂的问题也能被我们轻松解决的喵~ 加油,未来的算法大师们!