第一次参加算法竞赛时,我遇到了这样一道题:给定一棵家族树,要求快速查询任意两个人的最近共同祖先。当时我用了最朴素的暴力解法——让两个节点轮流往上跳,结果在数据量大的测试用例上直接超时。这就是经典的LCA问题,它在树结构处理中无处不在。
LCA全称Least Common Ancestors,就像它的名字一样直白:找两个节点在树中的最低公共祖先节点。想象你正在处理家族关系,需要计算两个人的血缘亲疏程度;或者在处理文件系统的目录结构,需要找到两个文件的最近共同父目录。这些场景都需要LCA算法。
暴力解法虽然直观,但时间复杂度高达O(n)每次查询。当遇到算法竞赛中精心设计的"毒瘤"数据(比如链状的树结构)时,这种解法就会崩溃。经过多次实战,我总结出两种高效的解法:倍增算法适合在线查询,Tarjan算法擅长离线处理。下面我就用最接地气的方式,带你掌握这两种算法的精髓。
倍增算法的核心思路很有意思——它让每个节点都记住向上跳1、2、4、8...步能到达哪些祖先,就像给树装上了电梯按钮。这种预处理使得查询时能快速"跳层",而不用一步步爬楼梯。
具体实现需要两个关键数组:
depth[u]记录节点u的深度fa[u][j]记录节点u向上跳2^j步到达的祖先预处理过程是个典型的动态规划:
python复制# 假设已经通过DFS获得了各节点的深度和直接父节点(fa[u][0])
for j in 1..MAX_LOG:
for u in 1..n:
fa[u][j] = fa[fa[u][j-1]][j-1] # 2^j = 2^(j-1) + 2^(j-1)
我在第一次实现时犯了个典型错误:把j循环放在了内层。这会导致在计算高层跳跃时,依赖的低层跳跃信息可能还没准备好。正确的做法应该像上面这样,先处理所有节点的小跳跃,再逐步扩大跳跃范围。
实际查询分为三个关键步骤:
这里有个精妙的位运算技巧。假设深度差为d,我们可以通过d的二进制表示来决定如何跳跃:
python复制def lca(x, y):
if depth[x] < depth[y]:
x, y = y, x # 保证x是较深的
# 对齐深度
for i in range(MAX_LOG, -1, -1):
if depth[x] - (1 << i) >= depth[y]:
x = fa[x][i]
if x == y: return x
# 同步上跳
for i in range(MAX_LOG, -1, -1):
if fa[x][i] != fa[y][i]:
x, y = fa[x][i], fa[y][i]
return fa[x][0]
在ACM比赛中,我常用的小优化是将MAX_LOG设为20(因为2^20足够覆盖百万级节点),并用快速输入输出处理大规模查询。实测下来,预处理O(nlogn)时间+每次查询O(logn)时间的表现非常稳定。
遇到需要处理海量查询的题目时,倍增算法可能力不从心。这时就该Tarjan算法登场了——它能在O(nα(n))时间内处理所有查询,其中α(n)是反阿克曼函数,增长极其缓慢。
Tarjan算法的精妙之处在于它把LCA查询和DFS遍历完美结合。算法执行过程中,维护一个并查集来记录已经访问过的节点关系。当处理完某个节点的所有子树后,就会将该节点与其父节点合并,同时检查所有与该节点相关的查询。
完整的Tarjan算法实现需要以下组件:
python复制def tarjan(u):
vis[u] = True
for v in children[u]:
tarjan(v)
union(u, v) # 将v合并到u的集合
ancestor[find(u)] = u # 设置集合代表元的祖先
for v in queries[u]:
if vis[v]:
lca = ancestor[find(v)]
# 记录u和v的LCA结果
在实际编码时,有几点需要特别注意:
记得有次比赛我因为忘记处理双向查询,导致一半的测试用例出错。调试了半天才发现这个隐蔽的bug,现在想来都是血泪教训。
让我们用具体数据感受两者的差异:
但要注意,Tarjan算法需要提前知道所有查询,这在交互式问题中就不适用了。而倍增算法虽然单次查询稍慢,但可以即时响应任意查询。
根据我的参赛经验,给出以下建议:
选择倍增算法当:
选择Tarjan算法当:
在最近的ICPC区域赛中,就有一道需要处理5e6次查询的题目。当时我们团队尝试用倍增算法,结果在最大规模数据上TLE。改用Tarjan算法后,运行时间直接从3秒降到了0.5秒,这个优化效果相当惊人。
cpp复制const int MAXN = 1e5+5;
const int MAX_LOG = 20;
vector<int> tree[MAXN];
int depth[MAXN], fa[MAXN][MAX_LOG];
void dfs(int u, int parent) {
fa[u][0] = parent;
for(int i=1; i<MAX_LOG; i++)
fa[u][i] = fa[fa[u][i-1]][i-1];
for(int v : tree[u]) {
if(v == parent) continue;
depth[v] = depth[u] + 1;
dfs(v, u);
}
}
int lca(int u, int v) {
if(depth[u] < depth[v]) swap(u,v);
for(int i=MAX_LOG-1; i>=0; i--)
if(depth[u] - (1<<i) >= depth[v])
u = fa[u][i];
if(u == v) return u;
for(int i=MAX_LOG-1; i>=0; i--)
if(fa[u][i] != fa[v][i])
u = fa[u][i], v = fa[v][i];
return fa[u][0];
}
这个模板有几个优化点:
cpp复制vector<int> tree[MAXN];
vector<pair<int,int>> queries[MAXN];
int parent[MAXN], ancestor[MAXN];
bool vis[MAXN];
int find(int u) {
return parent[u] == u ? u : parent[u] = find(parent[u]);
}
void unite(int u, int v) {
u = find(u), v = find(v);
if(u == v) return;
parent[v] = u;
}
void tarjan(int u) {
parent[u] = u;
ancestor[u] = u;
for(int v : tree[u]) {
tarjan(v);
unite(u, v);
ancestor[find(u)] = u;
}
vis[u] = true;
for(auto [v, idx] : queries[u]) {
if(vis[v]) {
// lca_result[idx] = ancestor[find(v)];
}
}
}
这个实现使用了路径压缩的并查集,但没有使用按秩合并,因为在实际测试中,路径压缩已经能提供足够的性能。对于需要极致优化的场景,可以进一步实现按秩合并。
在实现LCA算法时,新手容易踩这些坑:
倍增算法预处理不完整:记得MAX_LOG要足够大,我一般取20。有一次因为设成15,结果在n=1e5的数据上WA。
Tarjan算法查询存储不全:必须为每个查询(u,v)同时存储(v,u),否则可能漏掉某些情况。
边界条件处理不当:特别是当查询的两个节点相同,或者一个是另一个祖先时。
调试时我常用的方法:
有次比赛我花了1小时调试Tarjan算法,最后发现是忘记初始化vis数组。这种低级错误在压力下很容易出现,所以现在我总是先把初始化的代码写好。