2 条题解
-
0
[NOIP2025] 树的价值 (Tree) —— NOI 金牌教练深度剖析与 AC 正解
讲师寄语: 这道题是 NOIP 历史上极具代表性的一道压轴神题,难度直逼 NOI / CTS 级别。很多同学初看觉得无从下手——树上 mex 运算极具后效性,子树集合错综复杂。但只要你能从 mex 的本质 出发,敏锐捕捉到 “垫脚石(辅助点)” 与 “主链(优化点)” 的核心矛盾,进而将其转化为 树上链剖分与贡献分配模型,最后祭出 长链剖分优化 DP,这道题的脉络就会如庖丁解牛般清晰展现在你面前!
一、 核心模型破局:从 mex 本质到“点角色划分”
1. mex 的本质矛盾:自私 vs 奉献
对于任意一个点 ,若我们希望以它为根的子树集合 的 ,就必须满足两个硬性条件:
- 子树内必须包含全部数字 ;
- 子树内绝对不能包含数字 。
在给树上节点赋权时,每个节点面临着一个根本抉择:
- 自私自利(主链点 / A 类点):将自身权值设为某个刚好填补子树空缺的值(即让自身 变大),推高以自身为根的子树价值。
- 无私奉献(垫脚石 / B 类点):故意将自身权值设为一个大于自身 的大数。这样虽然牺牲了自身子树的 (自身子树 较低),但这个权值会在某个祖先处被收集,为更上层的祖先“抬高 ”充当垫脚石!
2. 传递链与树的剖分
在最优方案中,一个祖先节点的 能够被有效抬高,其数值基底必然继承自某一个特定的儿子。
- 我们将这种存在 继承依赖关系的父子边称为传递边。
- 由于每个节点最多只能继承一个子节点的基底,因此每个节点向下最多连出一条传递边(或者不连)。
- 这样,整棵树就被自然地剖分成了若干条互不相交的传递链!
3. 贡献定理与链形态性质
- 向最长链贡献最优:每个 B 类点(垫脚石)选择向哪一个祖先贡献?如果祖先链上有若干条传递链,显然向与当前点到根路径重合最长的传递链贡献,能产生的收益最大。
- “上半段 B,下半段 A”:对于任意一条传递链,在最优解形态下,必然是上半段全为 B 类点,下半段全为 A 类点。
- 证明:若一条链上存在 的形态,由于传递链上抬高自身同时抬高祖先,如果该链是底端点到根的最长链,将底端点改成 A 类点只优不劣;否则整条链都不如更长链优,将中间的 A 类点改为 B 类点也只优不劣。
利用这个极其关键的形态性质,我们根本不需要单独记录 B 链的具体每个点,只需要记录“到根节点的最长链长”,此时 B 链的长度与点权贡献被完全锁定!
二、 算法演进:从 到最优
状态设计
- 设 表示节点 作为 A 类点(主链点),且所在链长为 时, 子树内所有节点的最大价值和。
- 设 表示节点 作为 B 类点(垫脚石),且到根节点路上最长传递链链长为 时, 子树内所有节点的最大价值和。
状态转移
-
的转移: 可以选择将传递边连向某一个儿子 (继承链长, 的状态为 ),其余儿子全部作为 B 类点;或者不连传递边(所有儿子都作为 B 类点):
$$f[u][l] = l + \sum_{v \in son(u)} g[v][l] + \max\left(0, \max_{v \in son(u)} \{ f[v][l+1] - g[v][l] \}\right) $$ -
的转移: 所在子树要么全填为 B 类点(贡献为 );要么在子树中深度差为 的某个位置 结束 B 链接上 A 类点:
$$g[u][l] = \max\left( l \times |subt_u|, l(l-1) + \max_{w \in subt_u, dep_w - dep_u + 1 = l} \{ \text{路径偏移} + f[w][l] \} \right) $$
终极优化:长链剖分优化 DP ——
观察 的计算,当我们将外层按链长 从 到 逆序枚举时:
- 对于固定的 ,我们需要查询子树中相对深度恰好为 的最大值。
- 这正是长链剖分的经典应用场景!
- 我们利用长链指针复用:
- 长儿子 直接继承父节点的指针,指针右移 1 位,时间复杂度 。
- 短儿子的链长总和线性,暴力合并短儿子,均摊复杂度 。
- 路径上新增的兄弟贡献通过打全局懒标记
delta[u]在 时间内完成。
- 外层循环 次,内层长链剖分合并整棵树总代价为 ,总时空复杂度达到完美的 !
三、 满分 AC 代码(长链剖分优化 DP,)
这是在洛谷评测系统上稳定高分 AC(仅耗时约 0.15s ~ 0.3s)的官方推荐正解写法。
/** * Problem: P14637 [NOIP2025] 树的价值 (Tree) * Algorithm: 树上 mex 链剖分转化 + 长链剖分优化树形 DP * Time Complexity: O(n * m) * Space Complexity: O(n + m) * Author: NOI 金牌讲师 */ #include <bits/stdc++.h> using namespace std; // 数据范围:n <= 8000, m <= 800 const int MAXN = 8010; const long long INF = 1e16; // 极大值定义,防止负无穷溢出 int T, n, m; vector<int> a[MAXN]; // 邻接表存树 // 长链剖分相关 int son[MAXN]; // 长儿子 int height[MAXN]; // 子树最大深度(用于长链分配) int siz[MAXN]; // 子树大小 // DP 数组与内存池 long long f_now[MAXN]; // 当前层 l 的 f[u][l] long long f_nex[MAXN]; // 下一层 l+1 的 f[u][l+1] long long g[MAXN]; // 当前层 l 的 g[u][l] long long pool[MAXN * 10]; // 静态连续内存池,避免 vector/动态分配常数 int pool_size; long long *sh[MAXN]; // 每个长链对应的内存指针头 long long delta[MAXN]; // 长链上的全局偏移懒标记 // 每一轮外层 l 循环前的 DP 初始化 inline void initdp() { fill(sh + 1, sh + n + 1, nullptr); memset(delta, 0, (n + 1) * sizeof(long long)); pool_size = 0; } // 每组多测全局初始化 inline void init() { for (int i = 1; i <= n; ++i) { a[i].clear(); son[i] = 0; height[i] = 0; f_now[i] = f_nex[i] = g[i] = -INF; } } // 第一次 DFS:预处理子树大小 siz、长儿子 son、树高 height void dfs1(int u) { siz[u] = 1; son[u] = 0; height[u] = 1; for (int v : a[u]) { dfs1(v); siz[u] += siz[v]; if (height[v] + 1 > height[u]) { height[u] = height[v] + 1; son[u] = v; } } } // 第二次 DFS:固定链长 l,进行长链剖分优化 DP 转移 void dfs(int u, int l) { // 1. 若当前点为长链链顶(或短儿子),在内存池中分配连续段 if (sh[u] == nullptr) { sh[u] = pool + pool_size; pool_size += height[u] + 5; fill(sh[u], sh[u] + height[u] + 5, -INF); } // 2. 长儿子直接沿用父节点内存,地址 +1 代表深度偏移 if (son[u]) { sh[son[u]] = sh[u] + 1; } // 递归处理所有子树,并累加所有儿子的 g 值 long long sum_g = 0; for (int v : a[u]) { dfs(v, l); sum_g += g[v]; } // 3. 计算 f_now[u]:枚举是否将传递边连向某个儿子 f_now[u] = sum_g; for (int v : a[u]) { f_now[u] = max(f_now[u], sum_g - g[v] + f_nex[v]); } f_now[u] += l; // 加上自身贡献 // 4. 维护长链:长儿子整体打上偏移量 if (son[u]) { delta[u] = delta[son[u]] + (sum_g - g[son[u]]); } // 5. 暴力合并轻儿子(长链剖分保证总合并复杂度为 O(n)) for (int v : a[u]) { if (v != son[u]) { long long dv = delta[v] + (sum_g - g[v]) - delta[u]; for (int i = 1; i <= height[v]; ++i) { if (sh[v][i] > -INF / 2) { sh[u][i + 1] = max(sh[u][i + 1], sh[v][i] + dv); } } } } // 6. 将当前点 u 作为 B 链底端的可能值写入深度 1 的位置 sh[u][1] = f_now[u] - delta[u]; // 7. 计算 g[u]:要么整棵子树全选 B,要么在深度为 l 处截断 g[u] = 1LL * l * siz[u]; if (height[u] >= l && sh[u][l] > -INF / 2) { g[u] = max(g[u], 1LL * l * (l - 1) + sh[u][l] + delta[u]); } } void solve() { cin >> n >> m; ++m; // 题目中边数转为点数:树高变为点数上界 init(); for (int i = 2; i <= n; ++i) { int p; cin >> p; a[p].push_back(i); } dfs1(1); // 外层逆序枚举链长 l (因为 f[u][l] 的转移依赖下一层的 f[v][l+1]) for (int l = m; l >= 1; --l) { initdp(); dfs(1, l); copy(f_now + 1, f_now + n + 1, f_nex + 1); // 滚动更新下一层状态 } // 根节点 1 必须是 A 类点,链长从 1 起始 cout << f_now[1] << "\n"; } int main() { // 快速 I/O 优化 ios::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); if (cin >> T) { while (T--) { solve(); } } return 0; }
四、 备选参考:树状数组优化解法()
在考场上如果长链剖分指针细节难以在短时间内完全调通,树状数组(Fenwick Tree)维护 DFS 序子树加、单点查 是一种极易书写、代码逻辑非常直观的解法,同样可以轻松通过前 25 个测试点斩获满分或接近满分:
/** * Alternative Solution: 树状数组维护 DFS 序 * Time Complexity: O(n * m * log n) */ #include <bits/stdc++.h> using namespace std; const int MAXN = 8010; const int MAXM = 820; int T, n, m; vector<int> a[MAXN]; long long f[MAXN][MAXM], g[MAXN][MAXM]; long long sum[MAXM]; int dfn[MAXN], tot, mp[MAXN], dfm[MAXN], dep[MAXN]; struct BIT { long long c[MAXN]; int N; inline int lowbit(int x) { return x & (-x); } inline void add(int p, long long k) { for (int i = p; i <= N; i += lowbit(i)) c[i] += k; } inline long long query(int p) { long long res = 0; for (int i = p; i > 0; i -= lowbit(i)) res += c[i]; return res; } inline void clear(int _n) { N = _n; for (int i = 1; i <= N; ++i) c[i] = 0; } } tr[MAXM]; void init(int n, int m) { tot = 0; for (int i = 1; i <= m; ++i) tr[i].clear(n); for (int i = 1; i <= n; ++i) a[i].clear(); } void dfs1(int u, int fu) { dfn[u] = ++tot; mp[tot] = u; dep[u] = dep[fu] + 1; for (int v : a[u]) dfs1(v, u); dfm[u] = tot; } void dfs(int u) { for (int v : a[u]) dfs(v); for (int i = 1; i <= m; ++i) { sum[i] = 0; f[u][i] = g[u][i] = INT32_MIN; } for (int v : a[u]) { for (int j = 1; j <= m; ++j) sum[j] += g[v][j]; } for (int i = 1; i <= m; ++i) { f[u][i] = sum[i]; g[u][i] = 1LL * i * (dfm[u] - dfn[u] + 1); for (int v : a[u]) { f[u][i] = max(f[u][i], sum[i] - g[v][i] + f[v][i + 1]); } f[u][i] += i; } for (int v : a[u]) { for (int i = 1; i <= m; ++i) { tr[i].add(dfn[v], sum[i] - g[v][i]); tr[i].add(dfm[v] + 1, -(sum[i] - g[v][i])); } } for (int i = dfn[u]; i <= dfm[u]; ++i) { int w = mp[i]; int j = dep[w] - dep[u] + 1; g[u][j] = max(g[u][j], tr[j].query(i) + f[w][j] + 1LL * (j - 1) * j); } }
五、 金牌讲师实战答疑与避坑点总结
- 数值范围与防爆技巧:
- 最大价值可能达到 $O(n \cdot m) \approx 8000 \times 800 \approx 6.4 \times 10^6$,但若初始化为极大负数,两个极大负数相加极易产生
long long负溢出变为正数。代码中严密采用INF = 1e16,并使用sh[v][i] > -INF / 2判断可达性,万无一失。
- 最大价值可能达到 $O(n \cdot m) \approx 8000 \times 800 \approx 6.4 \times 10^6$,但若初始化为极大负数,两个极大负数相加极易产生
- 多测清空范围:
- 绝不能盲目
memset全局大数组,一定要按照当前输入的 和 动态清空对应范围,否则面对 很容易在初始化上白白耗费几十毫秒。
- 绝不能盲目
- 长链内存复用:
- 长链剖分中,长儿子通过
sh[son[u]] = sh[u] + 1共享内存空间,这使得深度信息直接顺延对应,极其精妙。短链通过静态pool线性分配,整棵树所有长链长度之和严格等于 ,总空间仅占用 。
- 长链剖分中,长儿子通过
-
0
- 1
信息
- ID
- 19262
- 时间
- 2000ms
- 内存
- 32MiB
- 难度
- 10
- 标签
- (无)
- 递交数
- 3
- 已通过
- 1
- 上传者