[算法学习笔记] 最近公共祖先 LCA

在讲解之前,我们先来看一道模板题:Luogu P3379 最近公共祖先(LCA)

What is LCA

LCA,即最近公共祖先。什么意思呢,我们举个例子:
image
将就着看吧qwq

这棵树中,0为根节点。若规定\(LCA(x,y)\)\(x,y\)的最近公共祖先,则\(LCA(5,6)=2;LCA(4,3)=1;LCA(5,3)=0\)。还有很多,这里不一一列举了。

讲到这里我们很容易想到暴力算法,先让 \(x,y\) 跳到同一个深度。然后再让 \(x,y\) 同时向上跳一层,重复跳跃,直到 \(x = y\) 停止。

这样的复杂度是很高的,接下来我们讲解 Tarjan 和 倍增求 LCA。

Tarjan

  • Tarjan算法是一种离线算法,跑一遍就能将所有需求的LCA计算完,因此效率比较高。
  • Tarjan实现应用了DFS搜索树+并查集 (相信来看的都懂吧

算法步骤
1.从根节点开始搜索树,如果搜到的点还有子节点则继续搜(DFS)

2.若搜到的节点没有子节点或子节点已经搜索过了,将父节点和子节点合并(并查集)

3.查找与当前节点\(now\)有询问关系的节点\(q_i\),若\(q_i\)已经回溯过则
\(LCA(now,q_i)=find(q_i)\) (\(find(q_i)\)指并查集操作,查找\(q_i\)的父节点)

需要注意,在STEP3中,\(q_i\)必须是已经回溯过而不是搜过,只有回溯过才有merge操作。(如果不理解可以先看下文模拟)

整个算法的过程非常巧妙,笔者水平有限,无法给出具体证明( 我太菜了ww),这里带着大家模拟一遍Tarjan流程吧,模拟完相信大家对Tarjan的理解就会更加深刻啦

模拟样例

我们还是模拟最先给出的那棵树:
image

为了简化题意,我们假设需求\(LCA(5,6)\)以及\(LCA(5,4)\)

显然,从0根节点开始DFS,先搜2,2还有子节点,先搜5,发现没有子节点。则merge(2,5)

此时,查找与5有询问关系的点:

  • 关系1:6号点。显然没有回溯过,不管
  • 关系2:4号点。显然也没有回溯过,不管
    此时回溯,返回2号,搜索6号。发现6没有子节点。则merge(2,6)

此时,查找与6有询问关系的点:

  • 关系1:5号点。已经回溯过,则\(LCA(5,6)=find(6)=2\)

此时回溯,返回0号,并且merge(2,0),搜索1号,1号有子节点,先搜4,merge(1,4)。
查找与4号有询问关系的点:

  • 关系1:5号点。已经回溯过,则\(LCA(4,5)=find(5)=0\)

显然还会继续搜索下去,和上边同理,相信大家对Tarjan算法已经有了深刻的理解,这里不继续模拟了。

通过模拟样例发现,在Tarjan过程中,是边DFS边merge,这样能够确保答案正确性,比较容易理解。具体证明不谈。

具体实现

通过Tarjan,我们可以求出所有需要求的LCA,但是如何存储答案,去重,输出呢?

我们可以用vector存树(注意一定是双向边),另开一个vector存储询问顺序实现按顺输出+去重。当然去重也可以用map,只不过浪费空间。

我们来看一下Tarjan模板:

void tarjan(int now)
{
    vis[now] = 1; //标记已访问
    for(int i=0;i<v[now].size();i++) //这里使用了vector存树
    {
        if(!vis[v[now][i]]) //如果子节点没访问过
        {
            tarjan(v[now][i]); //dfs继续搜
            fa[v[now][i]] = now; //merge操作
        }
        
    }
    for(int i=0;i<q[now].size();i++) //查找与now有询问关系的点
    {
      int p =find(q[now][i]);
        if(vis[q[now][i]] == 2) //如果询问的点回溯过
        {
            ans[id[now][i]] = p; //id存答案编号,即按序输出
        }
    }
    vis[now] = 2;//所有操作完毕后标记now已回溯
}

至于刚开始给出的模板题,由于已经给出了 Tarjan 板子,其余部分自己实现一下吧!

倍增 LCA

倍增 LCA 基于暴力,我们先回忆一下暴力是如何求解的。

在暴力求 LCA 的时候,我们先使 \(x,y\) 跳到同一深度,然后再同时一层一层的跳。

根据二进制唯一分解定理,任意的一个正整数都可以划分几个不重复的二的整次幂和。我们可以每次跳 \(2\) 的整次幂。

\(x,y\) 同时跳的时候,显然 LCA 及其以上的节点都是公共祖先。如果一步跳到公共祖先就输出我们无法确保这就是 LCA。

不妨每次不跳到公共祖先,这样最后跳到的节点 \(x\) 的父节点一定是公共祖先。

证明:二进制唯一分解定理。例如 \(x,y\) 需要同时跳 \(k\) 步才是 LCA。那么 \(k\) 一定可以分解成不重复的二的整次幂和的形式。

\(x,y\) 同时跳之前,需要确保 \(x,y\) 所在深度一致。这也很简单,如果深度不一致,则用倍增跳到一致即可。

显然我们需要预处理每个节点的深度以及每个点向上跳 \(2^i\) 步所能到达的节点。处理深度非常简单,对于处理每个节点 \(m\) 向上跳 \(2^i\),类似于动态规划的思想,如果设 \(f_{m,i}\) 表示节点 \(m\) 向上跳 \(2^i\) 步所能到达的节点,则满足:

\[f[m,i]=f[f[m,i-1],i-1] \]

类比 \(2^i=2^{i-1}+2^{i-1}\)

需要注意预处理 \(f[m,0]=fa_m\) ( \(fa_m\) 表示 \(m\) 的父亲 )

提供倍增 LCA 模板。如下。

倍增 LCA
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 500000+2;
int depth[N*2];
int fa[N][100];
int n,m,s;
vector <int> Edge[N*2];
void dfs(int now,int fat)
{
    fa[now][0] = fat; //预处理节点 now 向上跳 2^i 步所能到达的节点
    depth[now] = depth[fat] + 1; //深度
    for(int i=1;(1<<i) <= depth[now]; i++)
        fa[now][i] = fa[fa[now][i-1]][i-1]; // dp
    for(int i=0;i<Edge[now].size();i++)
    {
        int v = Edge[now][i];
        if(v != fat) dfs(v,now); //dfs 预处理
    }
}
int lca(int a,int b)
{
    if(depth[a] > depth[b]) swap(a,b);
    for(int i=20;i>=0;i--) //从大到小跳
    {
        if(depth[a] <= depth[b]-(1<<i)) b = fa[b][i]; //先跳到同一高度,倍增优化
    }
    if(a == b) return a; //由于跳跃过程用到的是倍增优化,所以如果是跳到了一起则一定是 lca
    for(int i=20;i>=0;i--)
    {
        if(fa[a][i] == fa[b][i]) continue;
        a = fa[a][i]; //如果不同则跳
        b = fa[b][i];
    }
    return fa[a][0]; //返回任意节点的父亲即可。
}
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    cin>>n>>m>>s;
    for(int i=1;i<=n-1;i++)
    {
        int x,y;
        cin>>x>>y;
        Edge[x].push_back(y);
        Edge[y].push_back(x);
    }
    dfs(s,0);
    for(int i=1;i<=m;i++)
    {
        int a,b;
        cin>>a>>b;
        cout<<lca(a,b)<<endl;
    }
    return 0;
}
posted @ 2023-06-24 10:57  SXqwq  阅读(55)  评论(0编辑  收藏  举报