习题加餐5. 最近公共祖先LCA查询

        

1.最近公共祖先LCA查询
问题描述
给定一棵有N个节点的树,每个节点有一个唯一的编号,从1到N。树的根节点是1号节点。接下来,你会得到Q个查询。对于每个查询,你将得到两个节点的编号,你的任务是找到这两个节点的最低公共祖先。
输入格式
第一行包含一个整数N,表示树的节点数。
接下来的N-1行,每行包含两个整数U和V,表示节点U和节点V之间有一条边。
下一行包含一个整数Q,表示查询的数量。
接下来的Q行,每行包含两个整数A和B,表示你需要找到节点A和节点B的最低公共祖先。
输出格式
对于每个查询,输出一行,该行包含一个整数,表示两个节点的最近公共祖先。
样例输入
5
12
13
24
25
3
45
34
3
5
样例输出
2
1
1
样例说明
对于第一个查询,4和5的最低公共祖先是2。
对于第二个查询,3和4的最低公共祖先是1。
对于第三个查询,3和5的最低公共祖先是1。
测评数据规模
2≤N≤10⁵,1≤Q≤10⁴,1≤U,V,A,B≤N,题目保证输入的边形成一棵树。
运行限制
语言
最大运行时间
最大运行内存
C
3s
128M
C++
3s
128M
Python3
5s
128M
Java
4s
128M
PyPy3
5s
128M
Go
5s
128M
JavaScript
5s
128M

我的答案:样例全部正确

一、信息

  • 问题描述:给定一棵有N个节点的树,每个节点有一个唯一编号(1到N)。需要处理Q个查询,每个查询给出两个节点,要求找到这两个节点的最近公共祖先(LCA)。
  • 输入格式
    • 第一行:整数N,表示树的节点数。
    • 接下来N-1行:每行两个整数U和V,表示U和V之间有一条边。
    • 接着一行:整数Q,表示查询数量。
    • 最后Q行:每行两个整数A和B,代表查询的两个节点。
  • 输出格式:对于每个查询,输出一个整数,即两个节点的最近公共祖先。
  • 数据规模:2 ≤ N ≤ 10⁵, 1 ≤ Q ≤ 10⁴, 1 ≤ U, V, A, B ≤ N。

二、分析

  • 信息作用:N和Q告诉我们节点数和查询数,U-V对描述了树的结构,A-B对是我们需要找LCA的节点对。
  • 思考过程:需要一种有效的方法来快速查询任意两节点的最近公共祖先。传统的方法,如从两节点向上遍历直到找到共同节点,会在最坏情况下导致O(N)的时间复杂度。
  • 算法选择:利用预处理和动态规划的方法,如Tarjan算法或基于倍增的LCA查询方法,可以将查询时间优化到接近O(1)。
  • 样例分析:给出的样例构成了一棵树,查询的答案通过图解可得。

三、算法设计

选择基于倍增(Binary Lifting)的LCA算法,因为它易于理解和实现,且效率较高。

  1. 预处理:对于树中的每个节点,预处理出它向上2^k步所到达的节点,其中k为满足2^k≤N的最大整数。
  2. 查询:对于每个查询(A, B),如果它们在不同深度,先调整到同一深度。然后如果它们不相同,一起向上跳,直到找到共同的祖先。

四、代码实现(C++)

#include <iostream>
#include <vector>
#include <cmath>
using namespace std;

const int MAXN = 1e5 + 5;
const int MAXLOG = 20; // 因为2^20 > 10^5
int up[MAXN][MAXLOG]; // up[v][j]表示节点v向上2^j步所到达的节点
int depth[MAXN];
vector<int> graph[MAXN];

void dfs(int v, int p) {
    up[v][0] = p; // 直接父节点
    for (int i = 1; i < MAXLOG; ++i) {
        if (up[v][i-1] != -1) up[v][i] = up[up[v][i-1]][i-1];
        else up[v][i] = -1;
    }
    for (int u : graph[v]) {
        if (u == p) continue;
        depth[u] = depth[v] + 1;
        dfs(u, v);
    }
}

int lca(int a, int b) {
    if (depth[a] < depth[b]) swap(a, b);
    int k = depth[a] - depth[b];
    for (int i = MAXLOG - 1; i >= 0; --i) {
        if (k & (1 << i)) {
            a = up[a][i];
        }
    }
    if (a == b) return a;
    for (int i = MAXLOG - 1; i >= 0; --i) {
        if (up[a][i] != up[b][i]) {
            a = up[a][
            i];
            b = up[b][i];
        }
    }
    return up[a][0]; // 返回共同的直接父节点
}

int main() {
    int n, q;
    cin >> n;
    for (int i = 1; i < n; ++i) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    // 初始化depth和up数组
    fill(depth, depth + MAXN, 0);
    fill(&up[0][0], &up[0][0] + MAXN*MAXLOG, -1);
    
    // DFS预处理
    dfs(1, -1); // 假设1号节点是根节点

    cin >> q;
    for (int i = 0; i < q; ++i) {
        int a, b;
        cin >> a >> b;
        cout << lca(a, b) << "\n";
    }
    return 0;
}

五、实现代码过程中可能遇到的问题

  1. 初始化问题:需要正确初始化up数组和depth数组。特别是up数组,需要将所有元素预设为-1,表示某些节点的祖先不存在。

  2. 边界条件处理:处理查询时,要确保两节点在同一深度,如果不在,则需要先通过向上跳步将深度较大的节点调整到与另一个节点同样的深度。

  3. 数组大小设置MAXNMAXLOG的设置需要根据问题的数据范围来决定,确保数组不会越界且能包含所有需要的数据。

  4. 效率问题:虽然基于倍增的LCA算法效率较高,但在处理极大数据量时,预处理步骤和查询步骤的效率仍可能成为瓶颈。优化代码实现,比如使用更快的I/O方法,可以进一步提高效率。

  5. 理解复杂度:明白该算法的时间复杂度对于预处理是O(NlogN),每次查询是O(logN),有助于在面对不同规模的数据时合理预估运行时间。


正确答案:

解题思路:

 源代码:

#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>
using namespace std;

const int MAXN = 1e5 + 5;
const int MAXLOG = 20;  // 2^20 > 1e5
vector<int> tree[MAXN];
int depth[MAXN];
int parent[MAXN][MAXLOG];

void dfs(int node, int prev) {
    for(int i = 1; i < MAXLOG; i++) {
        parent[node][i] = parent[parent[node][i - 1]][i - 1];
    }
    for(int child : tree[node]) {
        if(child != prev) {
            depth[child] = depth[node] + 1;
            parent[child][0] = node;
            dfs(child, node);
        }
    }
}

int lca(int u, int v) {
    if(depth[u] < depth[v]) swap(u, v);
    for(int i = MAXLOG - 1; i >= 0; i--) {
        if(depth[u] - (1 << i) >= depth[v]) {
            u = parent[u][i];
        }
    }
    if(u == v) return u;
    for(int i = MAXLOG - 1; i >= 0; i--) {
        if(parent[u][i] != parent[v][i]) {
            u = parent[u][i];
            v = parent[v][i];
        }
    }
    return parent[u][0];
}

int main() {
    int N, Q;
    cin >> N;
    for(int i = 1; i < N; i++) {
        int u, v;
        cin >> u >> v;
        tree[u].push_back(v);
        tree[v].push_back(u);
    }
    dfs(1, -1);
    cin >> Q;
    while(Q--) {
        int a, b;
        cin >> a >> b;
        cout << lca(a, b) << endl;
    }
    return 0;
}

我的理解:

段代码实现了"二进制提升"技术,有效地解决了最低公共祖先(LCA)查询问题。我们可以通过分析这段代码来理解其关键步骤和技巧:

关键步骤分析

  1. 预处理阶段

    • 使用深度优先搜索(DFS)从根节点开始遍历整棵树,为每个节点计算其所有可能的祖先。
    • 利用二维数组 parent[node][i] 来存储节点 node 的第 2i 个祖先。
    • 对于每个节点 node,通过关系 parent[node][i] = parent[parent[node][i-1]][i-1] 来计算更高层级的祖先。
  2. 查询阶段

    • 确保节点 u 的深度不小于节点 v,如果不是,则交换它们。
    • 利用预处理的信息,将 u 提升到与 v 相同的深度。
    • 如果提升后 uv 相同,则它们的最低公共祖先就是它们自己。
    • 否则,从最高层次开始尝试,逐层将 uv 向上提升,直到它们在同一层级上具有相同的祖先。
    • 最后,uv 的直接父节点即是它们的最低公共祖先。

方法优劣分析

  • 优点
    • 查询时间复杂度为 O(logN),对于每次查询都非常高效。
    • 预处理阶段虽然时间复杂度为 O(NlogN),但能显著提高后续查询的速度。
  • 缺点
    • 需要较大的空间来存储每个节点的所有可能祖先,空间复杂度为 O(NlogN)。
    • 预处理步骤增加了整体的前置计算时间和空间消耗。

时间复杂度分析

  • 预处理时间复杂度:O(NlogN),因为每个节点都要计算多达 logN 个祖先。
  • 查询时间复杂度:O(logN),因为每次查询都通过"二进制提升"技术在对数时间内完成。
  • 总时间复杂度:O((N+Q)logN),其中 N 是节点数,Q 是查询次数。

通过这个解析,我们可以深入理解"二进制提升"技术如何应用于LCA查询问题,并且认识到它在时间效率和空间利用上的权衡。这种方法对于处理大量LCA查询在实际应用中是非常有用的,特别是在那些节点数目和查询数目都很大的场景。


树链剖分的做法:

一、信息

  • 问题描述:给定一棵树和两个节点,需要找到这两个节点的最低公共祖先(LCA)。
  • 方法引入:树链剖分是解决树上路径查询和路径修改问题的一种方法,通过将树分解成若干条简单路径(即重链)来减少路径上的转折次数,以优化查询和修改操作。

二、分析

  • 信息作用:通过构建重链和轻儿子的概念,我们可以快速跳转在树的结构中,寻找两个节点的最低公共祖先。
  • 思考过程:树链剖分的关键在于如何高效地将一个复杂的树结构转化为易于查询和修改的线性结构。
  • 算法选择:选择树链剖分,因为它能够有效地处理树上的路径查询和修改问题,特别是对于频繁的LCA查询操作,可以显著提高效率。

三、算法设计

  1. 预处理DFS1:计算每个节点的深度、父节点、子树大小和重儿子节点。
  2. 建立重链DFS2:确定每个节点所在的重链的链头。
  3. LCA查询:通过不断跳跃至链头的父节点,直到两个查询节点位于同一条重链上,此时它们的LCA是深度较小的节点。

四、代码实现(C++)

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

const int N = 1e5 + 10;
vector<int> edge[N];
int n, m, deep[N], fa[N], son[N], siz[N], top[N];

void dfs1(int u, int father) {
    deep[u] = deep[father] + 1;
    fa[u] = father;
    siz[u] = 1;
    for (int v : edge[u]) {
        if (v == father) continue;
        dfs1(v, u);
        siz[u] += siz[v];
        if (son[u] == 0 || siz[son[u]] < siz[v]) son[u] = v;
    }
}

void dfs2(int x, int topx) {
    top[x] = topx;
    if (son[x] == 0) return;
    dfs2(son[x], topx);
    for (int y : edge[x]) {
        if (y == fa[x] || y == son[x]) continue;
        dfs2(y, y);
    }
}

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

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n - 1; i++) {
        int u, v; scanf("%d%d", &u, &v);
        edge[u].push_back(v);
        edge[v].push_back(u);
    }
    dfs1(1, 0);
    dfs2(1, 1);
    scanf("%d", &m);
    while (m--) {
        int x, y; scanf("%d%d", &x, &y);
        printf("%d\n", LCA(x, y));
    }
    return 0;
}

五、实现代码过程中可能遇到的问题

  • 理解难度:树链剖分相较于其他算法(如二进制提升)更为复杂,理解和实现门槛较高。
  • 调试问题:由于树链剖分涉及多个步骤的递归调用和多个全局数组的配合使用,代码调试难度较大,需要特别注意边界。

算法解释:

链剖分是解决树上问题的另一种强大方法,它特别适用于处理路径查询和修改任务。通过将树分割成若干条简单的路径(称为“重链”),该方法能够有效减少从任意节点到根节点路径上的转折次数,从而优化查询和修改操作。在最近公共祖先(LCA)查询中的应用如下所述:

树链剖分的核心概念:

  1. 重儿子:一个节点的所有儿子中,子树节点数最多的儿子称为重儿子。
  2. 轻儿子:除重儿子外的其他儿子。
  3. 重链:由节点到其重儿子之间形成的路径称为重链。
  4. 链头:每条重链最顶端的节点,即该链中深度最小的节点。

算法步骤:

  1. DFS预处理:第一次深度优先搜索(DFS1)计算每个节点的深度、父节点、子树大小和重儿子。
  2. 建立重链:第二次深度优先搜索(DFS2)根据重儿子关系,确定每个节点所在的重链的链头。
  3. LCA查询:对于查询中的两个节点,不断地将处于较深重链上的节点跳跃至其链头的父节点,直至两节点处于同一条重链上,此时它们的最近公共祖先就是两者中深度较小的那个。

算法优点:

  • 路径压缩:通过重链的概念,该方法有效减少了从任意节点向上遍历到根节点路径上的转折次数,优化了路径查询和修改的效率。
  • 适用性广:树链剖分不仅适用于LCA问题,还能解决树上路径查询和修改等多种问题。

算法缺点:

  • 实现复杂:相比于二进制提升等方法,树链剖分在理解和代码实现上较为复杂,需要更多的预处理工作。

时间复杂度分析:

  • 预处理时间复杂度:O(N),两次DFS遍历整棵树,每次遍历的时间复杂度为线性。
  • 查询时间复杂度:O(logN),每次查询最多跳跃O(logN)次,因为每次至少跳到当前节点所在重链的链头的父节点,而树的深度为O(logN)。

通过树链剖分,我们能够以较低的时间复杂度解决LCA问题,尤其是在涉及多次查询的场景中,这种方法的优势更为明显。

七、疑问

疑问1:三种方法哪个好?

1. 直接DFS寻找LCA

  • 优点:实现简单,不需要复杂的预处理。
  • 缺点:每次查询的时间复杂度为O(N),对于频繁的查询操作效率低下。不适合处理大量查询的场景。

适用场景:适用于查询次数少且树结构变化频繁的情况。

2. 树链剖分

  • 优点
    • 查询时间复杂度好,每次查询为O(logN)。
    • 除了LCA查询,还能高效处理树上路径查询和修改问题。
  • 缺点
    • 实现复杂,理解和编码难度较高。
    • 预处理时间和空间复杂度较高。

适用场景:适合于需要频繁进行树上路径查询和修改操作的场景,尤其是当这些操作和LCA查询混合使用时。

3. 二进制提升(Binary Lifting)

  • 优点
    • 查询时间复杂度为O(logN),预处理时间复杂度也是O(NlogN),在多数情况下表现良好。
    • 实现相对简单,理解容易。
    • 空间复杂度和实现复杂度均适中,是一种折衷的选择。
  • 缺点
    • 相较于直接DFS,需要一定的预处理时间。
    • 对于特别设计的树形数据(如链状结构),性能提升不是特别显著。

适用场景:适用于需要处理大量LCA查询,且树结构不经常变化的情况。

综合排行:

  1. 直接DFS寻找LCA:最劣。因为它在处理大量查询时效率极低,几乎不适用于任何需要频繁查询的场景。
  2. 树链剖分:次优。虽然在特定情况下表现出色,但由于实现复杂,不是所有情况下的首选。
  3. 二进制提升(Binary Lifting):最优。它在易于理解、实现上的平衡性,以及对于大量查询的高效处理上,提供了最佳的折衷方案。

总的来说,选择哪种方法取决于具体的应用场景、查询频率以及实现复杂度的考虑。对于大多数需要快速频繁查询LCA的应用,二进制提升提供了一个既高效又易于实现的解决方案。

疑问2:for (int u : graph[v]) { if (u == p) continue; depth[u] = depth[v] + 1; dfs(u, v); }中的for (int u : graph[v])什么意思

在C++中,for (int u : graph[v])是一个基于范围的for循环(Range-based for loop),用于遍历graph[v]中的所有元素。这种循环方式是C++11及以后版本引入的新特性,旨在提供一种更简洁易读的遍历容器(如数组、向量vector、列表list等)的方法。

在这个具体的例子中:

  • graph是一个邻接表,用于表示图(在这个场景下是一棵树)。graph[v]表示与节点v直接相连的所有节点的列表。
  • for (int u : graph[v])遍历这个列表,每次循环中,u依次取值为graph[v]中的每个元素。
  • 循环体内的代码会对每一个这样的u执行操作。在这个场景中,操作包括检查u是否是其父节点pif (u == p) continue;用于避免向父节点回溯),更新u的深度为v的深度加1(因为uv的子节点),然后递归地对u执行相同的处理流程。

简单来说,for (int u : graph[v])就是遍历节点v的所有邻接节点,并对每个邻接节点u执行后续的操作。这种遍历方式让代码更加清晰,也易于理解和维护。

八、总结

1. 树的深度优先搜索(DFS)

  • 基础DFS应用:通过DFS遍历树来计算节点深度、父节点、子树大小等基本属性,这是许多树相关算法的基础。
  • 递归思维:理解和应用递归思维来处理树结构,是解决树相关问题的关键。

2. 树的高级操作和概念

  • LCA的重要性:最近公共祖先在许多应用中都非常重要,比如生物信息学中的系统发育树分析、社交网络的关系分析等。
  • 树上路径和查询:理解如何在树上进行路径查询和修改,以及如何利用树的结构特性来优化这些操作。

3. 算法设计技巧

  • 二进制提升:一个高效处理LCA查询的技术,展示了如何利用二进制思想优化查找过程。
  • 树链剖分:一种更复杂的树结构处理技术,用于解决树上路径查询和修改问题,展示了分治思想和路径压缩的应用。
  • 预处理的威力:通过预处理信息来加速后续查询,是算法优化中的常见技巧。

4. 算法优化和选择

  • 理解不同算法的适用场景:通过比较直接DFS、二进制提升和树链剖分,理解不同算法在不同应用场景下的优劣,学会根据具体问题选择最合适的算法。
  • 时间复杂度和空间复杂度的权衡:每种方法都有其时间和空间效率的权衡,理解这一点对于设计高效算法至关重要。

5. 编程技巧和实践

  • 代码抽象和封装:通过函数的递归调用和模块化设计,提高代码的可读性和可复用性。
  • C++新特性应用:如基于范围的for循环,体现了现代C++编程技巧,使代码更简洁。

6. 问题分解和抽象能力

  • 从复杂问题中抽象出通用解决方案:无论是通过二进制提升还是树链剖分,本质上都是通过对问题的深入理解,将复杂问题分解为可管理的子问题来逐步求解。

7.不熟悉LCA的读者可以看看我这篇博客:

7.2.3 蓝桥杯基础数据结构之LCA

8.不熟悉树链剖分的可以看看我这篇博客:

7.2.6 蓝桥杯基础数据结构之数链剖分

  • 26
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏驰和徐策

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值