题解:洛谷 P3304 直径

【题目来源】

洛谷:P3304 [SDOI2013] 直径 - 洛谷

【题目描述】

小 Q 最近学习了一些图论知识。根据课本,有如下定义。树:无回路且连通的无向图,每条边都有正整数的权值来表示其长度。如果一棵树有 \(N\) 个节点,可以证明其有且仅有 \(N-1\) 条边。

路径:一棵树上,任意两个节点之间最多有一条简单路径。我们用 \(\text{dis}(a,b)\) 表示点 \(a\) 和点 \(b\) 的路径上各边长度之和。称 \(\text{dis}(a,b)\)\(a,b\) 两个节点间的距离。

直径:一棵树上,最长的路径为树的直径。树的直径可能不是唯一的。

现在小 Q 想知道,对于给定的一棵树,其直径的长度是多少,以及有多少条边满足所有的直径都经过该边。

【输入】

第一行包含一个整数 \(N\),表示节点数。

接下来 \(N-1\) 行,每行三个整数 \(a,b,c\),表示点 \(a\) 和点 \(b\) 之间有一条长度为 \(c\) 的无向边。

【输出】

共两行。第一行一个整数,表示直径的长度。第二行一个整数,表示被所有直径经过的边的数量。

【输入样例】

6
3 1 1000
1 4 10
4 2 100
4 5 50
4 6 100

【输出样例】

1110
2

【核心思想】

  1. 问题分析:给定一棵带正权边的树,首先需要求出树的直径长度(即树上最远两点之间的距离),然后统计有多少条边被所有直径共同经过(即这些边是任意一条直径的必经之路)。问题分为两步:第一步用两次 DFS 求出一条直径及其端点;第二步在直径路径上判断哪些边是“必经边”——若存在一条直径不经过某条边,则该边不满足条件。

  2. 算法选择

    • 两次 DFS 求直径:利用树的性质,从任意点出发找到最远点 \(l\),再从 \(l\) 出发找到最远点 \(r\),则 \(l \to r\) 即为一条直径,记录路径上的节点和父节点。
    • 路径标记与二次 DFS:标记主直径路径上的所有节点。对于路径上的每个节点 \(i\)(除端点外),从 \(i\) 出发向非主直径方向进行 DFS,求该分支内能到达的最远距离 \(d_{branch}\)。若 \(d_{branch}\) 等于 \(i\) 到直径某一端点的距离,则说明存在另一条直径不经过 \(i\) 与其相邻的某条边,从而更新公共边的边界。
    • 核心结论:所有直径的公共边构成主直径上的一段连续区间。从左端点 \(l\) 和右端点 \(r\) 向中间收缩,最终得到公共边的左右边界,边界内的边即为所有直径的必经边。
  3. 关键步骤

    • 第一次 DFS:从任意节点(如 1)出发,记录到各点的距离 \(d[u]\),维护最大距离 maxd 和对应节点 p,得到直径端点 \(l\)
    • 第二次 DFS:从 \(l\) 出发,再次 DFS,得到另一端点 \(r\),同时记录每个节点的父节点 pre[u],以便回溯直径路径。
    • 标记主直径路径:从 \(r\) 沿着 pre 回溯到 \(l\),将路径上的节点标记为 col[u] = 1
    • 收缩边界
      • \(left = l, right = r\)
      • \(r\)\(l\) 遍历直径上的节点 \(i\)(不包括 \(l\)):
        • 计算 \(i\)\(l\) 的距离 \(distL = d[i]\),到 \(r\) 的距离 \(distR = d[r] - d[i]\)
        • \(i\) 出发,不经过主直径上的节点,进行一次 DFS,得到分支内的最远距离 maxd
        • maxd == distR,说明存在一条直径经过 \(i\) 但不经过 \(i \to pre[i]\) 的边,则右边界 \(right\) 可收缩至 \(i\)
        • maxd == distL,说明存在一条直径经过 \(i\) 但不经过 \(i \to pre[i]\) 的边,则左边界 \(left\) 可收缩至 \(i\),并提前结束收缩(因为左边界向右移动)。
    • 统计答案:若 \(left == right\),则没有公共边;否则,\(left\)\(right\) 路径上的边数即为答案。
  4. 时间/空间复杂度

    • 时间复杂度:\(O(N)\),两次 DFS 遍历整棵树,后续收缩过程中的 DFS 只会遍历非主直径分支,总复杂度仍为 \(O(N)\)
    • 空间复杂度:\(O(N)\),存储邻接表和辅助数组。
  5. 树的直径与公共边的核心思想

    • 两次 DFS 的正确性:在带正权树中,从任意点出发的最远点一定是某条直径的端点,两次 DFS 可确定一条直径。
    • 公共边连续区间:所有直径的公共边在一条直径上形成连续段,因此只需找到左右边界即可。
    • 分支判定:对于直径上的节点 \(i\),若其非直径方向分支中存在一点到 \(i\) 的距离等于 \(i\) 到某一直径端点的距离,则说明存在另一条直径绕过 \(i\) 的某条邻边,使该边成为非公共边。
    • 适用场景:适用于求树的直径长度,以及所有直径共同经过的边数,是树形 DP 与直径性质的综合应用。

【算法标签】

普及+ #树形DP

【代码详解】

#include <bits/stdc++.h>
using namespace std;
#define int long long           // 将 int 定义为 long long,防止距离累加溢出
const int N = 200005, M = N * 2;    // N: 最大节点数, M: 最大边数(无向图双向边)
int n, p, l, r;                 // n: 节点数, p: 临时最远点, l: 直径左端点, r: 直径右端点
int h[N], e[M], w[M], ne[M], idx;   // 链式前向星存图
int pre[N];                     // pre[u]: DFS时u的父节点(用于回溯直径路径)
int d[N];                       // d[u]: 从某个起点到u的距离
int maxd;                       // 当前DFS找到的最大距离
int col[N];                     // col[u]: 标记u是否在主直径路径上

// 链式前向星:添加无向边
void add(int a, int b, int c)
{
    e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}

// DFS1:求树的直径端点,同时记录父节点信息
void dfs(int u, int fa)
{
    pre[u] = fa;                // 记录u的父节点

    // 遍历u的所有邻接点
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int v = e[i];
        if (v == fa) continue;  // 跳过父节点

        d[v] = d[u] + w[i];       // 更新v到起点的距离

        // 更新最远点
        if (d[v] > maxd)
        {
            maxd = d[v];
            p = v;
        }

        dfs(v, u);              // 递归DFS
    }
}

// DFS2:从直径路径上的某点出发,求不经过col标记点的最远距离
// 用于判断直径路径上的某条边是否在所有直径上
void dfs2(int u, int fa)
{
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int v = e[i];
        // 跳过父节点和已在主直径路径上的点(不能走到直径路径的另一侧)
        if (col[v] || v == fa) continue;

        d[v] = d[u] + w[i];       // 更新距离

        // 更新最大距离
        if (d[v] > maxd)
            maxd = d[v];

        dfs2(v, u);             // 递归DFS
    }
}

signed main()                   // 使用 signed 替代 int,因为 #define int long long
{
    cin >> n;                   // 读入节点数

    // 初始化链式前向星
    memset(h, -1, sizeof(h));

    // 读入n-1条边
    for (int i = 1; i < n; i++)
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c), add(b, a, c);     // 无向图,双向建边
    }

    // ========== 第一步:求树的直径 ==========
    // 第一次DFS:从任意点1出发,找到最远点l
    dfs(1, 0);
    d[p] = maxd = 0;            // 重置距离数组,准备第二次DFS
    l = p;                      // l是直径的一个端点

    // 第二次DFS:从l出发,找到最远点r,同时记录父节点
    dfs(l, 0);
    r = p;                      // r是直径的另一个端点

    // 输出直径长度
    cout << maxd << endl;

    // ========== 第二步:标记主直径路径上的所有点 ==========
    // 从r回溯到l,标记路径上的所有点
    for (int i = r; i; i = pre[i])
        col[i] = 1;

    // ========== 第三步:求所有直径的公共边 ==========
    // 核心思想:对于直径路径上的每条边,检查是否存在不经过该边的其他直径
    // 如果存在,则该边不在所有直径上

    int ll = l, rr = r;         // 记录原始的直径端点

    // 从r向l遍历直径路径上的每个点(不包括端点)
    for (int i = pre[rr]; i != ll; i = pre[i])
    {
        // ld: 从l到i的距离(i左侧的直径长度)
        // rd: 从i到r的距离(i右侧的直径长度)
        int ld = d[i], rd = d[rr] - d[i];

        // 从i出发,不经过主直径路径上的点,求最远距离
        d[i] = maxd = 0;
        dfs2(i, 0);

        // 如果从i出发能找到的最远距离等于rd,说明存在一条直径不经过i到pre[i]的边
        // 此时可以更新r,缩小公共边的范围
        if (maxd == rd)
            r = i;

        // 如果从i出发能找到的最远距离等于ld,说明存在一条直径不经过i到下一个点的边
        // 此时可以更新l,缩小公共边的范围
        if (maxd == ld)
        {
            l = i;
            break;              // 找到后可以直接退出
        }
    }

    // 输出被所有直径经过的边的数量
    if (l == r)
        cout << 0 << endl;      // 没有公共边
    else
    {
        int cnt = 1;            // 统计l到r路径上的边数
        for (int i = pre[r]; i != l; i = pre[i])
            cnt++;
        cout << cnt << endl;
    }

    return 0;
}

【运行结果】

6
3 1 1000
1 4 10
4 2 100
4 5 50
4 6 100
1110
2
posted @ 2026-09-01 23:08  团爸讲算法  阅读(8)  评论(0)    收藏  举报