【算法笔记】最近公共祖先(LCA)问题求解——倍增算法

news2025/1/4 19:37:47

0. 前言

最近公共祖先简称 LCA(Lowest Common Ancestor)。两个节点的最近公共祖先,就是这两个点的公共祖先里面,离根最远的那个。

这种算法应用很广泛,可以很容易解决树上最短路等问题。

为了方便,我们记某点集 S = { v 1 , v 2 , … , v n } S=\{v_1,v_2,\ldots,v_n\} S={v1,v2,,vn} 的最近公共祖先为 LCA ( v 1 , v 2 , … , v n ) \text{LCA}(v_1,v_2,\ldots,v_n) LCA(v1,v2,,vn) LCA ( S ) \text{LCA}(S) LCA(S)

部分内容参考 OI Wiki,文章中所有算法均使用C++实现。

例题:洛谷 P3379 【模板】最近公共祖先(LCA)

1. 性质

  1. LCA ( { u } ) = u \text{LCA}(\{u\})=u LCA({u})=u
  2. u u u v v v 的祖先,当且仅当 LCA ( u , v ) = u \text{LCA}(u,v)=u LCA(u,v)=u
  3. 如果 u u u 不为 v v v 的祖先并且 v v v 不为 u u u 的祖先,那么 u , v u,v u,v 分别处于 LCA ( u , v ) \text{LCA}(u,v) LCA(u,v) 的两棵不同子树中;
  4. 前序遍历中, LCA ( S ) \text{LCA}(S) LCA(S) 出现在所有 S S S 中元素之前,后序遍历中 LCA ( S ) \text{LCA}(S) LCA(S) 则出现在所有 S S S 中元素之后;
  5. 两点集并的最近公共祖先为两点集分别的最近公共祖先的最近公共祖先,即 LCA ( A ∪ B ) = LCA ( LCA ( A ) , LCA ( B ) ) \text{LCA}(A\cup B)=\text{LCA}(\text{LCA}(A), \text{LCA}(B)) LCA(AB)=LCA(LCA(A),LCA(B))
  6. 两点的最近公共祖先必定处在树上两点间的最短路上;
  7. d ( u , v ) = h ( u ) + h ( v ) − 2 h ( LCA ( u , v ) ) d(u,v)=h(u)+h(v)-2h(\text{LCA}(u,v)) d(u,v)=h(u)+h(v)2h(LCA(u,v)),其中 d d d 是树上两点间的距离, h h h 代表某点到树根的距离。

2. 求解算法

2.0 前置知识1:树的邻接表存储

简单来说,树的邻接表存储就是对于每个结点,存储其能通过一条有向或无向边,直接到达的所有结点。
传统的存储方式是使用链表(或模拟链表),这样实现比较麻烦,也容易写错。
此处为了更好的可读性我们使用STL中的可变长度顺序表vector

#include <vector> // 需要使用STL中的vector
#define maxn 100005 // 最大结点个数

std::vector<int> G[maxn];

此时,若要添加一条无向边 u ↔ v u\leftrightarrow v uv,可使用:

G[u].push_back(v);
G[v].push_back(u);

若要添加 u → v u\to v uv的有向边:

G[u].push_back(v);

遍历 v v v能直接到达的所有结点:

for(int u: G[v])
	cout << u << endl;

2.1 前置知识2:DFS 遍历 & 结点的深度计算

对于两种算法,都需要预处理出每个结点的深度。
一个结点的深度定义为这个结点到树根的距离。

要预处理出所有结点的深度,很简单:
运用树形dp的方法,令 h u h_u hu 表示结点 u u u 的深度,逐层向下推进:

#include <cstdio>
#include <vector>
#define maxn 100005
using namespace std;

vector<int> G[maxn]; // 邻接表存储
int depth[maxn]; // 每个结点的深度

void dfs(int v, int par) // dfs(当前结点,父亲结点)
{
	int d = depth[v] + 1; // 子结点的深度=当前结点的深度+1
	for(int u: G[v])
		if(u != par) // 不加这条判断会无限递归
		{
			depth[u] = d; // dp更新子结点深度
			dfs(u, v); // 往下dfs
		}
}

int main()
{
	// 构建一张图
	// ...
	// 假定图已存入邻接表G:
	int root = 0; // 默认树根为0号结点,根据实际情况设置
	dfs(root, -1); // 对于根结点,父亲结点为-1即为无父亲结点
	return 0;
}

2.2 朴素算法

u , v u,v u,v 表示两个待求 LCA 的结点。需提前预处理出每个结点的父亲(记结点 v v v 的父亲为 f v f_v fv)。

算法步骤:

  1. 使 u , v u,v u,v 的深度相同:可以让深度大的结点往上走,直到与深度小的结点深度相同。
  2. u ≠ v u\ne v u=v时: u ← f u , v ← f v u\gets f_u,v\gets f_v ufu,vfv
  3. 循环直到 u = v u=v u=v,此条件成立后 u u u v v v 的值即为我们要求的 LCA。

时间复杂度分析:

  • 预处理:DFS 遍历整棵树, O ( N ) \mathcal O(N) O(N)
  • 单次查询:最坏 O ( N ) \mathcal O(N) O(N),平均 O ( log ⁡ N ) \mathcal O(\log N) O(logN)(随机树的高为 ⌈ log ⁡ N ⌉ \lceil\log N\rceil logN

参考代码:

#include <cstdio>
#include <vector>
#include <algorithm>
#define maxn 500005
using namespace std;

vector<int> G[maxn];
int depth[maxn], par[maxn];

void dfs(int v)
{
	int d = depth[v] + 1;
	for(int u: G[v])
		if(u != par[v])
		{
			par[u] = v, depth[u] = d;
			dfs(u);
		}
}

int lca(int u, int v)
{
	if(depth[u] < depth[v])
		swap(u, v);
	while(depth[u] > depth[v])
		u = par[u];
	while(u != v)
		u = par[u], v = par[v];
	return u;
}

int main()
{
	int n, q, root;
	scanf("%d%d%d", &n, &q, &root);
	for(int i=1; i<n; i++)
	{
		int u, v;
		scanf("%d%d", &u, &v);
		G[u].push_back(v);
		G[v].push_back(u);
	}
	par[root] = -1, depth[root] = 0;
	dfs(root);
	while(q--)
	{
		int u, v;
		scanf("%d%d", &u, &v);
		printf("%d\n", lca(u, v));
	}
	return 0;
}

可以发现,程序在最后四个测试点上TLE了:

TLE

这是因为,这四个点是专门针对朴素算法设计的(正好是一个 Subtask),使算法的时间复杂度达到了最坏情况 O ( N Q ) \mathcal O(NQ) O(NQ),而 N , Q ≤ 5 × 1 0 5 N,Q\le 5\times 10^5 N,Q5×105,所以无法通过测试点。当然,朴素算法在随机树上回答 Q Q Q 次询问的时间复杂度还是 O ( N + Q log ⁡ N ) \mathcal O(N+Q\log N) O(N+QlogN),被极端数据卡掉也没办法

2.3 倍增

倍增算法是朴素算法的改进算法,也是最经典的 LCA 求法。

预处理:

  • fa x , i \text{fa}_{x,i} fax,i 表示点 x x x 的第 2 i 2^i 2i 个祖先。
  • dfs 预处理深度信息时,也可以预处理出 fa x , i \text{fa}_{x,i} fax,i
    • 首先考虑 i i i的范围: 2 i ≤ d x 2^i\le d_x 2idx(前面说的, d x d_x dx 表示结点 x x x 的深度),所以有 0 ≤ i ≤ ⌊ log ⁡ 2 d x ⌋ 0\le i\le \lfloor\log_2 d_x\rfloor 0ilog2dx
    • 对于 i = 0 i=0 i=0 2 i = 2 0 = 1 2^i=2^0=1 2i=20=1,所以直接令 fa x , 0 = ( x 的父亲 ) \text{fa}_{x,0}=(x\text{的父亲}) fax,0=(x的父亲) 即可。
    • 对于 1 ≤ i ≤ ⌊ log ⁡ 2 d x ⌋ 1\le i\le \lfloor\log_2 d_x\rfloor 1ilog2dx x x x 的第 2 i 2^i 2i 个祖先可看作 x x x 的第 2 i − 1 2^{i-1} 2i1 个祖先的第 2 i − 1 2^{i-1} 2i1 个祖先( 2 i − 1 + 2 i − 1 = 2 i 2^{i-1}+2^{i-1}=2^i 2i1+2i1=2i),即:
      fa x , i = fa fa x , i − 1 , i − 1 \text{fa}_{x,i}=\text{fa}_{\text{fa}_{x,i-1},i-1} fax,i=fafax,i1,i1

求解步骤:

  1. 使 u , v u,v u,v 的深度相同:计算出 u , v u,v u,v 两点的深度之差,设其为 y y y。通过将 y y y 进行二进制拆分,我们将 y y y 次游标跳转优化为「 y y y 的二进制表示所含 1 的个数」次游标跳转(详见代码)。
  2. 特判:如果此时 u = v u=v u=v,直接返回 u u u v v v 作为 LCA 结果。
  3. 同时上移 u u u v v v:从 i = ⌊ log ⁡ 2 d u ⌋ i=\lfloor\log_2 d_u\rfloor i=log2du 开始循环尝试,一直尝试到 0 0 0(包括 0 0 0),如果 fa u , i ≠ fa v , i \text{fa}_{u,i}\not=\text{fa}_{v,i} fau,i=fav,i,则 u ← fa u , i , v ← fa v , i u\gets\text{fa}_{u,i},v\gets\text{fa}_{v,i} ufau,i,vfav,i,那么最后的 LCA 为 fa u , 0 \text{fa}_{u,0} fau,0

时间复杂度分析:

  • 预处理: O ( N ) \mathcal O(N) O(N) DFS ×   O ( log ⁡ N ) \times~\mathcal O(\log N) × O(logN) 预处理 = O ( N log ⁡ N ) =\mathcal O(N\log N) =O(NlogN)
  • 单次查询:平均 O ( log ⁡ N ) O(\log N) O(logN),最坏 O ( log ⁡ N ) O(\log N) O(logN)
  • 预处理 + Q Q Q 次查询: O ( N + Q log ⁡ N ) \mathcal O(N+Q\log N) O(N+QlogN)

另外倍增算法可以通过交换 fa 数组的两维使较小维放在前面。这样可以减少 cache miss 次数,提高程序效率。

参考代码:

#include <cstdio>
#include <vector>
#include <cmath>
#define maxn 500005
using namespace std;

vector<int> G[maxn];
int fa[maxn][19]; // 2^19=524288
int depth[maxn];

void dfs(int v, int par)
{
	fa[v][0] = par;
	int d = depth[v] + 1;
	for(int i=1; (1<<i)<d; i++)
		fa[v][i] = fa[fa[v][i - 1]][i - 1];
	for(int u: G[v])
		if(u != par)
			depth[u] = d, dfs(u, v);
}

inline int lca(int u, int v)
{
	if(depth[u] < depth[v])
		u ^= v ^= u ^= v;
	int m = depth[u] - depth[v];
	for(int i=0; m; i++, m>>=1)
		if(m & 1)
			u = fa[u][i];
	if(u == v) return u; // 这句不能丢
	for(int i=log2(depth[u]); i>=0; i--)
		if(fa[u][i] != fa[v][i])
			u = fa[u][i], v = fa[v][i];
	return fa[u][0];
}

int main()
{
	int n, q, root;
	scanf("%d%d%d", &n, &q, &root);
	for(int i=1; i<n; i++)
	{
		int x, y;
		scanf("%d%d", &x, &y);
		G[--x].push_back(--y);
		G[y].push_back(x);
	}
	depth[--root] = 0;
	dfs(root, -1);
	while(q--)
	{
		int u, v;
		scanf("%d%d", &u, &v);
		printf("%d\n", lca(--u, --v) + 1);
	}
	return 0;
}

AC

3. 习题

  • 题目链接:洛谷 P8805 [蓝桥杯 2022 国 B] 机房
  • 题解:https://best-blogs.blog.luogu.org/solution-p8805

4. 总结

本文详细讲解了 LCA 问题以及求解 LCA 的两种算法。对比如下:

算法预处理时间复杂度单次查询时间复杂度1空间复杂度能否通过例题2
朴素算法 O ( N ) \mathcal O(N) O(N) O ( N ) \mathcal O(N) O(N) O ( N ) \mathcal O(N) O(N)
倍增算法 O ( N log ⁡ N ) \mathcal O(N\log N) O(NlogN) O ( log ⁡ N ) \mathcal O(\log N) O(logN) O ( N log ⁡ N ) \mathcal O(N\log N) O(NlogN)✔️

创作不易,希望大家能给个三连,感谢支持!


  1. 此时间复杂度按照最坏情况计算。 ↩︎

  2. 例题:洛谷 P3379 【模板】最近公共祖先(LCA) ↩︎

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/146671.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

星光不负赶路人|2022年终总结

时间真快&#xff0c;转眼又是年末。整理一篇文章来给自己好好做一次年终盘点&#xff0c;本着陈述事实&#xff0c;提炼精华&#xff0c;总结不足的思路&#xff0c;给自己这一年的工作、生活、成长画个句号。 工作 &#x1f3e2; 从经海路到中关村 去年换了工作&#xff0c…

Java设计模式中的创建者模式/单例模式是啥?单例模式其中的饿汉式与懒汉式又是啥?又可以用在哪些地方

继续整理记录这段时间来的收获&#xff0c;详细代码可在我的Gitee仓库SpringBoot克隆下载学习使用&#xff01; 4. 创建者模式 4.1 特点 使用者不需要知道对象的创建细节 4.2 单例模式 4.2.1使用场景 单例类&#xff1a;且仅能创建一个实例类访问类&#xff1a;使用单例类…

七、Gtk4-Defining a final class

1 定义一个最终类 1.1 一个非常简单的编辑器 在上一节中&#xff0c;我们创建了一个非常简单的文件查看器。现在我们继续重写它&#xff0c;并将其转换为非常简单的编辑器。它的源文件是tfe目录下的tfe1.c(文本文件编辑器1)。 GtkTextView是一个多行编辑器。因此&#xff0c…

java学习day71(乐友商城)购物车实现

今日目标&#xff1a; 1.实现未登录状态的购物车 2.实现登陆状态下的购物车 1.搭建购物车服务 1.1.创建module 1.2.pom依赖 <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://maven.apache.org/POM/4.0.0"xmlns:xsi&…

软件测试~测试分类

目录 1.按照是否查看代码划分 ① 黑盒测试(Black-box Testing) ② 白盒测试(White-box Testing) ③ 灰盒测试(Gray-Box Testing) 2.按照开发阶段划分 ① 单元测试(Unit Testing) ② 集成测试(Integration Testing) ③ 系统测试(System Testing) ④ 验收测试(Acceptance…

kNN分类

一、 概述 kNN(k nearest neighbor,k近邻)是一种基础分类算法&#xff0c;基于“物以类聚”的思想&#xff0c;将一个样本的类别归于它的邻近样本。 ![在这里插入图片描述] 二、算法描述 1.基本原理 给定训练数据集 T{(x1,y1),(x2,y2),...,(xN,yN)}T\left\{ \left( x_1,y_1 …

17. XML

文章目录一、XML概念二、XML语法1、基础语法2、快速入门3、组成部分4、约束1. 约束概述2. 分类3. DTD4. Schema三、XML解析1、操作xml文档2、 解析xml的方式1. DOM2. SAX3. xml常见的解析器&#xff08;工具包&#xff09;4. Jsoup&#xff08;1&#xff09;快速入门&#xff0…

VUE3 学习笔记(一):环境配置、项目创建

一、首先需要安装node.jsnodejs官网&#xff1a;Node.js (nodejs.org)下载安装包&#xff1a;下载稳定版本即可&#xff0c;目前&#xff08;2023-01-07&#xff09;是18.13.0版本c. 检查当前版本&#xff08;CMD&#xff09;&#xff1a;至此&#xff0c;nodejs已经安装成功&a…

电力系统机组组合(Matlab代码实现)

&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f49e;&#x1f49e;&#x1f49e;欢迎来到本博客❤️❤️❤️&#x1f4a5;&#x1f4a5;&#x1f4a5;&#x1f3c6;博主优势&#xff1a;&#x1f31e;&#x1f31e;&#x1f31e;博客内容尽量做到思维缜密&#xff0c;逻辑清…

EM算法学习总结

序 这个和我的硕士毕业论文的题目就有一定关系&#xff0c;我的导师让我按时向她汇报学习进度。然而我还在进行实习&#xff0c;还要准备自己明年的秋招&#xff0c;只能想办法游走于三者之间。 EM算法是一个常用的数据挖掘算法&#xff0c;想必从事数据挖掘的相关工作的同学…

机器人操作系统ROS/ROS2(1)

提示&#xff1a;文章写完后&#xff0c;目录可以自动生成&#xff0c;如何生成可参考右边的帮助文档 文章目录前言一、ROS和ROS2是什么&#xff1f;二、ROS2安装步骤1.Ubuntu虚拟机安装2.ROS2系统安装3.验证ROS2安装成功4.小海龟仿真示例总结前言 提示&#xff1a;这里可以添…

LeetCode刷题模版:21 - 30

目录 简介21. 合并两个有序链表22. 括号生成23. 合并K个升序链表24. 两两交换链表中的节点25. K 个一组翻转链表26. 删除有序数组中的重复项27. 移除元素28. 找出字符串中第一个匹配项的下标29. 两数相除【未理解】30. 串联所有单词的子串【未理解】结语简介 Hello! 非常感谢您…

devops 是什么东东了

DevOps&#xff0c;字面意思是Development &Operations的缩写。 DevOps是从实践中逐步总结提炼出的方法论理念。近而创造了DevOps这个词。 DevOps概念的萌芽阶段&#xff1a; 2008年敏捷大会上&#xff0c;来自Patrick Debois发表了题为 《Agile Infrastructure & Op…

基于.NET技术的动漫管理系统,给各位二次元老司机提供的动漫管理平台,基于.NET的前后端框架Blazor,含安装教程及使用说明

介绍 老司机驾驶舱——给各位二次元老司机提供的动漫管理平台。 下载地址&#xff1a;基于.NET技术的动漫管理系统 编写本应用的目的主要是实践巨硬&#xff08;Microsoft&#xff09;的新一代前端框架Blazor与EntityFramework&#xff0c;本应用也给想要学习这套框架的童鞋提…

Ubuntu20.04 hyperledger fabric2.4基于Docker搭建blockchain-explorer

准备 启动fabric测试网络。   这里默认已经完成了Fabric测试网络搭建以及运行。   后续会出Fabric安装&#xff0c;现在不会的就先去看别的博客 配置 1.在test-network 文件夹下面建立explorer文件夹&#xff1a; mkdir explorer2. 配置文件 2.1下载配置文件 先进文件…

Isaac Sim 机器人仿真器介绍、安装与 Docker [1]

前言与参考 此文书写于&#xff1a; January 6, 2023&#xff0c; 更新于 January 6, 2023 &#xff1b;可能会随着时间的变化 此教程会有过时概念哦 Isaac Sim 相关参考链接&#xff1a; 官方文档地址官方docker image 镜像地址官方讨论 论坛链接&#xff0c;建议没啥事就可…

【JavaGuide面试总结】Java基础篇·中

【JavaGuide面试总结】Java基础篇中1.重载和重写有什么区别&#xff1f;2.什么是可变长参数&#xff1f;3.为什么浮点数运算的时候会有精度丢失的风险&#xff1f;4.如何解决浮点数运算的精度丢失问题&#xff1f;5.超过 long 整型的数据应该如何表示&#xff1f;6.基本类型和包…

JSP——标准标签库 (JSTL)

✅作者简介&#xff1a;热爱国学的Java后端开发者&#xff0c;修心和技术同步精进。 &#x1f34e;个人主页&#xff1a;Java Fans的博客 &#x1f34a;个人信条&#xff1a;不迁怒&#xff0c;不贰过。小知识&#xff0c;大智慧。 &#x1f49e;当前专栏&#xff1a;JAVA开发者…

5G NR标准: 第19章 毫米波射频技术

第19章 毫米波射频技术 现有的 2G、3G 和 4G 移动通信 3GPP 规范适用于 6 GHz 以下的频率范围&#xff0c;相应的 RF 要求考虑了与 6 GHz 以下操作相关的技术方面。 NR 也在这些频率范围内运行&#xff08;标识为频率范围 1&#xff09;&#xff0c;但另外还将定义为在 24.25…

【人工智能原理自学】梯度下降和反向传播:能改

&#x1f60a;你好&#xff0c;我是小航&#xff0c;一个正在变秃、变强的文艺倾年。 &#x1f514;笔记来自B站UP主Ele实验室的《小白也能听懂的人工智能原理》。 &#x1f514;本文讲解梯度下降和反向传播&#xff1a;能改&#xff0c;一起卷起来叭&#xff01; 目录一、“挪…