算法学习笔记:树上最近公共祖先(LCA)

一些 udpate

update 2021/3/8:重构整篇文章,使语言更加简洁。具体表现:增加图片,使用例子而非直接语言阐述,增加时空复杂度分析,且添加树链剖分求解 LCA。

1. 前言

没错正如 update 所说,这篇博文是作者在时隔 8 个月之后重写的文章。

为什么作者会重构整篇文章?因为作者发现以前的描述太啰嗦,不简洁。

树上最近公共祖先(LCA),是一种图论算法,可以快速得到 有根树 中任意两个节点的最近公共祖先。

目前作者已经了解并且学会的有两种写法:

  1. 倍增写法求 LCA。
    这种写法是最便于新手理解的,也是入门写法。
    前置知识:树的基础知识(存储,DFS 遍历),然后只需要学过一小点递推思维即可。当然学过 ST 表这种基于倍增的数据结构更好。
  2. 树链剖分求 LCA。
    这种写法比倍增少一个 log,当然常数稍微有一点大。
    P.S. 写的好常数还是很小的。
    前置知识:树的基础知识(存储,DFS 遍历),树链剖分。

本篇博文两种写法都会讲。当然作者目前还没有写过树链剖分求 LCA。

2. 倍增写法求 LCA

模板:P3379 【模板】最近公共祖先(LCA)

2.1 原理解释

不管样例,我们首先上一张图。

在这里插入图片描述

扫描二维码关注公众号,回复: 12749134 查看本文章

接下来的所有讲解结合上面这张图。

倍增算法求 LCA 的关键就是利用可倍增性。

什么意思呢?通常情况下倍增算法是可以组合的,也就是说如果我们知道相邻两端长度为 l l l 的贡献,就可以直接组合出这一整段的贡献。

那么放到这里是什么意思呢?

我们设 f i , j f_{i,j} fi,j 表示第 i i i 个节点 向上跳 2 j 2^j 2j 步所到达的节点。 如果超出根节点限制就默认为根节点。

特别提醒:是跳 2 j 2^j 2j 步而不是跳 j j j 步。

比如 f 7 , 1 = 2 , f 11 , 2 = 1 f_{7,1}=2,f_{11,2}=1 f7,1=2,f11,2=1

那么可倍增性是什么意思呢?考虑这个式子:

f i , j = f f i , j − 1 , j − 1 f_{i,j}=f_{f_{i,j-1},j-1} fi,j=ffi,j1,j1

也就是说, i i i 先向上跳 2 j − 1 2^{j-1} 2j1 步,再向上跳 2 j − 1 2^{j-1} 2j1 步,等价于直接向上跳 2 j 2^j 2j 步。

如果学过 ST 表这个式子很简单,没学过理解一下也没问题。

于是我们首先一遍 dfs 确定 f i , 0 f_{i,0} fi,0 和深度(为什么?后面会讲)。

然后考虑求 LCA。

x , y x,y x,y 的 LCA 首先需要将 x , y x,y x,y 提到相同深度。为什么?这样便于控制,使得 x , y x,y x,y 到 LCA 的距离相等。

接下来以及代码中总是规定 x x x 深度较大。

于是我们可以通过下面的简单循环将 x x x 提到同一深度。

for (int i = 20; i >= 0; --i)
	if (dep[f[x][i]] >= dep[y]) x = f[x][i];

然后,分两种情况:

如果此时 x = y x=y x=y,直接返回 x x x 即可。

如果此时 x ≠ y x \ne y x=y

我们需要同时跳 x , y x,y x,y,不断往上跳,从大到小跳,如果发现跳上去的祖先是一样的就不跳,否则就跳。最后答案为 f x , 0 f_{x,0} fx,0

上面这些话你肯定看不懂,直接拿例子来说明吧。

比如我们要求 16,11 的 LCA。

在这里插入图片描述

先提到同一深度,因为已经在同一深度了,那么不需要操作。循环等于白执行。

然后从大到小往上跳,枚举 i ∈ [ 20 , 0 ] i \in [20,0] i[20,0]

注意这里的书写是不符合区间书写规范的,但是为了便于理解从大到小往上跳,将 [ 0 , 20 ] [0,20] [0,20] 写作 [ 20 , 0 ] [20,0] [20,0]

枚举中……

i = 2 i=2 i=2 时,发现: f 16 , 2 = f 11 , 2 = 1 f_{16,2}=f_{11,2}=1 f16,2=f11,2=1,此时不能跳,因为可能我们会漏过中间一些点(比如真正的 LCA 2 就被我们跳过了),不能挑。

i = 1 i=1 i=1 时,发现: f 16 , 1 = 5 , f 11 , 1 = 6 , f 16 , 1 ≠ f 11 , 1 f_{16,1}=5,f_{11,1}=6,f_{16,1} \ne f_{11,1} f16,1=5,f11,1=6,f16,1=f11,1,此时就要跳上去,因为在这两条路上不可能有点是他们的 LCA 了,需要跳上去缩小答案范围。

在这里插入图片描述

i = 0 i=0 i=0 时,发现: f 5 , 0 = f 6 , 0 = 2 f_{5,0}=f_{6,0}=2 f5,0=f6,0=2,此时根据个人喜好选择跳或不跳。

这句话是什么意思呢?为什么这个时候就可以根据个人喜好了呢?

啊,是这样的:

其实你模拟一下上面的过程,就会发现这很像二进制拆分。

当我们拆分到 i = 0 i=0 i=0 也就是个位的时候,相当于我们直接取这两个节点的父节点,此时父节点一定是 LCA。那么这个时候跳不跳就无所谓了。

如果你选择了跳,那么最后答案就是 x x x

如果你选择了不跳,那么最后答案是 f x , 0 f_{x,0} fx,0

本人一般采取的是第二种写法。

所以结合图片,应该理解了吧。

2.2 时空复杂度分析

关于时间复杂度:

一遍 dfs 为 O ( n ) O(n) O(n)

建立 f f f 数组为 O ( n log ⁡ n ) O(n \log n) O(nlogn)

当然代码里面的 20 是手动设定的,真实情况是 log ⁡ n \log n logn

单独求两个点的 LCA 是 log ⁡ n \log n logn,结合询问数就是 m log ⁡ n m \log n mlogn

因此时间复杂度为 O ( n + n log ⁡ n + m log ⁡ n ) O(n + n \log n + m \log n) O(n+nlogn+mlogn)

由于 n , m n,m n,m 同阶,可以简记为 O ( n log ⁡ n ) O(n \log n) O(nlogn)

关于空间复杂度:

存图的空间复杂度是 O ( n ) O(n) O(n)

d e p dep dep 数组(记录深度)是 O ( n ) O(n) O(n)

f f f 数组是 O ( n log ⁡ n ) O(n \log n) O(nlogn)

因此空间复杂度为 O ( n + n + n log ⁡ n ) O(n + n + n \log n) O(n+n+nlogn),即 O ( n log ⁡ n ) O(n \log n) O(nlogn)

2.3 代码

代码中还是有很多细节性的问题需要注意,全部用注释标出来了。

代码:

/*
========= Plozia =========
	Author:Plozia
	Problem:P3379 【模板】最近公共祖先(LCA)
	Date:2021/3/8
========= Plozia =========
*/

#include <bits/stdc++.h>
using std::vector;

typedef long long LL;
const int MAXN = 5e5 + 10;
int n, m, f[MAXN][21], dep[MAXN], root;
vector <int> Next[MAXN];

int read()
{
    
    
	int sum = 0, fh = 1; char ch = getchar();
	for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
	for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
	return (fh == 1) ? sum : -sum;
}

void dfs(int now, int fa, int depth)
{
    
    
	dep[now] = depth;//记录深度
	f[now][0] = fa;//初始化 f 数组
	for (int i = 0; i < Next[now].size(); ++i)
	{
    
    
		int u = Next[now][i];
		if (u == fa) continue;
		dfs(u, now, depth + 1);
	}
}

int lca(int x, int y)
{
    
    
	if (dep[x] < dep[y]) std::swap(x, y);//保证 x 深度较大
	for (int i = 20; i >= 0; --i)
		if (dep[f[x][i]] >= dep[y]) x = f[x][i];//提到同一深度
	if (x == y) return x;//特判
	for (int i = 20; i >= 0; --i)
		if (f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];//特别小心!不能拿深度判断,应该直接判断节点是否相同
	return f[x][0];//最后答案不能写错
}

int main()
{
    
    
	n = read(), m = read(), root = read();
	for (int i = 1; i < n; ++i)
	{
    
    
		int x = read(), y = read();
		Next[x].push_back(y), Next[y].push_back(x);
	}
	dfs(root, root, 1);
	for (int j = 1; j <= 20; ++j)//注意循环顺序!
		for (int i = 1; i <= n; ++i)
			f[i][j] = f[f[i][j - 1]][j - 1];//递推
	for (int i = 1; i <= m; ++i)
	{
    
    
		int x = read(), y = read();
		printf("%d\n", lca(x, y));
	}
	return 0;
}

总结一下:

倍增求 LCA 的步骤就是:

  1. 做一遍深 ,确定深度以及初始化 f f f 数组。
  2. f f f 数组。
  3. 将两个节点 到同一深度。
  4. 同时往上 ,最后找到答案。

可以简记为四字大法:搜建提跳

3. 树链剖分求 LCA

树链剖分求解 LCA?首先你需要学过树剖。

没有学过?传送门:算法学习笔记:树链剖分

下面假设你已经学会了树剖。

那么树剖求 LCA 还是比较简单的吧,不同于倍增算法的 搜建提跳 四字大法,树剖只需要处理出每一个节点的顶端节点就可以做了呀。

在往上跳的时候,仍然遵循一般树剖往上跳的原则,当两个点在同一条重链的时候,深度较小的点就是答案。

代码:

/*
========= Plozia =========
	Author:Plozia
	Problem:P3379 【模板】最近公共祖先(LCA)
	Date:2021/3/18
========= Plozia =========
*/

#include <bits/stdc++.h>

typedef long long LL;
const int MAXN = 5e5 + 10;
int n, m, root, cnt, Head[MAXN];
int Son[MAXN], Size[MAXN], dep[MAXN], fa[MAXN], Top[MAXN];
struct node {
    
    int to, Next;} Edge[MAXN << 1];

int read()
{
    
    
	int sum = 0, fh = 1; char ch = getchar();
	for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
	for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
	return (fh == 1) ? sum : -sum;
}
void add_Edge(int x, int y) {
    
    Edge[++cnt] = (node){
    
    y, Head[x]}; Head[x] = cnt;}

void dfs1(int now, int father, int depth)
{
    
    
	Size[now] = 1; fa[now] = father; dep[now] = depth;
	for (int i = Head[now]; i; i = Edge[i].Next)
	{
    
    
		int u = Edge[i].to;
		if (u == father) continue;
		dfs1(u, now, depth + 1);
		Size[now] += Size[u];
		if (Size[u] > Size[Son[now]]) Son[now] = u;
	}
}
void dfs2(int now, int top_father)
{
    
    
	Top[now] = top_father;
	if (!Son[now]) return ; dfs2(Son[now], top_father);
	for (int i = Head[now]; i; i = Edge[i].Next)
	{
    
    
		int u = Edge[i].to;
		if (u == Son[now] || u == fa[now]) continue;
		dfs2(u, u);
	}
}

int LCA(int x, int y)
{
    
    
	while (Top[x] != Top[y])
	{
    
    
		if (dep[Top[x]] < dep[Top[y]]) std::swap(x, y);
		x = fa[Top[x]];
	}
	return (dep[x] < dep[y]) ? x : y;
}

int main()
{
    
    
	n = read(), m = read(), root = read();
	for (int i = 1; i < n; ++i)
	{
    
    
		int x = read(), y = read();
		add_Edge(x, y); add_Edge(y, x);
	}
	dfs1(root, root, 1); dfs2(root, root);
	for (int i = 1; i <= m; ++i)
	{
    
    
		int x = read(), y = read();
		printf("%d\n", LCA(x, y));
	}
	return 0;
}

4. 两者时间复杂度分析

对于倍增:

搜: O ( n ) O(n) O(n)

建: O ( n log ⁡ n ) O(n \log n) O(nlogn)

提与跳:单次询问为 O ( 2 log ⁡ n ) O(2\log n) O(2logn),结合 m m m 次询问为 O ( 2 m log ⁡ n ) O(2m \log n) O(2mlogn)

总时间复杂度: O ( n + n log ⁡ n + 2 m log ⁡ n ) = O ( n log ⁡ n + m log ⁡ n ) O(n + n \log n + 2m \log n)=O(n \log n + m \log n) O(n+nlogn+2mlogn)=O(nlogn+mlogn)

如果 n , m n,m n,m 同阶,则可以简记为 O ( n log ⁡ n ) O(n \log n) O(nlogn)

对于树链剖分:

两次 dfs: O ( 2 n ) O(2n) O(2n)

查找 LCA:单次复杂度 O ( log ⁡ n ) O(\log n) O(logn),结合 m m m 次询问为 O ( m log ⁡ n ) O(m \log n) O(mlogn)

总时间复杂度: O ( 2 n + m log ⁡ n ) = O ( m log ⁡ n ) O(2n + m \log n)=O(m \log n) O(2n+mlogn)=O(mlogn)

同样的,如果 n , m n,m n,m 同阶,可以记为 O ( n log ⁡ n ) O(n \log n) O(nlogn)

5. 总结

倍增求解 LCA:搜建提跳。

树剖求解 LCA:正常找顶端节点。

猜你喜欢

转载自blog.csdn.net/BWzhuzehao/article/details/108280459
今日推荐