[JRKSJ R7] 茎

GitHub跳转原题关系图返回列表

先用一元生成函数统计普通子树的剪枝顺序,再在 1 到 x 的路径上做带“前后分配”的树形计数 DP。

OJ: luogu

题目 ID: P8935

难度:省选/NOI-

标签:动态规划树形DP组合计数计数

日期: 2026-06-21 10:41

题意

有一棵以 1 为根的树,每次可以选一个还没有被删掉的点 u,删掉它整棵子树。

一旦删掉 1,整个过程立刻结束。

现在指定了根到 x 的这条路径是“茎”,要求:

  • k 次操作必须主动选择 x
  • 在第 k 次及以前,不能通过删除 x 的祖先,把 x 连带删掉

问满足条件的操作序列有多少种,答案对 10^9+7 取模。

思路

先看一个可以直接验证想法的朴素解:

cpp
#include <bits/stdc++.h>
using namespace std;

// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。

const long long MOD = 1000000007LL;

int n, k, x;
vector<int> g[25];
vector<int> child[25];
int parent_node[25];
int tin[25], tout[25], timer_cnt;

unordered_map<unsigned long long, long long> memo;

void dfs_build(int u, int fa) {
    parent_node[u] = fa;
    tin[u] = ++timer_cnt;
    for (int v : g[u]) {
        if (v == fa) {
            continue;
        }
        child[u].push_back(v);
        dfs_build(v, u);
    }
    tout[u] = timer_cnt;
}

bool in_subtree(int u, int v) {
    return tin[u] <= tin[v] && tin[v] <= tout[u];
}

// mask 表示当前还活着的点集合,step 表示当前是第几次操作。
long long dfs(long long mask, int step) {
    if (((mask >> 0) & 1LL) == 0) {
        return step - 1 >= k ? 1 : 0;
    }

    unsigned long long key = (unsigned long long) mask * 32ULL + (unsigned long long) step;
    unordered_map<unsigned long long, long long>::iterator it = memo.find(key);
    if (it != memo.end()) {
        return it->second;
    }

    long long ans = 0;
    for (int u = 1; u <= n; u++) {
        if (((mask >> (u - 1)) & 1LL) == 0) {
            continue;
        }

        // x 只能在第 k 次被主动选择。
        if ((u == x) != (step == k)) {
            continue;
        }

        // 在第 k 次及以前,不能删掉 x 的祖先来连带剪掉 x。
        if (step <= k && u != x && in_subtree(u, x)) {
            continue;
        }

        long long new_mask = mask;
        for (int v = 1; v <= n; v++) {
            if (((mask >> (v - 1)) & 1LL) == 0) {
                continue;
            }
            if (in_subtree(u, v)) {
                new_mask &= ~(1LL << (v - 1));
            }
        }
        ans += dfs(new_mask, step + 1);
        if (ans >= MOD) {
            ans %= MOD;
        }
    }

    ans %= MOD;
    memo[key] = ans;
    return ans;
}

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

    cin >> n >> k >> x;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }

    dfs_build(1, 0);
    memo.clear();

    long long full_mask = (1LL << n) - 1;
    cout << dfs(full_mask, 1) % MOD << '\n';
    return 0;
}

暴力的做法很直接:把当前还活着的点集记下来,每次枚举下一步删哪个点,只要不违反“第 k 次必须删 x”和“第 k 次前不能删 x 的祖先”这两个限制,就继续递归。

这个方法能帮助我们把题意看清楚,但显然只能跑很小的数据。

真正的难点在于:不同子树里的操作可以互相穿插,直接做树形 DP 很容易把“穿插方案数”漏掉。

这里可以先把普通子树单独拿出来。

F_u(t) 表示在 u 的整棵子树里,恰好做 t 次操作的方案数,再把它改写成指数型生成函数的形式:

A_u(z) = sum(F_u(t) / t! * z^t)

这样一来,不同儿子子树之间的“任意穿插”就会自动变成普通乘法。

对于一个普通点 u

  • 如果最后不删 u,那就是把所有儿子子树各自处理完
  • 如果最后删 u,那就是先处理完所有儿子子树,再补一刀删 u

于是就能得到一元 DP。

递推公式:普通子树

设所有儿子的 EGF 乘积为:

Pu(z)=vson(u)Av(z) P_u(z)=\prod_{v \in son(u)} A_v(z)

如果不主动删除 u,贡献是 P_u(z);如果最后补一刀删除 u,在 EGF 中相当于积分一次。 因此:

Au(z)=Pu(z)+0zPu(t)dt A_u(z)=P_u(z)+\int_0^z P_u(t)\,dt

这正对应代码里 res[t] += prod[t]res[t+1] += prod[t] / (t+1)

接下来只剩下路径 1 -> x 需要特殊处理。

路径外的侧枝有一个很重要的性质:

  • 它们的操作既可以放在 x 前面,也可以放在 x 后面
  • 但是路径上的祖先如果被主动删除,那一定只能放在 x 后面

所以我们把路径 DP 压成两类数量:

  • a一定在 x 前面 的操作数
  • s:来自侧枝、可以自由分到 x 前后 的操作数

为了继续保留“任意穿插”的信息,代码里不是直接存普通方案数,而是存基底 p^a (p+q)^s 下的系数。

这样做的好处是:

  1. 侧枝合并时,只会把 s 增加,等价于乘一个一元多项式
  2. 某个祖先 u 被删除时,这一步一定发生在 x 之后,对应对变量 q 做一次积分

这里最关键的式子是:

∫ p^a (p+q)^s dq = (p^a (p+q)^(s+1) - p^(a+s+1)) / (s+1)

也就是说,删祖先以后:

  • 一部分贡献会进入 p^a (p+q)^(s+1)
  • 另一部分要减到 p^(a+s+1)

这就是代码里会出现“加一项、再减一项”的原因。

当我们一路推到根 1 以后,设当前状态是 (a, s)

  • a 个操作一定在 x 前面
  • s 个侧枝操作里,要再挑出 k-1-a 个放到 x 前面

于是最终答案就是把这些“前后分配”的组合数再乘回去:

  • 选哪些侧枝放前面:C(s, k-1-a)
  • 前面的操作内部任意排列:(k-1)!
  • 后面的操作内部任意排列:(s-(k-1-a))!

整个过程本质上就是:

  1. 普通子树做一元生成函数计数
  2. 茎上做一个带前后分配的树形计数 DP
  3. 最后把第 k 次落在 x 的限制还原回来

代码

cpp
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 505;
const int MOD = 1000000007;

int n, k, x;
vector<int> g[MAXN];
vector<int> child[MAXN];
vector<int> order_nodes;
vector<int> path_nodes;
vector<int> poly_all[MAXN]; // 普通子树的一元 EGF:系数 = 方案数 / t!

int parent_node[MAXN];
int stem_next[MAXN];
int inv_num[MAXN], fact[MAXN], comb_num[MAXN][MAXN];

int cur_all[MAXN][MAXN];
int open_dp[MAXN][MAXN];
int next_all[MAXN][MAXN];
int next_close[MAXN][MAXN];
int root_close[MAXN][MAXN];

// 模意义下加法。
inline void add_mod(int &x, long long y) {
    x = (x + y) % MOD;
}

// 模意义下减法。
inline void sub_mod(int &x, long long y) {
    x = (x - y) % MOD;
    if (x < 0) {
        x += MOD;
    }
}

// 建树,并把无根树转成以 1 为根的有根树。
void dfs_build(int u, int fa) {
    parent_node[u] = fa;
    order_nodes.push_back(u);
    for (int v : g[u]) {
        if (v == fa) {
            continue;
        }
        child[u].push_back(v);
        dfs_build(v, u);
    }
}

// 朴素多项式卷积。
vector<int> multiply_poly(const vector<int> &a, const vector<int> &b) {
    vector<int> c((int) a.size() + (int) b.size() - 1, 0);
    for (int i = 0; i < (int) a.size(); i++) {
        if (a[i] == 0) {
            continue;
        }
        for (int j = 0; j < (int) b.size(); j++) {
            if (b[j] == 0) {
                continue;
            }
            c[i + j] = (c[i + j] + 1LL * a[i] * b[j]) % MOD;
        }
    }
    return c;
}

// 计算普通子树的一元 EGF。
// poly_all[u][t] 表示:
// 在 u 的整棵子树里,做出恰好 t 次操作的方案数 / t!。
void build_plain_subtree_dp() {
    for (int idx = (int) order_nodes.size() - 1; idx >= 0; idx--) {
        int u = order_nodes[idx];
        vector<int> prod(1, 1);
        for (int v : child[u]) {
            prod = multiply_poly(prod, poly_all[v]);
        }

        vector<int> res((int) prod.size() + 1, 0);
        for (int t = 0; t < (int) prod.size(); t++) {
            // 不操作 u,自身保留。
            add_mod(res[t], prod[t]);
            // 最后再操作一次 u。
            add_mod(res[t + 1], 1LL * prod[t] * inv_num[t + 1]);
        }
        poly_all[u] = res;
    }
}

// 计算路径点 u 的所有侧枝(不含通向 x 的那条儿子)的一元 EGF。
vector<int> build_side_poly(int u) {
    vector<int> prod(1, 1);
    for (int v : child[u]) {
        if (v == stem_next[u]) {
            continue;
        }
        prod = multiply_poly(prod, poly_all[v]);
    }
    return prod;
}

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

    cin >> n >> k >> x;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }

    inv_num[1] = 1;
    for (int i = 2; i <= n + 1; i++) {
        inv_num[i] = MOD - 1LL * (MOD / i) * inv_num[MOD % i] % MOD;
    }
    fact[0] = 1;
    for (int i = 1; i <= n; i++) {
        fact[i] = 1LL * fact[i - 1] * i % MOD;
    }
    for (int i = 0; i <= n; i++) {
        comb_num[i][0] = comb_num[i][i] = 1;
        for (int j = 1; j < i; j++) {
            comb_num[i][j] = comb_num[i - 1][j - 1] + comb_num[i - 1][j];
            if (comb_num[i][j] >= MOD) {
                comb_num[i][j] -= MOD;
            }
        }
    }

    dfs_build(1, 0);
    build_plain_subtree_dp();

    // 找出从 1 到 x 的茎。
    int cur = x;
    while (cur != 0) {
        path_nodes.push_back(cur);
        cur = parent_node[cur];
    }
    reverse(path_nodes.begin(), path_nodes.end());
    for (int i = 0; i + 1 < (int) path_nodes.size(); i++) {
        stem_next[path_nodes[i]] = path_nodes[i + 1];
    }

    memset(cur_all, 0, sizeof(cur_all));
    memset(root_close, 0, sizeof(root_close));

    for (int idx = (int) path_nodes.size() - 1; idx >= 0; idx--) {
        int u = path_nodes[idx];
        vector<int> side_poly = build_side_poly(u);

        if (u == x) {
            memset(cur_all, 0, sizeof(cur_all));
            for (int a = 0; a < (int) side_poly.size(); a++) {
                cur_all[a][0] = side_poly[a];
            }
            if (u == 1) {
                memcpy(root_close, cur_all, sizeof(root_close));
            }
            continue;
        }

        memset(open_dp, 0, sizeof(open_dp));
        for (int a = 0; a <= n; a++) {
            for (int s = 0; s <= n; s++) {
                if (cur_all[a][s] == 0) {
                    continue;
                }
                int lim = min((int) side_poly.size() - 1, n - s);
                for (int r = 0; r <= lim; r++) {
                    if (side_poly[r] == 0) {
                        continue;
                    }
                    add_mod(open_dp[a][s + r], 1LL * cur_all[a][s] * side_poly[r]);
                }
            }
        }

        memset(next_all, 0, sizeof(next_all));
        memset(next_close, 0, sizeof(next_close));
        for (int a = 0; a <= n; a++) {
            for (int s = 0; s <= n; s++) {
                next_all[a][s] = open_dp[a][s];
            }
        }

        // 对 q 积分:
        // ∫ p^a (p+q)^s dq = (p^a (p+q)^(s+1) - p^(a+s+1)) / (s+1)
        for (int a = 0; a <= n; a++) {
            for (int s = 0; s <= n; s++) {
                int val = open_dp[a][s];
                if (val == 0) {
                    continue;
                }
                if (s + 1 <= n) {
                    int add = 1LL * val * inv_num[s + 1] % MOD;
                    add_mod(next_close[a][s + 1], add);
                    add_mod(next_all[a][s + 1], add);
                    if (a + s + 1 <= n) {
                        sub_mod(next_close[a + s + 1][0], add);
                        sub_mod(next_all[a + s + 1][0], add);
                    }
                }
            }
        }

        memcpy(cur_all, next_all, sizeof(cur_all));
        if (u == 1) {
            memcpy(root_close, next_close, sizeof(root_close));
        }
    }

    int need_before = k - 1;
    int answer = 0;
    for (int a = 0; a <= n; a++) {
        for (int s = 0; s <= n; s++) {
            int val = root_close[a][s];
            if (val < 0) {
                val += MOD;
            }
            if (val == 0) {
                continue;
            }
            int choose_before = need_before - a;
            if (choose_before < 0 || choose_before > s) {
                continue;
            }
            int after_cnt = s - choose_before;
            long long ways = 1LL * val * comb_num[s][choose_before] % MOD;
            ways = ways * fact[need_before] % MOD;
            ways = ways * fact[after_cnt] % MOD;
            add_mod(answer, ways);
        }
    }

    cout << answer % MOD << '\n';
    return 0;
}

复杂度

普通子树的一元 DP 总复杂度是 O(n2)O(n^2) 级别。

路径上的状态转移本质是若干次多项式卷积,整体复杂度为 O(n3)O(n^3)

空间复杂度是 O(n2)O(n^2)

总结

这题最难的地方不是“树”,而是“不同子树的操作顺序如何穿插”。

一旦用指数型生成函数把穿插方案数吸收进去,再把茎上的操作拆成:

  • 必须在 x 前面的
  • 可以自由分配到 x 前后的

整个计数结构就清楚了。关键在于祖先删除那一步对应的是“对后缀变量积分”,这一步想通以后,状态转移就会非常顺。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析