最近公共祖先(LCA)
顾名思义就是两节点最近的公共祖先
LCA常用求法:
- DFS + ST表
- 倍增
- Tarjan
- 树链剖分
-
求 LCA 的时候需要注意图的联通性,这在一些题中是会考到的,比如题中会给“无解输出 ‘-1’ 之类的条件”
维护连通性用并查集即可。
倍增求 LCA
首先预处理一下每个节点的第 (2^i) 个祖先
我们就可以跳着走避免一次走一步的龟速
引理:对于任意一个非零整数,我们都可以将他用2的次幂表示出来。
这个引理是始终成立的 很显然
写程序的时候需要注意一个地方就是我们要将相对深度关系保持一致,这样就避免使用同一个函数出现魔法错误的情况。
预处理代码
void dfs(int now, int father){
dep[now] = dep[father] + 1;
for(int i = 1; (1<<i) <= dep[now]; i++)
fa[now][i] = fa[fa[now][i-1]][i-1];
for(int i = head[now]; i; i = e[i].next){
if(e[i].v == father) continue; // 由于存的是无向图需要忽略父亲节点
fa[e[i].v][0] = now;
dfs(e[i].v, now);
}
return;
}
其中 dep[i]
指 (i) 点深度,fa[i][j]
指 (i) 节点向上第 (2^j) 个祖先节点,e[i]
是邻接表存图。
LCA 查询代码
int ask(int x, int y){
if(dep[x] < dep[y]) swap(x, y); // 保证深度关系
for(int i = 20; i >= 0; i--){
if(dep[fa[x][i]] >= dep[y])
x = fa[x][i];
if(x == y)
return x;
} // y 是 x 的祖先的情况
for(int i = 20; i >= 0; i--){
if(fa[x][i] != fa[y][i])
x = fa[x][i], y = fa[y][i];
} // y 不是 x 的祖先的情况,我们需要让它们尽量接近
return fa[x][0];
}
个人认为倍增查询的过程是一个不断折半搜索的过程,每一次都会折半地减小范围,直至搜索结束。
时间复杂度:(O((n+q) log n)) ( (n) 个节点,(q) 次询问)
空间复杂度:(O(n log n))
Tarjan 求 LCA
Tarjan 法是使用并查集对向上标记法的一个优化:
向上标记法:见李书P378
已经回溯的点标记 2,第一次访问的点标记 1,未访问的点无标记
这样当搜到第二个点时,从第一个点向上走找到的第一个标记是 1 的点即为 LCA。
但是向上一个一个爬父亲节点速度显然太慢,不是我们想要的。
于是采用并查集优化 :
对于每一个回溯到的点,赋予其标记 (2) 的同时将其合并到它的父节点所在集合上。
其中需要注意最初每个节点是独立的,也就是说,此时合并的父节点不仅满足标记为 (1) 而且父亲节点的 fa 是其自身。
此时对于所有关于现在标记为 (1) 的 (x) 点的询问,若另一个 (y) 点标记为 (2) ,LCA 为 find(y)
。
对于询问的处理:
将询问看作是一张无向图,当遍历到一个点时同时遍历关于该点的所有询问,若询问中另一个点已经被标记为 (2)
那么 LCA 为被标记 (2) 的节点的并查集父亲。
为什么不用考虑一点是另一点祖先的情况?
假设 (x) 是 (y) 的祖先,那么当我们搜索到 (y) 的时候肯定是不能出解的,但当我们从 (y) 回溯到达 (x) 时,(y) 已被标记 (2),而 (x) 被标记为 (1),就可以搜出来了。
用到的变量:
tag[MAXN]
:标记记录
cntQuery
:添加的询问边
ans[MAXN]
:按顺序记录答案
qPre[MAXN<<1]
:邻接表存图
fa[MAXN]
:并查集
询问结构部分:
struct query{
int u, v;
int next;
int id; // 记录这是第几次询问
}q[MAXN<<1];
void addQuery(int x, int y, int identity){
q[++cntQuery].u = x, q[cntQuery].v = y, q[cntQuery].next = qPre[x];
qPre[x] = cntQuery, q[cntQuery].id = identity;
q[++cntQuery].u = y, q[cntQuery].v = x, q[cntQuery].next = qPre[y];
qPre[y] = cntQuery, q[cntQuery].id = identity;
return;
}
并查集处理部分:
int find(int x){
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
Tarjan 部分:
void tarjan(int now){
tag[now] = 1; // 第一次搜索到标记 1
for(int i = head[now]; i; i = e[i].next)
if(!tag[e[i].v]){
tarjan(e[i].v);
fa[e[i].v] = now; // 回溯完就合并
} // 遍历部分
for(int i = qPre[now]; i; i = q[i].next)
if(tag[q[i].v] == 2 && !ans[q[i].id]) // 当前这个询问被回溯完时
ans[q[i].id] = find(q[i].v);
tag[now] = 2;
return;
// 注意最开始建的是双向边,所以我们每次记录答案的时候判断一下当前 ans 是否为 0,非 0 不更新
} // now 代表当前子树的根节点
时间复杂度:(O(m+n+q))
树剖求 LCA
树链剖分,计算机术语,指一种对树进行划分的算法,它先通过轻重边剖分将树分为多条链,保证每个点属于且只属于一条链,然后再通过数据结构(树状数组、BST、SPLAY、线段树等)来维护每一条链。
树剖其实就是一种对暴力求解的优化,让我们原本的暴力没那么“暴力”
对于一般的树剖,我们需要维护以下几个量:
维护在节点上的量:
`fa` :当前节点父亲
`hSon`:当前节点的重儿子
`dep`:当前节点的深度
`siz`:以当前节点为根节点的子树大小
`dfn`:当前节点的时间戳(DFS序)
`top`:当前节点所在链的顶端
维护在区间上的量:
`id[]`:区间上第 $cnt$ 个值对应的节点标号
实际上,在树剖处理 (LCA) 时我们并不需要维护 dfn
和 id[]
。
和常规的树剖一样,我们需要使用两次 (DFS),具体如下:
第一次 (DFS)(处理 fa
、dep
、siz
、hSon
):
void PreDFS(int now, int fa){
n[now].fa = fa, n[now].dep = n[fa].dep+1, n[now].siz = 1;
// 对当前节点的预处理
for(int i = head[now]; i; i = e[i].next){
if(e[i].v == fa) continue;
PreDFS(e[i].v, now);
n[now].siz += n[e[i].v].siz; // 更新当前节点的 siz
if(n[e[i].v].siz > n[n[now].hSon].siz)
n[now].hSon = e[i].v; // 更新当前节点的重儿子
}
return;
}
第二次 (DFS)(处理top
):
void SegDFS(int now, int top){
n[now].top = top;
if(!n[now].hSon) return;
SegDFS(n[now].hSon, top);
for(int i = head[now]; i; i = e[i].next)
if(!(e[i].v == n[now].fa || e[i].v == n[now].hSon))
SegDFS(e[i].v, e[i].v);
return;
}
注:通常的树剖还需要在第二次DFS中处理dfn和id[]
查询语句:
int Ask(int x, int y){
while(n[x].top != n[y].top){
if(n[n[x].top].dep < n[n[y].top].dep)
swap(x, y);
x = n[n[x].top].fa;
} // 始终保持 x 的深度更大
return n[x].dep < n[y].dep ? x : y; // 深度较浅的点即为 LCA
}
复杂度:(O(log_2{n}))
例题
结合生成树且需要判断联通性(某OI2013真题):洛谷 P1967 货车运输