Skip to content

Crying 与哈密顿路 - 题解

标签与难度

标签: 树形DP, 动态规划, 哈密顿路, LCA, 树上背包, 图论 难度: 2200

题目大意喵~

一位名叫 Crying 的朋友遇到了一点小麻烦,需要我们这些聪明的小我来帮忙,喵~

事情是这样哒:

  1. 首先,我们拿到了一棵有 nn 个节点的有根树,根节点是 1。每个节点 ii 都有一个自己的“心情值” wiw_i
  2. 然后,我们要根据这棵树,变魔法一样造出一张新的图!这张新图是无向完全图,也就是说,每两个不同的节点之间都有一条边。
  3. 新图中,连接节点 uuvv 的边的“价值”(也就是边权),等于它们在原来那棵树上的最近公共祖先 (LCA) 的心情值,即 wLCA(u,v)w_{\text{LCA}(u, v)}
  4. 我们的终极任务是,在这张新图上,找到一条哈密顿路,让这条路上所有边的价值总和最大。哈密顿路是一条不重复地经过图中每一个节点的路径,正好有 n1n-1 条边。

我们只需要告诉 Crying 这个最大的价值总和是多少就可以啦,是不是很有趣的挑战呢?喵~

解题思路分析

这道题看起来好复杂呀,又是哈密顿路,又是LCA的,但是别怕别怕,让我来带你一步步解开它的神秘面纱!(ฅ'ω'ฅ)

哈密顿路问题通常是 NP-Hard 的,直接暴力搜索所有排列肯定会超时的说。但是!这道题的边权不是随便给的,而是由一棵树的结构决定的。这种有特殊结构的问题,通常都可以用动态规划来解决,特别是利用它给定的树形结构,那当然是树形DP啦!

核心思想:拆解贡献

我们要求的最大价值是 wLCA(pi,pi+1)\sum w_{\text{LCA}(p_i, p_{i+1})},其中 p1,,pnp_1, \dots, p_n 是哈密顿路的节点序列。

我们可以换个角度思考,不去看每条边,而是看每个节点 xx 的心情值 wxw_x 对总和贡献了多少次。当哈密顿路中某条边 (u,v)(u, v)LCA(u,v)\text{LCA}(u,v) 正好是 xx 时,wxw_x 就会被加上。什么时候 LCA(u,v)=x\text{LCA}(u,v) = x 呢?这说明 uuvv 分别在 xx 的不同子树里(或者其中一个是 xx 本身)。

这启发我们,可以在树上从下往上进行计算。对于每个节点 uu,我们考虑它的子树,看看子树内的节点能构成什么样的路径,以及这些路径能产生多大的价值。

DP状态定义

既然是树形DP,那我们就来定义一个合适的DP状态吧!

我们定义 dp[u][i] 表示:在以 u 为根的子树中,将所有节点划分成 i 条互不相交的路径,能够得到的最大边权和是多少

这里说的“边权和”,指的是这些路径内部所有边的权值之和。一条边的权值,依然遵循题目给的LCA规则。

DP的转移过程(最有趣的部分!)

我们使用深度优先搜索(DFS)来进行后序遍历,这样在处理一个节点 u 时,它的所有子节点都已经被计算过了,喵~

  1. 基本情况(叶子节点): 对于一个叶子节点 u,它的子树里只有它自己。它只能形成一条只包含一个节点的“路径”,长度为0。所以,路径数为1,内部边权和为0。 即 dp[u][1] = 0。对于其他的 idp[u][i] 都是一个极小值(比如负无穷),表示不合法。

  2. 合并子树(状态转移): 这是最关键的一步!假设我们正在计算节点 u,并且已经计算完了它的一个子节点 v。现在要把 v 的子树信息合并到 u 上。

    在合并前,我们已经处理完了 u 和它的其他一些子节点,假设这些节点构成了 i 条路径,最大价值是 dp_u_temp[i]。而 v 的子树中,节点构成了 j 条路径,最大价值是 dp[v][j]

    现在,我们要把这两部分“焊接”起来。怎么焊呢?我们可以连接一条 u 这边某条路径的端点和 v 子树中某条路径的端点。任何这样一条新的连接边,它的两个端点一个来自 u 的(部分)子树,一个来自 v 的子树,它们的LCA必然是 u!所以,每建立一条这样的连接,总价值就会增加 wuw_u

    假设我们建立了 kk 条这样的新连接:

    • 价值变化: 总价值增加了 k×wuk \times w_u
    • 路径数变化: 原来总共有 i+ji+j 条路径。每建立一条连接,就会把两条路径合并成一条,总路径数减1。所以建立 kk 条连接后,总路径数变为 i+jki+j-k

    于是,我们得到了状态转移方程: new_dp[i+j-k] = max(new_dp[i+j-k], dp_u_temp[i] + dp[v][j] + k * w_u)

    我们只要遍历所有可能的 i, j, k,就能更新合并后的DP状态啦!

  3. k的取值范围: k 是连接数,它受限于两边可以用来连接的“端点”数量。

    • u 这边有 i 条路径,就有 2i2i 个端点(一个单节点路径也算有两个虚拟端点,可以连出两条边)。
    • v 这边有 j 条路径,就有 2j2j 个端点。
    • 所以,我们最多能建立 min(2i,2j)\min(2i, 2j) 条连接。也就是说 0kmin(2i,2j)0 \le k \le \min(2i, 2j)
  4. 一个重要的约束: 在合并的过程中,我们不能把当前子树内的所有节点连成一个环。一个环意味着没有端点可以向外连接了,而我们的目标是构建一条贯穿所有 nn 个节点的一条大路径。如果子树内部成环,就无法和外面的节点连接了。

    怎么判断成环了呢?如果合并后路径数变成了0,即 i+jk=0i+j-k=0,就说明形成了一个闭环。所以我们要加个条件 if (i+j-k > 0) 来避免这种情况。

最终答案

当我们从根节点 1 开始DFS,整个过程结束后,dp[1][i] 就存储了将整棵树的所有 nn 个节点划分为 i 条路径的最大价值。 因为题目要求的是一条哈密顿路,也就是一条包含所有节点的单条路径,所以我们想要的最终答案就是 dp[1][1] 啦!

这个过程就像是在玩乐高,先把小零件(叶子节点)拼成小模块(子树路径),再把小模块一层层拼成一个大作品(最终的哈密顿路),是不是很有趣呢,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码哦!变量名和注释都写得很清楚,希望能帮助你理解,喵~

cpp
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

// 使用 long long 防止结果溢出,喵~
using int64 = long long;

const int MAXN = 305;
const int64 INF = 1e18; // 一个足够大的负数表示无效状态

int n;
int64 weight[MAXN]; // 每个节点的心情值 w_i
vector<int> children[MAXN]; // 存储树的结构

// dp[u][i]: 在以 u 为根的子树中,所有节点构成 i 条路径的最大边权和
int64 dp[MAXN][MAXN];
int subtree_size[MAXN]; // 记录子树大小

// 用于合并时的临时DP数组
int64 temp_dp[MAXN];

void dfs(int u) {
    // 1. 初始化
    // 节点 u 自己构成一棵子树,大小为1
    subtree_size[u] = 1;
    // 初始化dp表,所有状态都设为无效
    for (int i = 0; i <= n; ++i) {
        dp[u][i] = -INF;
    }
    // 节点 u 自己是一条路径,内部边权和为0
    dp[u][1] = 0;

    // 2. 递归处理子节点并合并
    for (int v : children[u]) {
        dfs(v);

        // 准备一个临时数组来存放合并后的新状态,防止在计算时污染当前 dp[u] 的值
        for (int i = 0; i <= subtree_size[u] + subtree_size[v]; ++i) {
            temp_dp[i] = -INF;
        }

        // i: 当前 u 所在组件的路径数
        for (int i = 1; i <= subtree_size[u]; ++i) {
            // 如果 dp[u][i] 是个无效状态,就没必要从这里转移了
            if (dp[u][i] < -INF / 2) continue;

            // j: 子节点 v 的子树中的路径数
            for (int j = 1; j <= subtree_size[v]; ++j) {
                if (dp[v][j] < -INF / 2) continue;
                
                // k: 在 u 组件和 v 子树之间建立 k 条连接
                // k 的上限是 min(2*i, 2*j)
                for (int k = 0; k <= 2 * min(i, j); ++k) {
                    // 合并后形成 i+j-k 条路径
                    int new_paths = i + j - k;
                    if (new_paths > 0) { // 必须至少有一条路径,不能形成环
                        int64 current_val = dp[u][i] + dp[v][j] + (int64)k * weight[u];
                        temp_dp[new_paths] = max(temp_dp[new_paths], current_val);
                    }
                }
            }
        }
        
        // 更新 u 的子树大小
        subtree_size[u] += subtree_size[v];
        // 将合并后的结果拷回 dp[u]
        for (int i = 1; i <= subtree_size[u]; ++i) {
            dp[u][i] = temp_dp[i];
        }
    }
}

int main() {
    // 为了更快的I/O,喵~
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> weight[i];
    }
    for (int i = 2; i <= n; ++i) {
        int parent;
        cin >> parent;
        children[parent].push_back(i);
    }

    dfs(1);

    // 最终答案是整棵树构成1条路径(哈密顿路)时的最大价值
    cout << dp[1][1] << endl;

    return 0;
}

复杂度分析

  • 时间复杂度: O(N3)O(N^3) 我们对树进行了一次深度优先搜索。在每个节点 u,我们会遍历它的所有子节点 v 并进行合并。合并操作是复杂度的主要来源,它有三层循环,分别遍历 i, j, ki 的范围是 $[1, \text{size}(u)]j 的范围是 $[1, \text{size}(v)]k 的范围是 [0,2min(i,j)][0, 2\min(i, j)]。 这是一个典型的树上背包问题。虽然看起来循环很多,但经过仔细分析(或者可以记作一个结论),这类将子树大小作为DP维度的合并操作,总的时间复杂度通常是 O(N2)O(N^2)O(N3)O(N^3)。在这个问题中,由于有第三层 k 循环,其复杂度更接近于 O(N3)O(N^3)。对于 N=300N=300 的数据规模,30032.7×107300^3 \approx 2.7 \times 10^7,是可以通过的。

  • 空间复杂度: O(N2)O(N^2) 主要的开销是 dp 数组,它的大小是 MAXN * MAXN,即 O(N2)O(N^2)children 邻接表和 subtree_size 数组占用 O(N)O(N) 空间,temp_dp 数组占用 O(N)O(N) 空间。所以总空间复杂度是 O(N2)O(N^2)

知识点总结

这道题真是一次有趣的冒险,我们从中可以学到很多东西呢!

  1. 树形DP: 解决树上问题的强大武器!核心是找到合适的DP状态,并通过DFS(通常是后序遍历)自底向上地合并子问题的解。
  2. 树上背包问题: 一种经典的树形DP模型。特征是在父节点合并子节点信息时,状态的维度(比如本题的路径数)会相加,很像背包问题中物品体积的合并。
  3. 问题转化: 学会将一个复杂的问题(求最大权哈密顿路)转化为另一个等价且更易于处理的问题(计算各节点权值的贡献,并用DP求解)。
  4. LCA的应用: LCA不仅在查询问题中常见,也可以作为一种定义图中关系的工具,如此题中定义边权。
  5. 细节处理: 在DP过程中,要注意边界条件、无效状态的表示(用负无穷),以及使用临时数组来避免状态更新的相互干扰,这些都是写对DP代码的好习惯,喵~

希望这篇题解能帮到你!如果还有不明白的地方,随时可以再来问我哦!一起加油,喵呜~ (づ。◕‿‿◕。)づ