题解:洛谷 P13019 树上旅行

【题目来源】

洛谷:P13019 [GESP202506 八级] 树上旅行

【题目描述】

给定一棵有 \(n\) 个结点的 有根树,结点依次以 \(1,2,\dots,n\) 编号,其中根结点的编号为 \(1\)

小 A 计划在这棵有根树上进行 \(q\) 次旅行。在第 \(i\) 次旅行中,小 A 首先选定结点 \(s_i\) 作为起点,并移动若干次。移动分为以下两种:

  1. 移动至当前结点的父结点。特殊地,如果当前位于根结点,则不进行移动。
  2. 移动至当前结点的所有子结点中编号最小的结点。特殊地,如果当前位于叶子结点,则不进行移动。

由于移动次数可能很大,对于第 \(i\) 次旅行,旅行中的移动以 \(k_i\) 个不为零的整数构成的序列 \(a_{i,1}, a_{i,2}, \dots, a_{i,k_i}\) 表示。对 \(a_{i,j}\),若 \(a_{i,j} > 0\) 则代表进行 \(a_{i,j}\) 次第一种移动;若 \(a_{i,j} < 0\) 则代表进行 \(-a_{i,j}\) 次第二种移动。根据给出的序列从左至右完成所有移动后,小 A 所在的结点即是旅行的终点

给定每次旅行的起点与移动序列,请你求出旅行终点的结点编号。

【输入】

第一行,两个正整数 \(n, q\),分别表示有根树的结点数量,以及旅行次数。

第二行,\(n-1\) 个整数 \(p_2, p_3, \dots, p_n\),其中 \(p_i\) 表示结点 \(i\) 的父结点编号。

接下来 \(2q\) 行中的第 \(2i-1\) 行(\(1 \leq i \leq q\))包含两个正整数 \(s_i, k_i\),分别表示第 \(i\) 次旅行的起点编号,以及移动序列的长度。第 \(2i\) 行包含 \(k_i\) 个整数 \(a_{i,1}, a_{i,2}, \dots, a_{i,k_i}\),表示移动序列。

【输出】

输出共 \(q\) 行,第 \(i\) 行包含一个整数,表示第 \(i\) 次旅行终点的结点编号。

【输入样例】

5 4
1 1 2 2
3 3
1 -1 -1
2 5
1 -1 1 -1 1
5 8
1 1 1 -1 -1 -1 -1 -1
5 3
-1 -1 1

【输出样例】

4
1
4
2

【核心思想】

  1. 问题分析:给定一棵以 \(1\) 为根的有根树,进行 \(q\) 次旅行。每次旅行从节点 \(s_i\) 出发,按序列执行移动:正数 \(a\) 表示向上走 \(a\) 步(到父节点),负数 \(a\) 表示向下走 \(|a|\) 步(每次到最小编号子节点)。求每次旅行的终点。由于单次移动步数可能极大,需要加速跳跃

  2. 算法选择

    • 倍增法(Binary Lifting):预处理每个节点向上 \(2^i\) 级祖先 \(\text{fa}[u][i]\) 和向下连续走 \(2^i\) 次最小编号子节点 \(\text{son}[u][i]\),通过二进制拆分将 \(O(a)\) 的线性移动优化为 \(O(\log a)\)
  3. 关键步骤

    • 建图与初始化:读取父节点数组,构建邻接表 \(g[u]\) 存储子节点,根节点父节点设为自己
    • DFS预处理倍增表
      • 向上表\(\text{fa}[u][i] = \text{fa}[\text{fa}[u][i-1]][i-1]\),即 \(u\)\(2^i\) 级祖先等于其 \(2^{i-1}\) 级祖先的 \(2^{i-1}\) 级祖先
      • 最小编号子节点:遍历子节点时维护 \(\text{son}[u][0]\) 为编号最小的直接子节点
      • 向下表\(\text{son}[u][i] = \text{son}[\text{son}[u][i-1]][i-1]\),即连续向下走 \(2^i\) 步等于先走 \(2^{i-1}\) 步再走 \(2^{i-1}\)
    • 二进制拆分移动
      • \(\text{moveup}(u, a)\):遍历 \(a\) 的每一位,若第 \(i\) 位为 \(1\),则 \(u = \text{fa}[u][i]\)
      • \(\text{movedown}(u, a)\):同理使用 \(\text{son}\) 表加速向下跳跃
    • 处理查询:对每个移动指令,根据正负调用对应函数,更新当前节点 \(s\),最终输出终点
  4. 时间/空间复杂度

    • 预处理时间复杂度:\(O(n \log n)\),每个节点处理 \(\log n\) 层倍增
    • 单次查询时间复杂度:\(O(k \log a_{\max})\)\(k\) 为指令数,每次移动 \(O(\log a)\)
    • 空间复杂度:\(O(n \log n)\),两个倍增表各 \(n \times \log n\)
  5. 倍增法加速树路径的核心思想

    • 二进制拆分任意步数:将任意整数 \(a\) 拆分为 \(2^i\) 的和,通过预处理的 \(2^i\) 步跳跃表,将线性遍历转化为对数级跳跃
    • 向上与向下的对称处理:向上走父节点链和向下走固定策略(最小编号子节点)均可倍增,区别在于向下表依赖"策略一致性"(每次必须走最小编号子节点)
    • 根节点自环处理\(\text{fa}[1][0] = 1\) 确保向上走到根后不再移动,避免越界
    • 离线预处理优势:先一次性 \(O(n \log n)\) 预处理,之后每次查询 \(O(\log a)\),适合大量查询场景
    • 适用于树上快速跳跃、LCA、路径查询、大规模步数移动问题

【算法标签】

普及+ #倍增

【代码详解】

#include <bits/stdc++.h>
using namespace std;
const int N = 100005;                   // 定义最大节点数常量N为100005
int n, q;                               // n为节点数,q为旅行次数
int p[N];                               // p[i]表示节点i的父节点编号(未使用,用fa数组替代)
int fa[N][21], son[N][21];              // fa[u][i]表示u的2^i级祖先;son[u][i]表示u连续走2^i次"最小编号子节点"到达的节点
vector<int> g[N];                       // 邻接表,g[u]存储u的所有子节点

void dfs(int u)
// 深度优先搜索,预处理每个节点的倍增祖先表和最小编号子节点倍增表
{
    // 预处理向上倍增表:fa[u][i]表示u向上走2^i步到达的祖先
    for (int i=1; i<=20; i++)          // 枚举倍增层次,2^20约100万,足够覆盖N=1e5
        fa[u][i] = fa[fa[u][i-1]][i-1];  // u的2^i级祖先 = u的2^(i-1)级祖先的2^(i-1)级祖先

    son[u][0] = u;                      // son[u][0]初始化为u自身,用于比较找最小编号子节点
    for (auto v : g[u])                // 遍历u的所有子节点v
    {
        dfs(v);                         // 递归处理子节点v
        // 更新u的最小编号子节点:如果当前还没有子节点,或者v的编号更小
        if (son[u][0]==u || v < son[u][0])
        {
            son[u][0] = v;              // son[u][0]记录u的最小编号直接子节点
        }
    }

    // 预处理向下倍增表:son[u][i]表示从u连续走2^i次"最小编号子节点"到达的节点
    for (int i=1; i<=20; i++)          // 枚举倍增层次
        son[u][i] = son[son[u][i-1]][i-1];  // 连续走2^i次 = 先走2^(i-1)次,再走2^(i-1)次
}

int moveup(int u, int a)
// 二进制拆分向上移动:从u向上移动a步(走到父节点),使用倍增加速
{
    for (int i=0; i<=20; i++)          // 枚举每一位二进制
        if ((a>>i)&1)                   // 如果a的第i位为1,表示需要向上走2^i步
            u = fa[u][i];               // u跳到其2^i级祖先
    return u;                           // 返回向上移动a步后的节点
}
int movedown(int u, int a)
// 二进制拆分向下移动:从u向下移动a步(每次走到最小编号子节点),使用倍增加速
{
    for (int i=0; i<=20; i++)          // 枚举每一位二进制
        if ((a>>i)&1)                   // 如果a的第i位为1,表示需要向下走2^i步
            u = son[u][i];              // u跳到连续走2^i次最小编号子节点后的位置
    return u;                           // 返回向下移动a步后的节点
}

int main()
{
    cin >> n >> q;                      // 读入节点数n和旅行次数q
    for (int i=2; i<=n; i++)           // 循环读入节点2到n的父节点信息
    {
        cin >> fa[i][0];                // 读入节点i的父节点fa[i][0]
        g[fa[i][0]].push_back(i);       // 将i加入父节点的子节点列表
    }
    fa[1][0] = 1;                       // 根节点1的父节点设为自己(向上走到根就停止)
    dfs(1);                             // 从根节点开始DFS预处理倍增表
    while (q--)                         // 循环处理q次旅行
    {
        int s, k, a;                    // s为起点,k为移动序列长度,a为每次移动的步数
        cin >> s >> k;                  // 读入起点s和序列长度k
        while (k--)                     // 循环处理序列中的k个移动指令
        {
            cin >> a;                   // 读入当前移动指令a
            if (a>0)                    // 如果a为正数,表示向上移动a步(走到父节点)
                s = moveup(s, a);       // 从s向上移动a步
            else                         // 如果a为负数,表示向下移动|a|步(走到最小编号子节点)
                s = movedown(s, -a);    // 从s向下移动-a步
        }
        cout << s << endl;              // 输出第i次旅行的终点编号
    }
    return 0;
}

【运行结果】

5 4
1 1 2 2
3 3
1 -1 -1
4
2 5
1 -1 1 -1 1
1
5 8
1 1 1 -1 -1 -1 -1 -1
4
5 3
-1 -1 1
2
posted @ 2026-08-28 15:54  团爸讲算法  阅读(3)  评论(0)    收藏  举报