倍增法求解 LCA 详解
给定一棵有根树,对于任意两个节点 u 和 v,LCA (u, v) 就是这两个节点在树中的最近公共祖先节点,即距离 u 和 v 最近且同时是 u 和 v 祖先的节点。倍增法是一种高效求解 LCA 问题的算法,它通过预处理和对数级别的查询操作,能够快速地找到任意两个节点的最近公共祖先。在实际应用中,比如在分析家族树的亲属关系、计算文件系统中目录的公共父目录等场景中,LCA 问题及其求解算法都有着广泛的应用。
倍增思想基础
倍增法的核心思想是利用二进制来表示节点向上跳跃的步数。我们知道,任何一个正整数都可以用二进制表示,例如数字 7 的二进制表示为 111,它可以拆分为 \(2^0 + 2^1 + 2^2\)。在树中,我们可以通过一系列的 “跳跃” 操作,从一个节点到达它的祖先节点。而这些跳跃的步长可以设置为 \(2^0, 2^1, 2^2, \cdots\)。通过这种方式,我们可以在对数级别的时间复杂度内找到目标祖先节点。
所以我们需要进行一次预处理。首先,统计树的基本信息(每个节点的深度,父节点)。在计算深度的同时,我们还需要维护一个二维数组 \(f[i][j]\),其中 \(f[i][j]\) 表示节点 i 的 \(2^j\) 辈祖先。
具体的预处理步骤如下:
-
从根节点开始进行 DFS 遍历树。在遍历过程中,对于每个节点 i,首先记录它的深度 \(d[i]\),假设当前节点 i 的深度为 \(d\),那么它的子节点 j 的深度就是 \(d + 1\)。
-
初始化 \(f[i][0]\) 为节点 i 的父节点,即 \(f[i][0] = parent[i]\)。
-
递推 \(j > 0\) 的情况,通过递推关系 \(f[i][j] = f[f[i][j - 1]][j - 1]\) 来计算 \(f[i][j]\)。这是因为节点 i 的 \(2^j\) 辈祖先等于节点 i 的 \(2^{j - 1}\) 辈祖先的 \(2^{j - 1}\) 辈祖先。例如,如果我们已经知道节点 i 的 \(2^2\) 辈祖先为 \(f[i][2]\),那么节点 i 的 \(2^3\) 辈祖先就是 \(f[f[i][2]][2]\)。
查询过程
当我们完成预处理后,就可以进行 LCA 的查询操作了。对于给定的两个节点 u 和 v,我们的目标是通过一系列的跳跃操作,让 u 和 v 到达同一个节点,这个节点就是它们的最近公共祖先。
具体的查询步骤如下:
-
首先,假定节点 u 的深度不小于节点 v 的深度。如果不是,交换 u 和 v。这是为了方便后续的操作,使得我们可以从深度较大的节点开始向上跳跃。
-
让更深的一个点往上跳。计算 u 和 v 深度的差值 \(diff = d[u] - d[v]\)。将 \(diff\) 用二进制表示,然后通过二进制的每一位来决定节点 u 需要向上跳跃的步长。例如,如果 \(diff\) 的二进制表示为 101,那么我们需要让节点 u 依次向上跳跃 \(2^0\) 和 \(2^2\) 步,这样就可以使 u 和 v 处于同一深度。
-
当 u 和 v 处于同一深度后,我们开始同时向上跳跃 u 和 v。同样,通过二进制的方式来决定跳跃的步长。我们从最大的步长 \(2^k\)(k 满足 \(2^k \leq n\),n 为树中节点的数量)开始尝试。如果 \(f[u][k] \neq f[v][k]\),说明 u 和 v 的 \(2^k\) 辈祖先不是同一个节点,那么我们就让 u 和 v 同时跳跃 \(2^k\) 步。不断减小 k,直到找到一个 k 使得 \(f[u][k] = f[v][k]\)。此时,我们找到了 u 和 v 在同一深度下距离它们最近且相同的祖先节点的下一层节点。
-
最后,将 u 和 v 再向上跳跃一步,即 \(u = f[u][0]\),此时 u 就是 u 和 v 的最近公共祖先。
#include <iostream>
#include <vector>
using namespace std;
const int N = 1e5 + 10; // 树中节点数量的上限
int n, m; // n为节点数,m为查询次数
vector<int> g[N]; // 邻接表存储树结构
int d[N]; // 存储每个节点的深度
int f[N][20]; // f[i][j]表示节点i的2^j辈祖先
// 深度优先搜索计算节点深度和f数组
void dfs(int u, int fa) {
d[u] = d[fa] + 1;
f[u][0] = fa;
for (int i = 1; (1 << i) <= d[u]; i++) {
f[u][i] = f[f[u][i - 1]][i - 1];
}
for (int i = 0; i < g[u].size(); i++) {
int v = g[u][i];
if (v != fa) {
dfs(v, u);
}
}
}
// 求解LCA
int lca(int u, int v) {
if (d[u] < d[v]) swap(u, v);
int diff = d[u] - d[v];
for (int i = 0; (1 << i) <= diff; i++) {
if (diff & (1 << i)) {
u = f[u][i];
}
}
if (u == v) return u;
for (int i = 19; i >= 0; i--) {
if (f[u][i] != f[v][i]) {
u = f[u][i];
v = f[v][i];
}
}
return f[u][0];
}
int main() {
cin >> n >> m;
for (int i = 1; i < n; i++) {
int a, b;
cin >> a >> b;
g[a].push_back(b);
g[b].push_back(a);
}
dfs(1, 0); // 假设根节点为1
while (m--) {
int u, v;
cin >> u >> v;
cout << lca(u, v) << endl;
}
return 0;
}
预处理时间复杂度
预处理过程中,我们通过一次 DFS 遍历树,对于每个节点,计算 \(f[i][j]\) 的操作时间复杂度为 \(O(\log n)\),其中 n 为树中节点的数量。由于树中有 n 个节点,所以预处理的总时间复杂度为 \(O(n \log n)\)。
查询时间复杂度
在查询过程中,首先将 u 和 v 调整到同一深度的操作时间复杂度为 \(O(\log n)\),因为深度差最大为 n,通过二进制表示进行跳跃操作的时间复杂度与深度差的二进制位数成正比,而深度差的二进制位数最多为 \(\log n\)。然后让 u 和 v 同时向上跳跃找到最近公共祖先的操作时间复杂度同样为 \(O(\log n)\)。所以每次查询的时间复杂度为 \(O(\log n)\)。