【题解】数字积木:树上 MEX 求和与动态骨架维护 (DFS版)

1. 题目背景与大意

题目名称: Bingbong的积木树 (Tree MEX Sum)

给定一棵包含n 个节点的树,节点权值为0 \sim n-1 的全排列。

定义一个 连通子图 的权值为:该子图所有节点权值中 未出现过的最小非负整数(即\text{MEX})。

求树上 所有 连通子图的权值之和。答案对10^9 + 7 取模。

数据范围:N \le 2 \times 10^5


2. 涉及算法与核心思想

这道题是一道综合性很强的树上计数问题,解题过程中涉及以下核心算法与思想:

  1. 贡献法 (Contribution Technique)

    将“求每个子图的 MEX 值之和”转化为“求每一个数值k 对答案的贡献次数”。

  2. 树形 DP (Tree DP)

    预处理以每个节点为根的子树中,包含根节点的连通子图方案数。

  3. 斯坦纳树 (Steiner Tree) / 最小连通骨架

    动态维护包含特定点集\{0, 1, \dots, k\} 的最小连通子图。

  4. 裂项相消与动态维护

    利用乘法逆元,通过“除旧乘新”的操作,在O(1)O(\text{dist}) 时间内更新骨架的方案数。

  5. 模运算下的除法处理

    使用自定义结构体处理模数为0 时没有逆元的情况(工程技巧)。


3. 核心解题步骤详解

步骤一:视角的转换(积分思想)

直接枚举所有连通子图并计算\text{MEX} 是不可行的。我们需要利用数学转化:

\text{Total Ans} = \sum_{S} \text{MEX}(S) = \sum_{k \ge 1} (\text{包含集合 } \{0, 1, \dots, k-1\} \text{ 的连通子图数量})

通俗解释:

我们将总分拆解为层级分。

  • 如果一个子图包含了\{0\},它至少贡献1 分。

  • 如果它还包含了\{1\}(即拥有\{0, 1\}),它再贡献1 分。

  • 以此类推,对于每一个k,我们只需要计算:树上有多少个连通子图同时包含了节点0k-1

步骤二:预处理子树方案 (DFS)

我们需要知道每个节点作为“挂件”时的方案数。

定义DP[u]:在以u 为根的子树中,必须包含u 的连通子图方案数。

  • 转移方程

    DP[u] = \prod_{v \in son(u)} (DP[v] + 1)

    这里的+1 代表子节点v 这个分支可以选择“不选”。

  • 实现:跑一遍 DFS 即可求出所有DP 值和父子关系。

步骤三:动态维护“骨架”

我们从k=1 开始,依次加入节点1, 2, \dots, n-1

为了连通\{0, \dots, k\},这些点在树上的最小连通路径构成了 “骨架”

当我们要把一个新节点u 加入骨架时,需要将其到当前骨架的路径全部“点亮”。对于路径上的每个点,发生如下状态转变:

  1. 状态转变:该节点从 “可选的挂件” 变成了 “必选的骨架”

  2. 数学操作

    • 剔除旧贡献:作为挂件时,它贡献了\times (DP[u] + 1)。现在它不再“可选”,所以要 除以(DP[u] + 1)

    • 加入新贡献:变成骨架后,它的所有子节点变成了新的挂件。我们需要把它们的贡献\times (DP[v] + 1) 乘入 总方案数。

这一步利用了 Safe_Product 结构体来安全地进行模运算除法。


4. 关键工程细节:Safe_Product

在实现“除法”时,有一个巨大的坑:模数0 问题

在模10^9+7 下,如果(DP[u] + 1) 恰好是模数的倍数,通过费马小定理求逆元会失效($0$ 没有逆元)。

我们必须封装一个结构体来通过 “记账法” 处理0

  • 单独记录因子中0 的个数 (zero_cnt)。

  • 0 时计数器+1,除0 时计数器-1

  • 只有计数器为0 时才输出 product,否则输出0


5. AC 代码 (C++ DFS版)

C++

#include <bits/stdc++.h>
#define endl '\n'
using namespace std;
using i64 = long long;
const int mod = 1e9 + 7;

// --- 基础工具:快速幂求逆元 ---
int power(int a, int k) {
    int res = 1;
    while (k) {
        if (k & 1) {
            res = 1ll * res * a % mod;
        }
        a = 1ll * a * a % mod;
        k >>= 1;
    }
    return res;
}

// --- 核心工具:Safe_Product ---
// 用于处理模运算中“除以 0”的特殊情况
struct Safe_Product {
    int zero_cnt = 0;
    int product = 1;
    
    // 安全乘法
    void mul(int x) {
        x %= mod; // 【关键】必须先取模,防止 x 是 mod 的倍数
        if (x == 0) {
            zero_cnt++;
        } else {
            product = 1ll * product * x % mod;
        }
    }
    
    // 安全除法 (乘逆元)
    void div(int x) {
        x %= mod; // 【关键】必须先取模
        if (x == 0) {
            zero_cnt--;
        } else {
            product = 1ll * product * power(x, mod - 2) % mod;
        }
    }
    
    // 获取真实值
    int val() {
        if (zero_cnt > 0) {
            return 0;
        } else {
            return product;
        }
    }
};

void solve() {
    int n;
    cin >> n;
    vector<int> a(n), pos(n);
    // 读入权值,pos[v] 记录权值为 v 的节点编号
    for (int i = 0; i < n; i ++ ) {
        cin >> a[i];
        pos[a[i]] = i;
    }
    
    vector<vector<int>> adj(n);
    for (int i = 1; i < n; i ++ ) {
        int u, v;
        cin >> u >> v;
        u -- , v -- ; // 转为 0-based 索引
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    vector<int> fa(n);
    vector<Safe_Product> dp(n);
    
    // --- 步骤一:DFS 预处理 ---
    // 计算父子关系 fa[] 和子树方案数 dp[]
    auto dfs = [&](auto && self, int u, int p) -> void {
        fa[u] = p;
        for (auto v : adj[u]) {
            if (v != p) {
                self(self, v, u);
                // 状态转移:累乘 (子树方案 + 1)
                dp[u].mul(dp[v].val() + 1);
            }
        }
    };
    
    int root = pos[0]; // 必须包含 0,故以 0 所在节点为根
    dfs(dfs, root, -1);

    vector<int> st(n);   // st[u]=1 表示 u 已经在骨架中
    st[root] = 1;
    auto sum = dp[root]; // 当前骨架的总方案数
    int ans = sum.val(); // k=1 时的贡献 (即包含 {0} 的方案数)
    
    // --- 步骤二:动态维护骨架 ---
    // 依次尝试加入 1, 2, ..., n-1
    for (int i = 1; i < n; i ++ ) {
        int u = pos[i];
        
        // 从 u 向上爬,直到遇到已经点亮的骨架
        while (!st[u]) {
            st[u] = 1;
            
            // 核心逻辑:
            // 1. u 从“挂件”变为“骨架”,除掉旧贡献 (DP[u]+1)
            sum.div(dp[u].val() + 1);
            
            // 2. u 的子节点变为新的“挂件”,乘上新贡献
            // 注意:只乘子节点,不要把父节点乘进去了
            for (auto v : adj[u]) {
                if (v != fa[u]) {
                    sum.mul(dp[v].val() + 1);
                }
            }
            u = fa[u];
        }
        
        // 累加当前 MEX >= i+1 的方案数
        ans = (ans + sum.val()) % mod;
    }
    cout << ans << endl;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int T = 1;
    // cin >> T; 
    while (T -- ) {
        solve();
    }

    return 0;
}

6. 复杂度分析

  • 时间复杂度

    • 预处理 DFS:每个节点访问一次,复杂度为O(N)

    • 动态维护:虽然代码中有双重循环,但均摊分析如下:

      • 骨架扩展:每个节点最多只会被加入骨架一次(通过 st 数组标记),这部分均摊是O(N)

      • 乘法逆元:每次加入骨架时,涉及到除法运算。求逆元的时间复杂度是O(\log M)(其中M = 10^9+7)。

    • 总时间复杂度:整体为O(N \log M)

  • 空间复杂度

    • 需要存储邻接表、DP 数组、父节点数组 fa 等,均为线性空间,故空间复杂度为O(N)