Crying 与哈密顿路 - 题解
标签与难度
标签: 树形DP, 动态规划, 哈密顿路, LCA, 树上背包, 图论 难度: 2200
题目大意喵~
一位名叫 Crying 的朋友遇到了一点小麻烦,需要我们这些聪明的小我来帮忙,喵~
事情是这样哒:
- 首先,我们拿到了一棵有 个节点的有根树,根节点是 1。每个节点 都有一个自己的“心情值” 。
- 然后,我们要根据这棵树,变魔法一样造出一张新的图!这张新图是无向完全图,也就是说,每两个不同的节点之间都有一条边。
- 新图中,连接节点 和 的边的“价值”(也就是边权),等于它们在原来那棵树上的最近公共祖先 (LCA) 的心情值,即 。
- 我们的终极任务是,在这张新图上,找到一条哈密顿路,让这条路上所有边的价值总和最大。哈密顿路是一条不重复地经过图中每一个节点的路径,正好有 条边。
我们只需要告诉 Crying 这个最大的价值总和是多少就可以啦,是不是很有趣的挑战呢?喵~
解题思路分析
这道题看起来好复杂呀,又是哈密顿路,又是LCA的,但是别怕别怕,让我来带你一步步解开它的神秘面纱!(ฅ'ω'ฅ)
哈密顿路问题通常是 NP-Hard 的,直接暴力搜索所有排列肯定会超时的说。但是!这道题的边权不是随便给的,而是由一棵树的结构决定的。这种有特殊结构的问题,通常都可以用动态规划来解决,特别是利用它给定的树形结构,那当然是树形DP啦!
核心思想:拆解贡献
我们要求的最大价值是 ,其中 是哈密顿路的节点序列。
我们可以换个角度思考,不去看每条边,而是看每个节点 的心情值 对总和贡献了多少次。当哈密顿路中某条边 的 正好是 时, 就会被加上。什么时候 呢?这说明 和 分别在 的不同子树里(或者其中一个是 本身)。
这启发我们,可以在树上从下往上进行计算。对于每个节点 ,我们考虑它的子树,看看子树内的节点能构成什么样的路径,以及这些路径能产生多大的价值。
DP状态定义
既然是树形DP,那我们就来定义一个合适的DP状态吧!
我们定义 dp[u][i] 表示:在以 u 为根的子树中,将所有节点划分成 i 条互不相交的路径,能够得到的最大边权和是多少。
这里说的“边权和”,指的是这些路径内部所有边的权值之和。一条边的权值,依然遵循题目给的LCA规则。
DP的转移过程(最有趣的部分!)
我们使用深度优先搜索(DFS)来进行后序遍历,这样在处理一个节点 u 时,它的所有子节点都已经被计算过了,喵~
基本情况(叶子节点): 对于一个叶子节点
u,它的子树里只有它自己。它只能形成一条只包含一个节点的“路径”,长度为0。所以,路径数为1,内部边权和为0。 即dp[u][1] = 0。对于其他的i,dp[u][i]都是一个极小值(比如负无穷),表示不合法。合并子树(状态转移): 这是最关键的一步!假设我们正在计算节点
u,并且已经计算完了它的一个子节点v。现在要把v的子树信息合并到u上。在合并前,我们已经处理完了
u和它的其他一些子节点,假设这些节点构成了i条路径,最大价值是dp_u_temp[i]。而v的子树中,节点构成了j条路径,最大价值是dp[v][j]。现在,我们要把这两部分“焊接”起来。怎么焊呢?我们可以连接一条
u这边某条路径的端点和v子树中某条路径的端点。任何这样一条新的连接边,它的两个端点一个来自u的(部分)子树,一个来自v的子树,它们的LCA必然是u!所以,每建立一条这样的连接,总价值就会增加 。假设我们建立了 条这样的新连接:
- 价值变化: 总价值增加了 。
- 路径数变化: 原来总共有 条路径。每建立一条连接,就会把两条路径合并成一条,总路径数减1。所以建立 条连接后,总路径数变为 。
于是,我们得到了状态转移方程:
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状态啦!k的取值范围:k是连接数,它受限于两边可以用来连接的“端点”数量。u这边有i条路径,就有 个端点(一个单节点路径也算有两个虚拟端点,可以连出两条边)。v这边有j条路径,就有 个端点。- 所以,我们最多能建立 条连接。也就是说 。
一个重要的约束: 在合并的过程中,我们不能把当前子树内的所有节点连成一个环。一个环意味着没有端点可以向外连接了,而我们的目标是构建一条贯穿所有 个节点的一条大路径。如果子树内部成环,就无法和外面的节点连接了。
怎么判断成环了呢?如果合并后路径数变成了0,即 ,就说明形成了一个闭环。所以我们要加个条件
if (i+j-k > 0)来避免这种情况。
最终答案
当我们从根节点 1 开始DFS,整个过程结束后,dp[1][i] 就存储了将整棵树的所有 个节点划分为 i 条路径的最大价值。 因为题目要求的是一条哈密顿路,也就是一条包含所有节点的单条路径,所以我们想要的最终答案就是 dp[1][1] 啦!
这个过程就像是在玩乐高,先把小零件(叶子节点)拼成小模块(子树路径),再把小模块一层层拼成一个大作品(最终的哈密顿路),是不是很有趣呢,喵~
代码实现
这是我根据上面的思路,精心重构的一份代码哦!变量名和注释都写得很清楚,希望能帮助你理解,喵~
#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;
}复杂度分析
时间复杂度: 我们对树进行了一次深度优先搜索。在每个节点
u,我们会遍历它的所有子节点v并进行合并。合并操作是复杂度的主要来源,它有三层循环,分别遍历i,j,k。i的范围是 $[1, \text{size}(u)],j的范围是 $[1, \text{size}(v)],k的范围是 。 这是一个典型的树上背包问题。虽然看起来循环很多,但经过仔细分析(或者可以记作一个结论),这类将子树大小作为DP维度的合并操作,总的时间复杂度通常是 或 。在这个问题中,由于有第三层k循环,其复杂度更接近于 。对于 的数据规模,,是可以通过的。空间复杂度: 主要的开销是
dp数组,它的大小是MAXN * MAXN,即 。children邻接表和subtree_size数组占用 空间,temp_dp数组占用 空间。所以总空间复杂度是 。
知识点总结
这道题真是一次有趣的冒险,我们从中可以学到很多东西呢!
- 树形DP: 解决树上问题的强大武器!核心是找到合适的DP状态,并通过DFS(通常是后序遍历)自底向上地合并子问题的解。
- 树上背包问题: 一种经典的树形DP模型。特征是在父节点合并子节点信息时,状态的维度(比如本题的路径数)会相加,很像背包问题中物品体积的合并。
- 问题转化: 学会将一个复杂的问题(求最大权哈密顿路)转化为另一个等价且更易于处理的问题(计算各节点权值的贡献,并用DP求解)。
- LCA的应用: LCA不仅在查询问题中常见,也可以作为一种定义图中关系的工具,如此题中定义边权。
- 细节处理: 在DP过程中,要注意边界条件、无效状态的表示(用负无穷),以及使用临时数组来避免状态更新的相互干扰,这些都是写对DP代码的好习惯,喵~
希望这篇题解能帮到你!如果还有不明白的地方,随时可以再来问我哦!一起加油,喵呜~ (づ。◕‿‿◕。)づ