题解:洛谷 P3379 最近公共祖先(LCA)
【题目来源】
【题目描述】
如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先。
【输入】
第一行包含三个正整数 \(N,M,S\),分别表示树的结点个数、询问的个数和树根结点的序号。
接下来 \(N-1\) 行每行包含两个正整数 \(x, y\),表示 \(x\) 结点和 \(y\) 结点之间有一条直接连接的边(数据保证可以构成树)。
接下来 \(M\) 行每行包含两个正整数 \(a, b\),表示询问 \(a\) 结点和 \(b\) 结点的最近公共祖先。
【输出】
输出包含 \(M\) 行,每行包含一个正整数,依次为每一个询问的结果。
【输入样例】
5 5 4
3 1
2 4
5 1
1 4
2 4
3 2
3 5
1 2
4 5
【输出样例】
4
4
1
4
4
【核心思想】
-
问题分析:给定一棵有根多叉树,节点数为 \(N\),根节点为 \(S\),需要回答 \(M\) 次查询,每次查询给出两个节点 \(u, v\),求它们的最近公共祖先(LCA),即深度最大且同时是 \(u\) 和 \(v\) 的祖先的节点。这是树结构中最基础也是最重要的查询问题,广泛应用于路径统计、树上差分等场景。
-
算法选择:
- 倍增法(在线):预处理每个节点的 \(2^k\) 级祖先和深度,每次查询通过“将较深节点上提至同深度”和“两节点同步上跳”两步,在 \(O(\log N)\) 时间内得到 LCA。适合在线查询,实现简单,时空复杂度均衡。
- Tarjan 离线算法:利用 DFS 遍历树,并用并查集维护已访问节点的祖先关系,在回溯时回答所有查询,总复杂度 \(O(N + M)\),适合所有查询已知的离线场景。
- 树链剖分:通过两次 DFS 将树剖分成若干条链,查询时通过链顶跳跃求 LCA,单次查询 \(O(\log N)\),也是一种常用方法。
- 本模板题提供三种实现,通常以倍增法为首选,因其在线特性且代码易于理解。
-
关键步骤(以倍增法为例):
- 预处理:从根节点 \(S\) 开始 DFS,计算每个节点的深度 \(dep[u]\),并记录直接父节点 \(fa[u][0]\)。然后递推 \(fa[u][i] = fa[fa[u][i-1]][i-1]\)(\(i=1..\lfloor \log N \rfloor\)),得到所有 \(2^i\) 级祖先。
- 查询 LCA(u, v):
- 若 \(dep[u] < dep[v]\),交换 \(u, v\),保证 \(u\) 更深。
- 将 \(u\) 向上跳至与 \(v\) 同深度:从高位向低位尝试,若 \(dep[fa[u][i]] \ge dep[v]\),则令 \(u = fa[u][i]\)。
- 若 \(u == v\),则 \(v\) 即为 LCA。
- 否则,\(u\) 和 \(v\) 同时向上跳:从高位向低位尝试,若 \(fa[u][i] \neq fa[v][i]\),则令 \(u = fa[u][i], v = fa[v][i]\)。
- 最终 \(fa[u][0]\) 即为 LCA。
- Tarjan 离线法:DFS 遍历树,访问节点 \(u\) 后标记为已访问,并将其子节点合并到 \(u\) 的并查集中;处理所有与 \(u\) 相关的查询 \((u, v)\),若 \(v\) 已访问,则答案为 \(find(v)\)(即 \(v\) 所在集合的根,代表当前已访问的最深祖先)。
- 树链剖分法:第一次 DFS 计算父节点、深度、子树大小和重儿子;第二次 DFS 确定每个节点所在链的顶端。查询时不断将链顶深度较大的节点上跳,直到两节点在同一条链上,深度较小的即为 LCA。
-
时间/空间复杂度:
- 倍增法:预处理 \(O(N \log N)\),每次查询 \(O(\log N)\),空间 \(O(N \log N)\)。
- Tarjan 离线法:总时间复杂度 \(O((N+M) \cdot \alpha(N))\),空间 \(O(N+M)\)。
- 树链剖分法:预处理 \(O(N)\),每次查询 \(O(\log N)\),空间 \(O(N)\)。
-
倍增法(LCA)的核心思想:
- 二进制拆分:将“向上跳 \(d\) 步”分解为若干个 \(2^i\) 步的组合,利用预处理好的 \(2^i\) 级祖先快速跳转,避免逐级上爬。
- 深度对齐:先将较深节点提升到与另一节点相同深度,保证两节点在“同一水平线”上。
- 同步跳跃:从最大步长向下尝试,若跳 \(2^i\) 步后两节点祖先不同,则同时上跳,最终两节点的父节点即为 LCA。该过程保证了不跳过 LCA,且能精确到达。
- 适用场景:树上的祖先查询、路径长度计算、树上差分等需要频繁查询 LCA 的问题。
【算法标签】
普及 #最近公共祖先
【代码详解】
// 倍增法
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
int n, m, s, a, b; // n: 节点数,m: 查询数,s: 根节点
vector<int> e[N]; // 邻接表存储树
int dep[N]; // 节点的深度
int fa[N][20]; // 倍增祖先表,fa[u][i]表示u的2^i级祖先
// 深度优先搜索,预处理深度和祖先表
void dfs(int u, int father)
{
// 计算当前节点的深度
dep[u] = dep[father] + 1;
// 初始化直接父节点
fa[u][0] = father;
// 预处理倍增祖先表
for (int i = 1; i <= 19; i++)
fa[u][i] = fa[fa[u][i-1]][i-1];
// 遍历子节点
for (int v : e[u])
if (v != father) // 避免走回父节点
dfs(v, u);
}
// 求两个节点的最近公共祖先
int lca(int u, int v)
{
// 第一步:将u和v调整到同一深度
if (dep[u] < dep[v]) swap(u, v);
// 将u向上跳,直到与v同深度
for (int i = 19; i >= 0; i--)
if (dep[fa[u][i]] >= dep[v])
u = fa[u][i];
// 如果此时u==v,说明v是u的祖先
if (u == v) return v;
// 第二步:u和v同时向上跳
for (int i = 19; i >= 0; i--)
if (fa[u][i] != fa[v][i]) // 如果祖先不同,就一起向上跳
u = fa[u][i], v = fa[v][i];
// 此时u和v的父节点就是LCA
return fa[u][0];
}
int main()
{
// 输入树的信息
cin >> n >> m >> s;
// 读入n-1条边
for (int i = 1; i < n; i++)
{
int x, y;
cin >> x >> y;
e[x].push_back(y);
e[y].push_back(x);
}
// 从根节点s开始DFS,预处理深度和祖先表
dfs(s, 0);
// 处理m个查询
for (int i = 1; i <= m; i++)
{
int a, b;
cin >> a >> b;
cout << lca(a, b) << endl; // 输出LCA
}
return 0;
}
// tarjan算法
#include <bits/stdc++.h>
using namespace std;
const int N = 500005, M = 500005; // N: 最大节点数, M: 最大询问数
int n, m, s; // n: 节点数, m: 询问数, s: 根节点
vector<int> e[N]; // 邻接表:存储树的边
vector<pair<int, int> > query[N]; // 存储每个节点相关的询问:first=另一节点, second=询问编号
int p[N]; // 并查集父节点数组(Tarjan中用于维护已访问节点的祖先关系)
int vis[N]; // vis[i]: 标记节点i是否已被DFS访问过
int ans[M]; // ans[i]: 第i个询问的答案(LCA)
// 并查集查找(路径压缩)
// 在Tarjan算法中,find(x)返回的是x所在集合的代表元素
// 该代表元素即为x的当前最近已访问祖先(即LCA)
int find(int x)
{
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
// Tarjan离线LCA算法:DFS遍历树的同时回答询问
void tarjan(int u)
{
vis[u] = true; // 标记u已访问
// 遍历u的所有子节点
for (auto v : e[u])
{
if (!vis[v]) // v未访问,说明v是u的子节点
{
tarjan(v); // 递归DFS子树
p[v] = u; // 子树遍历完毕后,将v的集合合并到u(v的祖先设为u)
}
}
// 处理所有与u相关的询问
for (auto q : query[u])
{
int v = q.first; // 询问的另一节点
int i = q.second; // 询问编号
// 如果v已被访问,则u和v的LCA就是find(v)
// 原理:v所在集合的代表元素,就是v的最近已访问祖先
// 由于u刚刚访问完所有子树,此时u是v的最近公共祖先
if (vis[v]) ans[i] = find(v);
}
}
int main()
{
cin >> n >> m >> s; // 读入节点数、询问数、根节点
// 读入N-1条边,建立无向图(树)
for (int i = 1; i < n; i++)
{
int u, v;
cin >> u >> v;
e[u].push_back(v);
e[v].push_back(u);
}
// 读入M个询问,每个询问存储在两个端点的query列表中
for (int i = 1; i <= m; i++)
{
int u, v;
cin >> u >> v;
query[u].push_back({v, i}); // u的询问列表中记录(v, 编号i)
query[v].push_back({u, i}); // v的询问列表中记录(u, 编号i)
}
// 初始化并查集:每个节点自成一个集合
for (int i = 1; i < N; i++) p[i] = i;
// 从根节点开始Tarjan DFS
tarjan(s);
// 输出所有询问的答案
for (int i = 1; i <= m; i++)
cout << ans[i] << endl;
return 0;
}
// 树链剖分算法
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
int n, m, s; // n: 节点数, m: 询问数, s: 根节点编号
vector<int> g[N]; // 邻接表存储树
int fa[N], dep[N], son[N], sz[N];
int top[N]; // 树链剖分中的链顶
// 第一次 DFS:计算父节点、深度、子树大小、重儿子
void dfs1(int u, int father)
{
fa[u] = father;
dep[u] = dep[father] + 1;
sz[u] = 1;
for (int v : g[u])
{
if (v == father)
continue;
dfs1(v, u);
sz[u] += sz[v];
if (sz[son[u]] < sz[v])
son[u] = v; // 更新重儿子(子树最大的儿子)
}
}
// 第二次 DFS:进行树链剖分,确定每个节点所在链的顶端
void dfs2(int u, int t)
{
top[u] = t; // 当前节点所在链的顶端
if (!son[u])
return; // 叶子节点,无重儿子
dfs2(son[u], t); // 优先处理重儿子,延续当前链
for (int v : g[u])
{
if (v == fa[u] || v == son[u])
continue;
dfs2(v, v); // 轻儿子单独开一条新链
}
}
// 利用树链剖分求最近公共祖先 (LCA)
int lca(int u, int v)
{
while (top[u] != top[v]) // 不在同一条链上
{
if (dep[top[u]] < dep[top[v]]) // 保证 u 所在链的顶端更深
swap(u, v);
u = fa[top[u]]; // 将 u 跳到链顶的父亲
}
// 此时在同一链上,深度较小的即为 LCA
return dep[u] < dep[v] ? u : v;
}
int main()
{
cin >> n >> m >> s; // 输入节点数、询问数、根节点
for (int i = 1; i < n; i++)
{
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfs1(s, 0); // 从根开始预处理
dfs2(s, s); // 树链剖分
while (m--)
{
int a, b;
cin >> a >> b;
cout << lca(a, b) << endl;
}
return 0;
}
【运行结果】
5 5 4
3 1
2 4
5 1
1 4
2 4
4
3 2
4
3 5
1
1 2
4
4 5
4
浙公网安备 33010602011771号