后序树 - 题解
标签与难度
标签: 树形DP, 动态规划, 线段树合并, 数据结构, 树 难度: 2300
题目大意喵~
各位Master,大家好呀~!咱是我小助手,最喜欢和大家一起挑战算法题了,喵~
这道题是关于一棵可爱的二叉树的呐!题目给了我们一棵有 个节点的、以节点1为根的二叉树。每个节点上都有一个权值 。
我们的任务是,要找出这棵树上有多少个“连通块”,满足一个特殊的性质。
这个性质是这样定义的:对于一个连通块,我们把它里面的所有节点,按照它们在整棵树的后序遍历中的顺序排成一队。然后我们看这些节点的权值,要求这个权值序列必须是非严格递增的。也就是说,后面的节点的权值不能比前面的小,喵~
最后,因为答案可能会很大,我们要把结果对 取模。
名词解释时间喵~
- 连通块: 树上的一个点集 ,如果里面随便抓两个点 ,它们在原树上的唯一简单路径上的所有点也都在 里,那 就是一个连通块。简单来说,它就是原树的一个“剪下来”的连通子图,喵~
- 后序遍历: 对二叉树来说,就是先走左子树,再走右子树,最后访问根节点的顺序。
解题思路分析
这道题是在树上计数,而且问题的答案跟子树的结构有很大关系,这强烈地暗示了我们要用树形DP来解决,的说!
我们采用自底向上的方式,也就是在后序遍历的过程中进行DP计算。当我们处理一个节点 时,我们假设它的左孩子 和右孩子 的信息都已经计算好了。
核心问题与DP状态设计
首先,我们把问题分解一下。总的方案数,等于以每个节点 为“根”的合法连通块数量之和。这里的“根”指的是连通块里深度最小的那个节点。
那么,对于一个以 为根的连通块,它由节点 本身,以及可能连接的、分别以 和 为根的连通块 和 组成。
接下来,我们来分析那个超关键的“后序遍历权值非递增”的条件。 整棵树的后序遍历顺序是:(左子树的所有节点) -> (右子树的所有节点) -> 根节点。 所以,对于一个由 , 和 构成的连通块,它的节点按照全局后序遍历的顺序,会是 的节点们,接着是 的节点们,最后是 。
为了让整个序列的权值非递减,必须满足以下几个条件:
- 内部的权值序列本身要非递减(也就是说 本身是个合法的连通块)。
- 内部的权值序列本身要非递减( 也是合法的)。
- 的最后一个节点的权值 的第一个节点的权值。
- 的最后一个节点的权值 的权值。
- 如果只选 不选 ,那就要满足 的最后一个节点的权值 的权值。
听起来有点复杂,我们来简化一下这些“第一个”、“最后一个”节点。
- 在一个子树中,后序遍历的最后一个节点,永远是这个子树的根。所以 的最后一个节点是 , 的最后一个节点是 。
- 在一个子树中,后序遍历的第一个节点,是它最“左下角”的那个。我们把它记作
post_first(subtree)。 - 对于一个连通块 (它本身是 子树的一部分),它的第一个节点
post_first(S_r),就是 中所有节点里,全局后序遍历序号最小的那个。
于是,条件变成了:
- 和 都是合法的。
这里有一个非常重要的隐藏条件喵!对于一个合法的连通块 ,由于它内部的权值序列要非递减,它的第一个节点的权值必然小于等于它最后一个节点的权值。也就是说,。
综合起来,当我们要把 组合在一起时,需要满足:
这给了我们DP的思路!我们需要知道对于每个子树,能形成的合法连通块中,它们的 post_first 节点的权值分布是怎样的。
DP状态: 我们为每个节点 维护一个数据结构 dp[u],它记录了以 为根的、所有合法连通块的信息。具体来说,dp[u] 是一个映射(或者说是一个频率数组),dp[u][v] 表示以 为根的合法连通块中,post_first 节点的权值为 的方案数。
由于权值可能很大,我们需要先对所有权值进行离散化。 这个 dp 数组的更新和查询涉及到区间求和,所以我们可以用线段树来实现。seg_root[u] 就指向代表 dp[u] 的那棵线段树的根。因为我们需要合并子树的信息,所以线段树合并就是不二之选啦,喵!
DP转移方程
我们用 dfs(u) 函数来计算节点 u 的DP信息:
递归处理:先调用
dfs(l)和dfs(r),计算好左右子树的信息。初始化
u:u自身可以构成一个最简单的连通块{u}。它的post_first节点就是u,权值为val[u]。所以我们为u创建一棵新的线段树,在val[u]的位置上插入1。合并子树:现在考虑如何把 和 的连通块“嫁接”到 上。
- 只合并左子树:对于任意一个以 为根的合法连通块 ,我们可以构成新连通块 。条件是 。如果满足,
dp[u]就要加上dp[l]的所有信息。在线段树上,就是seg_root[u] = merge(seg_root[u], seg_root[l])。 - 只合并右子树:同理,如果 ,我们就把
seg_root[r]合并到seg_root[u]。 - 同时合并左右子树:这是最有趣的部分!对于一个 ,我们要找有多少个 可以和它配对。
- 条件是 。
- 假设 成立,我们需要找
dp[r]中,post_first权值在 区间内的方案数。这个可以用一次线段树区间查询query(seg_root[r], val[l], val[r])得到。我们称这个数量为ways_r。 - 现在,对于任何一个来自左子树的连通块 ,我们都有
ways_r种来自右子树的连通块 可以与之配对,形成 。 - 同时,这个 也可以不和右子树配对,只和 结合,这有 1 种方式。
- 所以,对于每一个 ,总共有
(ways_r + 1)种方式把它扩展成一个以 为根的新连通块。 - 这意味着,我们需要把
dp[l]的整个线段树的所有值都乘以(ways_r + 1)。这可以通过在线段树的根节点打上一个乘法懒标记来实现!
- 只合并左子树:对于任意一个以 为根的合法连通块 ,我们可以构成新连通块 。条件是 。如果满足,
最终计算: 在
dfs(u)的最后,seg_root[u]中存储了所有以u为根的合法连通块的方案数。我们把这棵树里所有值的总和累加到全局答案ans中。
整个过程就是这样,通过树形DP和线段树合并,我们就能高效地解决这个问题啦!是不是很有趣呢?喵~
代码实现
这是我根据上面的思路,精心为大家准备的代码哦!希望对你有帮助,喵~
#include <iostream>
#include <vector>
#include <algorithm>
#include <map>
using namespace std;
const int MOD = 998244353;
const int MAXN = 500005;
// 树的结构
int node_val[MAXN];
int left_child[MAXN], right_child[MAXN];
int seg_root[MAXN]; // 每个节点u对应的线段树的根
long long total_ans = 0;
int discrete_map_size = 0;
// 动态开点线段树
struct SegNode {
int lc = 0, rc = 0;
long long sum = 0;
long long mul_tag = 1;
};
vector<SegNode> seg_tree;
int seg_idx = 0;
// 新建一个线段树节点
int new_node() {
seg_tree.emplace_back();
return ++seg_idx;
}
// 懒标记下传
void push_down(int p) {
if (seg_tree[p].mul_tag == 1) return;
long long tag = seg_tree[p].mul_tag;
if (seg_tree[p].lc) {
int lc = seg_tree[p].lc;
seg_tree[lc].sum = (seg_tree[lc].sum * tag) % MOD;
seg_tree[lc].mul_tag = (seg_tree[lc].mul_tag * tag) % MOD;
}
if (seg_tree[p].rc) {
int rc = seg_tree[p].rc;
seg_tree[rc].sum = (seg_tree[rc].sum * tag) % MOD;
seg_tree[rc].mul_tag = (seg_tree[rc].mul_tag * tag) % MOD;
}
seg_tree[p].mul_tag = 1;
}
// 向上更新节点信息
void push_up(int p) {
seg_tree[p].sum = 0;
if (seg_tree[p].lc) seg_tree[p].sum = (seg_tree[p].sum + seg_tree[seg_tree[p].lc].sum) % MOD;
if (seg_tree[p].rc) seg_tree[p].sum = (seg_tree[p].sum + seg_tree[seg_tree[p].rc].sum) % MOD;
}
// 单点更新
void update(int& p, int l, int r, int pos, int val) {
if (!p) p = new_node();
if (l == r) {
seg_tree[p].sum = (seg_tree[p].sum + val) % MOD;
return;
}
push_down(p);
int mid = l + (r - l) / 2;
if (pos <= mid) update(seg_tree[p].lc, l, mid, pos, val);
else update(seg_tree[p].rc, mid + 1, r, pos, val);
push_up(p);
}
// 区间查询
long long query(int p, int l, int r, int ql, int qr) {
if (!p || ql > qr) return 0;
if (ql <= l && r <= qr) {
return seg_tree[p].sum;
}
push_down(p);
int mid = l + (r - l) / 2;
long long res = 0;
if (ql <= mid) res = (res + query(seg_tree[p].lc, l, mid, ql, qr)) % MOD;
if (qr > mid) res = (res + query(seg_tree[p].rc, mid + 1, r, ql, qr)) % MOD;
return res;
}
// 线段树合并
int merge(int p, int q, int l, int r) {
if (!p) return q;
if (!q) return p;
if (l == r) {
seg_tree[p].sum = (seg_tree[p].sum + seg_tree[q].sum) % MOD;
return p;
}
push_down(p);
push_down(q);
int mid = l + (r - l) / 2;
seg_tree[p].lc = merge(seg_tree[p].lc, seg_tree[q].lc, l, mid);
seg_tree[p].rc = merge(seg_tree[p].rc, seg_tree[q].rc, mid + 1, r);
push_up(p);
return p;
}
// 对整棵子树应用乘法懒标记
void apply_mul_tag(int p, long long val) {
if (!p) return;
seg_tree[p].sum = (seg_tree[p].sum * val) % MOD;
seg_tree[p].mul_tag = (seg_tree[p].mul_tag * val) % MOD;
}
// 树形DP的核心DFS函数
void dfs(int u) {
if (u == 0) return; // 节点不存在,直接返回
// 后序遍历,先处理子节点
dfs(left_child[u]);
dfs(right_child[u]);
// --- DP计算开始 ---
// 1. 初始化u自己的连通块 {u}
int u_root = 0;
update(u_root, 1, discrete_map_size, node_val[u], 1);
int l = left_child[u];
int r = right_child[u];
if (l && r) { // 情况一:左右孩子都存在
long long ways_r_options = 0;
// 计算组合 {u} U S_l U S_r 的方案数
// 条件: val[r] <= val[u] 和 val[l] <= val[post_first(S_r)] <= val[r]
if (node_val[r] <= node_val[u] && node_val[l] <= node_val[r]) {
ways_r_options = query(seg_root[r], 1, discrete_map_size, node_val[l], node_val[r]);
}
// 合并左子树的贡献
if (node_val[l] <= node_val[u]) {
// 每个S_l可以和{u}组成新块(1种),或和{u}及合法的S_r组成新块(ways_r_options种)
apply_mul_tag(seg_root[l], (ways_r_options + 1) % MOD);
u_root = merge(u_root, seg_root[l], 1, discrete_map_size);
}
// 合并右子树的贡献 (只和{u}组合)
if (node_val[r] <= node_val[u]) {
u_root = merge(u_root, seg_root[r], 1, discrete_map_size);
}
} else if (l) { // 情况二:只有左孩子
if (node_val[l] <= node_val[u]) {
u_root = merge(u_root, seg_root[l], 1, discrete_map_size);
}
} else if (r) { // 情况三:只有右孩子
if (node_val[r] <= node_val[u]) {
u_root = merge(u_root, seg_root[r], 1, discrete_map_size);
}
}
// 情况四:叶子节点,u_root已在开始时初始化好了
seg_root[u] = u_root;
// 将以u为根的所有合法连通块数量计入总答案
long long u_total_ways = query(seg_root[u], 1, discrete_map_size, 1, discrete_map_size);
total_ans = (total_ans + u_total_ways) % MOD;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n;
cin >> n;
vector<int> all_vals;
for (int i = 1; i <= n; ++i) {
cin >> node_val[i];
all_vals.push_back(node_val[i]);
}
for (int i = 1; i <= n; ++i) {
cin >> left_child[i] >> right_child[i];
}
// 离散化
sort(all_vals.begin(), all_vals.end());
all_vals.erase(unique(all_vals.begin(), all_vals.end()), all_vals.end());
discrete_map_size = all_vals.size();
for (int i = 1; i <= n; ++i) {
node_val[i] = lower_bound(all_vals.begin(), all_vals.end(), node_val[i]) - all_vals.begin() + 1;
}
// 预留一些空间给线段树,防止越界
seg_tree.reserve(n * 20);
dfs(1);
cout << total_ans << endl;
return 0;
}复杂度分析
时间复杂度:
- 我们需要对权值进行离散化,排序需要 。
- 我们对树进行了一次DFS遍历,每个节点访问一次。
- 在每个节点,我们执行了线段树的合并、查询和更新操作。一次查询或更新的复杂度是 ,其中 是离散化后权值的数量()。
- 线段树合并的总复杂度是关键。在整个DFS过程中,每个线段树节点只会被创建一次,也只会被合并一次(当它所在的子树被合并到父节点时)。所以所有合并操作的总时间与总的线段树节点数成正比。动态开点线段树在最坏情况下会用到 个节点。
- 因此,总的时间复杂度是 。
空间复杂度:
- 存储树的结构和节点权值需要 的空间。
- 动态开点线段树是空间占用的主要部分。在整个过程中,我们最多会创建 个线段树节点,所以空间复杂度是 。
知识点总结
这道题真是一次精彩的冒险呢,喵!我们来总结一下旅途中的宝藏吧:
- 树形DP的建模: 解决树上计数问题的强大武器。关键在于找到正确的DP状态和清晰的转移方程。这道题的难点在于DP状态需要捕捉一个分布信息(
post_first节点的权值分布),而不是一个简单的数值。 - 线段树合并: 当树形DP的
dp状态本身是一个“值域上的频率数组或映射”时,线段树合并就是优化转移的神器!它可以高效地将子节点的信息聚合到父节点。 - 懒标记 (Lazy Propagation): 在线段树合并的过程中,我们有时需要对一整个子树的DP结果进行批量修改(比如本题的乘法操作),这时懒标记就派上用场了,避免了逐个修改的高昂代价。
- 问题分析与简化: 解题中最重要的一步,就是抽丝剥茧地分析题目条件。本题中,从复杂的后序遍历约束中,提炼出 这个关键的隐藏条件,是让DP转移变得清晰可行的重要一步。
希望这次的题解能帮到大家!以后也要一起努力,攻克更多有趣的题目呀,喵~!