LCA问题的高效解法:欧拉序 + RMQ 详解

一、什么是LCA问题?在树形结构中,LCA(Lowest Common Ancestor,最近公共祖先) 是指两个节点在树中深度最大的共同祖先。例如,在一个家族树中,两个成员的最近公共祖先就是他们血缘关系最近的共同长辈。LCA问题在计算机科学中有着广泛的应用,比如在社交网络分析、文件系统路径查询、以及图论算法(如最小生成树)中都会遇到。对于一棵有根树,给定两个节点u和v,我们需要找到它们的LCA。朴素的做法是从根节点开始,分别记录u和v的祖先路径,然后找第一个共同节点,时间复杂度为O(n)(n为节点数)。当需要频繁查询时,这种线性复杂度可能不够高效。于是,我们需要更快的预处理+查询方法。## 二、核心思想:用欧拉序将树转化为数组欧拉序(Euler Tour) 是一种将树结构转化为线性数组的技术。它的核心思想是:对树进行深度优先搜索(DFS),每次访问一个节点(无论是首次到达还是从子树回溯回来)时,都记录下该节点的编号。这样,树中的路径信息就被编码在一个数组中。更具体地说,对于树中任意两个节点u和v,它们在欧拉序中首次出现位置之间的区间,包含了从u到v路径上的所有节点。而LCA就是这些节点中深度最小的那个。因此,LCA问题转化为在欧拉序数组的某个区间内查询最小值(按深度)的问题,这就是经典的RMQ(Range Minimum Query,区间最小值查询)。### 欧拉序的构建步骤(以图为例)假设我们有一棵二叉树(如下图所示),节点编号为1~5,根节点为1,边为(1,2)、(1,3)、(2,4)、(2,5)。 1 / \ 2 3 / \ 4 5DFS顺序(先访问左子树,再访问右子树)如下:- 从根1开始,记录1- 进入左子节点2,记录2- 进入2的左子节点4,记录4- 回溯到2,记录2- 进入2的右子节点5,记录5- 回溯到2,记录2- 回溯到1,记录1- 进入右子节点3,记录3- 回溯到1,记录1得到的欧拉序数组为:[1, 2, 4, 2, 5, 2, 1, 3, 1]同时,我们需要记录每个节点首次出现的位置(first occurrence)以及每个节点的深度。例如:- 节点1:首次出现位置0,深度0- 节点2:首次出现位置1,深度1- 节点4:首次出现位置2,深度2- 节点5:首次出现位置4,深度2- 节点3:首次出现位置7,深度1现在,如果我们想求节点4和节点5的LCA,根据欧拉序,它们首次出现的位置分别是2和4,区间为[2,4](包含位置2,3,4)。对应节点值为4,2,5。其中深度最小的节点是2(深度1),因此LCA为2。这与实际结果一致。## 三、RMQ的高效实现:稀疏表(Sparse Table)RMQ问题有多种解法,包括线段树、树状数组、平方分解等。但为了达到O(1)查询,我们通常使用稀疏表(Sparse Table)。稀疏表是一种基于动态规划的预处理方法,可以在O(n log n)时间内构建,然后每次查询O(1)。### 稀疏表原理稀疏表维护一个二维数组st[i][j],表示从位置i开始,长度为2^j的区间中的最小值(这里我们比较的是节点的深度)。通过递推:- st[i][0] = depth[arr[i]](即位置i处节点的深度)- st[i][j] = min(st[i][j-1], st[i + 2^(j-1)][j-1])查询区间[l, r]时,计算长度len = r - l + 1,取k = floor(log2(len)),则结果为min(st[l][k], st[r - 2^k + 1][k])。## 四、完整代码示例(Python)下面是一个完整的Python实现,包含树的构建、欧拉序生成、稀疏表预处理以及LCA查询。pythonimport mathclass LCA: def __init__(self, n, edges, root=0): """ n: 节点数 (节点编号 0 ~ n-1) edges: 边列表,如 [(0,1), (1,2), ...] root: 根节点编号 """ self.n = n self.adj = [[] for _ in range(n)] for u, v in edges: self.adj[u].append(v) self.adj[v].append(u) # 存储欧拉序、深度、首次出现位置 self.euler = [] self.depth = [] self.first = [-1] * n # DFS 遍历 self._dfs(root, -1, 0) # 构建稀疏表用于RMQ self._build_sparse_table() def _dfs(self, node, parent, dep): """DFS遍历,记录欧拉序和深度""" self.first[node] = len(self.euler) self.euler.append(node) self.depth.append(dep) for neighbor in self.adj[node]: if neighbor != parent: self._dfs(neighbor, node, dep + 1) # 回溯时再次记录当前节点 self.euler.append(node) self.depth.append(dep) def _build_sparse_table(self): """构建稀疏表,用于快速查询区间最小深度对应的节点""" m = len(self.euler) self.log = [0] * (m + 1) for i in range(2, m + 1): self.log[i] = self.log[i // 2] + 1 k = self.log[m] + 1 self.st = [[0] * k for _ in range(m)] # 初始化长度为1的区间 for i in range(m): self.st[i][0] = i # 存储的是索引,而不是深度值 # 动态规划填表 j = 1 while (1 << j) <= m: i = 0 while i + (1 << j) - 1 < m: left = self.st[i][j - 1] right = self.st[i + (1 << (j - 1))][j - 1] # 比较左半和右半的最小深度 if self.depth[left] < self.depth[right]: self.st[i][j] = left else: self.st[i][j] = right i += 1 j += 1 def query(self, u, v): """返回节点u和v的LCA""" l = self.first[u] r = self.first[v] if l > r: l, r = r, l # 区间长度 length = r - l + 1 k = self.log[length] left_idx = self.st[l][k] right_idx = self.st[r - (1 << k) + 1][k] if self.depth[left_idx] < self.depth[right_idx]: return self.euler[left_idx] else: return self.euler[right_idx]# 测试代码if __name__ == "__main__": # 构建上面示例中的树 (节点0~4,对应1~5) n = 5 edges = [(0,1), (0,2), (1,3), (1,4)] # 0=1号,1=2号,2=3号,3=4号,4=5号 lca_solver = LCA(n, edges, root=0) # 查询节点3和4(原4号和5号)的LCA ancestor = lca_solver.query(3, 4) print(f"节点3和4的LCA是节点{ancestor}") # 预期输出: 节点1(即2号)## 五、高级应用与优化### 1. 多组查询与在线算法上述稀疏表预处理后,每次查询为O(1),适合大量查询的场景。如果树是动态变化的(如添加节点),则需要使用更复杂的数据结构,如Link-Cut Tree。### 2. 空间优化欧拉序数组的长度为2n-1,因为每个非根节点被访问两次(进入和离开),根节点被访问多次?实际上,对于有n个节点的树,欧拉序长度为2n-1(每个节点首次出现后,回溯时再次出现,但根节点最后也会被记录)。稀疏表占用的空间为O(n log n),对于n=10^5,log n大约17,空间可以接受。### 3. 与其他算法对比- 二进制跳转法(倍增法):预处理O(n log n),查询O(log n),实现简单,适合静态树。- Tarjan算法:离线处理,时间复杂度O(n + q)(q为查询数),但需要并查集。- 欧拉序+RMQ:查询速度最快,适合需要频繁在线查询的场景。### 4. 处理非二叉树上述方法适用于任意树结构,包括多叉树,因为DFS遍历不依赖子节点数量。## 六、总结通过将树转化为欧拉序,我们将LCA问题巧妙地转化为RMQ问题,并利用稀疏表实现了O(1)查询。这种方法的核心优势在于:- 预处理时间:O(n log n),通常可接受。- 查询时间:O(1),是已知最优的在线查询算法之一。- 实现简洁:代码逻辑清晰,适合竞赛和工程应用。在实际应用中,如果你需要频繁查询一棵静态树的LCA(例如在游戏地图路径规划或网络拓扑分析中),欧拉序+RMQ是一个极佳的选择。理解这一方法,不仅有助于解决LCA问题,还能加深对树与数组转换、以及动态规划思想的理解。希望本文能帮助你掌握这一高效技术!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐