H - Head out to the Target - 题解
标签与难度
标签: 树论, 最近公共祖先(LCA), 二进制划分(Binary Lifting), 贪心, 数据结构 难度: 2200
题目大意喵~
Nyahello~!各位算法大师们,今天我们来攻略一道关于树上移动的有趣问题,喵~
是这样的:我们有一棵 个节点的有根树,根是节点1。还有一个小棋子,一开始乖乖地待在节点1。
接下来,会有 个不重叠的时间段。在第 个时间段 里,会在节点 出现一个目标。
棋子的移动规则是这样哒:在任何一个时刻,如果:
- 正好有一个目标是激活状态(即当前时间在某个 内)。
- 棋子和目标在树上是连通的。
- 棋子还没到达目标点。
那么,棋子就会沿着它和目标之间的唯一简单路径,向目标移动一步。如果棋子到达了目标点(或者在某个时刻开始时就已经在目标点了),就发生一次“相遇”。
我们有一个超能力,喵!就是在任何时刻,我们可以剪断树上的任意多条边,而且剪掉后就再也回不来了。
我们的任务是,通过策略性地剪边,找到最早可能发生“相遇”的时刻。如果永远也无法相遇,就告诉人家 -1,好吗?
解题思路分析
这道题看起来有点复杂,又是移动又是剪边的,但别怕,让我来带你一步步解开它的秘密,喵~
核心能力:剪边的力量!
首先,我们来分析一下“剪边”这个超能力到底意味着什么。
- 当棋子在一个位置
p,目标在u时,棋子会沿着p到u的路径移动。 - 如果我们不希望棋子移动,怎么办呢?很简单,只要把
p和它路径上的下一个节点之间的边剪断,它们就不连通了,棋子就会原地不动啦! - 这个能力非常强大,它意味着我们可以选择性地忽略任何一个目标。我们可以让棋子停在当前位置,等待一个更有利的未来目标出现。
“可停泊区”的概念
既然棋子可以被我们控制停在某个地方,我们可以想象一个“可停泊区”(Parkable Zone)的概念。这是一个节点的集合,代表了在当前时间点之前,我们通过各种策略(移动或不移动)可以让棋子最终停在的所有可能位置。
- 初始状态:在时间0,棋子只在节点1。所以,初始的“可停泊区”
P就是{1}。 - 状态转移:我们按时间顺序处理每一个目标事件
(u_i, l_i, r_i)。- 在第
i个事件开始前,我们有一个“可停泊区”P_i。 - 对于这个新目标
u_i,我们可以从P_i中的任何一个节点p出发。 - 我们要找最早的相遇时间,所以自然会选择一个离
u_i最近的出发点p,这样路上花的时间最少。 - 走完这个事件后,棋子会移动到一个新的位置,或者我们选择让它停在原来的某个位置。所有这些新的可能位置,就构成了下一个事件开始前的“可停泊区”
P_{i+1}。
- 在第
贪心策略与高效实现
因为题目要求最早的相遇时间,而且时间区间是按顺序给出的,一个自然的贪心思路就出现啦:对于每个目标,我们都尽力去达成相遇。一旦成功,那个时刻就是我们能找到的最早时刻。
现在问题变成了:对于每个目标 (u, l, r),如何高效地计算出从当前“可停泊区”出发,能否在时间 [l, r] 内与 u 相遇?
寻找最佳起点: “可停泊区”
P里的点可能会越来越多。但仔细想想,P中的所有点都是从根节点1出发能到达的,所以它们会形成一个包含根的连通块。对于一个目标点u,离它最近的“可停泊”点,一定是u的某个祖先节点。 所以,我们的任务是找到u的所有祖先中,深度最大(也就是离u最近)的那个“可停泊”点。我们称它为p_start。计算相遇时间:
- 从
p_start出发到u,需要移动的步数是它们之间的距离,也就是dist(p_start, u) = depth(u) - depth(p_start)。 - 每次移动花费一个时刻。所以,总共需要
dist(p_start, u)个时刻。 - 移动从时间
l开始,那么相遇的时刻就是l + dist(p_start, u) - 1。 - 我们需要检查这个相遇时刻是否在允许的时间段内,即
l + dist(p_start, u) - 1 <= r。这等价于区间的时长r - l + 1是否足够长。 - 如果足够长,恭喜!我们找到了一个可行的相遇方案。因为我们是按时间顺序处理的,这一定是全局最早的相遇时刻。直接输出答案,任务完成!
- 从
更新“可停泊区”:
- 如果当前时间段不够长,无法完成相遇,我们也不能浪费这段时间呀,喵~
- 棋子会从
p_start向u移动,总共移动r - l + 1步。 - 它会到达一个新的终点
p_final。 - 这意味着,从
p_start到p_final的这条路径上的所有节点,现在都变成了新的“可停泊”点!我们需要把它们都加入到我们的“可停泊区”中。
算法黑魔法:二进制划分 (Binary Lifting)
为了让上面的操作变得飞快,我们需要一些树论的黑魔法,也就是二进制划分(也常用于LCA)。
预处理 ():我们先用一次DFS遍历整棵树,计算出每个节点的深度
depth和它的直接父节点parent[v][0]。然后通过递推parent[v][j] = parent[parent[v][j-1]][j-1],我们就能在 的时间里找到任何节点的第 个祖先。高效操作 ():
- 寻找
p_start:要找到u的最深的“可停泊”祖先,我们可以反过来找u的最高的“不可停泊”祖先。利用二进制划分,我们可以从u向上跳,每次跳最大的一步,只要终点还是“不可停泊”的就跳。最后找到的那个点,它的父节点就是p_start。 - 寻找
p_final:计算出p_final的目标深度后,我们可以从u开始,利用二进制划分向上跳depth(u) - depth(p_final)步,就能精确地定位到p_final。
- 寻找
更新“可停泊区”:找到
p_final后,我们从p_final开始,不断地沿着父节点往上走,直到遇到一个已经“可停泊”的节点为止,把沿途所有节点都标记为“可停泊”。虽然单次更新可能走很长的路,但每个节点一生只会被标记一次,所以所有更新操作的总成本是均摊 的。
结合起来,总的时间复杂度就是预处理的 加上处理 个查询的 ,对于 的数据量来说是绰绰有余的,喵~
代码实现
这是我根据上面的思路,精心重构的一份代码,注释超详细的哦,希望能帮到你,喵~
#include <iostream>
#include <vector>
#include <numeric>
// 一些常量定义,喵~
const int MAXN = 1000005;
const int LOGN = 20; // 2^19 > 10^6
int n, k;
std::vector<int> adj[MAXN]; // 邻接表存树
int parent[MAXN][LOGN]; // parent[u][j] 是 u 的第 2^j 个祖先
int depth[MAXN]; // 节点的深度
bool is_parkable[MAXN]; // 标记一个节点是否是“可停泊”的
// DFS预处理,计算深度和直接父节点
void dfs_precompute(int u, int p, int d) {
depth[u] = d;
parent[u][0] = p;
for (int v : adj[u]) {
// 题目保证 f_i < i,所以不会有环,不用检查 v != p
dfs_precompute(v, u, d + 1);
}
}
// 构建二进制划分(LCA)的辅助表
void build_lca_table() {
// 根节点1的父节点设为0,深度为0
dfs_precompute(1, 0, 0);
for (int j = 1; j < LOGN; ++j) {
for (int i = 1; i <= n; ++i) {
if (parent[i][j - 1] != 0) {
parent[i][j] = parent[parent[i][j - 1]][j - 1];
} else {
parent[i][j] = 0; // 祖先不存在
}
}
}
}
// 找到 u 的第 dist 个祖先
int get_ancestor(int u, int dist) {
for (int j = LOGN - 1; j >= 0; --j) {
if ((dist >> j) & 1) {
u = parent[u][j];
}
}
return u;
}
void solve() {
std::cin >> n >> k;
for (int i = 2; i <= n; ++i) {
int f;
std::cin >> f;
adj[f].push_back(i);
}
build_lca_table();
// 最初,只有根节点1是可停泊的
is_parkable[1] = true;
for (int i = 0; i < k; ++i) {
long long u_ll, l_ll, r_ll;
std::cin >> u_ll >> l_ll >> r_ll;
int target_node = u_ll;
long long start_time = l_ll;
long long end_time = r_ll;
// 如果目标点本身就是可停泊的,我们直接在 l_i 时刻相遇
if (is_parkable[target_node]) {
std::cout << start_time << '\n';
return;
}
// 寻找离 target_node 最近的可停泊祖先
// 策略:找到 target_node 最高的“不可停泊”祖先
int furthest_unparkable = target_node;
for (int j = LOGN - 1; j >= 0; --j) {
int ancestor = parent[furthest_unparkable][j];
if (ancestor != 0 && !is_parkable[ancestor]) {
furthest_unparkable = ancestor;
}
}
// 最佳起点就是那个最高不可停泊点的父节点
int start_node = parent[furthest_unparkable][0];
long long dist_needed = depth[target_node] - depth[start_node];
long long time_available = end_time - start_time + 1;
if (time_available >= dist_needed) {
// 时间足够,可以相遇!
long long meet_time = start_time + dist_needed - 1;
std::cout << meet_time << '\n';
return;
} else {
// 时间不够,只能移动一部分距离,然后更新可停泊区
long long dist_traveled = time_available;
// 棋子最终会停在离 start_node 距离为 dist_traveled 的节点上
// 这个节点也就是 target_node 的某个祖先
int final_pos = get_ancestor(target_node, depth[target_node] - (depth[start_node] + dist_traveled));
// 将从 final_pos到已停泊区之间的路径全部标记为可停泊
int current_node = final_pos;
while (!is_parkable[current_node]) {
is_parkable[current_node] = true;
current_node = parent[current_node][0];
}
}
}
// 所有机会都用完了还是没相遇
std::cout << -1 << '\n';
}
int main() {
// 加速输入输出,喵~
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
solve();
return 0;
}复杂度分析
时间复杂度:
- 预处理:
build_lca_table函数包括一次DFS ()和填充二进制划分表 ()。总共是 。 - 查询处理: 我们有 个查询。对于每个查询:
- 寻找最佳起点
start_node需要 的时间。 - 计算
get_ancestor找到final_pos需要 的时间。 - 更新
is_parkable数组时,虽然while循环的次数不确定,但每个节点一生只会被标记一次。所以所有 次查询中,这部分的总工作量是 。
- 寻找最佳起点
- 因此,总的时间复杂度是 ,非常高效!
- 预处理:
空间复杂度:
- 我们主要的空间开销是邻接表
adj(),深度数组depth(),可停泊标记is_parkable(),以及二进制划分表 parent ()。所以瓶颈在于 parent 表。
- 我们主要的空间开销是邻接表
知识点总结
这道题是一道很棒的树上问题,融合了多种思想:
- 问题转化: 关键在于理解“剪边”的本质,将其转化为“棋子可以停在已到达过的任意位置”,从而引出“可停泊区”的概念。
- 贪心思想: 按时间顺序处理事件,对于每个事件都尝试以最优方式解决,如果能解决则直接得到全局最优解。
- 二进制划分 (Binary Lifting): 这是解决许多树上路径、祖先查询问题的标准高效工具。它能让我们在对数时间内完成向上跳跃固定步数的操作。
- 均摊分析: 在更新“可停泊区”时,虽然单次操作可能耗时较长,但通过分析总的操作次数(每个节点只被标记一次),我们得出总成本是线性的。这是一种重要的复杂度分析技巧。
希望这篇题解能让你对树上问题有更深的理解,喵~ 如果还有不懂的地方,随时可以再来问我哦!一起加油,成为更厉害的算法大师吧!