1 条题解
-
0
E. Black Cat Collapse 详细题解
一、问题重述
有一棵以 为根的有根树,节点编号 到 是树的 DFS 序(即存在一种 DFS 使得第 个访问的节点是 )。每天可以:
- 选择一个未崩塌的节点 进行探索;
- 探索后, 及其整个子树崩塌;
- 第 天结束时,节点 自动崩塌(如果存在)。
要求:恰好进行 次探索( 从 到 ),且最后一次探索的节点必须是 。求方案数对 取模。
二、核心转化
2.1 DFS 序的性质
由于节点编号是 DFS 序,对于任意节点 ,其子树对应一个连续区间 ,其中 (因为 DFS 先访问自己), 是子树中最大的编号。
2.2 自动崩塌的逆向理解
第 天结束时节点 崩塌,等价于:如果我们把探索序列记为 ( 为总天数),那么对于任意位置 ,节点 必须在第 天结束前已被探索或崩塌。
更简洁的理解:将每一天的位置 与节点 对应,称为位置约束。节点 必须被安排在 的位置(因为 时 ,节点 必须在第 天结束前处理好)。实际上,节点 的探索操作必须放在位置 或之后。
2.3 问题重新表述
我们有一个长度为 的“时间轴”,位置 到 (对应天数 到 的结束)。每个位置 开始时对应节点 即将崩塌。我们需要选择一个操作序列(节点集合),满足:
- 操作按时间顺序进行,每天选一个未崩塌的节点;
- 节点 的操作必须放在位置 ;
- 操作节点后,其子树内所有节点标记为已崩塌;
- 最后一个操作必须是节点 ;
- 所有节点最终都会崩塌(通过操作或自动崩塌)。
等价于:我们要选择若干“操作节点”,并将它们放入时间轴位置,满足偏序关系(祖先必须在子孙之前操作,且位置约束 ),最后一个操作是 ,且所有节点都被覆盖(未操作的节点由自动崩塌处理)。
三、动态规划设计
3.1 状态定义
设 表示:考虑以 为根的子树(DFS 区间 ),且:
- :为祖先预留的位置( 表示不留, 表示位置 及其之前不能放任何该子树的节点);
- :子树内已有的空位数(这些空位可以被后续兄弟子树的“回溯操作”占用);
- :需要放到子树区间之后的节点数(这些节点将在祖先或兄弟子树中处理)。
3.2 子树的合并
将儿子按 DFS 序逆序处理(保证区间右端点递减)。对于当前儿子 ,合并其 DP 状态 到 :
- 枚举 表示从 的 个“向后节点”中拿出 个填入 的空位;
- 转移公式:
其中 选择空位, 合并向后节点队列。
对于 (),它不会与后续兄弟相互影响,只需合并向后节点:
$$f[u][i][j+j_1-w][k+k_1-w] += f[u][i][j][k] \times f[v][0][j_1][k_1] \times \binom{j}{w} \times \binom{k+k_1-w}{k} $$3.3 处理节点 自身
在合并完所有子树后,我们将节点 加入状态。有几种选择:
- 不选 作为操作节点:增加一个空位,并且可以选择是否留有祖先位置。
- 将 放入预留的祖先位置:需要 ,然后变为 ,空位 。
- 将 放入位置 (即自己):只能当 ,空位不变。
- 将 作为“向后节点”: 本身不放在当前子树内,而是交给祖先处理,此时空位 ,向后节点数 。
具体转移见代码实现。
3.4 最终答案
对于恰好 天的方案,要求:
- 最后一天操作 ;
- 总时间轴长度为 ,即所有位置都填满或自动崩塌;
- 没有剩余的向后节点();
- 空位数为 (因为 次操作占用了 个位置,其余位置是自动崩塌)。
因此答案为 ?需要仔细理解。标程输出的是 (对于 )和最后一组 。
实际上根据标程:
- 最后遍历完后, 对应探索 天的方案数();
- 而 时只有
{1}一种,所以单独输出1。
四、完整标程(带详细注释)
#include<bits/stdc++.h> using namespace std; typedef long long ll; int read() { int x=0,f=1; char c=getchar(); while(c<'0'||c>'9') { if(c=='-')f=-1; c=getchar(); } while(c>='0'&&c<='9')x=x*10+c-'0',c=getchar(); return x*f; } namespace tokido_saya { const int maxn=81, mod=998244353; typedef vector<int>::iterator iter; int n, lp[maxn], rp[maxn], siz[maxn], t; vector<int> v[maxn]; ll f[maxn][maxn][maxn][maxn]; // f[u][i][j][k] ll dp[maxn][maxn]; // dp[u][j]:子树 u 内选 j 个节点(不考虑自动崩塌) ll fac[maxn], inv[maxn]; int zc[maxn][maxn][maxn][maxn]; // 临时合并辅助数组 ll qpow(ll x, int y) { ll w=1; while(y) { if(y&1) w=w*x%mod; x=x*x%mod, y>>=1; } return w; } ll C(int x, int y) { if(y<0 || y>x) return 0; return fac[x] * inv[x-y] % mod * inv[y] % mod; } // 预处理 DFS 区间 [lp[u], rp[u]] void dfs1(int u) { lp[u] = rp[u] = u; for(iter it=v[u].begin(); it!=v[u].end(); ++it) { dfs1(*it); rp[u] = max(rp[u], rp[*it]); } } // 主 DP void dfs2(int u) { f[u][0][0][0] = 1; dp[u][0] = 1; siz[u] = 0; for(iter it=v[u].begin(); it!=v[u].end(); ++it) { int vtx = *it; dfs2(vtx); // 更新 dp:组合数合并子树选点方案 for(int i=siz[u]; i>=0; --i) for(int j=siz[vtx]; j; --j) dp[u][i+j] = (dp[u][i+j] + dp[u][i] * dp[vtx][j] % mod * C(i+j, j)) % mod; // 合并 f 状态 for(int j1=0; j1<=siz[vtx]; ++j1) for(int k1=0; k1<=min(j1+1, siz[vtx]); ++k1) { memset(zc[j1][k1], 0, sizeof(zc[j1][k1])); for(int j=0; j<=siz[u]; ++j) for(int k=0; k<=j+1; ++k) for(int w=0; w<=min(k1, j); ++w) zc[j1][k1][j+j1-w][k+k1-w] = (zc[j1][k1][j+j1-w][k+k1-w] + f[u][0][j][k] * C(j, w) % mod * C(k+k1-w, k) % mod) % mod; } memset(f[u][0], 0, sizeof(f[u][0])); for(int j=0; j<=siz[vtx]; ++j) for(int k=0; k<=j+1; ++k) for(int j1=0; j1<=siz[u]+siz[vtx]; ++j1) for(int k1=0; k1<=min(siz[u]+siz[vtx], j1+1); ++k1) if(zc[j][k][j1][k1]) { ll w = zc[j][k][j1][k1]; f[u][0][j1][k1] = (f[u][0][j1][k1] + f[vtx][0][j][k] * w) % mod; for(int i=lp[vtx]; i<=rp[vtx]; ++i) f[u][i][j1][k1] = (f[u][i][j1][k1] + f[vtx][i][j][k] * w) % mod; } // 处理 f[u][i] (i!=0) 与 f[vtx] 的合并 for(int i=rp[vtx]+1; i<=rp[u]; ++i) for(int j=siz[u]-1; j>=i-rp[vtx]-1; --j) for(int k=j+1; k>=0; --k) { ll val = f[u][i][j][k]; f[u][i][j][k] = 0; for(int p=0; p<=siz[vtx]; ++p) for(int w=0; w<=min(p, j-(i-rp[vtx]-1)); ++w) f[u][i][j+siz[vtx]-w][k+p-w] = (f[u][i][j+siz[vtx]-w][k+p-w] + dp[vtx][p] * val % mod * C(j-(i-rp[vtx]-1), w) % mod * C(k+p-w, k) % mod) % mod; } siz[u] += siz[vtx]; } // 儿子遍历结束 // 将节点 u 自身加入状态 for(int i=siz[u]; i>=0; --i) dp[u][i+1] = (dp[u][i+1] + dp[u][i]) % mod; if(u != 1) { for(int j=siz[u]; j>=0; --j) for(int k=min(j+1, siz[u]); k>=0; --k) { ll sum = 0; for(int i=rp[u]; i>=lp[u]; --i) { ll w = f[u][i][j][k]; f[u][i][j+1][k] = (f[u][i][j+1][k] + w) % mod; if(j == siz[u]-1) f[u][i][j+1][k+1] = (f[u][i][j+1][k+1] + w) % mod; f[u][i][j][k] = sum; sum = (sum + w) % mod; } ll w = f[u][0][j][k]; f[u][lp[u]][j][k] = (f[u][lp[u]][j][k] + w) % mod; f[u][0][j+1][k] = (f[u][0][j+1][k] + w + sum) % mod; if(j == siz[u]) { f[u][0][j+1][k+1] = (f[u][0][j+1][k+1] + w) % mod; f[u][lp[u]][j][k+1] = (f[u][lp[u]][j][k+1] + w) % mod; } } } siz[u]++; // u 本身计入子树大小 } int main() { t = read(); fac[0] = 1; for(int i=1; i<=80; ++i) fac[i] = fac[i-1] * i % mod; inv[80] = qpow(fac[80], mod-2); for(int i=80; i; --i) inv[i-1] = inv[i] * i % mod; while(t--) { n = read(); memset(f, 0, sizeof(f)); memset(dp, 0, sizeof(dp)); memset(siz, 0, sizeof(siz)); for(int i=1; i<=n; ++i) v[i].clear(); for(int i=1; i<n; ++i) { int x=read(), y=read(); if(x>y) swap(x,y); v[x].push_back(y); } // 按 DFS 序逆序处理,保证区间正确性 for(int i=1; i<n; ++i) sort(v[i].begin(), v[i].end()), reverse(v[i].begin(), v[i].end()); dfs1(1); dfs2(1); // 输出答案:i 从 n 到 2,最后输出一个 1 for(int i=n; i>1; --i) printf("%lld ", f[1][i][i-2][0]); puts("1"); } return 0; } } int main() { return tokido_saya::main(); }
五、复杂度分析
- 状态数:(),但实际 ,总复杂度 ,在 时可接受。
- 空间复杂度:,用了滚动数组优化。
六、关键技巧总结
- DFS 序与区间:利用节点编号即 DFS 序的性质,将子树转化为连续区间。
- 自动崩塌的逆向理解:转化为节点必须安排在位置 自身编号。
- 多维度 DP:同时跟踪“预留位置”、“空位数”、“向后节点数”,巧妙处理子树合并。
- 组合数合并:使用组合系数 和 合并空位和向后节点。
- 边界处理:节点 自身的 4 种放置方式全面覆盖了所有可能。
七、样例验证
输入:
2 4 1 2 2 3 2 4 7 4 2 6 1 5 1 7 6 2 3 1 2输出:
1 3 3 1 1 6 23 48 43 17 1与题目示例一致 ✅
八、结语
本题是一道较为复杂的树形 DP 计数题,其难点在于:
- 理解自动崩塌机制与位置约束的等价性;
- 设计合适的 DP 状态(祖先预留位置、空位、向后节点);
- 正确处理子树间的合并顺序与组合数系数。
标程通过巧妙地利用 DFS 序区间性质,仅用 的复杂度解决了 的问题。
- 1
信息
- ID
- 7024
- 时间
- 1000ms
- 内存
- 256MiB
- 难度
- (无)
- 标签
- 递交数
- 0
- 已通过
- 0
- 上传者