D. Tree Jumps - 题解
比赛与标签
比赛: Educational Codeforces Round 175 (Rated for Div. 2) 标签: dfs and similar, dp, trees 难度: *1600
题目大意喵~
主人你好呀~!这道题是关于在一棵有根树上跳跃的有趣问题哦,喵~
我们有一棵 n
个节点的树,根节点是 1
。一开始,我们的小芯片就乖乖地待在根节点 1
上。我们可以进行一种特殊的“跳跃”操作:
- 移动规则: 从当前节点
v
跳到另一个节点u
,必须满足u
的深度比v
的深度恰好多 1 (d_u = d_v + 1
)。也就是说,我们只能往更深的一层跳。 - 特殊限制:
- 如果芯片在根节点 (
v=1
),它可以跳到下一层(深度为1)的任何一个节点。 - 如果芯片在非根节点 (
v != 1
),它可以跳到下一层的任何一个节点u
,除非u
是v
的邻居(也就是v
的孩子)。
- 如果芯片在根节点 (
一个“有效序列”就是一个顶点序列,表示了小芯片从根节点出发,按照序列顺序依次访问,并且只访问这些节点所形成的一条合法路径。
我们的任务就是,计算出所有可能的有效序列的总数是多少,结果要对 998244353
取模哦!
解题思路,来分析一下吧!
这道题要求我们计算所有合法路径的数量,一看到“计数”和“路径”,我们聪明的猫猫脑袋里就应该想到动态规划(DP)啦,喵!
我们的 DP 思路是基于树的层次(深度)来进行的。
DP状态定义
很自然地,我们可以定义 dp[u]
为:以节点 u
结尾的有效序列的数量。 我们的最终答案,就是所有 dp[u]
的总和,因为任何一个以 u
结尾的有效序列都是一个需要被计数的答案。
DP状态转移
要计算 dp[u]
,我们需要想一想,小芯片是怎么跳到 u
的呢?它一定是从 u
的上一层,也就是深度为 d[u] - 1
的某个节点 v
跳过来的。
我们来根据题目的限制条件,仔细分析一下状态转移方程:
基础情况 (Base Case): 最短的有效序列就是
[1]
,只访问根节点。所以,dp[1] = 1
。这是我们所有计算的起点!转移逻辑: 假设节点
u
的深度是k
(d[u] = k
)。那么,跳到u
的前一个节点v
的深度一定是k-1
。当
k = 1
时 (即u
是根节点的直接孩子): 根据规则,从根节点1
可以跳到任何一个深度为 1 的节点。所以,任何一个到达根节点1
的序列,都可以再下一步跳到u
。因为只有一种序列[1]
到达根节点,所以dp[u] = dp[1] = 1
。当
k > 1
时 (即u
不是根节点的直接孩子): 前一个节点v
在深度k-1
,并且v
不是根节点。根据规则,从v
跳到u
是被允许的,除非u
是v
的孩子。 换句话说,要想到达u
,我们可以从深度为k-1
的任何一个节点出发,除了u
的父节点p[u]
。 所以,dp[u]
的值就等于所有深度为k-1
的节点的dp
值之和,再减去dp[p[u]]
。dp[u] = (sum(dp[v] for all v where d[v] = k-1)) - dp[p[u]]
优化一下喵~
每次计算 dp[u]
都去求和一遍 dp[v]
会很慢。我们可以用一个辅助数组 S
来优化它! 我们定义 S[k]
为所有深度为 k
的节点的 dp
值之和。 S[k] = sum(dp[v] for all v where d[v] = k)
这样,我们的状态转移方程就变得超级清爽啦:
- 对于深度为 1 的节点
u
:dp[u] = S[0]
(因为S[0] = dp[1]
) - 对于深度大于 1 的节点
u
:dp[u] = (S[d[u]-1] - dp[p[u]]) % MOD
算法步骤总结
- 预处理: 遍历所有节点,计算出每个节点的深度
d[i]
,并把所有节点按深度分组存起来。因为题目保证了p_i < i
,我们可以按i
从 2 到n
的顺序一次性算完所有深度,非常方便! - 初始化:
dp[1] = 1
,S[0] = 1
。总答案total_ans
初始化为 1(因为[1]
是一个有效序列)。 - 按层DP: 从深度
k = 1
开始,逐层计算:- 对于每个深度为
k
的节点u
,使用上面的转移方程计算dp[u]
。 - 将计算出的
dp[u]
累加到S[k]
中。 - 当一层(深度
k
)的所有节点都计算完毕后,S[k]
就是这一层所有dp
值的和。我们将S[k]
加到total_ans
中。
- 对于每个深度为
- 最终答案: 循环结束后,
total_ans
就是我们要求的总数啦!
这样一步步下来,问题就迎刃而解了,是不是很简单呢,喵~
代码实现喵~
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
void solve() {
int n;
std::cin >> n;
std::vector<int> p(n + 1, 0);
for (int i = 2; i <= n; ++i) {
std::cin >> p[i];
}
// 步骤1: 计算深度并将节点按深度分组
// p_i < i 的约束保证了我们在计算 d[i] 时, d[p[i]] 已经算好了, 喵~
std::vector<int> d(n + 1, 0);
std::vector<std::vector<int>> nodes_at_depth(n);
int max_depth = 0;
nodes_at_depth[0].push_back(1); // 根节点1在深度0
for (int i = 2; i <= n; ++i) {
d[i] = d[p[i]] + 1;
max_depth = std::max(max_depth, d[i]);
if (d[i] < n) {
nodes_at_depth[d[i]].push_back(i);
}
}
// 步骤2 & 3: 动态规划
long long MOD = 998244353;
// dp[u] = 以节点 u 结尾的有效序列数量
std::vector<long long> dp(n + 1, 0);
// S[k] = 所有深度为 k 的节点的 dp 值之和
std::vector<long long> S(max_depth + 1, 0);
// Base Case: 序列 [1] 是有效的
dp[1] = 1;
S[0] = 1;
long long total_ans = 1; // 初始答案包含序列 [1]
// 按深度 k = 1, 2, ... 迭代
for (int k = 1; k <= max_depth; ++k) {
// 遍历所有深度为 k 的节点 u
for (int u : nodes_at_depth[k]) {
// 要形成一个以 u (深度k) 结尾的序列, 前一个节点 v 必须在深度 k-1
if (k == 1) {
// 如果 u 是根的直接孩子, 前一个节点必须是根 (v=1)
// 从根出发的跳跃总是允许的
// 所以以 u 结尾的序列数等于以根结尾的序列数
dp[u] = S[0]; // S[0] 就是 dp[1]
} else {
// 如果 u 不是根的直接孩子, 前一个节点 v (深度k-1) 就不是根
// 跳跃 v -> u 被允许, 当且仅当 u 不是 v 的邻居
// 因为 d[u] = d[v] + 1, 它们是邻居 <=> v 是 u 的父节点
// 所以我们可以从深度 k-1 的任何节点 v 跳过来, 除了 v=p[u]
// 方案数 = (深度k-1的所有dp和) - dp[p[u]]
dp[u] = (S[k - 1] - dp[p[u]] + MOD) % MOD; // +MOD 防止减出负数
}
// 将 dp[u] 累加到 S[k] 中
S[k] = (S[k] + dp[u]) % MOD;
}
// 这一层所有可能的序列都计算完了, 把它们加到总答案里
total_ans = (total_ans + S[k]) % MOD;
}
std::cout << total_ans << "\n";
}
int main() {
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int t;
std::cin >> t;
while (t--) {
solve();
}
return 0;
}
复杂度分析的说
时间复杂度: O(n) 的说 我们对每个节点只访问了常数次。首先,用一个
for
循环计算所有节点的深度并分组,这是 O(n) 的。然后,在 DP 过程中,我们再次遍历每个节点恰好一次来计算它的dp
值。所以总的时间复杂度是 O(n) + O(n) = O(n),非常高效呐!空间复杂度: O(n) 的说 我们用了几个数组,
p
,d
,dp
都是 O(n) 大小。nodes_at_depth
这个二维向量总共存储了n
个节点,也是 O(n) 的空间。S
数组的大小是最大深度,最坏情况下也是 O(n)。所以总的空间复杂度是 O(n)。
知识点与总结
这道题真是一道非常经典的树上动态规划问题,主人~!通过解决它,我们可以学到:
- 树上DP思想: 很多树上的计数问题都可以通过从根到叶(或反之)的DP来解决。关键在于找到正确的状态定义和状态转移方程。这道题就是按深度(层次)进行DP的一个绝佳范例。
- DP优化技巧: 直接计算状态转移可能会很慢,要时刻想着有没有可以优化的方法。本题中用
S[k]
数组来预计算每层的DP和,就是一个把 O(N^2) 级别的计算优化到 O(N) 的经典技巧,类似于前缀和思想。 - 巧用输入特性:
p_i < i
这个小小的约束,让我们可以用一个简单的循环来建树和计算深度,避免了写复杂的BFS或DFS,让代码变得更简洁。 - 模运算细节: 在做减法取模时,
(a - b) % MOD
可能会得到负数。安全的写法是(a - b + MOD) % MOD
,这样就能保证结果总是在[0, MOD-1]
范围内啦。
总之,遇到树上的计数问题,不妨先从DP的角度思考一下,定义好状态,理清转移关系,再看看有没有可以优化的地方,问题也许就迎刃而解了!继续加油哦,主人!喵~ >w<