倍增 & 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问题属于高级图论,所以希望读者学习过初级图论,知道图的一些基本知识,并懂得深搜算法的实现方式.这样 ...
随机推荐
- 天翼云CDN全站加速产品对websocket协议的支持
本文分享自天翼云开发者社区<天翼云CDN全站加速产品对websocket协议的支持>,作者:郭****迎 1.背景介绍 HTTP 协议有一个缺陷:通信只能由客户端发起.这种单向请求的特点, ...
- 从零开始的函数式编程(2) —— Church Boolean 编码
[!quote] 关于λ表达式-- 详见λ表达式 本文导出自Obsidian,可能存在格式偏差(例如链接.Callout等) 本文地址:https://www.cnblogs.com/oberon-z ...
- Linux驱动---LED
目录 一.pinctrl子系统 二.GPIO子系统 三.GPIO操作步骤 3.1.获取GPIO描述符 3.2.设置方向 3.3.读写值 四.编写LED驱动 4.1.硬件原理图 4.2.修改设备树 4. ...
- STC15F104E的外部中断工作异常
STC15F104E使用了外部中断,发现中断工作有时会失效,必需重新上电才能恢复,使用中不时会失效. 1 /********************************************** ...
- Kettle - 使用案例
原文链接:https://blog.csdn.net/gdkyxy2013/article/details/117106691 案例一:把seaking的数据按id同步到seaking2,seakin ...
- P4688 [Ynoi Easy Round 2016] 掉进兔子洞
莫队可以维护种类数 但是无法维护出现次数 考虑离散化以后我们后面腾出了一些空位 那么我们就可以填进那些坑里面 这样做我们就可以用 bitset 直接做与运算 那么 莫队 + bitset 即可
- wordpress设置自定义字体
wordpress设置自定义字体: 失败的操作过程: 写在最前:试了一天多的引用字体,方法包括但不限于: 下载.ttf..otf格式字体,转化为wotf .wotf2格式,挂在github仓库用CDN ...
- JMeter 性能优化
Jmeter 性能优化:(3优化 + 1补充) 1.在 jmx 文件中 Disable 所有的结果输出,如: View Results Tree / Graph Results / Aggrega ...
- mongodb关机重启
正确关闭 mongodb 查看 mongodb 进程 ps -ef | grep mongodb # 或者 ps -aux | grep mongodb 杀掉 mongodb 进程(不推荐) kill ...
- go goroutine 怎样更好的进行错误处理
前言 在 Go 语言程序开发中,goroutine 的使用是比较频繁的,因此在日常编码的时候 goroutine 里的错误处理,怎么做会比较好呢? 一般我们的业务代码如下: func main() { ...