LCA最近公共祖先问题详解
图论和树结构中最近公共祖先(Least Common Ancestor,LCA)是一个经典的问题,本文我将详细介绍LCA问题的常见求解算法、优化策略,并结合Java代码示例,带你全面掌握这一重要算法问题的求解方法。
一、LCA问题定义
1.1 问题描述
给定一棵有根树 T T T ,对于树中的任意两个节点 u u u 和 v v v ,最近公共祖先指的是在树中同时是 u u u 和 v v v 祖先的节点中距离根节点最远(深度最大)的那个节点 。特别地,如果 u u u 是 v v v 的祖先(或反之),那么 u u u(或 v v v)就是它们的最近公共祖先;如果 u = v u = v u=v ,则 u u u(或 v v v)本身就是自己和自己的最近公共祖先。
例如,在下图所示的树中:
1
/ \
2 3
/ \ / \
4 5 6 7
节点 4 4 4 和 5 5 5 的最近公共祖先是节点 2 2 2;节点 4 4 4 和 7 7 7 的最近公共祖先是节点 1 1 1;节点 6 6 6 和 6 6 6 的最近公共祖先是节点 6 6 6 。
1.2 应用场景
- 数据查询:在数据库的树形存储结构中,通过求解LCA可以快速找到两个数据节点的最近公共分类,用于优化查询操作。
- 网络路由:在计算机网络的树形拓扑结构中,LCA可用于确定两个节点之间通信的最短路径需要经过的关键节点,提高网络传输效率。
- 编译器设计:在语法树中,求解LCA有助于分析代码中不同语法单元之间的关系,辅助进行语法检查和代码优化。
二、常见求解算法
2.1 暴力搜索
思路
从节点 u u u 和 v v v 开始,分别向上遍历它们到根节点的路径,记录路径上的所有节点。然后比较两条路径,找到第一个相同的节点,该节点即为 u u u 和 v v v 的最近公共祖先。
实现步骤
- 分别从节点 u u u 和 v v v 出发,向上遍历树,将经过的节点依次存入两个集合(或列表) p a t h U pathU pathU 和 p a t h V pathV pathV 。
- 从两个集合的末尾开始向前比较,找到第一个相同的节点,该节点就是最近公共祖先。
Java代码示例
import java.util.ArrayList;
import java.util.List;
class TreeNode {
int val;
TreeNode parent;
TreeNode left;
TreeNode right;
TreeNode(int val) {
this.val = val;
}
}
public class BruteForceLCA {
public static TreeNode bruteForceLCA(TreeNode u, TreeNode v) {
List<TreeNode> pathU = new ArrayList<>();
List<TreeNode> pathV = new ArrayList<>();
// 构建节点u到根节点的路径
TreeNode current = u;
while (current != null) {
pathU.add(current);
current = current.parent;
}
// 构建节点v到根节点的路径
current = v;
while (current != null) {
pathV.add(current);
current = current.parent;
}
// 从路径末尾开始比较,找到第一个相同节点
int i = pathU.size() - 1;
int j = pathV.size() - 1;
while (i >= 0 && j >= 0 && pathU.get(i) == pathV.get(j)) {
i--;
j--;
}
return pathU.get(i + 1);
}
public static void main(String[] args) {
TreeNode root = new TreeNode(1);
TreeNode node2 = new TreeNode(2);
TreeNode node3 = new TreeNode(3);
TreeNode node4 = new TreeNode(4);
TreeNode node5 = new TreeNode(5);
root.left = node2;
root.right = node3;
node2.left = node4;
node2.right = node5;
node2.parent = root;
node3.parent = root;
node4.parent = node2;
node5.parent = node2;
TreeNode lca = bruteForceLCA(node4, node5);
System.out.println("最近公共祖先的值: " + lca.val);
}
}
步骤简洁:递归优化
核心逻辑
:一样属于求解二叉树LCA问题的暴力求解法,但是胜在步骤清晰,过程简洁,通过递归遍历二叉树的左右子树,利用后序遍历
的性质判断祖先关系。
- 若当前节点是 p 或 q,直接返回当前节点。
- 若左右子树分别找到 p 和 q,则当前节点为 LCA。
- 否则返回非空的子树结果(说明 p 或 q 在该子树中)。
适用于单次查询,易于理解,但在树高度较大时可能导致栈溢出。
class Solution {
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// 终止条件:当前节点为空,或找到p/q中的一个
if (root == null || root == p || root == q) return root;
// 递归遍历左右子树
TreeNode left = lowestCommonAncestor(root.left, p, q);
TreeNode right = lowestCommonAncestor(root.right, p, q);
// 情况1:左右子树都找到目标节点,当前节点为LCA
if (left != null && right != null) return root;
// 情况2:左右子树都没找到目标节点
if (left == null && right == null) return null;
// 情况3:只有左子树或右子树找到目标节点,返回非空的结果
return left == null ? right : left;
}
}
时间与空间复杂度
- 时间复杂度:最坏情况下,需要遍历从 u u u 和 v v v 到根节点的路径,假设树的高度为 h h h ,则时间复杂度为 O ( h ) O(h) O(h) 。对于一般的树, h h h 可能接近节点数 n n n ,所以时间复杂度为 O ( n ) O(n) O(n) 。
- 空间复杂度:需要存储两条路径,最坏情况下路径长度为 h h h ,所以空间复杂度为 O ( h ) O(h) O(h) ,即 O ( n ) O(n) O(n) 。
2.2 倍增算法
思路
倍增算法基于动态规划的思想,利用倍增数组记录每个节点向上跳跃 2 k 2^k 2k 步后的祖先节点。通过预处理构建倍增数组,在查询时,先将深度较大的节点向上调整到与另一个节点相同深度,然后同时向上跳跃,找到最近公共祖先。
实现步骤
- 预处理:
- 定义两个数组:
depth[]
记录每个节点的深度,ancestor[i][j]
表示节点 i i i 向上跳跃 2 j 2^j 2j 步后的祖先节点。 - 从根节点开始进行深度优先搜索(DFS),初始化
depth[]
和ancestor[i][0]
(即节点 i i i 的父节点)。 - 利用递推公式
ancestor[i][j] = ancestor[ancestor[i][j - 1]][j - 1]
计算其他ancestor[i][j]
的值 。
- 定义两个数组:
- 查询:
- 首先将深度较大的节点向上调整到与另一个节点相同深度。
- 然后从最大的跳跃步长开始,同时向上跳跃两个节点,直到找到最近公共祖先。
Java代码示例
import java.util.ArrayList;
import java.util.List;
class TreeNode {
int val;
List<TreeNode> children;
TreeNode(int val) {
this.val = val;
children = new ArrayList<>();
}
}
public class DoublingLCA {
private static int MAX_LOG = 20;
private static int[] depth;
private static TreeNode[][] ancestor;
// 预处理函数,计算深度和倍增数组
private static void dfs(TreeNode node, TreeNode parent, int d) {
depth[node.val] = d;
ancestor[node.val][0] = parent;
for (TreeNode child : node.children) {
if (child != parent) {
dfs(child, node, d + 1);
}
}
}
private static void preprocess(TreeNode root) {
depth = new int[10001];
ancestor = new TreeNode[10001][MAX_LOG];
dfs(root, null, 0);
for (int j = 1; j < MAX_LOG; j++) {
for (int i = 1; i <= 10000; i++) {
if (ancestor[i][j - 1] != null) {
ancestor[i][j] = ancestor[ancestor[i][j - 1]][j - 1];
}
}
}
}
// 查询函数,计算最近公共祖先
public static TreeNode query(TreeNode u, TreeNode v) {
if (depth[u.val] < depth[v.val]) {
TreeNode temp = u;
u = v;
v = temp;
}
int diff = depth[u.val] - depth[v.val];
for (int i = 0; i < MAX_LOG; i++) {
if ((diff & (1 << i)) != 0) {
u = ancestor[u.val][i];
}
}
if (u == v) {
return u;
}
for (int i = MAX_LOG - 1; i >= 0; i--) {
if (ancestor[u.val][i] != null && ancestor[u.val][i] != ancestor[v.val][i]) {
u = ancestor[u.val][i];
v = ancestor[v.val][i];
}
}
return ancestor[u.val][0];
}
public static void main(String[] args) {
TreeNode root = new TreeNode(1);
TreeNode node2 = new TreeNode(2);
TreeNode node3 = new TreeNode(3);
TreeNode node4 = new TreeNode(4);
TreeNode node5 = new TreeNode(5);
root.children.add(node2);
root.children.add(node3);
node2.children.add(node4);
node2.children.add(node5);
preprocess(root);
TreeNode lca = query(node4, node5);
System.out.println("最近公共祖先的值: " + lca.val);
}
}
时间与空间复杂度
- 时间复杂度:预处理阶段,深度优先搜索时间复杂度为 O ( n ) O(n) O(n) ,计算倍增数组时间复杂度为 O ( n log n ) O(n \log n) O(nlogn) ;查询阶段,每次查询时间复杂度为 O ( log n ) O(\log n) O(logn) 。总体而言,预处理时间复杂度为 O ( n log n ) O(n \log n) O(nlogn) ,单次查询时间复杂度为 O ( log n ) O(\log n) O(logn) 。
- 空间复杂度:需要存储深度数组和倍增数组,空间复杂度为 O ( n log n ) O(n \log n) O(nlogn) 。
2.3 Tarjan算法(离线算法)
思路
Tarjan算法是一种离线算法(即需要提前知道所有查询),基于并查集和深度优先搜索实现。在深度优先搜索过程中,对于每个访问到的节点,将其所在集合合并到父节点所在集合,并标记已访问。当处理完一个节点的所有子树后,对于针对该节点的所有查询,检查另一个查询节点是否已访问,若已访问,则它们的最近公共祖先就是另一个查询节点所在集合的根节点。
实现步骤
- 初始化并查集,每个节点自成一个集合。
- 从根节点开始进行深度优先搜索,对于当前节点 u u u :
- 标记 u u u 已访问。
- 递归处理 u u u 的所有子树。
- 将 u u u 所在集合合并到其父节点所在集合。
- 处理针对 u u u 的所有查询,若另一个查询节点 v v v 已访问,则 L C A ( u , v ) LCA(u, v) LCA(u,v) 为 v v v 所在集合的根节点。
Java代码示例
import java.util.ArrayList;
import java.util.List;
class TreeNode {
int val;
List<TreeNode> children;
TreeNode(int val) {
this.val = val;
children = new ArrayList<>();
}
}
class Query {
int node1;
int node2;
int index;
Query(int node1, int node2, int index) {
this.node1 = node1;
this.node2 = node2;
this.index = index;
}
}
public class TarjanLCA {
private static int[] parent;
private static boolean[] visited;
private static int[] result;
private static int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
private static void union(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootY] = rootX;
}
}
private static void tarjan(TreeNode node, List<Query>[] queries) {
visited[node.val] = true;
for (TreeNode child : node.children) {
if (!visited[child.val]) {
tarjan(child, queries);
union(node.val, child.val);
}
}
for (Query query : queries[node.val]) {
if (visited[query.node2]) {
result[query.index] = find(query.node2);
}
}
}
public static int[] solve(TreeNode root, List<Query> queries) {
int n = 10001;
parent = new int[n];
visited = new boolean[n];
result = new int[queries.size()];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
@SuppressWarnings("unchecked")
List<Query>[] queryLists = new ArrayList[n];
for (int i = 0; i < n; i++) {
queryLists[i] = new ArrayList<>();
}
for (int i = 0; i < queries.size(); i++) {
Query query = queries.get(i);
queryLists[query.node1].add(query);
queryLists[query.node2].add(new Query(query.node2, query.node1, i));
}
tarjan(root, queryLists);
return result;
}
public static void main(String[] args) {
TreeNode root = new TreeNode(1);
TreeNode node2 = new TreeNode(2);
TreeNode node3 = new TreeNode(3);
TreeNode node4 = new TreeNode(4);
TreeNode node5 = new TreeNode(5);
root.children.add(node2);
root.children.add(node3);
node2.children.add(node4);
node2.children.add(node5);
List<Query> queries = new ArrayList<>();
queries.add(new Query(4, 5, 0));
int[] lcaResults = solve(root, queries);
System.out.println("最近公共祖先的值: " + lcaResults[0]);
}
}
时间与空间复杂度
- 时间复杂度:深度优先搜索时间复杂度为 O ( n + m ) O(n + m) O(n+m) ,其中 n n n 是节点数, m m m 是查询数;并查集操作时间复杂度为 O ( ( n + m ) α ( n ) ) O((n + m) \alpha(n)) O((n+m)α(n)) , α ( n ) \alpha(n) α(n) 是阿克曼函数的反函数,增长极其缓慢,可近似看作常数。总体时间复杂度为 O ( n + m ) O(n + m) O(n+m) 。
- 空间复杂度:需要存储并查集、访问标记数组、结果数组以及查询列表,空间复杂度为 O ( n + m ) O(n + m) O(n+m) 。
三、算法优化与拓展
3.1 优化方向
- 减少空间占用:对于倍增算法,可以通过压缩存储方式,减少倍增数组的空间占用;对于Tarjan算法,可优化并查集的实现,进一步降低空间复杂度。
- 提高查询效率:在处理大量查询时,可以对算法进行并行化改造,利用多核处理器提高查询速度;对于在线算法,还可以采用缓存机制,存储最近查询的结果,减少重复计算。
3.2 拓展问题
- 动态LCA问题:在树结构动态变化(如节点插入、删除)的情况下,如何高效地维护最近公共祖先信息。
- 多源LCA问题:给定多个节点,求它们的最近公共祖先,这在一些复杂的数据结构和应用场景中具有重要意义。
总结
本文我介绍了暴力搜索、倍增算法和Tarjan算法三种常见的求解方法,每种算法都有适用场景,暴力搜索算法简单直观,适用于小规模数据;倍增算法是高效的在线算法,适合多次查询;Tarjan算法作为离线算法,在批量处理查询时表现出色。
That’s all, thanks for reading!
觉得有用就点个赞
、收进收藏
夹吧!关注
我,获取更多干货~