Inner World - 题解
标签与难度
标签: 数据结构, 线段树, 离线处理, 树上问题, DFS序, 差分思想 难度: 2300
题目大意喵~
好久不见,指挥官!这次我们来到了一个奇妙的森林,里面有好多好多的树,喵~
一开始,我们有 棵树,编号从 到 。每棵树都只有一个孤零零的根节点,标签是 。
接下来,农夫给了我们 个“种植任务”。每个任务是 (u, v, l, r) 的形式,意思是:对于编号从 到 的所有树,我们都要在它们已有的、标签为 u 的节点下面,种一个新的节点,新节点的标签是 v。这些种植任务是按顺序执行的。
所有种植任务完成后,农夫又给了我们 个“查询任务”。每个查询是 (x, l, r) 的形式,问的是:在编号从 到 的这些树中,所有标签为 x 的节点的子树大小之和是多少?如果某棵树里没有标签为 x 的节点,那它的子树大小就看作是 啦。
我们的任务就是帮 Gromah 和 LZR 完成所有查询,这样就能通关啦!加油,喵~
解题思路分析
这道题看起来有点复杂,因为它混合了树结构、区间操作和区间查询,但是别怕,让我来一步步拆解它,呐!
关键洞察:唯一的“元宇宙”树
首先,我们来观察一下这些“种植任务”。任务 (u, v, l, r) 定义了一个父子关系:节点 v 的父亲是节点 u。虽然这个操作应用在了一个区间的树上,但这个父子关系 u -> v 本身是固定的。所有的 个种植任务,实际上共同定义了一个唯一的、包含了所有可能节点(标签从 到 )的**“元树” (Meta-Tree)** 结构。
每个节点(除了根节点 1),比如说节点 v,都是通过某个任务 (u, v, l_v, r_v) 添加的。我们可以说,节点 v 在第 i 棵实际的树中“存在”或“激活”,当且仅当 。根节点 1 比较特殊,它在所有树 [1, n] 中都存在。
将问题数学化
一个查询 (x, l, r) 要求计算:
其中 size(subtree_x in tree i) 是节点 x 在第 i 棵树里的子树大小。
这个子树大小又是什么呢?它等于所有在元树里是 x 的后代(包括 x 自己)、并且在第 i 棵树中被激活的节点的总数。
这里的 [...] 是艾弗森括号,如果条件为真,值为1,否则为0。
把两个公式合在一起,再交换一下求和顺序,我们得到:
这个式子告诉我们,答案等于:对于元树中 x 的每一个后代 y,计算它的激活区间 [l_y, r_y] 与查询区间 [l, r] 的交集长度,然后把所有这些长度加起来。直接计算这个当然太慢啦,我们需要更聪明的办法,喵!
必杀技:DFS序 + 差分 + 离线处理
当看到“子树求和”时,我的DNA动了!这不就是 DFS序 的经典应用场景嘛!我们可以对元树进行一次深度优先搜索(DFS),把树结构拍平成一个序列。这样,任何一个节点 x 的子树,都会对应到DFS序上的一个连续区间 [dfn_in[x], dfn_out[x]]。
利用这个性质,对子树 x 的求和就变成了对DFS序上 [dfn_in[x], dfn_out[x]] 这个区间的求和。 再利用差分思想,对一个区间的查询可以拆成两个前缀查询: Query(range) = Query(prefix_end) - Query(prefix_start - 1)。
所以,一个查询 (x, l, r) 可以被我们巧妙地拆解为: (在DFS序 [1, dfn_out[x]] 范围内的节点,对树区间 [l,r] 的贡献总和)- (在DFS序 [1, dfn_in[x]-1] 范围内的节点,对树区间 [l,r] 的贡献总和)
这引导我们走向离线处理和扫描线大法! 我们可以把所有查询拆分成事件,然后按DFS序进行扫描。
构建元树和DFS序:根据 个种植任务,建立元树的邻接表。然后从根节点
1开始DFS,计算出每个节点的dfn_in和dfn_out,并得到一个dfs_order数组,dfs_order[i]表示DFS序为i的是哪个节点。拆分查询为事件:将每个查询
(x, l, r)拆分为两个事件:- 一个“加”事件,在DFS序
dfn_out[x]的位置,查询树区间[l, r]。 - 一个“减”事件,在DFS序
dfn_in[x] - 1的位置,查询树区间[l, r]。 我们可以用一个vector数组events_at_dfs_time[...来存放这些事件。
- 一个“加”事件,在DFS序
扫描线:我们从
t = 1到m+1遍历DFS序。在每一步t,我们处理DFS序为t的节点v = dfs_order[t]:- 节点
v的贡献是:它会在树l_v到r_v上出现。 - 我们用一个数据结构来维护所有树
1...n的状态。当处理到节点v时,我们就给这个数据结构中[l_v, r_v]区间内的所有树的计数器都+1。 - 做完这个更新后,我们处理所有挂在当前DFS时间点
t的查询事件。对于一个查询(l, r),我们就在数据结构里查询[l, r]区间的和。 - 这个数据结构需要支持 区间增加 和 区间求和。这不就是我们最喜欢的线段树嘛!喵~
- 节点
总结一下我们的无敌算法:
- 读入数据,构建元树。
- DFS元树,得到
dfn_in,dfn_out,dfs_order。 - 将所有
q个查询拆分为2q个事件,按DFS时间点挂好。 - 初始化一个支持区间加、区间求和的线段树(大小为
n)。 - 按
t从1到m+1扫描: a. 取出当前节点v = dfs_order[t],及其激活区间[l_v, r_v]。 b. 在线段树上,对区间[l_v, r_v]执行+1操作。 c. 处理所有在时间点t的查询事件。对每个事件(l, r, sign, q_id),在线段树上查询区间[l, r]的和,将结果乘以sign(+1或-1)累加到ans[q_id]上。 - 最后,输出所有查询的答案。
这样,我们就把一个复杂的二维问题(树上结构 + 树的编号)转化成了一维扫描线加数据结构维护的问题,是不是很酷?喵~
代码实现
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAX_NODES = 300005;
// 元树的邻接表
vector<int> meta_adj[MAX_NODES];
// 每个节点在哪些树上是激活的
int node_active_l[MAX_NODES], node_active_r[MAX_NODES];
// DFS相关
int dfn_in[MAX_NODES], dfn_out[MAX_NODES];
int dfs_order[MAX_NODES]; // dfs_order[i] = DFS序为i的节点编号
int dfs_clock = 0;
// 查询事件结构体
struct QueryEvent {
int l, r, sign, query_id;
};
vector<QueryEvent> events_at_dfs_time[MAX_NODES];
// 最终答案
long long answers[MAX_NODES];
// 线段树部分
struct SegTreeNode {
long long sum;
long long lazy_add;
} seg_tree[MAX_NODES * 4];
void push_down(int node_idx, int l, int r) {
if (seg_tree[node_idx].lazy_add == 0) {
return;
}
int mid = l + (r - l) / 2;
long long val = seg_tree[node_idx].lazy_add;
// 更新左子节点
seg_tree[node_idx * 2].sum += val * (mid - l + 1);
seg_tree[node_idx * 2].lazy_add += val;
// 更新右子节点
seg_tree[node_idx * 2 + 1].sum += val * (r - mid);
seg_tree[node_idx * 2 + 1].lazy_add += val;
seg_tree[node_idx].lazy_add = 0;
}
void push_up(int node_idx) {
seg_tree[node_idx].sum = seg_tree[node_idx * 2].sum + seg_tree[node_idx * 2 + 1].sum;
}
void range_update(int node_idx, int l, int r, int update_l, int update_r, int val) {
if (update_l <= l && r <= update_r) {
seg_tree[node_idx].sum += (long long)val * (r - l + 1);
seg_tree[node_idx].lazy_add += val;
return;
}
push_down(node_idx, l, r);
int mid = l + (r - l) / 2;
if (update_l <= mid) {
range_update(node_idx * 2, l, mid, update_l, update_r, val);
}
if (update_r > mid) {
range_update(node_idx * 2 + 1, mid + 1, r, update_l, update_r, val);
}
push_up(node_idx);
}
long long range_query(int node_idx, int l, int r, int query_l, int query_r) {
if (query_l <= l && r <= query_r) {
return seg_tree[node_idx].sum;
}
push_down(node_idx, l, r);
int mid = l + (r - l) / 2;
long long result = 0;
if (query_l <= mid) {
result += range_query(node_idx * 2, l, mid, query_l, query_r);
}
if (query_r > mid) {
result += range_query(node_idx * 2 + 1, mid + 1, r, query_l, query_r);
}
return result;
}
// 对元树进行DFS
void build_dfs_order(int u) {
dfs_clock++;
dfn_in[u] = dfs_clock;
dfs_order[dfs_clock] = u;
for (int v : meta_adj[u]) {
build_dfs_order(v);
}
dfn_out[u] = dfs_clock;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
// 根节点1在所有树中都激活
node_active_l[1] = 1;
node_active_r[1] = n;
int total_nodes = m + 1;
for (int i = 0; i < m; ++i) {
int u, v, l, r;
cin >> u >> v >> l >> r;
meta_adj[u].push_back(v);
node_active_l[v] = l;
node_active_r[v] = r;
}
// 1. 构建DFS序
build_dfs_order(1);
int q;
cin >> q;
for (int i = 0; i < q; ++i) {
int x, l, r;
cin >> x >> l >> r;
// 2. 拆分查询为事件
if (dfn_out[x] > 0) { // 确保节点存在
events_at_dfs_time[dfn_out[x]].push_back({l, r, 1, i});
}
if (dfn_in[x] > 1) {
events_at_dfs_time[dfn_in[x] - 1].push_back({l, r, -1, i});
}
}
// 3. 扫描线处理
for (int t = 1; t <= total_nodes; ++t) {
int current_node = dfs_order[t];
int l = node_active_l[current_node];
int r = node_active_r[current_node];
// a. 更新线段树
if (l <= r) {
range_update(1, 1, n, l, r, 1);
}
// b. 处理当前时间点的所有查询事件
for (const auto& event : events_at_dfs_time[t]) {
long long query_res = range_query(1, 1, n, event.l, event.r);
answers[event.query_id] += event.sign * query_res;
}
}
// 4. 输出答案
for (int i = 0; i < q; ++i) {
cout << answers[i] << "\n";
}
return 0;
}复杂度分析
时间复杂度:
- 构建元树和进行DFS是 。
- 将查询拆分成事件是 。
- 扫描线循环执行 次。在每次循环中:
- 我们进行一次线段树的区间更新,耗时 。
- 我们处理挂在该时间点的所有查询事件。总共有 个事件,每个事件需要一次线段树的区间查询,耗时 。
- 所以总的时间复杂度是 。
空间复杂度:
- 元树邻接表需要 空间。
- DFS相关的数组需要 空间。
- 存储查询事件的
vector数组总共需要 空间。 - 线段树需要 的空间,也就是 。
- 因此总空间复杂度为 。
知识点总结
这道题是一道非常棒的综合题,它将好几种算法思想巧妙地结合在了一起,喵~
- 问题转化: 核心是识别出固定的“元树”结构和变化的“激活区间”,将一个动态的、多棵树的问题转化为对一个静态结构附加属性的查询问题。
- DFS序: 这是处理树上子树问题的标准技巧。它能把子树操作转化为序列上的区间操作,是树上问题“降维打击”的利器!
- 差分思想/前缀和: 将对一个区间的查询
[a, b]转化为prefix(b) - prefix(a-1),这是算法竞赛中无处不在的强大思想。 - 离线处理与扫描线: 当查询可以被拆分并重新排序处理时,离线算法往往能大显身手。扫描线是一种系统化处理几何问题或区间问题的框架,通过在一个维度上移动扫描线,动态维护另一个维度的信息。
- 线段树: 作为数据结构的瑞士军刀,线段树在这里完美地承担了动态维护区间信息(区间加、区间和)的任务。
希望这篇题解能帮助你理解这道题的精髓,喵~ 多多练习,你也能成为算法大师的!