Skip to content

H - Head out to the Target - 题解

标签与难度

标签: 树论, 最近公共祖先(LCA), 二进制划分(Binary Lifting), 贪心, 数据结构 难度: 2200

题目大意喵~

Nyahello~!各位算法大师们,今天我们来攻略一道关于树上移动的有趣问题,喵~

是这样的:我们有一棵 nn 个节点的有根树,根是节点1。还有一个小棋子,一开始乖乖地待在节点1。

接下来,会有 kk不重叠的时间段。在第 ii 个时间段 [li,ri][l_i, r_i] 里,会在节点 uiu_i 出现一个目标。

棋子的移动规则是这样哒:在任何一个时刻,如果:

  1. 正好有一个目标是激活状态(即当前时间在某个 [li,ri][l_i, r_i] 内)。
  2. 棋子和目标在树上是连通的。
  3. 棋子还没到达目标点。

那么,棋子就会沿着它和目标之间的唯一简单路径,向目标移动一步。如果棋子到达了目标点(或者在某个时刻开始时就已经在目标点了),就发生一次“相遇”。

我们有一个超能力,喵!就是在任何时刻,我们可以剪断树上的任意多条边,而且剪掉后就再也回不来了。

我们的任务是,通过策略性地剪边,找到最早可能发生“相遇”的时刻。如果永远也无法相遇,就告诉人家 -1,好吗?

解题思路分析

这道题看起来有点复杂,又是移动又是剪边的,但别怕,让我来带你一步步解开它的秘密,喵~

核心能力:剪边的力量!

首先,我们来分析一下“剪边”这个超能力到底意味着什么。

  • 当棋子在一个位置 p,目标在 u 时,棋子会沿着 pu 的路径移动。
  • 如果我们不希望棋子移动,怎么办呢?很简单,只要把 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 相遇?

  1. 寻找最佳起点: “可停泊区” P 里的点可能会越来越多。但仔细想想,P 中的所有点都是从根节点1出发能到达的,所以它们会形成一个包含根的连通块。对于一个目标点 u,离它最近的“可停泊”点,一定是 u 的某个祖先节点。 所以,我们的任务是找到 u 的所有祖先中,深度最大(也就是离u最近)的那个“可停泊”点。我们称它为 p_start

  2. 计算相遇时间

    • 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 是否足够长。
    • 如果足够长,恭喜!我们找到了一个可行的相遇方案。因为我们是按时间顺序处理的,这一定是全局最早的相遇时刻。直接输出答案,任务完成!
  3. 更新“可停泊区”

    • 如果当前时间段不够长,无法完成相遇,我们也不能浪费这段时间呀,喵~
    • 棋子会从 p_startu 移动,总共移动 r - l + 1 步。
    • 它会到达一个新的终点 p_final
    • 这意味着,从 p_startp_final 的这条路径上的所有节点,现在都变成了新的“可停泊”点!我们需要把它们都加入到我们的“可停泊区”中。

算法黑魔法:二进制划分 (Binary Lifting)

为了让上面的操作变得飞快,我们需要一些树论的黑魔法,也就是二进制划分(也常用于LCA)。

  • 预处理 (O(NlogN)O(N \log N)):我们先用一次DFS遍历整棵树,计算出每个节点的深度 depth 和它的直接父节点 parent[v][0]。然后通过递推 parent[v][j] = parent[parent[v][j-1]][j-1],我们就能在 O(logN)O(\log N) 的时间里找到任何节点的第 2j2^j 个祖先。

  • 高效操作 (O(logN)O(\log N))

    • 寻找 p_start:要找到 u 的最深的“可停泊”祖先,我们可以反过来找 u最高的“不可停泊”祖先。利用二进制划分,我们可以从 u 向上跳,每次跳最大的一步,只要终点还是“不可停泊”的就跳。最后找到的那个点,它的父节点就是 p_start
    • 寻找 p_final:计算出 p_final 的目标深度后,我们可以从 u 开始,利用二进制划分向上跳 depth(u) - depth(p_final) 步,就能精确地定位到 p_final
  • 更新“可停泊区”:找到 p_final 后,我们从 p_final 开始,不断地沿着父节点往上走,直到遇到一个已经“可停泊”的节点为止,把沿途所有节点都标记为“可停泊”。虽然单次更新可能走很长的路,但每个节点一生只会被标记一次,所以所有更新操作的总成本是均摊 O(N)O(N) 的。

结合起来,总的时间复杂度就是预处理的 O(NlogN)O(N \log N) 加上处理 KK 个查询的 O(KlogN)O(K \log N),对于 10610^6 的数据量来说是绰绰有余的,喵~

代码实现

这是我根据上面的思路,精心重构的一份代码,注释超详细的哦,希望能帮到你,喵~

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

复杂度分析

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

    • 预处理: build_lca_table 函数包括一次DFS (O(N)O(N))和填充二进制划分表 (O(NlogN)O(N \log N))。总共是 O(NlogN)O(N \log N)
    • 查询处理: 我们有 KK 个查询。对于每个查询:
      • 寻找最佳起点 start_node 需要 O(logN)O(\log N) 的时间。
      • 计算 get_ancestor 找到 final_pos 需要 O(logN)O(\log N) 的时间。
      • 更新 is_parkable 数组时,虽然while循环的次数不确定,但每个节点一生只会被标记一次。所以所有 KK 次查询中,这部分的总工作量是 O(N)O(N)
    • 因此,总的时间复杂度是 O(NlogN+KlogN)O(N \log N + K \log N),非常高效!
  • 空间复杂度: O(NlogN)O(N \log N)

    • 我们主要的空间开销是邻接表 adj (O(N)O(N)),深度数组 depth (O(N)O(N)),可停泊标记 is_parkable (O(N)O(N)),以及二进制划分表 parent (O(NlogN)O(N \log N))。所以瓶颈在于 parent 表。

知识点总结

这道题是一道很棒的树上问题,融合了多种思想:

  1. 问题转化: 关键在于理解“剪边”的本质,将其转化为“棋子可以停在已到达过的任意位置”,从而引出“可停泊区”的概念。
  2. 贪心思想: 按时间顺序处理事件,对于每个事件都尝试以最优方式解决,如果能解决则直接得到全局最优解。
  3. 二进制划分 (Binary Lifting): 这是解决许多树上路径、祖先查询问题的标准高效工具。它能让我们在对数时间内完成向上跳跃固定步数的操作。
  4. 均摊分析: 在更新“可停泊区”时,虽然单次操作可能耗时较长,但通过分析总的操作次数(每个节点只被标记一次),我们得出总成本是线性的。这是一种重要的复杂度分析技巧。

希望这篇题解能让你对树上问题有更深的理解,喵~ 如果还有不懂的地方,随时可以再来问我哦!一起加油,成为更厉害的算法大师吧!