题解:洛谷 P1399 快餐店

【题目来源】

洛谷:P1399 [NOI2013] 快餐店

【题目描述】

小 T 打算在城市 C 开设一家外送快餐店。送餐到某一个地点的时间与外卖店到该地点之间最短路径长度是成正比的,小 T 希望快餐店的地址选在离最远的顾客距离最近的地方。

快餐店的顾客分布在城市 C 的 \(N\) 个建筑中,这 \(N\) 个建筑通过恰好 \(N\) 条双向道路连接起来,不存在任何两条道路连接了相同的两个建筑。任意两个建筑之间至少存在一条由双向道路连接而成的路径。小 T 的快餐店可以开设在任一建筑中,也可以开设在任意一条道路的某个位置上(该位置与道路两端的建筑的距离不一定是整数)。

现给定城市 C 的地图(道路分布及其长度),请找出最佳的快餐店选址,输出其与最远的顾客之间的距离。

【输入】

第一行包含一个整数 \(N\),表示城市 C 中的建筑和道路数目。

接下来 \(N\) 行,每行 \(3\) 个整数,\(A_i,B_i,L_i\)\(1\leq i\leq N\)\(L_i>0\)),表示一条道路连接了建筑 \(A_i\)\(B_i\),其长度为 \(L_i\)

【输出】

输出仅包含一个实数,四舍五入保留恰好一位小数,表示最佳快餐店选址距离最远用户的距离。

注意:你的结果必须恰好有一位小数,小数位数不正确不得分。

【输入样例】

4
1 2 1
1 4 2
1 3 2
2 4 1

【输出样例】

2.0

【核心思想】

  1. 问题分析:给定一个由 \(N\) 个点、\(N\) 条边组成的连通无向图(即一棵基环树,每个连通分量恰好有一个环)。每条边有正权值。可以在任意节点或边上的任意位置开设快餐店,要求最小化该点到所有其他节点的最大距离(即最小化“偏心距”)。在树上,该值等于直径的一半;在基环树上,由于环的存在,需要特殊处理环上的路径和外向树结构。本质是在基环树上求最小半径,等价于求基环树直径后除以 2。

  2. 算法选择

    • 基环树拆环 + 树形 DP + 环上 DP:利用基环树“一个环 + 若干棵外向树”的结构。先找出环,对每棵外向树内部用树形 DP 求出其直径(ans1)和每棵子树根节点出发的最长链长度 d[u]。然后,将环上的每个节点视为一个“挂载点”,其权值为 d[u],环边有权重。问题转化为:在环上(带权边)放置一个点,使所有“环上点权值 + 到该点的环上距离”的最大值最小。通过断环成链,使用前缀/后缀 DP 枚举最优断点,得到环上部分的最小可能直径 ans2。整棵基环树的直径为 max(ans1, ans2),最小半径为直径的一半。
  3. 关键步骤

    • 找环:用 DFS 标记父节点,找到环上的节点和边权,存入 cv[1..cnt](环上节点顺序)和 cw[1..cnt](环上边权,cw[i]cv[i]cv[i-1] 的边权,首尾通过 cw[cnt] 连接)。
    • 外向树 DP:对每个环上节点 cv[i],DFS 其非环子树(inc[v] == 0),计算:
      • d[u]:从 u 出发到其子树内节点的最长链长度。
      • ans1 = max(ans1, max1 + max2),其中 max1max2 是当前节点子树中最深的两条链,用于更新子树内直径。
    • 环上 DP(断环成链):将环断开成链,以便计算经过环的最优路径。定义环上每个节点的“权值”为 d[cv[i]]
      • 正向预处理:A[i] = max(A[i-1], sum + d[cv[i]])B[i] = max(B[i-1], mx + d[cv[i]] + sum),其中 sum 为环边前缀和,mxd[cv[k]] - sum 的最大值。A[i] 表示前缀中“深度 + 距离”的最大值,B[i] 表示前缀中“两点深度和 + 距离”的最大值(即经过环上部分的前缀直径)。
      • 反向同理得到 C[i]D[i]
    • 枚举断边:枚举断开的环边位置 i\(1 \le i < cnt\)),将环变成链,此时经过环的候选直径为 res = max(B[i], D[i+1], A[i] + C[i+1] + cw[cnt]),其中 cw[cnt] 是首尾相连的最后一条边(即被断开的边)。取所有 res 的最小值作为 ans2
    • 计算答案:整棵基环树的直径为 max(ans1, ans2),最小半径为 max(ans1, ans2) / 2,输出保留一位小数。
  4. 时间/空间复杂度

    • 时间复杂度:\(O(N)\),每个节点和边在找环、树形 DP 和环上 DP 中各被访问常数次。
    • 空间复杂度:\(O(N)\),存储邻接表、环数组和 DP 数组。
  5. 基环树 + 环上 DP 的核心思想

    • 分解结构:基环树由一个环和若干棵外向树组成,处理时分开计算树内贡献和环上贡献。
    • 断环成链:环上最优解可视为在某个位置断开环,转化为链上的覆盖问题,通过枚举断点取最小值。
    • 前缀/后缀 DP:利用前缀和与最大值的递推,高效计算链上任意两点的最大距离,避免 \(O(cnt^2)\) 的枚举。
    • 直径与半径的关系:在图中,最小覆盖半径等于直径的一半,因此最终答案由基环树直径决定。
    • 适用于基环树上的中心选址、最小最大距离等问题,是处理带环树结构的经典方法。

【算法标签】

提高+ #基环树

【代码详解】

#include <bits/stdc++.h>
using namespace std;

const int N = 100005, M = N * 2;

int n;
int h[N], e[M], w[M], ne[M], idx;       // 链式前向星存图
int vis[N], fa[N], wi[N];               // vis: 访问标记, fa: 父节点, wi: 到父节点的边权
int inc[N], cv[N], cw[N], cnt;          // inc: 是否在环上, cv: 环上节点编号, cw: 环上边权(按顺序)
double d[N], A[N], B[N], C[N], D[N];    // d[u]: 以u为根的外向子树中最长链长度, A/B/C/D用于环上DP
double ans1, ans2 = 1e18;               // ans1: 所有外向子树内的最大直径, ans2: 经过环上的最小可能直径

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

// 寻找基环树上的环,并标记环上的节点和边权
bool find(int u)
{
    vis[u] = true;
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int v = e[i];
        if (v == fa[u])
            continue;
        fa[v] = u;
        wi[v] = w[i];
        if (!vis[v])
        {
            if (find(v))
                return true;
        }
        else
        {
            // 找到环,从u向上回溯收集环上节点
            int p = u;
            while (true)
            {
                inc[p] = 1;                 // 标记在环上
                cv[++cnt] = p;              // 存储环上节点
                cw[cnt] = wi[p];            // 存储环上边权(从p到其父节点)
                p = fa[p];
                if (p == u)
                    break;
            }
            return true;
        }
    }
    return false;
}

// DFS计算每棵外向树(挂在环上的子树)内部的最长链长度 d[u],并更新子树内直径 ans1
void dfs(int u, int fa)
{
    double max1 = 0, max2 = 0;          // 到子树内最深的两条链
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int v = e[i];
        if (!inc[v] && v != fa)         // 只处理不在环上的节点(即外向树部分)
        {
            dfs(v, u);
            double path = w[i] + d[v];
            if (path > max1)
            {
                max2 = max1;
                max1 = path;
            }
            else if (path > max2)
            {
                max2 = path;
            }
        }
    }
    ans1 = max(ans1, max1 + max2);      // 子树内的直径
    d[u] = max1;                        // 从u出发的最长向下链
}

int main()
{
    cin >> n;
    memset(h, -1, sizeof(h));
    for (int i = 1; i <= n; i++)
    {
        int a, b, c;
        cin >> a >> b >> c;
        add(a, b, c);
        add(b, a, c);
    }

    find(1);                            // 从任意节点开始找环

    // 对环上每个节点,处理其外向子树,得到 d[cv[i]] 和子树内直径 ans1
    for (int i = 1; i <= cnt; i++)
    {
        dfs(cv[i], 0);
    }

    // 环上DP,计算经过环的最长路径(直径)的候选值
    double sum = 0, mx = 0;
    // 正向(顺时针)预处理 A[i] = max(前缀环边权和 + 子树高度), B[i] = 前缀中两棵树的深度+这两个节点间的距离
    for (int i = 1; i <= cnt; i++)
    {
        sum += cw[i - 1];                                       // 累加从上一个节点到当前节点的环边
        A[i] = max(A[i - 1], sum + d[cv[i]]);
        B[i] = max(B[i - 1], mx + d[cv[i]] + sum);
        mx = max(mx, d[cv[i]] - sum);
    }

    sum = 0;
    mx = 0;
    double cn_1 = cw[cnt];              // 保存最后一条环边(首尾相连的边)
    cw[cnt] = 0;                        // 临时置零,方便反向处理
    // 反向(逆时针)预处理 C[i] = max(后缀环边权和 + 子树高度), D[i] = 后缀中两棵树的深度+这两个节点间的距离
    for (int i = cnt; i >= 1; i--)
    {
        sum += cw[i];
        C[i] = max(C[i + 1], sum + d[cv[i]]);
        D[i] = max(D[i + 1], mx + d[cv[i]] + sum);
        mx = max(mx, d[cv[i]] - sum);
    }

    // 枚举断开环的位置,计算经过环的最短可能直径(尽量使最大距离最小,此处求的是直径最小值)
    double res;
    for (int i = 1; i < cnt; i++)
    {
        // 三种情况:全部在左边,全部在右边,跨过断边(补上最后一条边 cn_1)
        res = max({B[i], D[i + 1], A[i] + C[i + 1] + cn_1});
        ans2 = min(ans2, res);          // 取所有断环方案中的最小值
    }

    // 最终答案为整棵基环树直径的一半(即半径),输出保留一位小数
    printf("%.1lf\n", max(ans1, ans2) / 2);
    return 0;
}

【运行结果】

4
1 2 1
1 4 2
1 3 2
2 4 1
2.0
posted @ 2026-09-02 17:22  团爸讲算法  阅读(8)  评论(0)    收藏  举报