倍增 & Tarjan 求解LCA
什么是LCA?
假设我们有一棵树:
1
/ \
2 3
/ \ /
4 5 6
对于 \(2\) 和 \(6\) 的LCA,就是最近公共祖先,即为距离 \(2\) 和 \(6\) 最近的两个节点公有的节点。怎么求呢?这里就有三种算法。
普通算法
我们可以把这一棵树存好,方式随便(这里展示使用邻接表),可以看到存好之后的树如下:
1 - 2 - 3
2 - 1 - 4 - 5
3 - 1 - 6
4 - 2
5 - 2
6 - 3
其中 \(4,5,6\) 均为叶子节点。现在我们假设要求 \(2\) 和 \(6\) 的LCA,步骤如下:
- 首先,因为 \(6\) 的深度 \(>2\) 所以我们要 \(6\) 先跳到 \(2\) 的高度。
- 此时,我们的 \(6\) 节点来到了 \(3\) 节点,\(2\) 节点不变。
- 现在,把 \(2\) 和 \(3\) 节点同时上提。
- 经过一次上提之后,两个节点都来到了 \(1\) 位置。那么 \(1\) 就是 \(2\) 和 \(6\) 的LCA。
算法实现如下:
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
const int MAXN = 500010;
vector<int> tree[MAXN];
int depth[MAXN];
int parent[MAXN];
// DFS预处理每个节点的深度和父节点
void dfs(int u, int p) {
parent[u] = p;
depth[u] = depth[p] + 1;
for (int v : tree[u]) {
if (v != p) {
dfs(v, u);
}
}
}
// 暴力方法求LCA
int lca(int u, int v) {
// 将两个节点提到同一深度
while (depth[u] > depth[v]) u = parent[u];
while (depth[v] > depth[u]) v = parent[v];
// 然后一起向上找
while (u != v) {
u = parent[u];
v = parent[v];
}
return u;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, M, S;
cin >> N >> M >> S;
// 建树
for (int i = 1; i < N; ++i) {
int x, y;
cin >> x >> y;
tree[x].push_back(y);
tree[y].push_back(x);
}
// 预处理
depth[0] = -1; // 根节点的父节点设为0,深度为-1+1=0
dfs(S, 0);
// 处理查询
while (M--) {
int a, b;
cin >> a >> b;
cout << lca(a, b) << '\n';
}
return 0;
}
可以得知,在最坏情况下(树是一条链),树的高度 \(dis\) 和询问次数 \(m\) 直接关系到查询,会变得很慢,时间复杂度 \(O(nm)\) ,在大多数题目中会被卡掉。那么有没有什么优化的办法呢?当然有。
倍增求解LCA
我们由上面的讲解可以知道,暴力处理LCA并不是一个好的算法。如何优化呢?暴力算法的每次操作只把节点提高了 \(1\) 次。导致上升得很慢,所以我们当然可以一次上升多个节点来满足快速上升高度的需求。那新的问题又来了,我们一次上升多少高度呢?这里就要涉及到一个“数学小常识”了,我们可以证明:,任意一个非 \(0\) 自然数可以被写作若干个 \(2\) 的幂之和。比如:\(10 = 2^3+2^1\) , \(17 = 2^4 +2^0\) 。这其实也就是二进制转十进制的计算方法(扯远了)。所以我们可以得知,只要上提若干个 \(2\) 的幂次方步就能得到结果(为了快速一点,代码好写一点,我们通常从大的幂枚举到小的幂)。现在问题就简单了,我们最后的一部就只要求出 \(2^k\) 次幂是多少就可以了。这里我们开一个 dp[100000][20] 。其中 dp[i][j] 表示从节点 \(i\) 向上 \(2^j\) 到达的节点。推导 dp[i][j] 的公式如下:
\]
最后加上我们暴力的解法就可以了:
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
const int MAXN = 500010;
const int LOG = 20; // log2(500000) ≈ 19
vector<int> tree[MAXN];
int depth[MAXN];
int parent[MAXN][LOG]; // parent[u][k]表示u的2^k级祖先
// DFS预处理每个节点的深度和倍增数组
void dfs(int u, int p) {
parent[u][0] = p;
depth[u] = depth[p] + 1;
// 预处理倍增数组
for (int k = 1; k < LOG; ++k) {
parent[u][k] = parent[parent[u][k-1]][k-1];
}
for (int v : tree[u]) {
if (v != p) {
dfs(v, u);
}
}
}
// 二进制倍增法求LCA
int lca(int u, int v) {
// 确保u是较深的节点
if (depth[u] < depth[v]) swap(u, v);
// 将u提到与v同一深度
for (int k = LOG-1; k >= 0; --k) {
if (depth[parent[u][k]] >= depth[v]) {
u = parent[u][k];
}
}
// 如果此时u==v,说明v就是u的祖先
if (u == v) return u;
// 现在u和v在同一深度,一起向上找LCA
for (int k = LOG-1; k >= 0; --k) {
if (parent[u][k] != parent[v][k]) {
u = parent[u][k];
v = parent[v][k];
}
}
// 此时u和v的父节点就是LCA
return parent[u][0];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, M, S;
cin >> N >> M >> S;
// 建树
for (int i = 1; i < N; ++i) {
int x, y;
cin >> x >> y;
tree[x].push_back(y);
tree[y].push_back(x);
}
// 初始化
depth[0] = -1; // 虚拟根节点的深度设为-1,这样根节点S的深度为0
// 预处理
dfs(S, 0); // 0作为虚拟父节点
// 处理查询
while (M--) {
int a, b;
cin >> a >> b;
cout << lca(a, b) << '\n';
}
return 0;
}
时间复杂度:预处理 \(O(n\log n)\) ,每次查询 \(O(\log n)\),总体时间复杂度 \(O(n \log n + m \log n)\)。
Tarjan算法
Tarjan算法求LCA,应当是我认为的难度最高的,也是我最喜欢的解法。我们举个例子说明一下Tarjan算法,还是刚才的那张图:
1
/ \
2 3
/ \ /
4 5 6
此时询问: \(2\) 和 \(6\) 的LCA 是谁呀?
Tarjan:
- 来到 \(1\) 节点,标记为访问过。
- 来到 \(2\) 节点,标记为访问过。
- 来到 \(4\) 节点,标记为访问过。
- 处理 \(4\) 节点的所有询问:
- 没有询问,跳过。
- 来到 \(5\) 节点,标记为访问过
- 处理 \(5\) 节点的所有询问:
- 没有询问,跳过。
- 处理 \(2\) 的所有访问:
- 找到了与 \(6\) 的一条询问,但是 \(6\) 节点没有被访问过,无法处理。
- 来到 \(3\) 节点,标记为访问过。
- 来到 \(6\) 节点,标记为访问过。
- 处理 \(6\) 节点的所有询问:
- 找到了与 \(2\) 的一条访问。此时 \(6\) 的祖先节点是 \(1\) 。则这条询问是 \(1\)
- 处理 \(3\) 的所有访问:
- 没有询问,跳过。
那这时有的人就要问了,怎么知道 \(6\) 的祖先是 \(1\) 的?我们添加一个并查集不就好了吗?每次访问一个节点,若子节点没有访问,那就将此节点指向子节点。将子树添加进来到一个集合中。因为下一次回溯来到当前节点的时候,一定是当前子树被处理过了。且如果询问的另一方已经被访问,更新的LCA一定是当前节点的祖先。
- 没有询问,跳过。
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;
const int MAXN = 500010;
vector<int> tree[MAXN];
vector<pair<int, int>> queries[MAXN]; // 存储查询,queries[u] = {v, 查询编号}
int ans[MAXN]; // 存储每个查询的答案
int parent[MAXN]; // 并查集父节点
bool visited[MAXN]; // 标记节点是否已被访问
// 并查集查找函数
int find(int u) {
if (parent[u] != u) {
parent[u] = find(parent[u]); // 路径压缩
}
return parent[u];
}
// Tarjan算法主函数
void tarjan(int u) {
visited[u] = true;
parent[u] = u; // 初始化并查集
// 遍历所有子节点
for (int v : tree[u]) {
if (!visited[v]) {
tarjan(v);
parent[v] = u; // 合并子树
}
}
// 处理所有与u相关的查询
for (auto [v, idx] : queries[u]) {
if (visited[v]) {
ans[idx] = find(v);
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int N, M, S;
cin >> N >> M >> S;
// 建树
for (int i = 1; i < N; ++i) {
int x, y;
cin >> x >> y;
tree[x].push_back(y);
tree[y].push_back(x);
}
// 存储查询
for (int i = 0; i < M; ++i) {
int a, b;
cin >> a >> b;
// 双向存储查询
queries[a].emplace_back(b, i);
if (a != b) {
queries[b].emplace_back(a, i);
}
}
// 运行Tarjan算法
tarjan(S);
// 输出查询结果
for (int i = 0; i < M; ++i) {
cout << ans[i] << '\n';
}
return 0;
}
Tarjan算法的时间复杂度为 \(O(n + m(\alpha (n))\),其中 \((\alpha n )\) 通常小于二。
课后习题:
The End.
倍增 & Tarjan 求解LCA的更多相关文章
- 倍增 Tarjan 求LCA
...
- 倍增\ tarjan求lca
对于每个节点v,记录anc[v][k],表示从它向上走2k步后到达的节点(如果越过了根节点,那么anc[v][k]就是根节点). dfs函数对树进行的dfs,先求出anc[v][0],再利用anc[v ...
- 图论分支-倍增Tarjan求LCA
LCA,最近公共祖先,这是树上最常用的算法之一,因为它可以求距离,也可以求路径等等 LCA有两种写法,一种是倍增思想,另一种是Tarjan求法,我们可以通过一道题来看一看, 题目描述 欢乐岛上有个非常 ...
- 浅谈倍增法求解LCA
Luogu P3379 最近公共祖先 原题展现 题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先. 输入格式 第一行包含三个正整数 \(N,M,S\),分别表示树的结点个数.询问 ...
- LCA—倍增法求解
LCA(Least Common Ancestors) 即最近公共祖先,是指在有根树中,找出某两个结点u和v最近的公共祖先. 常见解法一般有三种 这里讲解一种在线算法-倍增 首先我们定义fa[u][j ...
- [CF 191C]Fools and Roads[LCA Tarjan算法][LCA 与 RMQ问题的转化][LCA ST算法]
参考: 1. 郭华阳 - 算法合集之<RMQ与LCA问题>. 讲得很清楚! 2. http://www.cnblogs.com/lazycal/archive/2012/08/11/263 ...
- Tarjan求LCA
LCA问题算是一类比较经典的树上的问题 做法比较多样 比如说暴力啊,倍增啊等等 今天在这里给大家讲一下tarjan算法! tarjan求LCA是一种稳定高速的算法 时间复杂度能做到预处理O(n + m ...
- 详解使用 Tarjan 求 LCA 问题(图解)
LCA问题有多种求法,例如倍增,Tarjan. 本篇博文讲解如何使用Tarjan求LCA. 如果你还不知道什么是LCA,没关系,本文会详细解释. 在本文中,因为我懒为方便理解,使用二叉树进行示范. L ...
- tarjan求lca的神奇
题目描述 如题,给定一棵有根多叉树,请求出指定两个点直接最近的公共祖先. 输入输出格式 输入格式: 第一行包含三个正整数N.M.S,分别表示树的结点个数.询问的个数和树根结点的序号. 接下来N-1行每 ...
- 求解LCA问题的几种方式
求解LCA问题的几种方式 这篇随笔讲解图论中LCA问题(最近公共祖先)的几种求解方式及实现方法.LCA问题属于高级图论,所以希望读者学习过初级图论,知道图的一些基本知识,并懂得深搜算法的实现方式.这样 ...
随机推荐
- DPDK简介和原理
本文分享自天翼云开发者社区<DPDK简介和原理>,作者:s****n DPDK是一种绕过内核直接在用户态收发包来解决内核性能的瓶颈技术. 什么是中断 了解DPDK之前,首先需要先了解什么是 ...
- Ceph的crush算法与一致性hash对比介绍
本文分享自天翼云开发者社区<Ceph的crush算法与一致性hash对比介绍>,作者:l****n 首先,我们先回顾下一致性hash以及其在经典存储系统中的应用. 一致性hash的基本原理 ...
- Kyuubi支持Iceberg配置
一.简述 Kyuubi调用Spark来查询iceberg表,修改Spark配置信息即可. 二.服务配置 1.上传jar包到Kyuubi server节点 可以选择emr spark组件后,按照配置组( ...
- 解决微信小程序原生云开发退款报错“特约子商户商户号未授权服务商的产品权限”的问题
背景:微信小程序云开发支付没问题,退款时就会报这个错. 现象: 解决方法流程: 1.打开微信小程序开发者工具上面的云开发界面: 2.进入设置: 3.其他设置: 需要授权退款API权限,我这里已经授权了 ...
- Windows 网络存储ISCSI
本文介绍网络存储ISCSI的主要知识点以及如何通过代码控制挂载. Windows网络存储有很多协议,我目前学习.稍微有了解的是FTP.SMB.ISCSI,FTP.SMB类似可以用来添加共享文件夹,或者 ...
- FANUC发那科机器人主板维修,故障问题检测
电容损坏引发的故障在电子设备中是特别高的,其中尤其以电解电容的损坏为常见 电容损坏表现为:1.容量变小:2.完全失去容量:3.漏电:4.短路. 电容在电路中所起的作用不同,引起的故障也各有特点.在发那 ...
- 淘宝H5 sign加密算法
淘宝H5 sign加密算法 淘宝H5 sign加密算法 淘宝对于h5的访问采用了和客户端不同的方式,由于在h5的js代码中保存appsercret具有较高的风险,mtop采用了随机分配令牌的方式, ...
- ruoyi-vue 界面框架构造
界面框架: 我采用了flex布局,先分左右,然后右侧再分上下. 步骤: 1. 首先实现简单的菜单 1.1 菜单是个菜单项数组 [] 1.2 菜单项结构 例子 { id:'001', name: '历史 ...
- mybatis - [09] 动态SQL
题记部分 一.if & test 如果id,name,age不为空,则按照指定的值进行查询.如果这三者都是空(null和空字符串),则该sql执行结果为全表查询的结果集. <select ...
- Hi3516EV200 编译环境配置及交叉编译软件包
基础信息 OS: Ubuntu 16.04 xenial SDK 版本: Hi3516EV200R001C01SPC012 - Hi3516EV200_SDK_V1.0.1.1 SDK 包路径:Hi3 ...