S 老师的合并 - 题解
标签与难度
标签: 动态规划, 树形DP, 计数问题, 递归, 记忆化搜索 难度: 2600
题目大意喵~
主人你好呀,喵~ S老师这次又带来了有趣的挑战哦!
我们有两棵有根树 和 ,分别有 和 个节点。我们需要将这两棵树的所有节点合并成一棵新的、更大的有根树 。
这棵新的树 需要满足两个非常严格的条件,呐:
- 祖先关系不变:对于原来在同一棵树(比如 )里的任意两个节点 和 ,如果 是 的祖先,那么在新的树 中, 也必须是 的祖先。反之亦然。
- DFS序相对不变:对于原来在同一棵树里的任意两个节点 和 ,如果 在 中的DFS序小于 ,那么在新的树 中, 的DFS序也必须小于 。反之亦然。
题目中的DFS序是指,当我们访问一个节点时,总是按照从左到右的顺序访问它的孩子们。
我们的任务就是计算,总共有多少种满足这些条件的、不同的新树 呢?结果需要对 取模,喵~
解题思路分析
这道题看起来有点吓人,要把两棵树揉在一起,还要保持那么多性质不变,简直就像要把两种不同口味的猫粮完美地混合在一起,还不能串味!不过别担心,跟着我的思路一步步来,你会发现其中的奥秘的,喵~
关键条件的转化
首先,我们来仔细分析一下这两个条件意味着什么吧。
祖先关系不变:这个条件非常强。它告诉我们,原来树中的父子关系链不能断裂。比如,在 中,如果 是 的父亲, 是 的孩子,那么在新的树 中, 必须是 的祖先, 也必须是 的祖先。这限制了我们不能随意地把一个节点的子树拆开,然后挂到别的地方去。
DFS序相对不变:这个条件更具体地约束了兄弟节点之间的关系。我们知道DFS序是由父节点访问子节点的顺序决定的。为了保持任意两个节点(来自同一原树)的相对DFS序,一个节点的所有孩子(也来自同一原树)在新的树 中,必须保持它们原来的相对顺序。 例如,如果节点 在 中的孩子顺序是 ,那么在新的树 中, 的所有孩子里,凡是来自 的,也必须是 这样的相对顺序。我们可以把来自 的节点插入到它们之间,但不能打乱 的顺序。
递归合并的思想
结合这两个条件,我们可以得出一个非常重要的结论:合并两棵树的过程,本质上是在每个层级上,将两组有序的兄弟子树进行合并。
想象一下,我们想构造新树 。它的根节点要么是 的根 ,要么是 的根 。我们分开讨论这两种情况,最后把结果加起来就好啦。
情况一:新树的根是
- 成为了新树的根。为了满足祖先关系,整个 树都必须成为 的后代。最直接的方法,就是让 的根 成为 的一个孩子。
- 现在, 的孩子列表是由它在 中原来的孩子(设为列表 )和 这个新孩子组成的。为了保持DFS序, 中元素的相对顺序不能变。所以,我们要做的是,将 插入到有序列表 的任意位置。
- 但这还不够!这只是最简单的合并。题目允许更复杂的操作。比如,我们可以不把整个 挂在 下,而是把 的孩子们(设为列表 )拿出来,和 的孩子们(列表 )混合在一起,成为 的新孩子列表!
- 这个“混合”操作,就是我们解题的核心!我们可以将 和 的元素交错排列,只要保持 内部和 内部的相对顺序不变即可。这就像洗牌一样,喵~
这个过程是递归的。当我们把 的某个孩子 变成了 的孩子后, 本身也有一群孩子。这些孩子在后续的构造中,又可以和别的节点的子节点列表进行合并。
动态规划闪亮登场!
这种递归结构,闻起来就像是动态规划的味道!我们可以定义一个函数来解决这个核心的“合并”子问题。
solve(A, B):计算合并两个有序的节点列表(或者说是森林) 和 的方案数。 来自 的某一层级的一组兄弟, 来自 的某一层级的一组兄弟。
我们用 dp[d1][l1..r1][d2][l2..r2] 来表示这个状态,其中 d1, l1, r1 定义了 中的一个兄弟节点列表,d2, l2, r2 定义了 中的一个。为了方便,我们先通过一次DFS,把两棵树按深度分层,并记录下每个节点在同层兄弟中的左右顺序。
现在,我们来推导 solve(A, B) 的计算方法。设 ,。我们要把这两组节点(以及它们各自的子树)合并成一个有序的列表。我们可以从后往前构建这个合并后的列表,考虑最后一个元素是什么:
普通合并 (Interleaving)
- 选择 :合并后列表的最后一个节点是 。它的子树结构不变。剩下的 和 需要继续合并。方案数是
solve(A', B)。 - 选择 :同理,最后一个节点是 。方案数是
solve(A, B'),其中 。
- 选择 :合并后列表的最后一个节点是 。它的子树结构不变。剩下的 和 需要继续合并。方案数是
奇妙的嫁接 (Grafting) 这可是本题最精髓、最像魔法的地方,喵!我们不一定总是把节点并列放置,还可以把一个列表里的某些树,嫁接到另一个列表里某棵树的根上!
- 将 的后缀嫁接到 上:我们取出 的一个后缀,比如 。然后,我们把这 棵树,与 的孩子们进行合并!这个子问题的方案数是
solve(A_suf, children(b_m))。 完成嫁接后,我们得到了一个“增强版”的 。现在,我们需要将剩下的部分 和 进行合并。这个方案数是solve(A_pre, B')。 根据乘法原理,这种情况的总方案数是solve(A_pre, B') * solve(A_suf, children(b_m))。我们需要对所有可能的后缀长度 (从 1 到 ) 求和。 - 将 的后缀嫁接到 上:同理,我们也可以把 的后缀嫁接到 上。方案数是
solve(A', B_pre) * solve(children(a_k), B_suf)。对所有可能的后缀长度求和。
- 将 的后缀嫁接到 上:我们取出 的一个后缀,比如 。然后,我们把这 棵树,与 的孩子们进行合并!这个子问题的方案数是
把以上所有情况的方案数加起来,就是 solve(A, B) 的总方案数啦!
当然,要用记忆化搜索来实现,不然会重复计算到天荒地老,喵~
最终答案
最终的答案就是两种根选择的总和:
- 新根是 :方案数为
solve(children(r1), {r2})。 - 新根是 :方案数为
solve({r1}, children(r2))。
把它们加起来就大功告成啦!
代码实现
这是我根据上面的思路,精心重构的一份代码,加满了注释,希望能帮助你理解哦!
#include <iostream>
#include <vector>
#include <numeric>
#include <algorithm>
using namespace std;
const int MOD = 998244353;
const int MAXN = 105;
// T1 和 T2 的邻接表
vector<int> adj1[MAXN], adj2[MAXN];
int n1, n2;
// nodes_by_depth[d] 存储了在深度 d 的所有节点,按DFS序排列
vector<int> nodes_by_depth1[MAXN], nodes_by_depth2[MAXN];
// node_pos_in_depth[u] 存储节点 u 在其所在深度的 vector 中的索引
int node_pos_in_depth1[MAXN], node_pos_in_depth2[MAXN];
// dp[l1][r1][l2][r2] 存储记忆化搜索的结果
// key是四个节点的ID,而不是索引
int dp[MAXN][MAXN][MAXN][MAXN];
bool visited[MAXN][MAXN][MAXN][MAXN];
// 预处理,通过DFS将节点按深度分组,并记录它们在同深度的顺序
void preprocess_dfs(int u, int depth, int tree_type, vector<int> adj[], vector<int> nodes_by_depth[], int node_pos_in_depth[]) {
nodes_by_depth[depth].push_back(u);
node_pos_in_depth[u] = nodes_by_depth[depth].size() - 1;
for (int v : adj[u]) {
preprocess_dfs(v, depth + 1, tree_type, adj, nodes_by_depth, node_pos_in_depth);
}
}
long long solve(int d1, int l1_idx, int r1_idx, int d2, int l2_idx, int r2_idx) {
// 基线条件:如果任一节点列表为空,则只有一种合并方式(即保持另一列表不变)
if (l1_idx > r1_idx) {
return 1;
}
if (l2_idx > r2_idx) {
return 1;
}
// 使用节点ID作为记忆化的键
int u1_l = nodes_by_depth1[d1][l1_idx];
int u1_r = nodes_by_depth1[d1][r1_idx];
int u2_l = nodes_by_depth2[d2][l2_idx];
int u2_r = nodes_by_depth2[d2][r2_idx];
if (visited[u1_l][u1_r][u2_l][u2_r]) {
return dp[u1_l][u1_r][u2_l][u2_r];
}
long long count = 0;
// --- Case 1: 普通合并 ---
// 1a: 将T1的最右侧节点 u1_r 作为合并列表的最后一个元素
count = (count + solve(d1, l1_idx, r1_idx - 1, d2, l2_idx, r2_idx)) % MOD;
// 1b: 将T2的最右侧节点 u2_r 作为合并列表的最后一个元素
count = (count + solve(d1, l1_idx, r1_idx, d2, l2_idx, r2_idx - 1)) % MOD;
// --- Case 2: 嫁接 ---
// 2a: 将 T1 列表的后缀嫁接到 T2 列表的最右节点 u2_r 上
int child_d2 = d2 + 1;
int child_l2_idx = adj2[u2_r].empty() ? 1 : node_pos_in_depth2[adj2[u2_r][0]];
int child_r2_idx = adj2[u2_r].empty() ? 0 : node_pos_in_depth2[adj2[u2_r].back()];
for (int i = 1; i <= r1_idx - l1_idx + 1; ++i) {
int suffix_start_idx1 = r1_idx - i + 1;
long long ways_graft = solve(d1, suffix_start_idx1, r1_idx, child_d2, child_l2_idx, child_r2_idx);
long long ways_rest = solve(d1, l1_idx, suffix_start_idx1 - 1, d2, l2_idx, r2_idx - 1);
count = (count + ways_graft * ways_rest) % MOD;
}
// 2b: 将 T2 列表的后缀嫁接到 T1 列表的最右节点 u1_r 上
int child_d1 = d1 + 1;
int child_l1_idx = adj1[u1_r].empty() ? 1 : node_pos_in_depth1[adj1[u1_r][0]];
int child_r1_idx = adj1[u1_r].empty() ? 0 : node_pos_in_depth1[adj1[u1_r].back()];
for (int i = 1; i <= r2_idx - l2_idx + 1; ++i) {
int suffix_start_idx2 = r2_idx - i + 1;
long long ways_graft = solve(child_d1, child_l1_idx, child_r1_idx, d2, suffix_start_idx2, r2_idx);
long long ways_rest = solve(d1, l1_idx, r1_idx - 1, d2, l2_idx, suffix_start_idx2 - 1);
count = (count + ways_graft * ways_rest) % MOD;
}
visited[u1_l][u1_r][u2_l][u2_r] = true;
return dp[u1_l][u1_r][u2_l][u2_r] = count;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
cin >> n1;
for (int i = 2; i <= n1; ++i) {
int p;
cin >> p;
adj1[p].push_back(i);
}
cin >> n2;
for (int i = 2; i <= n2; ++i) {
int p;
cin >> p;
adj2[p].push_back(i);
}
preprocess_dfs(1, 0, 1, adj1, nodes_by_depth1, node_pos_in_depth1);
preprocess_dfs(1, 0, 2, adj2, nodes_by_depth2, node_pos_in_depth2);
long long total_ways = 0;
// Case 1: 新树的根是 T1 的根 (节点1)
// 合并 T1 根的孩子们 和 T2 的根
int r1_child_d = 1;
int r1_child_l_idx = adj1[1].empty() ? 1 : node_pos_in_depth1[adj1[1][0]];
int r1_child_r_idx = adj1[1].empty() ? 0 : node_pos_in_depth1[adj1[1].back()];
total_ways = (total_ways + solve(r1_child_d, r1_child_l_idx, r1_child_r_idx, 0, 0, 0)) % MOD;
// Case 2: 新树的根是 T2 的根 (节点1)
// 合并 T1 的根 和 T2 根的孩子们
int r2_child_d = 1;
int r2_child_l_idx = adj2[1].empty() ? 1 : node_pos_in_depth2[adj2[1][0]];
int r2_child_r_idx = adj2[1].empty() ? 0 : node_pos_in_depth2[adj2[1].back()];
total_ways = (total_ways + solve(0, 0, 0, r2_child_d, r2_child_l_idx, r2_child_r_idx)) % MOD;
cout << total_ways << endl;
return 0;
}复杂度分析
时间复杂度: 这个问题的状态数比较难以精确计算。一个状态由两棵树中两组兄弟节点的范围
(d1, l1, r1, d2, l2, r2)决定。虽然理论上状态空间很大,但很多状态是不可达的。实际可达的状态数取决于树的结构,对于 的数据范围是可以通过的。每个状态的计算涉及到两个循环,长度最多为 ,所以总的时间复杂度可以粗略估计为 。空间复杂度: 我们使用了一个四维数组
dp[MAXN][MAXN][MAXN][MAXN]来进行记忆化。虽然键是节点ID,但我们为了方便直接开了 的空间。不过,由于节点ID是从1开始的,更精确的空间是 ,或者说与可达状态数成正比。此外,还需要 的空间来存储树的结构和预处理信息。
知识点总结
这真是一道锻炼思维的好题呀,喵!解决它需要我们掌握以下知识点:
- 问题转化: 能将抽象的“保持结构不变”的条件,转化为具体的“合并有序列表”的操作,是解题的第一步。
- 树形动态规划: 问题的递归性质非常明显,天然适合使用DP解决。这里的DP不是在单个树上进行,而是在两棵树的“积”结构上进行。
- 记忆化搜索: 复杂的递归关系和重叠子问题,让我们毫不犹豫地选择记忆化搜索,它能大大简化编码实现,避免重复计算。
- 组合计数思想: 整个DP的过程,实际上是在用不同的方式对合并操作进行分类计数,包含了加法原理和乘法原理的综合运用。特别是“嫁接”操作,是一个非常巧妙的计数点。
希望这篇题解能帮到你,主人!如果还有不懂的地方,随时可以再来问我哦~ 喵~