题解:洛谷 P2195 HXY造公园
【题目来源】
【题目描述】
现在有一个现成的公园,有 \(n\) 个休息点和 \(m\) 条双向边连接两个休息点。众所周知,HXY 是一个 SXBK 的强迫症患者,所以她打算施展魔法来改造公园并即时了解改造情况。她可以进行以下两种操作:
- 对某个休息点 \(x\),查询公园中可以与该点互相到达的休息点组成的路径中的最长路径。
- 对于两个休息点 \(x,y\),如果 \(x,y\) 已经可以互相到达则忽略此次操作。否则,在 \(x\) 可到达的所有休息点和 \(y\) 可到达的所有休息点(包括 \(x,y\) 自身)分别选择一个休息点,然后在这两个休息点之间连一条边,并且这个选择应该满足对于连接后的公园,\(x\) 和 \(y\) 所在的区域(即 \(x,y\) 可达到的所有休息点和边组成的集合)中的最长路径的长度最小。
HXY打算进行 \(q\) 个操作,请你回答她的对于公园情况的询问(操作 1)或者执行她的操作(操作 2)。
注:所有边的长度皆为 \(1\)。保证不存在环。最长路径定义为:对于点 \(v_1,v_2\cdots v_k\),如果对于其中任意的 \(v_i\) 和 \(v_{i+1}\quad (1\le i\le k-1)\),都有边相连接,那么 \(v_j\quad(1\le j\le k)\) 所在区域的最长路径就是 \(k-1\)。
【输入】
- 第一行,三个正整数,分别为 \(n,m,q\)。
- 接下来的 \(m\) 行,每一行有两个正整数 \(x_i,y_i\),表示 \(x_i\) 和 \(y_i\) 有一条双向边相连。
- 再接下来的 \(q\) 行,每一行表示一个操作。
- 若该行第一个数为 \(1\),则表示操作 1,之后还有一个正整数 \(x_i\),表示要查询的休息点。
- 若该行第一个数为 \(2\),则表示操作 2,之后还有两个正整数 \(x_i,y_i\),表示需要执行操作的两个休息点。
【输出】
输出行数为操作 1 的个数。
每行输出对于操作 1 询问的回答。
【输入样例】
6 0 6
2 1 2
2 3 4
2 5 6
2 3 2
2 5 3
1 1
【输出样例】
4
【核心思想】
-
问题分析:给定一个初始森林(由 \(n\) 个点、\(m\) 条边构成,保证无环),支持两种操作:
- 查询某个节点所在连通块的最长路径(即树的直径);
- 合并两个不同的连通块:在两个连通块中各选一个点连一条边,使得合并后新连通块的直径尽可能小,并执行该合并。
需要输出所有查询操作的答案。该问题本质是动态维护森林的直径,关键点在于合并两个连通块时,最优连接策略是选择两个连通块的直径中点相连,使得新直径最小。
-
算法选择:
- 并查集:维护连通块的归属,支持快速合并和查询根节点。
- 树的直径预处理:初始森林中,对每个连通块(树)使用树形 DP(或两次 DFS)求出其直径,并存储到该连通块的根节点下。
- 合并时的直径更新:当合并两个连通块时,新直径 = \(\max(d_1, d_2, \lceil d_1/2 \rceil + \lceil d_2/2 \rceil + 1)\),其中 \(d_1, d_2\) 分别为两个连通块的直径,\(\lceil d/2 \rceil\) 表示直径的半径(即直径中点到最远叶子的距离),新边长度为 1。该公式保证了新连通块直径最小。
- 预处理方法:代码提供了树形 DP(
dfs函数记录每个节点到子树最远距离,同时更新全局直径)和两次 DFS 两种方式,树形 DP 实现更简洁,无需反复重置数组。
-
关键步骤:
- 初始化:读入 \(n, m, q\),初始化并查集
fa[i] = i,构建邻接表。 - 读入初始边:对每条边 \((u, v)\),在邻接表中加入双向边,并合并并查集。
- 预处理每个连通块的直径:
- 使用树形 DP 对每个连通块(根节点为并查集代表元)执行
dfs(root, 0),在递归过程中计算子树内最长链和经过节点的最长路径,得到直径diameter[root]。
- 使用树形 DP 对每个连通块(根节点为并查集代表元)执行
- 处理操作:
- 操作 1:查询点 \(x\) 所在连通块的直径:输出
diameter[find(x)]。 - 操作 2:合并点 \(x\) 和 \(y\) 所在的连通块:
- 若
find(x) == find(y),忽略。 - 否则,令
rx = find(x),ry = find(y),合并并查集(如fa[rx] = ry)。 - 计算新直径:
diameter[ry] = max(diameter[rx], diameter[ry], (diameter[rx] + 1) / 2 + (diameter[ry] + 1) / 2 + 1)。
- 若
- 操作 1:查询点 \(x\) 所在连通块的直径:输出
- 输出:所有操作 1 的答案。
- 初始化:读入 \(n, m, q\),初始化并查集
-
时间/空间复杂度:
- 预处理直径:\(O(n)\)(树形 DP 遍历所有节点)。
- 每次操作:\(O(\alpha(n))\)(并查集操作)。
- 总时间复杂度:\(O(n + q \cdot \alpha(n))\)。
- 空间复杂度:\(O(n)\)。
-
核心思想:
- 直径与半径的关系:树的直径 \(d\) 的“半径”定义为从直径中点到两端点的距离,即 \(\lceil d/2 \rceil\)。当合并两个连通块时,为了最小化新直径,应选择两个连通块的直径中点相连。
- 最优合并公式:新直径 = \(\max(d_1, d_2, r_1 + r_2 + 1)\),其中 \(r_i = \lceil d_i/2 \rceil\)。该公式保证了新连通块中任意两点间的距离不超过这个值,且这是最优的。
- 树形 DP 求直径:对每个节点,维护其子树内到该节点的最长距离,并通过“最长链 + 次长链 + 1”更新全局直径,同时无需依赖父节点信息,适用于森林的预处理。
- 适用场景:适用于需要动态合并连通块并维护其直径的问题,常见于“动态树”的简化版本,利用并查集和预计算即可高效解决。
【算法标签】
普及+ #树形DP
【代码详解】
// 树形DP版本
#include <bits/stdc++.h>
using namespace std;
const int N = 300005, M = N * 2; // N: 最大休息点数, M: 最大边数(无向图双向边)
int n, m, q; // n: 休息点数, m: 初始边数, q: 操作数
int h[N], e[M], ne[M], idx; // 链式前向星存图
int fa[N]; // 并查集父节点数组:维护连通分量
int l, r, p, maxd; // l,r: 直径端点, p: 临时最远点, maxd: 当前最大距离
int d[N]; // d[u]: 树形DP中u到子树内最远点的距离
int pre[N]; // pre[u]: DFS时u的父节点(代码中未使用)
int zj[N]; // zj[root]: 连通分量root的直径长度(最长路径)
// 链式前向星:添加无向边
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
// 树形DP:求以u为根的子树的直径(最长路径)
// 同时d[u]记录u到子树内最远点的距离
void dfs(int u, int fa)
{
for (int i = h[u]; i != -1; i = ne[i])
{
int v = e[i];
if (v == fa) continue; // 跳过父节点
dfs(v, u); // 递归处理子树
// 更新以u为端点的最长路径:经过u连接两个子树的最长链
maxd = max(maxd, d[u] + d[v] + 1);
// 更新u到子树内最远点的距离
d[u] = max(d[u], d[v] + 1);
}
}
// 并查集查找(路径压缩)
int find(int x)
{
if (fa[x] != x) fa[x] = find(fa[x]);
return fa[x];
}
int main()
{
cin >> n >> m >> q; // 读入休息点数、初始边数、操作数
// 初始化并查集:每个点自成一个连通分量
for (int i = 1; i <= n; i++) fa[i] = i;
// 初始化链式前向星
memset(h, -1, sizeof(h));
// 读入m条初始边
while (m--)
{
int a, b;
cin >> a >> b;
add(a, b), add(b, a); // 无向图,双向建边
fa[find(a)] = find(b); // 合并a和b所在的连通分量
}
// 预处理每个连通分量的直径
for (int i = 1; i <= n; i++)
{
// i是连通分量的根节点(代表元)
if (fa[i] == i)
{
maxd = 0; // 重置直径
memset(d, 0, sizeof(d)); // 重置距离数组
dfs(i, 0); // 树形DP求该连通分量的直径
zj[i] = maxd; // 记录该连通分量的直径
}
}
// 处理q个操作
while (q--)
{
int op, x, y;
cin >> op >> x;
if (op == 1) // 操作1:查询x所在连通分量的最长路径
{
cout << zj[find(x)] << endl;
}
else // 操作2:合并x和y所在的连通分量
{
cin >> y;
x = find(x); // x所在连通分量的根
y = find(y); // y所在连通分量的根
if (x != y) // 不在同一连通分量才合并
{
fa[x] = y; // 将x所在分量合并到y
// 计算合并后的新直径:
// 新直径 = max(原两个分量的直径, 经过新边的最长路径)
// 经过新边的最长路径 = 从x分量直径中点到y分量直径中点的距离
// = (zj[x]+1)/2 + (zj[y]+1)/2 + 1(新边长度为1)
zj[y] = max((zj[x] + 1) / 2 + (zj[y] + 1) / 2 + 1, max(zj[x], zj[y]));
}
}
}
return 0;
}
// 双DFS版本(注意需要多次重置)
#include <bits/stdc++.h>
using namespace std;
const int N = 300005, M = N * 2; // N: 最大休息点数, M: 最大边数(无向图双向边)
int n, m, q; // n: 休息点数, m: 初始边数, q: 操作数
int h[N], e[M], ne[M], idx; // 链式前向星存图
int fa[N]; // 并查集父节点数组:维护连通分量
int l, r, p, maxd; // l,r: 直径端点, p: 临时最远点, maxd: 当前最大距离
int d[N]; // d[u]: 从当前起点到u的距离(DFS求直径时)
int pre[N]; // pre[u]: DFS时u的父节点(代码中未使用)
int zj[N]; // zj[root]: 连通分量root的直径长度
// DFS:求树的直径端点,同时记录距离
// 注意:该DFS依赖全局变量d[]和maxd,调用前需要重置
// 由于d[]只会在访问到的节点上更新,且每次DFS前会将起点d[]设为0,
// 未访问节点的d[]值虽然残留但不会造成影响(因为不会走到)
// 但已访问节点若未被覆盖,可能导致距离计算错误
void dfs(int u, int fa)
{
// 遍历u的所有邻接点
for (int i = h[u]; i != -1; i = ne[i])
{
int v = e[i];
if (v == fa) continue; // 跳过父节点
d[v] = d[u] + 1; // 更新v到起点的距离
// 更新最远点
if (d[v] > maxd)
{
maxd = d[v];
p = v;
}
dfs(v, u); // 递归DFS
}
}
// 并查集查找(路径压缩)
int find(int x)
{
if (fa[x] != x) fa[x] = find(fa[x]);
return fa[x];
}
// 链式前向星:添加无向边
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
int main()
{
cin >> n >> m >> q; // 读入休息点数、初始边数、操作数
// 初始化并查集:每个点自成一个连通分量
for (int i = 1; i <= n; i++) fa[i] = i;
// 初始化链式前向星
memset(h, -1, sizeof(h));
// 读入m条初始边
while (m--)
{
int a, b;
cin >> a >> b;
add(a, b), add(b, a); // 无向图,双向建边
fa[find(a)] = find(b); // 合并a和b所在的连通分量
}
// 预处理每个连通分量的直径
for (int i = 1; i <= n; i++)
{
// i是连通分量的根节点(代表元)
if (fa[i] == i)
{
// ========== 第一次DFS:找到直径的一个端点l ==========
p = maxd = 0; // 重置最远点和最大距离
// 注意:d[]数组未完全重置,但起点i的d[i]默认为0(全局变量初始为0)
// 如果之前其他DFS访问过某些节点,d[]可能残留旧值
// 但由于每次DFS从新的起点开始,且只走未访问方向,实际影响有限
dfs(i, 0); // 从i出发,找到最远点p
l = p; // l是直径的一个端点
// ========== 第二次DFS:找到直径的另一个端点r ==========
// 重置l的距离和最大距离,p也重置
d[l] = maxd = p = 0;
// 同样d[]未完全重置,但l作为起点距离为0,后续会覆盖访问路径
dfs(l, 0); // 从l出发,找到最远点r
r = p; // r是直径的另一个端点
zj[i] = maxd; // 记录该连通分量的直径
}
}
// 处理q个操作
while (q--)
{
int op, x, y;
cin >> op >> x;
if (op == 1) // 操作1:查询x所在连通分量的最长路径
{
cout << zj[find(x)] << endl;
}
else // 操作2:合并x和y所在的连通分量
{
cin >> y;
x = find(x); // x所在连通分量的根
y = find(y); // y所在连通分量的根
if (x != y) // 不在同一连通分量才合并
{
fa[x] = y; // 将x所在分量合并到y
// 计算合并后的新直径:
// 新直径 = max(原两个分量的直径, 经过新边的最长路径)
// 经过新边的最长路径 = 两个分量半径之和 + 1
// 分量半径 = (直径+1)/2,即直径中点到最远点的距离
zj[y] = max((zj[x] + 1) / 2 + (zj[y] + 1) / 2 + 1, max(zj[x], zj[y]));
}
}
}
return 0;
}
【运行结果】
6 0 6
2 1 2
2 3 4
2 5 6
2 3 2
2 5 3
1 1
4
浙公网安备 33010602011771号