2 条题解

  • 0
    @ 2026-10-21 12:15:42

    [NOIP2025] 树的价值 (Tree) —— NOI 金牌教练深度剖析与 AC 正解

    讲师寄语: 这道题是 NOIP 历史上极具代表性的一道压轴神题,难度直逼 NOI / CTS 级别。很多同学初看觉得无从下手——树上 mex 运算极具后效性,子树集合错综复杂。但只要你能从 mex 的本质 出发,敏锐捕捉到 “垫脚石(辅助点)” 与 “主链(优化点)” 的核心矛盾,进而将其转化为 树上链剖分与贡献分配模型,最后祭出 长链剖分优化 DP,这道题的脉络就会如庖丁解牛般清晰展现在你面前!


    一、 核心模型破局:从 mex 本质到“点角色划分”

    1. mex 的本质矛盾:自私 vs 奉献

    对于任意一个点 uu,若我们希望以它为根的子树集合 SuS_u 的 mex(Su)=K\text{mex}(S_u) = K,就必须满足两个硬性条件:

    1. 子树内必须包含全部数字 0,1,2,…,K−10, 1, 2, \dots, K-1;
    2. 子树内绝对不能包含数字 KK。

    在给树上节点赋权时,每个节点面临着一个根本抉择:

    • 自私自利(主链点 / A 类点):将自身权值设为某个刚好填补子树空缺的值(即让自身 mex\text{mex} 变大),推高以自身为根的子树价值。
    • 无私奉献(垫脚石 / B 类点):故意将自身权值设为一个大于自身 mex\text{mex} 的大数。这样虽然牺牲了自身子树的 mex\text{mex}(自身子树 mex\text{mex} 较低),但这个权值会在某个祖先处被收集,为更上层的祖先“抬高 mex\text{mex}”充当垫脚石!

    2. 传递链与树的剖分

    在最优方案中,一个祖先节点的 mex\text{mex} 能够被有效抬高,其数值基底必然继承自某一个特定的儿子。

    • 我们将这种存在 mex\text{mex} 继承依赖关系的父子边称为传递边。
    • 由于每个节点最多只能继承一个子节点的基底,因此每个节点向下最多连出一条传递边(或者不连)。
    • 这样,整棵树就被自然地剖分成了若干条互不相交的传递链!

    3. 贡献定理与链形态性质

    1. 向最长链贡献最优:每个 B 类点(垫脚石)选择向哪一个祖先贡献?如果祖先链上有若干条传递链,显然向与当前点到根路径重合最长的传递链贡献,能产生的收益最大。
    2. “上半段 B,下半段 A”:对于任意一条传递链,在最优解形态下,必然是上半段全为 B 类点,下半段全为 A 类点。
      • 证明:若一条链上存在 B→A→BB \to A \to B 的形态,由于传递链上抬高自身同时抬高祖先,如果该链是底端点到根的最长链,将底端点改成 A 类点只优不劣;否则整条链都不如更长链优,将中间的 A 类点改为 B 类点也只优不劣。

    利用这个极其关键的形态性质,我们根本不需要单独记录 B 链的具体每个点,只需要记录“到根节点的最长链长”,此时 B 链的长度与点权贡献被完全锁定!


    二、 算法演进:从 O(nm2)O(nm^2) 到最优 O(nm)O(nm)

    状态设计

    • 设 f[u][l]f[u][l] 表示节点 uu 作为 A 类点(主链点),且所在链长为 ll 时,uu 子树内所有节点的最大价值和。
    • 设 g[u][l]g[u][l] 表示节点 uu 作为 B 类点(垫脚石),且到根节点路上最长传递链链长为 ll 时,uu 子树内所有节点的最大价值和。

    状态转移

    1. ff 的转移: uu 可以选择将传递边连向某一个儿子 vv(继承链长,vv 的状态为 f[v][l+1]f[v][l+1]),其余儿子全部作为 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) $$
    2. gg 的转移: uu 所在子树要么全填为 B 类点(贡献为 l×∣subtu∣l \times |subt_u|);要么在子树中深度差为 ll 的某个位置 ww 结束 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 —— O(nm)O(nm)

    观察 g[u][l]g[u][l] 的计算,当我们将外层按链长 ll 从 mm 到 11 逆序枚举时:

    • 对于固定的 ll,我们需要查询子树中相对深度恰好为 ll 的最大值。
    • 这正是长链剖分的经典应用场景!
    • 我们利用长链指针复用:
      • 长儿子 son(u)son(u) 直接继承父节点的指针,指针右移 1 位,时间复杂度 O(1)O(1)。
      • 短儿子的链长总和线性,暴力合并短儿子,均摊复杂度 O(n)O(n)。
      • 路径上新增的兄弟贡献通过打全局懒标记 delta[u] 在 O(1)O(1) 时间内完成。
    • 外层循环 mm 次,内层长链剖分合并整棵树总代价为 O(n)O(n),总时空复杂度达到完美的 O(nm)O(nm)!

    三、 满分 AC 代码(长链剖分优化 DP,O(nm)O(nm))

    这是在洛谷评测系统上稳定高分 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;
    }
    

    四、 备选参考:树状数组优化解法(O(nmlog⁡n)O(nm \log n))

    在考场上如果长链剖分指针细节难以在短时间内完全调通,树状数组(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);
        }
    }
    

    五、 金牌讲师实战答疑与避坑点总结

    1. 数值范围与防爆技巧:
      • 最大价值可能达到 $O(n \cdot m) \approx 8000 \times 800 \approx 6.4 \times 10^6$,但若初始化为极大负数,两个极大负数相加极易产生 long long 负溢出变为正数。代码中严密采用 INF = 1e16,并使用 sh[v][i] > -INF / 2 判断可达性,万无一失。
    2. 多测清空范围:
      • 绝不能盲目 memset 全局大数组,一定要按照当前输入的 nn 和 mm 动态清空对应范围,否则面对 T=5T=5 很容易在初始化上白白耗费几十毫秒。
    3. 长链内存复用:
      • 长链剖分中,长儿子通过 sh[son[u]] = sh[u] + 1 共享内存空间,这使得深度信息直接顺延对应,极其精妙。短链通过静态 pool 线性分配,整棵树所有长链长度之和严格等于 nn,总空间仅占用 O(n)O(n)。
    • 1

    P14637 [NOIP2025 T3] 树的价值 / tree (黑)

    信息

    ID
    19262
    时间
    2000ms
    内存
    32MiB
    难度
    10
    标签
    (无)
    递交数
    3
    已通过
    1
    上传者