题解:洛谷 P3629 巡逻

【题目来源】

洛谷:P3629 [APIO2010] 巡逻 - 洛谷

【题目描述】

在一个地区中有 \(n\) 个村庄,编号为 \(1, 2, \dots, n\)。有 \(n-1\) 条道路连接着这些村庄,每条道路刚好连接两个村庄,从任何一个村庄,都可以通过这些道路到达其他任何一个村庄。每条道路的长度均为 \(1\) 个单位。为保证该地区的安全,巡警车每天要到所有的道路上巡逻。警察局设在编号为 \(1\) 的村庄里,每天巡警车总是从警察局出发,最终又回到警察局。下图表示一个有 \(8\) 个村庄的地区,其中村庄用圆表示(其中村庄 \(1\) 用黑色的圆表示),道路是连接这些圆的线段。为了遍历所有的道路,巡警车需要走的距离为 \(14\) 个单位,每条道路都需要经过两次。

img/Pasted image 20260628101014.png

为了减少总的巡逻距离,该地区准备在这些村庄之间建立 \(K\) 条新的道路,每条新道路可以连接任意两个村庄。两条新道路可以在同一个村庄会合或结束,如下面的图例 (c)。一条新道路甚至可以是一个环,即其两端连接到同一个村庄。由于资金有限,\(K\) 只能是 \(1\)\(2\)。同时,为了不浪费资金,每天巡警车必须经过新建的道路正好一次。下图给出了一些建立新道路的例子:

img/Pasted image 20260628101019.png

在 (a) 中,新建了一条道路,总的距离是 \(11\)。在 (b) 中,新建了两条道路,总的巡逻距离是 \(10\)。在 (c) 中,新建了两条道路,但由于巡警车要经过每条新道路正好一次,总的距离变为了 \(15\)。试编写一个程序,读取村庄间道路的信息和需要新建的道路数,计算出最佳的新建道路的方案使得总的巡逻距离最小,并输出这个最小的巡逻距离。

【输入】

第一行包含两个整数 \(n, K(1 ≤ K ≤ 2)\)。接下来 \(n-1\) 行,每行两个整数 \(a,b\),表示村庄 \(a\)\(b\) 之间有一条道路 \((1 ≤ a, b ≤ n)\)

【输出】

输出一个整数,表示新建了 \(K\) 条道路后能达到的最小巡逻距离。

【输入样例】

8 1
1 2
3 1
3 4
5 3
7 5
8 5
5 6

【输出样例】

11

【核心思想】

  1. 问题分析:给定一棵 \(n\) 个节点的树(边权均为 1),巡警车从节点 1 出发,需要遍历所有边并回到起点,初始总路程为 \(2(n-1)\)(每条边走两次)。现在可以新建 \(K\) 条边(\(K=1\)\(2\)),每条新边必须被恰好经过一次,求最小总巡逻距离。新建边的作用是减少需要重复走的边,最优策略是将新边连接在树的直径两端,使得直径上的边只需走一次。当 \(K=2\) 时,第二条新边应连接在直径路径上某个分支的最长链两端,进一步节省距离。

  2. 算法选择

    • 两次 DFS 求直径:先求树的直径 \(D_1\),并记录直径路径上的节点。
    • \(K=1\):答案 \(= 2(n-1) - D_1\),因为直径上的边可以从走两次变为走一次。
    • \(K=2\):在直径路径上,将边权标记为 \(-1\)(表示这些边已受益于第一条新边),然后对整棵树做树形 DP,求出直径路径上的最长链(即第二条新边能节省的最大长度)\(D_2\)。最终答案 \(= 2(n-1) - D_1 - D_2\)
  3. 关键步骤

    • 建图:读入 \(n, K\)\(n-1\) 条边,构建无向邻接表。
    • 求直径:第一次 DFS 从 1 出发找到最远点 \(p\),第二次从 \(p\) 出发找到直径另一端 \(q\),同时记录父节点 pre,得到直径长度 \(D_1\)
    • \(K=1\):直接输出 \(2(n-1) - D_1\)
    • \(K=2\)
      • 标记直径路径上的所有节点(col=1)。
      • 将直径路径上的边权修改为 \(-1\)(因为第一条新边已经让这些边只需走一次)。
      • 对整棵树进行树形 DP,计算最长链 \(D_2\):对每个节点 \(u\),维护以 \(u\) 为端点的最长链长度 \(dp[u]\),更新全局最大值 \(D_2 = \max(D_2, dp[u] + dp[v] + w(u,v))\)
      • 输出 \(2(n-1) - D_1 - D_2\)
  4. 时间/空间复杂度

    • 时间复杂度:\(O(n)\),两次 DFS 和一次树形 DP。
    • 空间复杂度:\(O(n)\)
  5. 核心思想

    • 新建边的作用:在原树中,每条边需要走两次。新建一条边连接两个节点后,这两个节点之间的路径上的边可以只走一次,从而节省距离。
    • 最优路径在直径上:为了最大化节省,第一条新边应连接直径两端;第二条新边应连接直径路径上某个分支的最长链两端。
    • 树形 DP 处理第二条边:在直径路径被第一条边“压缩”后,通过树形 DP 找出剩余部分能形成的最长路径,即为第二条新边可节省的距离。
    • 适用场景:树上的巡逻、遍历问题,通过新建边减少重复路径的优化。

【算法标签】

省选 #树形DP

【代码详解】

#include <bits/stdc++.h>
using namespace std;
const int N = 100005, M = N * 2;    // N: 最大村庄数, M: 最大边数(无向图双向边)
int n, k;                           // n: 村庄数, k: 新建道路数(1或2)
int h[N], e[M], w[M], ne[M], idx;   // 链式前向星存图
int pre[N];                         // pre[u]: DFS时u的父节点(用于回溯直径路径)
int d[N];                           // d[u]: 从某个起点到u的距离(DFS求直径时)/ 树形DP时存最长链
int p;                              // p: 临时最远点
int maxd;                           // 当前DFS找到的最大距离
int d1, d2;                         // d1: 树的直径长度, d2: 直径路径上"树形DP最长链"的长度
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
    }
}

// 树形DP:求直径路径上的树形DP最长链
// 同时修改直径路径上的边权为-1(表示新建道路后这些边只需走一次)
void dp(int u, int fa)
{
    for (int i = h[u]; i != -1; i = ne[i])
    {
        int v = e[i];
        if (v == fa) continue;      // 跳过父节点

        // 如果u和v都在直径路径上,则这条边在直径上
        // 新建一条道路连接直径两端后,直径上的边只需走一次(而不是两次)
        // 将边权改为-1:原来贡献+2(来回),现在贡献+1(只需一次),相当于减少了1
        if (col[u] && col[v])
        {
            w[i] = -1;
            w[i ^ 1] = -1;
        }

        dp(v, u);                   // 递归处理子树

        // 树形DP:更新经过u的最长链
        // d2记录全局最长链(直径路径上某点往两侧分支延伸的最长距离)
        d2 = max(d2, d[u] + d[v] + w[i]);
        // d[u]记录以u为端点的最长链
        d[u] = max(d[u], d[v] + w[i]);
    }
}

int main()
{
    cin >> n >> k;                  // 读入村庄数和新建道路数

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

    // 读入n-1条道路(树结构)
    for (int i = 1; i < n; i++)
    {
        int a, b;
        cin >> a >> b;
        add(a, b, 1), add(b, a, 1);   // 无向边,长度为1
    }

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

    // 第二次DFS:从p出发,找到最远点,同时记录父节点
    dfs(p, 0);
    d1 = d[p];                      // d1为树的直径长度

    // ========== K=1的情况 ==========
    // 新建一条道路连接直径两端,直径上的边只需走一次
    // 总巡逻距离 = 2*(n-1) - d1 = 2n - 2 - d1 = 2n - d1 - 1
    // 解释:原来每条边走两次,共2(n-1);直径上的d1条边现在只需走一次,节省了d1
    if (k == 1)
    {
        cout << 2 * n - d1 - 1 << endl;
        return 0;
    }

    // ========== K=2的情况 ==========
    // 标记直径路径上的所有点
    for (int i = p; i; i = pre[i])
        col[i] = 1;

    // 重置距离数组,准备树形DP
    memset(d, 0, sizeof(d));

    // 树形DP:求"直径路径上的树形DP最长链" d2
    // 核心思想:新建两条道路,第一条连接直径两端,第二条连接某分支的两端
    // 第二条道路应该连接从直径上某点延伸出的最长分支链的两端
    dp(1, 0);

    // 输出答案
    // 总巡逻距离 = 2*(n-1) - d1 - d2 = 2n - 2 - d1 - d2 = 2n - d1 - d2 - 1?
    // 注意:代码中是 2*n - d1 - d2,因为边权改为-1后,d2已经包含了节省的量
    // 实际上:原来总距离2(n-1),第一条道路节省d1,第二条道路节省d2
    // 最终距离 = 2(n-1) - d1 - d2 = 2n - 2 - d1 - d2
    // 但代码输出2n - d1 - d2,这是因为dp中边权改为-1后,d2的计算方式已经调整
    cout << 2 * n - d1 - d2 << endl;

    return 0;
}

【运行结果】

8 1
1 2
3 1
3 4
5 3
7 5
8 5
5 6
11
posted @ 2026-09-01 23:09  团爸讲算法  阅读(11)  评论(0)    收藏  举报