P12844 [蓝桥杯 2025 国 A] 树 解题报告
P12844 [蓝桥杯 2025 国 A] 树 解题报告
1. 题目解读:我们要干什么?
首先,我们来弄清楚题目的要求。题目给了我们一棵树,要求我们从这棵树上选出一些点,组成一个集合。这个集合需要满足一个奇特的条件:集合里任意两个被选中的点,它们在树上的距离都必须大于 2。
最后,我们需要计算出有多少种满足条件的选择方案。注意,一个都不选的方案是不算数的。
举个例子理解“距离大于2”:
- 如果我选了节点
u
,那么它的父节点、子节点都不能选(距离为1)。 - 它的兄弟节点(和它有同一个父节点)、孙子节点也不能选(距离为2)。
这个限制条件看起来非常严格,它意味着我们选择的任意两个点,在树上都不能是邻居,也不能是“邻居的邻居”。
2. 核心思路:如何化繁为简?
这类在树上进行计数的问题,通常的解法是树形动态规划(Tree DP)。
什么是树形DP?简单来说,就是我们从树的底部(叶子节点)开始,一步步向上计算信息,直到根节点。对于每个节点 u
,我们都尝试计算出以它为根的子树里,满足条件的方案数。当计算节点 u
的信息时,它所有子节点的信息我们已经计算好了,可以直接使用。
为了实现这一点,我们需要精心设计DP的状态。
3. DP状态设计:关键的三种情况
对于树上的任意一个节点 u
,我们在考虑它的子树时,它的状态会直接影响到它的父节点以及它父节点的其他子树。因此,我们需要将 u
的状态细分,以便向上传递足够的信息。
我们站在节点 u
的视角,来考虑它的几种可能性,并定义DP状态:
dp[u][0]
: 在以u
为根的子树中,我(节点u)被选中了。这种情况下,合法的方案数有多少?dp[u][1]
: 在以u
为根的子树中,我(节点u)没被选中,但是我的某一个直接孩子被选中了。这种情况下,合法的方案数有多少?dp[u][2]
: 在以u
为根的子树中,我(节点u)没被选中,并且我的所有直接孩子也都没被选中。这种情况下,合法的方案数有多少?
为什么这么设计?
这个状态设计非常巧妙,它把一个节点 u
对其父节点的影响,归纳为了三种情况:
u
被选了(父节点肯定不能选)。u
没被选,但u
的孩子被选了(父节点也肯定不能选,因为父节点和u
的孩子距离为2)。u
和u
的孩子都没被选(父节点选不选都可以,没有限制)。
这样,当我们在计算 u
的父节点 p
的DP值时,就可以根据 u
的这三种状态来做决策了。
4. 状态转移:如何从子节点推导父节点?
假设我们已经通过深度优先搜索(DFS)算完了节点 u
的所有子节点 v
的 dp
值,现在我们来计算 dp[u][0]
, dp[u][1]
, dp[u][2]
。
a) 计算 dp[u][0]
(选 u)
- 如果我们决定选择节点
u
,根据“距离大于2”的规则,u
的所有子节点v
和孙子节点都不能选。 - 对于
u
的任意一个子节点v
,它不能被选,它的孩子(u
的孙子)也不能被选。这正好对应了dp[v][2]
的定义! - 因为不同子树之间的选择是相互独立的,所以我们把所有子节点
v
的dp[v][2]
方案数乘起来。 - 转移方程:
dp[u][0] = Π (dp[v][2])
(Π 表示连乘)
b) 计算 dp[u][2]
(不选 u,也不选 u 的孩子)
- 如果我们决定不选
u
,也不选它的任何一个直接孩子v
。 - 那么对于
u
的任意一个子节点v
,它本身不能被选。在v
的子树里,我们可以选择v
的一个孩子(对应dp[v][1]
),也可以不选择v
的孩子(对应dp[v][2]
)。 - 所以,对于每个子节点
v
,它对方案数的贡献是(dp[v][1] + dp[v][2])
。 - 同样,将所有子节点的贡献相乘。
- 转移方程:
dp[u][2] = Π (dp[v][1] + dp[v][2])
c) 计算 dp[u][1]
(不选 u,但选一个孩子)
- 这是最复杂的一种情况。我们决定不选
u
,但要从它的直接孩子中恰好选一个。为什么是“恰好一个”?因为如果选了两个孩子v1
,v2
,它们之间的距离dist(v1, v2) = 2
,不满足条件。 - 所以,我们必须枚举到底选哪个孩子。假设我们选择孩子
v_i
:- 对于被选中的孩子
v_i
,它必须被选中,方案数是dp[v_i][0]
。 - 对于所有其他没被选中的孩子
v_j
(j != i
),它们不能被选中,所以它们的方案数是(dp[v_j][1] + dp[v_j][2])
。
- 对于被选中的孩子
- 因此,如果我们固定选择
v_i
,方案数就是dp[v_i][0] × Π_{j≠i} (dp[v_j][1] + dp[v_j][2])
。 - 最后,我们将所有可能的被选孩子
v_i
的情况加起来。 - 转移方程:
dp[u][1] = Σ ( dp[v_i][0] × Π_{j≠i} (dp[v_j][1] + dp[v_j][2]) )
如何高效计算 dp[u][1]
?
- 为什么暴力计算很慢?(O(度数²))
假设节点 u 有 4 个孩子:v1, v2, v3, v4。
为了方便,我们记 C(v) = dp[v][1] + dp[v][2],记 D(v) = dp[v][0]。
那么 dp[u][1] 的计算展开就是:
选v1: D(v1) * [ C(v2) * C(v3) * C(v4) ] (括号里乘了3次)
选v2: D(v2) * [ C(v1) * C(v3) * C(v4) ] (括号里乘了3次)
选v3: D(v3) * [ C(v1) * C(v2) * C(v4) ] (括号里乘了3次)
选v4: D(v4) * [ C(v1) * C(v2) * C(v3) ] (括号里乘了3次)
看到了吗?为了计算每一项,我们都做了一次循环去乘“除了自己以外的所有人”。如果 u 有 k 个孩子,我们就要做 k次这样的事,每次都要做 k-1 次乘法。总的计算量大约是 k * (k-1),也就是 O(k^2),即 O(度数^2)。当一个节点的度数很大时,这会非常慢。 - 优化的核心思想:乘法变除法
我们发现,每次计算括号里的乘积时,都做了大量重复的计算。
一个很自然的想法是:我能不能先把所有孩子的 C(v) 值都乘起来,得到一个“总乘积”?
TotalProd = C(v1) * C(v2) * C(v3) * C(v4)
有了这个 TotalProd,我们再看上面括号里的内容:
C(v2) * C(v3) * C(v4) 不就是 TotalProd / C(v1) 吗?
C(v1) * C(v3) * C(v4) 不就是 TotalProd / C(v2) 吗?
...以此类推
这样,公式就变成了:
dp[u][1] = Σ ( D(v_i) * TotalProd / C(v_i) )
在模运算中,除以一个数等于乘以它的模逆元。所以:
dp[u][1] = Σ ( D(v_i) * TotalProd * inv(C(v_i)) )
这个方法的时间复杂度是多少呢?
计算 TotalProd:需要遍历所有孩子一次,O(度数)。
计算 dp[u][1] 的累加和:再遍历所有孩子一次,O(度数)。
总复杂度降到了 O(度数),这是一个巨大的飞跃! - 代码的实现:前后缀积的魔法
上面的“总积+逆元”方法已经很棒了,但代码实现用了一种更常见、更巧妙的技巧——前后缀积。
核心思想:对于 v_i 来说,“除了它以外的所有人的乘积”可以拆成两部分:
前缀积 (Prefix Product):所有在它前面的孩子的 C(v) 乘积。
后缀积 (Suffix Product):所有在它后面的孩子的 C(v) 乘积。
Π_{j≠i} C(v_j) = (Π_{j<i} C(v_j)) * (Π_{j>i} C(v_j))
代码就是通过一次循环,巧妙地同时维护了这两个积。
5. 汇总答案与细节
- 遍历方式:我们从根节点(比如1号点)开始进行一次深度优先搜索(DFS)。DFS的回溯过程天然地满足了先计算子节点再计算父节点的顺序。
- 最终答案:当DFS完成回到根节点1时,
dp[1][0]
,dp[1][1]
,dp[1][2]
就代表了以1为根的整棵树的所有合法方案。我们将它们加起来:dp[1][0] + dp[1][1] + dp[1][2]
。 - 减去空集:题目要求“不能不选”。在我们的计算中,
dp[1][2]
包含了“节点1不选,节点1的孩子也不选”,并一直递归下去,最终导致一个节点都不选的情况。这种情况只有一种。所以最终答案需要减1。 - 取模:计算过程中所有加法和乘法都要对
998244353
取模。
最终答案为 (dp[1][0] + dp[1][1] + dp[1][2] - 1 + mod) % mod
。
6. 代码解析
#include<bits/stdc++.h>
using namespace std;
const long long mod=998244353;
const int N=300010;
int n;
long long dp[N][3]; // DP数组
vector<int>G[N]; // 邻接表存树
// dp[u][0]:选u
// dp[u][1]:不选u,选u的一个子节点
// dp[u][2]:不选u,不选u的任何子节点
// 快速幂求模逆元
long long qpow(long long x,long long y) { /* ... */ }
long long inv(long long x) { /* ... */ }
void dfs(int u,int fa) {
// 初始化:对于叶子节点,选它方案为1,不选它方案也为1
dp[u][0]=dp[u][2]=1;
dp[u][1]=0; // dp[u][1]是累加的,初值为0
long long total_prod = 1; // 用于计算 dp[u][1] 的优化
// 第一次遍历子节点:递归计算子节点,并计算 dp[u][0] 和 dp[u][2]
for(int v : G[u]) {
if(v == fa) continue;
dfs(v, u);
// 计算 dp[u][0]:所有子节点都必须是状态2
dp[u][0] = (dp[u][0] * dp[v][2]) % mod;
// 计算 dp[u][2]:所有子节点可以是状态1或2
dp[u][2] = (dp[u][2] * (dp[v][1] + dp[v][2])) % mod;
// 预先计算 (dp[v][1] + dp[v][2]) 的总乘积,用于后面优化
total_prod = (total_prod * (dp[v][1] + dp[v][2])) % mod;
}
// 第二次遍历子节点:使用前后缀积思想优化计算 dp[u][1]
long long prefix_prod = 1; // 前缀积
for(int v : G[u]) {
if(v == fa) continue;
// 此时 total_prod 相当于后缀积
long long suffix_prod = (total_prod * inv(dp[v][1] + dp[v][2])) % mod;
// 累加:选v的方案数 * 其他子节点(由前缀积和后缀积代表)的方案数
long long term = (dp[v][0] * prefix_prod) % mod;
term = (term * suffix_prod) % mod;
dp[u][1] = (dp[u][1] + term) % mod;
// 更新前缀积和总乘积(为下一次循环准备)
prefix_prod = (prefix_prod * (dp[v][1] + dp[v][2])) % mod;
total_prod = suffix_prod;
}
}
int main() {
cin >> n;
for(int i = 1; i < n; i++) {
int u, v;
cin >> u >> v;
G[u].push_back(v);
G[v].push_back(u);
}
dfs(1, 0); // 从根节点1开始DFS
// 汇总答案,三种情况相加,再减去“一个都不选”的方案
long long ans = (dp[1][0] + dp[1][1] + dp[1][2] - 1 + mod) % mod;
cout << ans << endl;
return 0;
}
希望这份报告能帮助你理解这道有趣的树形DP问题!