倍增 & 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问题属于高级图论,所以希望读者学习过初级图论,知道图的一些基本知识,并懂得深搜算法的实现方式.这样 ...
随机推荐
- Git的一些基本用法
本文分享自天翼云开发者社区<Git的一些基本用法>,作者:l****n 基本操作 git branch 查看当前分支 git branch -a 查看所有分支 git pull 更新当前分 ...
- WEB系统安全之开源软件风险使用评估
本文分享自天翼云开发者社区<WEB系统安全之开源软件风险使用评估>,作者:Coding 中国信息通信研究院(China Academy of Information and Communi ...
- 将VSCode设置为中文(汉化)
1.VSCode安装好后默认语言为英文,此汉化过程的VSCode版本为1.42.1,如下图所示: 2.下载安装完成后,打开VSCode,使用快捷键Ctrl+Shift+P,然后在出现的输入框中输入co ...
- 耳分解、双极定向和 P9394 Solution
耳分解 设无向图 \(G'(V',E')\subset G(V,E)\),简单路径或简单环 \(P:x_1\to \dots \to x_k\) 被称为 \(G\) 关于 \(G'\) 的耳,当且仅当 ...
- 面试官:说说你项目中JWT的执行流程?
JWT 在目前的项目开发中使用到的频率是非常高的,因此它也是面试常问的一类问题,所以今天我们就来看看"项目中 JWT 的执行流程?"这个问题. 1.什么是 JWT? JWT(JSO ...
- [BZOJ3622] 已经没有什么好害怕的了 题解
发现难以维护差值,于是令 \(K=\frac{n+k}2\),这样就把问题转化为了"糖果"比"药片"大的组数为 \(K\) 的情况有多少种. 设 \(dp_{i ...
- QT5笔记:13. QString 的常用功能
QString采用Unicode码,所以任何一个字符不管中英文,在size或者count时都算作一个字符,不会有宽字符区分 常用的方法 append prepend toUpper toLower l ...
- Java进阶 - [1-4] 反射
一.类加载区别 当我们刚接触java语言的时候,我们最常见的代码应该就是初始化某个对象,然后调用该对象的方法. 1.使用new创建对象,返回对象的引用.Student student = new St ...
- 创建一个MCP服务器,并在Cline中使用,增强自定义功能。
MCP介绍 MCP 是一个开放协议,它标准化了应用程序如何向LLMs提供上下文.可以将 MCP 视为 AI 应用程序的 USB-C 端口.正如 USB-C 提供了一种标准化的方法来将您的设备连接到各种 ...
- Ethernet协议和ARP协议分析
实验介绍: 在信息的传输中,协议至关重要. 使用的实验环境是 kali 2017.arp协议还需要用到靶机centos7 如果是kali的其他版本可能会导致无法抓包. 一:网络分析工具(kali) S ...