题解:洛谷 P13019 树上旅行
【题目来源】
洛谷:P13019 [GESP202506 八级] 树上旅行
【题目描述】
给定一棵有 \(n\) 个结点的 有根树,结点依次以 \(1,2,\dots,n\) 编号,其中根结点的编号为 \(1\)。
小 A 计划在这棵有根树上进行 \(q\) 次旅行。在第 \(i\) 次旅行中,小 A 首先选定结点 \(s_i\) 作为起点,并移动若干次。移动分为以下两种:
- 移动至当前结点的父结点。特殊地,如果当前位于根结点,则不进行移动。
- 移动至当前结点的所有子结点中编号最小的结点。特殊地,如果当前位于叶子结点,则不进行移动。
由于移动次数可能很大,对于第 \(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\) 为根的有根树,进行 \(q\) 次旅行。每次旅行从节点 \(s_i\) 出发,按序列执行移动:正数 \(a\) 表示向上走 \(a\) 步(到父节点),负数 \(a\) 表示向下走 \(|a|\) 步(每次到最小编号子节点)。求每次旅行的终点。由于单次移动步数可能极大,需要加速跳跃。
-
算法选择:
- 倍增法(Binary Lifting):预处理每个节点向上 \(2^i\) 级祖先 \(\text{fa}[u][i]\) 和向下连续走 \(2^i\) 次最小编号子节点 \(\text{son}[u][i]\),通过二进制拆分将 \(O(a)\) 的线性移动优化为 \(O(\log a)\)
-
关键步骤:
- 建图与初始化:读取父节点数组,构建邻接表 \(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\),最终输出终点
-
时间/空间复杂度:
- 预处理时间复杂度:\(O(n \log n)\),每个节点处理 \(\log n\) 层倍增
- 单次查询时间复杂度:\(O(k \log a_{\max})\),\(k\) 为指令数,每次移动 \(O(\log a)\)
- 空间复杂度:\(O(n \log n)\),两个倍增表各 \(n \times \log n\)
-
倍增法加速树路径的核心思想:
- 二进制拆分任意步数:将任意整数 \(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
浙公网安备 33010602011771号