Skip to content

后序树 - 题解

标签与难度

标签: 树形DP, 动态规划, 线段树合并, 数据结构, 树 难度: 2300

题目大意喵~

各位Master,大家好呀~!咱是我小助手,最喜欢和大家一起挑战算法题了,喵~

这道题是关于一棵可爱的二叉树的呐!题目给了我们一棵有 nn 个节点的、以节点1为根的二叉树。每个节点上都有一个权值 valival_i

我们的任务是,要找出这棵树上有多少个“连通块”,满足一个特殊的性质。

这个性质是这样定义的:对于一个连通块,我们把它里面的所有节点,按照它们在整棵树后序遍历中的顺序排成一队。然后我们看这些节点的权值,要求这个权值序列必须是非严格递增的。也就是说,后面的节点的权值不能比前面的小,喵~

最后,因为答案可能会很大,我们要把结果对 998244353998244353 取模。

名词解释时间喵~

  • 连通块: 树上的一个点集 SS,如果里面随便抓两个点 u,vu, v,它们在原树上的唯一简单路径上的所有点也都在 SS 里,那 SS 就是一个连通块。简单来说,它就是原树的一个“剪下来”的连通子图,喵~
  • 后序遍历: 对二叉树来说,就是先走左子树,再走右子树,最后访问根节点的顺序。

解题思路分析

这道题是在树上计数,而且问题的答案跟子树的结构有很大关系,这强烈地暗示了我们要用树形DP来解决,的说!

我们采用自底向上的方式,也就是在后序遍历的过程中进行DP计算。当我们处理一个节点 uu 时,我们假设它的左孩子 ll 和右孩子 rr 的信息都已经计算好了。

核心问题与DP状态设计

首先,我们把问题分解一下。总的方案数,等于以每个节点 uu 为“根”的合法连通块数量之和。这里的“根”指的是连通块里深度最小的那个节点。

那么,对于一个以 uu 为根的连通块,它由节点 uu 本身,以及可能连接的、分别以 llrr 为根的连通块 SlS_lSrS_r 组成。

接下来,我们来分析那个超关键的“后序遍历权值非递增”的条件。 整棵树的后序遍历顺序是:(左子树的所有节点) -> (右子树的所有节点) -> 根节点。 所以,对于一个由 SlS_l, SrS_r{u}\{u\} 构成的连通块,它的节点按照全局后序遍历的顺序,会是 SlS_l 的节点们,接着是 SrS_r 的节点们,最后是 uu

为了让整个序列的权值非递减,必须满足以下几个条件:

  1. SlS_l 内部的权值序列本身要非递减(也就是说 SlS_l 本身是个合法的连通块)。
  2. SrS_r 内部的权值序列本身要非递减(SrS_r 也是合法的)。
  3. SlS_l 的最后一个节点的权值 \le SrS_r 的第一个节点的权值。
  4. SrS_r 的最后一个节点的权值 \le uu 的权值。
  5. 如果只选 SlS_l 不选 SrS_r,那就要满足 SlS_l 的最后一个节点的权值 \le uu 的权值。

听起来有点复杂,我们来简化一下这些“第一个”、“最后一个”节点。

  • 在一个子树中,后序遍历的最后一个节点,永远是这个子树的根。所以 SlS_l 的最后一个节点是 llSrS_r 的最后一个节点是 rr
  • 在一个子树中,后序遍历的第一个节点,是它最“左下角”的那个。我们把它记作 post_first(subtree)
  • 对于一个连通块 SrS_r(它本身是 rr 子树的一部分),它的第一个节点 post_first(S_r),就是 SrS_r 中所有节点里,全局后序遍历序号最小的那个。

于是,条件变成了:

  1. SlS_lSrS_r 都是合法的。
  2. val[l]val[post_first(Sr)]val[l] \le val[\text{post\_first}(S_r)]
  3. val[r]val[u]val[r] \le val[u]

这里有一个非常重要的隐藏条件喵!对于一个合法的连通块 SrS_r,由于它内部的权值序列要非递减,它的第一个节点的权值必然小于等于它最后一个节点的权值。也就是说,val[post_first(Sr)]val[r]val[\text{post\_first}(S_r)] \le val[r]

综合起来,当我们要把 Sl,Sr,{u}S_l, S_r, \{u\} 组合在一起时,需要满足:

val[l]val[post_first(Sr)]val[r]val[u]val[l] \le val[\text{post\_first}(S_r)] \le val[r] \le val[u]

这给了我们DP的思路!我们需要知道对于每个子树,能形成的合法连通块中,它们的 post_first 节点的权值分布是怎样的。

DP状态: 我们为每个节点 uu 维护一个数据结构 dp[u],它记录了以 uu 为根的、所有合法连通块的信息。具体来说,dp[u] 是一个映射(或者说是一个频率数组),dp[u][v] 表示以 uu 为根的合法连通块中,post_first 节点的权值为 vv 的方案数。

由于权值可能很大,我们需要先对所有权值进行离散化。 这个 dp 数组的更新和查询涉及到区间求和,所以我们可以用线段树来实现。seg_root[u] 就指向代表 dp[u] 的那棵线段树的根。因为我们需要合并子树的信息,所以线段树合并就是不二之选啦,喵!

DP转移方程

我们用 dfs(u) 函数来计算节点 u 的DP信息:

  1. 递归处理:先调用 dfs(l)dfs(r),计算好左右子树的信息。

  2. 初始化 uu 自身可以构成一个最简单的连通块 {u}。它的 post_first 节点就是 u,权值为 val[u]。所以我们为 u 创建一棵新的线段树,在 val[u] 的位置上插入1。

  3. 合并子树:现在考虑如何把 llrr 的连通块“嫁接”到 uu 上。

    • 只合并左子树:对于任意一个以 ll 为根的合法连通块 SlS_l,我们可以构成新连通块 {u}Sl\{u\} \cup S_l。条件是 val[l]val[u]val[l] \le val[u]。如果满足,dp[u] 就要加上 dp[l] 的所有信息。在线段树上,就是 seg_root[u] = merge(seg_root[u], seg_root[l])
    • 只合并右子树:同理,如果 val[r]val[u]val[r] \le val[u],我们就把 seg_root[r] 合并到 seg_root[u]
    • 同时合并左右子树:这是最有趣的部分!对于一个 SlS_l,我们要找有多少个 SrS_r 可以和它配对。
      • 条件是 val[l]val[post_first(Sr)]val[r]val[u]val[l] \le val[\text{post\_first}(S_r)] \le val[r] \le val[u]
      • 假设 val[r]val[u]val[r] \le val[u] 成立,我们需要找 dp[r] 中,post_first 权值在 [val[l],val[r]][val[l], val[r]] 区间内的方案数。这个可以用一次线段树区间查询 query(seg_root[r], val[l], val[r]) 得到。我们称这个数量为 ways_r
      • 现在,对于任何一个来自左子树的连通块 SlS_l,我们都有 ways_r 种来自右子树的连通块 SrS_r 可以与之配对,形成 {u}SlSr\{u\} \cup S_l \cup S_r
      • 同时,这个 SlS_l 也可以不和右子树配对,只和 {u}\{u\} 结合,这有 1 种方式。
      • 所以,对于每一个 SlS_l,总共有 (ways_r + 1) 种方式把它扩展成一个以 uu 为根的新连通块。
      • 这意味着,我们需要把 dp[l] 的整个线段树的所有值都乘以 (ways_r + 1)。这可以通过在线段树的根节点打上一个乘法懒标记来实现!
  4. 最终计算: 在 dfs(u) 的最后,seg_root[u] 中存储了所有以 u 为根的合法连通块的方案数。我们把这棵树里所有值的总和累加到全局答案 ans 中。

整个过程就是这样,通过树形DP和线段树合并,我们就能高效地解决这个问题啦!是不是很有趣呢?喵~

代码实现

这是我根据上面的思路,精心为大家准备的代码哦!希望对你有帮助,喵~

cpp
#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;
}

复杂度分析

  • 时间复杂度: O(NlogN)O(N \log N)

    • 我们需要对权值进行离散化,排序需要 O(NlogN)O(N \log N)
    • 我们对树进行了一次DFS遍历,每个节点访问一次。
    • 在每个节点,我们执行了线段树的合并、查询和更新操作。一次查询或更新的复杂度是 O(logM)O(\log M),其中 MM 是离散化后权值的数量(MNM \le N)。
    • 线段树合并的总复杂度是关键。在整个DFS过程中,每个线段树节点只会被创建一次,也只会被合并一次(当它所在的子树被合并到父节点时)。所以所有合并操作的总时间与总的线段树节点数成正比。动态开点线段树在最坏情况下会用到 O(NlogM)O(N \log M) 个节点。
    • 因此,总的时间复杂度是 O(NlogN)O(N \log N)
  • 空间复杂度: O(NlogN)O(N \log N)

    • 存储树的结构和节点权值需要 O(N)O(N) 的空间。
    • 动态开点线段树是空间占用的主要部分。在整个过程中,我们最多会创建 O(NlogM)O(N \log M) 个线段树节点,所以空间复杂度是 O(NlogN)O(N \log N)

知识点总结

这道题真是一次精彩的冒险呢,喵!我们来总结一下旅途中的宝藏吧:

  1. 树形DP的建模: 解决树上计数问题的强大武器。关键在于找到正确的DP状态和清晰的转移方程。这道题的难点在于DP状态需要捕捉一个分布信息(post_first 节点的权值分布),而不是一个简单的数值。
  2. 线段树合并: 当树形DP的dp状态本身是一个“值域上的频率数组或映射”时,线段树合并就是优化转移的神器!它可以高效地将子节点的信息聚合到父节点。
  3. 懒标记 (Lazy Propagation): 在线段树合并的过程中,我们有时需要对一整个子树的DP结果进行批量修改(比如本题的乘法操作),这时懒标记就派上用场了,避免了逐个修改的高昂代价。
  4. 问题分析与简化: 解题中最重要的一步,就是抽丝剥茧地分析题目条件。本题中,从复杂的后序遍历约束中,提炼出 val[post_first(Sr)]val[r]val[\text{post\_first}(S_r)] \le val[r] 这个关键的隐藏条件,是让DP转移变得清晰可行的重要一步。

希望这次的题解能帮到大家!以后也要一起努力,攻克更多有趣的题目呀,喵~!