题解:洛谷 P2195 HXY造公园

【题目来源】

洛谷:P2195 HXY造公园 - 洛谷

【题目描述】

现在有一个现成的公园,有 \(n\) 个休息点和 \(m\) 条双向边连接两个休息点。众所周知,HXY 是一个 SXBK 的强迫症患者,所以她打算施展魔法来改造公园并即时了解改造情况。她可以进行以下两种操作:

  1. 对某个休息点 \(x\),查询公园中可以与该点互相到达的休息点组成的路径中的最长路径。
  2. 对于两个休息点 \(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

【核心思想】

  1. 问题分析:给定一个初始森林(由 \(n\) 个点、\(m\) 条边构成,保证无环),支持两种操作:

    • 查询某个节点所在连通块的最长路径(即树的直径);
    • 合并两个不同的连通块:在两个连通块中各选一个点连一条边,使得合并后新连通块的直径尽可能小,并执行该合并。
      需要输出所有查询操作的答案。该问题本质是动态维护森林的直径,关键点在于合并两个连通块时,最优连接策略是选择两个连通块的直径中点相连,使得新直径最小。
  2. 算法选择

    • 并查集:维护连通块的归属,支持快速合并和查询根节点。
    • 树的直径预处理:初始森林中,对每个连通块(树)使用树形 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。该公式保证了新连通块直径最小。
    • 预处理方法:代码提供了树形 DPdfs 函数记录每个节点到子树最远距离,同时更新全局直径)和两次 DFS 两种方式,树形 DP 实现更简洁,无需反复重置数组。
  3. 关键步骤

    • 初始化:读入 \(n, m, q\),初始化并查集 fa[i] = i,构建邻接表。
    • 读入初始边:对每条边 \((u, v)\),在邻接表中加入双向边,并合并并查集。
    • 预处理每个连通块的直径
      • 使用树形 DP 对每个连通块(根节点为并查集代表元)执行 dfs(root, 0),在递归过程中计算子树内最长链和经过节点的最长路径,得到直径 diameter[root]
    • 处理操作
      • 操作 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 的答案。
  4. 时间/空间复杂度

    • 预处理直径:\(O(n)\)(树形 DP 遍历所有节点)。
    • 每次操作:\(O(\alpha(n))\)(并查集操作)。
    • 总时间复杂度:\(O(n + q \cdot \alpha(n))\)
    • 空间复杂度:\(O(n)\)
  5. 核心思想

    • 直径与半径的关系:树的直径 \(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
posted @ 2026-09-01 23:08  团爸讲算法  阅读(14)  评论(0)    收藏  举报