原题链接:https://www.luogu.com.cn/problem/P3379
题意解读:最近公共祖先(Lowest Common Ancestor,LCA)是指在有根树中,两个节点的所有公共祖先中离根最远(即深度最大)的那个祖先节点。
解题思路:(以下题解部分内容为AI生成)
方法一:朴素算法
基本思想
朴素算法求解 LCA 的核心思路是利用树的层次结构特性,先将两个目标节点调整到同一深度,然后同时沿着它们的父节点指针向上移动,直到找到它们的公共祖先节点,这个公共祖先节点就是最近公共祖先。
具体步骤
- 深度优先搜索(DFS)预处理:
- 从树的根节点开始进行深度优先搜索。在搜索过程中,记录每个节点的父节点信息(存储在 fa 数组中)以及该节点的深度信息(存储在 depth 数组中)。深度信息用于后续调整节点到同一深度,父节点信息用于向上回溯查找公共祖先。
- 对于每个节点,其深度等于其父节点的深度加 1。例如,若节点 u 的父节点是 p,则 depth[u] = depth[p] + 1。
- 调整节点深度:
- 比较两个目标节点 u 和 v 的深度。如果 u 的深度大于 v 的深度,那么让 u 沿着其父节点指针向上移动,直到 u 和 v 的深度相同。移动的次数等于它们深度的差值。
- 例如,若 depth[u] = 5,depth[v] = 3,则 u 需要向上移动 5 - 3 = 2 次。
- 同时向上移动查找公共祖先:
- 当 u 和 v 处于同一深度后,同时沿着它们的父节点指针向上移动,每次移动一个节点。在移动过程中,不断比较 u 和 v 是否相等。
- 如果相等,那么这个节点就是它们的最近公共祖先;如果不相等,则继续向上移动,直到找到相等的节点。
复杂度分析
- 时间复杂度:
- 预处理阶段,深度优先搜索需要遍历树的所有节点,时间复杂度为O(n),其中n是树的节点数量。
- 每次查询时,最坏情况下需要将节点向上移动到根节点,因此查询的时间复杂度也是O(n)。如果有q个查询,总的时间复杂度为O(n+qn)。
- 空间复杂度:主要用于存储 parent 数组和 depth 数组,每个数组的长度为n,因此空间复杂度为O(n) 。
适用场景
该方法适用于树的节点数量较少,且查询次数不多的情况。由于其查询时间复杂度较高,在大规模数据和频繁查询的场景下效率较低。
90分代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
vector<int> g[N];
int depth[N];
int fa[N];
int n, m, s;
void dfs(int u, int parent)
{
fa[u] = parent;
depth[u] = depth[parent] + 1;
for(auto v : g[u])
{
if(v == parent) continue;
dfs(v, u);
}
}
int lca(int u, int v)
{
if(depth[u] < depth[v]) swap(u, v);
while(depth[u] > depth[v]) u = fa[u];
while(u != v)
{
u = fa[u];
v = fa[v];
}
return u;
}
int main()
{
cin >> n >> m >> s;
int u, v;
for(int i = 1; i < n; i++)
{
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfs(s, 0);
while(m--)
{
cin >> u >> v;
cout << lca(u, v) << endl;
}
return 0;
}
方法二:倍增算法(Binary Lifting)
基本思想
倍增算法的核心在于利用二进制的思想,通过预处理每个节点的2^i级祖先(i=0,1,2...),将查找最近公共祖先的过程进行优化。在查询时,利用这些预处理的信息可以快速地进行跳跃式的向上查找,避免了朴素算法中每次只能向上移动一个节点的低效方式。
具体步骤
- 深度优先搜索(DFS)预处理:
- 同样从树的根节点开始进行深度优先搜索,记录每个节点的父节点信息(存储在 parent[u][0] 中)和深度信息(存储在 depth 数组中)。这里的 parent[u][i] 表示节点 u 的2^i级祖先。
- 例如,parent[u][0] 就是节点 u 的直接父节点。
- 预处理每个节点的2^i级祖先:
- 通过动态规划的思想,利用已经计算出的 parent[u][i - 1] 来计算 parent[u][i]。具体公式为 parent[u][i] = parent[parent[u][i - 1]][i - 1]。
- 这是因为节点 u 的2^i级祖先可以通过先找到其2^(i-1)级祖先,再找到该祖先的2^(i-1)级祖先得到。例如,节点 u 的2^2=4级祖先可以通过先找到其2^1=2级祖先 v,再找到 v 的2^1=2级祖先得到。
- 调整节点深度:
- 比较两个目标节点 u 和 v 的深度。如果 u 的深度大于 v 的深度,利用预处理的2^i级祖先信息,通过二进制拆分的方式,让 u 快速向上移动到和 v 相同的深度。
- 例如,若 depth[u] - depth[v] = 5,可以将 5 拆分为2^2+2^0,则 u 先向上移动到其2^2级祖先,再向上移动到该祖先的2^0级祖先,从而快速到达和 v 相同的深度。
- 同时向上移动查找公共祖先:
- 当 u 和 v 处于同一深度后,从最大的i(log2(n))开始,尝试让 u 和 v 同时向上移动2^i级祖先。如果移动后 u 和 v 不相等,则更新 u 和 v 为它们移动后的节点;如果相等,则不移动,继续尝试更小的 。
- 最后,当i = 0时,u 和 v 的父节点就是它们的最近公共祖先。
复杂度分析
- 时间复杂度:
- 预处理阶段,需要遍历树的所有节点,并且对于每个节点要计算其O(logn)个2^i级祖先,因此预处理的时间复杂度为O(nlogn)。
- 每次查询时,最多需要进行O(logn)次跳跃操作,因此查询的时间复杂度为O(logn)。如果有q个查询,总的时间复杂度为O(nlogn+qlogn)。
- 空间复杂度:主要用于存储 parent 数组,其大小为n*logn,因此空间复杂度为O(nlogn)。
适用场景
该方法适用于树的节点数量较多,且查询次数频繁的情况。由于其查询时间复杂度为O(logn),在大规模数据和频繁查询的场景下具有较好的性能。
100分代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
vector<int> g[N];
int depth[N];
int fa[N][20];
int n, m, s;
void dfs(int u, int parent)
{
fa[u][0] = parent;
depth[u] = depth[parent] + 1;
for(auto v : g[u])
{
if(v == parent) continue;
dfs(v, u);
}
}
void init()
{
for(int j = 1; j <= 18; j++)
for(int i = 1; i <= n; i++)
fa[i][j] = fa[fa[i][j - 1]][j - 1];
}
int lca(int u, int v)
{
if(depth[u] < depth[v]) swap(u, v);
for(int j = 18; j >= 0; j--)
{
if(depth[fa[u][j]] >= depth[v])
{
u = fa[u][j];
}
}
if(u == v) return u;
for(int j = 18; j >= 0; j--)
{
if(fa[u][j] != fa[v][j])
{
u = fa[u][j];
v = fa[v][j];
}
}
return fa[u][0];
}
int main()
{
cin >> n >> m >> s;
int u, v;
for(int i = 1; i < n; i++)
{
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
dfs(s, 0);
init();
while(m--)
{
cin >> u >> v;
cout << lca(u, v) << endl;
}
return 0;
}
方法三:Tarjan 算法
基本思想
Tarjan 算法是一种离线算法,即需要一次性处理所有的查询。它利用并查集(Union-Find)数据结构,在深度优先搜索的过程中,将已经访问过的节点合并到同一个集合中,通过回溯和并查集的查找操作来确定最近公共祖先。
具体步骤
- 构建查询列表:
- 对于每个查询 (u, v),将其存储在 queries[u] 和 queries[v] 中,表示 u 有一个关于 v 的查询,v 也有一个关于 u 的查询。同时记录每个查询的编号,方便后续输出结果。
- 深度优先搜索(DFS)与并查集操作:
- 从树的根节点开始进行深度优先搜索。在搜索过程中,标记当前节点为已访问。
- 对于当前节点 u 的每个未访问的子节点 v,递归调用 DFS 处理 v。处理完 v 后,将 v 合并到 u 所在的集合中(使用并查集的合并操作)。
- 对于 queries[u] 中的每个查询 (v, idx),如果 v 已经被访问过,那么通过并查集的查找操作找到 v 所在集合的代表元素,这个代表元素就是 u 和 v 的最近公共祖先,将结果存储在 ans[idx] 中。
- 输出结果:
- 处理完所有节点后,根据查询编号输出每个查询的最近公共祖先。
复杂度分析
- 时间复杂度:
- 深度优先搜索需要遍历树的所有节点,时间复杂度为O(n)。
- 并查集的合并和查找操作在路径压缩和按秩合并的优化下,平均时间复杂度接近O(1)。对于q个查询,总的时间复杂度为O(n+q)。
- 空间复杂度:主要用于存储邻接表、查询列表和并查集数组,空间复杂度为O(n+q)。
适用场景
该方法适用于需要一次性处理大量查询的情况。由于它是离线算法,不适合动态查询的场景。但在处理批量查询时,其时间复杂度较低,性能较好。
100分代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 500005;
vector<int> g[N];
vector<pair<int, int>> queries[N];
int p[N];
int vis[N];
int n, m, s;
int ans[N];
int find(int x)
{
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
void tarjan(int u)
{
vis[u] = true;
for(auto v : g[u])
{
if(vis[v]) continue;
tarjan(v);
p[v] = u;
}
for(auto q : queries[u])
{
int v = q.first, idx = q.second;
if(vis[v]) ans[idx] = find(v);
}
}
int main()
{
cin >> n >> m >> s;
int u, v;
for(int i = 1; i < n; i++)
{
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
for(int i = 1; i <= m; i++)
{
cin >> u >> v;
queries[u].push_back({v, i});
queries[v].push_back({u, i});
}
for(int i = 1; i <= n; i++) p[i] = i;
tarjan(s);
for(int i = 1; i <= m; i++) cout << ans[i] << endl;
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?
2024-03-05 洛谷题单指南-搜索-P1036 [NOIP2002 普及组] 选数
2024-03-05 洛谷题单指南-搜索-P2895 [USACO08FEB] Meteor Shower S
2024-03-05 洛谷题单指南-搜索-P1135 奇怪的电梯