ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

LCA 相关 && 树链剖分

2022-08-15 00:03:50  阅读:157  来源: 互联网

标签:结点 剖分 int 树链 maxn && LCA now


LCA

基本定义:最近公共祖先简称 LCA(Lowest Common Ancestor)。两个结点的最近公共祖先,就是这两个点的公共祖先里面,离根最远的那个。

简单来讲,就是两个点到根的路径上,深度最深的重合点

常用的求解方法:

  1. 朴素方法

每次选择深度较深的那个结点,往上走一步,直到两个结点重合,这个重合点就是 \(LCA\)

此方法查询一次 \(LCA\) 的时间复杂度为 \(O(n)\)

const int maxn = 5e5 + 10;
vector<int>gra[maxn];
int dep[maxn], fa[maxn];

// 预处理深度
void dfs(int now, int pre, int d)
{
    dep[now] = d;
    fa[now] = pre;
    for(int i=0; i<gra[now].size(); i++)
    {
        int nex = gra[now][i];
        if(nex == pre) continue;
        dfs(nex, now, d + 1);
    }
}

// 一步步向上求 LCA
int LCA(int a, int b)
{
    while(a != b)
    {
        if(dep[a] < dep[b]) swap(a, b);
        a = fa[a];
    }
    return a;
}
  1. 倍增法

上述方法慢的地方在于,每次都只走一步,因此我们优化的时候考虑每次走向上走 \(2^i\) 步

具体实现:

  1. 如果两个点深度不一致:选取较深的点,往上跳,直到深度一致

  2. 深度相同,判断一下是不是同一个点,如果是,则说明 \(LCA\) 就是该点,否则进行 步骤 3

  3. 两个点同时往上走,从大到小枚举,判断他们的第 \(k\) 级祖先是否相等,如果不相等,则证明肯定不是 LCA,往上跳

  4. 步骤 3 结束后,得到的应该是两个 \(LCA\) 的子结点,因此直接返回其中一个结点的父结点

第三个步骤,类似于二分的过程

接下来考虑如何计算所有结点的第 \(2^k\) 级祖先

设 \(x\) 结点的第 \(2^k\) 级祖先为 \(fa[x][k]\)

\(dfs\) 可以直接求出第 \(2^0\) 级祖先(父亲),即 \(fa[x][0]\)

考虑状态转移:\(fa[j][i] = fa[fa[j][i-1]][i-1]\)

因此我们只要先计算出所有结点的第 \(2^{i-1}\) 级祖先,就可以计算出其第 \(2^i\) 级祖先

预处理时间复杂度:\(O(nlogn)\)

询问一次 LCA 的时间复杂度:\(O(logn)\)

const int maxn = 5e5 + 10;
vector<int>gra[maxn];
int fa[maxn][25], dep[maxn];

// 求深度 以及 所有结点的第 0 级祖先
void dfs(int now, int pre, int cur)
{
    if(dep[now]) return;
    dep[now] = cur;
    fa[now][0] = pre;
    for(int i=0; i<gra[now].size(); i++)
    {
        int nex = gra[now][i];
        if(nex == pre) continue;
        dfs(nex, now, cur + 1);
    }
}

// 预处理
void init()
{
    for(int i=1; i<=20; i++)
        for(int j=1; j<=n; j++)
            fa[j][i] = fa[fa[j][i-1]][i-1];
}

// 查询 LCA
int LCA(int a, int b)
{
    if(dep[a] < dep[b]) swap(a, b);
    int dif = dep[a] - dep[b];
    for(int i=20; i>=0; i--)
    {
        if(dif >= (1 << i))
        {
            a = fa[a][i];
            dif -= 1 << i;
        }
    }
    if(a == b) return a;
    for(int i=20; i>=0; i--)
    {
        if(fa[a][i] != fa[b][i])
        {
            a = fa[a][i];
            b = fa[b][i];
        }
    }
    return fa[a][0];
}

树上两点的最短路径

树上最短路径显然就是从一个结点上升到 \(LCA\) 后再下降到另一个结点的路径

考虑如果多次询问两点间的最短路径:

  1. 考虑预处理 \(2^k\) 级祖先的时候,同时加上距离来预处理

  2. 考虑利用容斥:两个点到根的距离,再减去 \(LCA\) 到根的距离

显然第二种方法更优秀:\(O(n)\)

ll query(int u, int v)
{
    int lca = LCA(u, v);
    return dis[u] - dis[lca] * 2 + dis[v];
}

树上差分

树上差分用于树上两点间路径的区间修改,能做到修改时间复杂度 \(O(logn)\),查询的时间复杂度为 \(O(n)\)

虽然这个时间复杂度上不及树链剖分,但是好写,核心代码就几行

我们定义点 \(x\) 的值为 \(val[x]\),并且定义一个差分数组 \(dif\),有 \(dif[x] = val[x] - \sum_{son_x}val[x]\)

根据上述的定义,我们可以先求子节点的值,再求本身的值,所以整个查询过程就是 \(dfs\) 的过程

代码的话其实可以不考虑点原始的初始值,只用给 \(dif\) 数组进行操作就行,最终的答案就是 修改后的增值 + 初始值

const int maxn = 1e5 + 10;
int dif[maxn];
void dfs(int now, int pre)
{
    for(int nex : gra[now])
    {
        if(nex == pre) continue;
        dfs(nex, now);
        dif[now] += dif[nex];
    }
}

考虑修改的过程:

如果让树上一个点的 \(dif[u] + x\),根据查询的过程,就会将树上从 \(u\) 到 根 的整一条链都加上一个值,如果在这条链上的某个点 \(v\) 同样进行 \(dif[fa[v]] - x\) 的操作,我们就使链 \(u\) 到 \(v\) 区间加和了 \(x\)

将 \(4\) 号结点视为 \(u\)

将 \(3\) 号结点视为 \(v\)

无论是怎样的两点之间树上路径,我们都可以将其差分成两个直链

因此树上差分可以写成以下方式

inline void add (int u, int v, int x)
{
    int lca = LCA(u, v);
    dif[u] += x;
    dif[u] += x;
    dif[lca] -= x;
    dif[fa[lca]] -= x;
}

有的时候并不是维护树上点权,而是维护树上边权,就要考虑将边权化为点权

我们可以利用树上每个点仅有一条边是指向根的方向,将所有的边权视为它深度较深的点的点权

但是差分的时候并不用考虑 \(LCA\) 的值,因此是变成两个不相交的直链

inline void add(int u, int v, int x)
{
    int lca = LCA(u, v);
    dif[u] += x;
    dif[v] += x;
    dif[lca] -= x * 2;
}

\(dfs\) 序

\(dfs\) 序:每个结点在 \(dfs\) 深度优先遍历中的进出栈的时间序列

const int maxn = 1e5 + 10;
int dfn[maxn], tp = 0;
void dfs(int now, int pre)
{
    tp++;
    dfn[now] = tp;
    for(int i=0; i<gra[now].size(); i++)
    {
        int nex = gra[now][i];
        if(nex == pre) continue;
        dfs(nex, now);
    }
}

\(dfs\) 序,其实相当于重新给每个点进行编号,根据搜索回溯的过程,可以发现子树上的 \(dfn\) 序是连续的一段

在上述的基础上,我们可以以子树为单位维护一些信息

例如:子树上所有点都加上 \(x\),我们可以直接套用 分块线段树树状数组 等数据结构,将其视为连续序列,直接修改

因此我们的 \(dfs\) 序要维护两个东西,子树中,连续 \(dfn\) 的左端和右端

const int maxn = 1e5 + 10;
int dfn[maxn], bot[maxn], tp = 0;
void dfs(int now, int pre)
{
    tp++;
    dfn[now] = tp;
    for(int i=0; i<gra[now].size(); i++)
    {
        int nex = gra[now][i];
        if(nex == pre) continue;
        dfs(nex, now);
    }
    bot[now] = tp;
}

针对于 \(dfs\) 序,还有另外一种写法:每进入和回溯的时候都记录一下当时的时间戳,并让时间戳 \(+1\)

这样做的好处是,\(O(n)\) 预处理后,可以在 \(O(1)\) 的复杂度下,判断两个点之间的祖先关系

const int maxn = 1e5 + 10;
int in[maxn], out[maxn], tp = 0;
void dfs(int now, int pre)
{
    dfn[now] = ++tp;
    for(int i=0; i<gra[now].size(); i++)
    {
        int nex = gra[now][i];
        if(nex == pre) continue;
        dfs(nex, now);
    }
    out[now] = ++tp;
}

inline bool is_ance(int u, int v)
{
    return in[u] <= in[v] && out[u] >= out[v];
}

上述这种维护方式也可以引申出一个 欧拉序 的求解 \(LCA\) 的做法:将 \(LCA\) 问题在 \(O(n)\) 的复杂度下,转化为 \(RMQ\) 问题

树链剖分

树链剖分的形式有很多种:重链剖分、长链剖分、\(LCT\) 中的剖分,我们下面讲的是重链剖分

考虑在树上求解一些区间问题:

  1. 子树区间修改、查询

  2. 两点之间路径的修改、查询

上述所说的 \(dfn\) 序能够很好的处理第一个问题,但是第二个问题仍得到不到解决

思考我们现有的区间修改数据结构:差分、分块、线段树、树状数组

线段树和树状数组要求是在下标连续的区间内作修改,因此我们考虑能不能利用 \(dfn\) 序,将一棵树剖分成若干条链,然后维护每一条链上的信息,做到区间修改

构造重链剖分

给出部分定义:

  1. 重子结点:该结点的子结点中子树最大的子结点,且每个结点最多只有一个重子结点,如果有多个最大的子结点,则选取其中一个作为其重子结点

  2. 轻子结点:除了重子结点外的所有子结点

  3. 重边:结点到重子结点的边

  4. 轻边:结点到轻子结点的边

\(dfs\) 的时候,就优先按照重子结点搜,形成一条条链

图源:oi-wiki

树链剖分总共要维护以下信息:

  • \(fa[x]\):\(x\) 结点的父结点

  • \(dep[x]\):\(x\) 结点所在的深度

  • \(siz[x]\):以 \(x\) 为根的子树大小(所含结点数)

  • \(hson[x]\):\(x\) 结点的重子结点

  • \(dfn[x]\):\(x\) 结点的 \(dfs\) 序

  • \(rnk[x]\):\(dfs\) 序的第 \(x\) 个结点,\(rnk[dfn[x]] = x\)

  • \(top[x]\):\(x\) 结点所在链的链顶结点(深度最小)

  • \(bot[x]\):以 \(x\) 为根的子树中,最大的 \(dfs\) 序

上述信息建议分成两次 \(dfs\) 来解决

第一次 \(dfs\) 维护:\(fa\)、\(dep\)、\(siz\)、\(hson\) 的信息

int dep[maxn], siz[maxn], hson[maxn], fa[maxn];
void dfs1(int now, int pre, int d)
{
    dep[now] = d;
    siz[now] = 1;
    hson[now] = -1;
    fa[now] = pre;
    for(auto nex : gra[now])
    {
        if(nex == pre) continue;
        dfs1(nex, now, d + 1);
        siz[now] += siz[nex];
        if(hson[now] == -1 || siz[hson[now]] < siz[nex])
            hson[now] = nex;
    }
}

第二次 \(dfs\) 维护:\(dfn\)、\(bot\)、\(rnk\)、\(top\)

int tp = 0;
int dfn[maxn], bot[maxn], rnk[maxn], top[maxn];
void dfs2(int now, int t)
{
    tp++;
    dfn[now] = tp;
    rnk[tp] = now;
    top[now] = t;
    if(hson[now] != -1)
    {
        dfs2(hson[now], t);
        for(auto nex : gra[now])
        {
            if(nex == fa[now] || nex == hson[now]) continue;
            dfs2(nex, nex);
        }
    }
    bot[now] = tp;
}

综上,可以保证每个结点属于且仅属于一条重链,树链剖分预处理的时间复杂度为:\(O(n)\)

树链剖分后,在树上遍历任意一条路径的时间复杂度:\(O(logn)\)

与启发式合并相类似,如果当前点处于一个轻边上,则每往上走一步,整颗子树的大小必然增大至少一倍

这样每个结点最多切换 \(logn\) 次轻边

重链剖分使用

  1. 求 \(LCA\)

分割重链之后,每次都选取两个结点所在的重链中,\(top\) 较深的那个,然后往上升,直到两个结点同处于一条链中

如果同处于一条链中,则 \(LCA\) 为深度较浅的那个点

int LCA(int a, int b)
{
    while(top[a] != top[b])
    {
        if(dep[top[a]] < dep[top[b]]) swap(a, b);
        a = fa[top[a]]; // 一定要记得到了链顶还要再往上走一步,上升到另外一条重链
    }
    return dep[a] < dep[b] ? a : b;
}
  1. 路径上维护信息

例题:Aladdin and the Return Journey

大意:给出一个树,每个点有点权,有两种操作:

  • 给一个点的点权加上 \(x\)

  • 求两个点的最短路径上的全部点权和

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <vector>
using namespace std;
const int maxn = 3e4 + 10;
vector<int>gra[maxn];
int dep[maxn];
int siz[maxn];
int hson[maxn];
int fa[maxn];
int top[maxn];
int dfn[maxn];
int rnk[maxn];

int tr[maxn << 2];
int w[maxn]; // 每个点的起始点权

// 建树,特别要注意的是,建树是根据 dfn 来建树的!
// 这里的 l r 是 dfn 的值,而不是点的编号
void build(int now, int l, int r)
{
    if(l == r)
    {
        // rnk[l],返回 dfn 第 l 个点的编号,然后赋初值
        tr[now] = w[rnk[l]];
        return;
    }
    int mid = l + r >> 1;
    build(now << 1, l, mid);
    build(now << 1 | 1, mid + 1, r);
    tr[now] = tr[now << 1] + tr[now << 1 | 1];
}

// 区间查询
int query(int now, int l, int r, int L, int R)
{
    if(L <= l && r <= R)
        return tr[now];
    int mid = l + r >> 1;
    int ans = 0;
    if(L <= mid)
        ans += query(now << 1, l, mid, L, R);
    if(R > mid)
        ans += query(now << 1 | 1, mid + 1, r, L, R);
    return ans;
}

// 单点修改
void update(int now, int l, int r, int x, int val)
{
    if(l == r)
    {
        tr[now] = val;
        return;
    }
    int mid = l + r >> 1;
    if(x <= mid)
        update(now << 1, l, mid, x, val);
    else
        update(now << 1 | 1, mid + 1, r, x, val);
    tr[now] = tr[now << 1] + tr[now << 1 | 1];
}

// 第一次 dfs
void dfs1(int now, int pre, int d)
{
    siz[now] = 1;
    hson[now] = -1;
    dep[now] = d;
    fa[now] = pre;
    for(int i=0; i<gra[now].size(); i++)
    {
        int nex = gra[now][i];
        if(nex == fa[now]) continue;
        dfs1(nex, now, d + 1);
        siz[now] += siz[nex];
        if(hson[now] == -1 || siz[hson[now]] < siz[nex])
            hson[now] = nex;
    }
}

int tp = 0;
// 第二次 dfs
void dfs2(int now, int t)
{
    top[now] = t;
    tp++;
    dfn[now] = tp;
    rnk[tp] = now;
    if(hson[now] != -1)
    {
        dfs2(hson[now], t);
        for(int i=0; i<gra[now].size(); i++)
        {
            int nex = gra[now][i];
            if(nex == fa[now] || nex == hson[now]) continue;
            dfs2(nex, nex);
        }
    }
}

// 初始化
void init(int n, int rt = 1)
{
    tp = 0;
    dfs1(rt, rt, 1);
    dfs2(rt, rt);
    build(1, 1, n);
    for(int i=0; i<=n; i++) gra[i].clear();
}

int solve(int a, int b, int n)
{
    int ans = 0;
    // 在寻找 LCA 的过程中,顺便统计路径上的全部值
    while(top[a] != top[b])
    {
        if(dep[top[a]] < dep[top[b]]) swap(a, b);
        ans += query(1, 1, n, dfn[top[a]], dfn[a]);
        a = fa[top[a]];
    }
    if(dep[a] > dep[b]) swap(a, b);

    // 在同一条链上的时候统计最后一段
    ans += query(1, 1, n, dfn[a], dfn[b]);
    return ans;
}

int main()
{
    int t;
    scanf("%d", &t);
    for(int casee=1; casee<=t; casee++)
    {
        printf("Case %d:\n", casee);
        int n;
        scanf("%d", &n);
        for(int i=0; i<n; i++) scanf("%d", &w[i]);
        for(int i=1; i<n; i++)
        {
            int x, y;
            scanf("%d%d", &x, &y);
            gra[x].push_back(y);
            gra[y].push_back(x);
        }
        init(n, 0);
        int q;
        scanf("%d", &q);
        while(q--)
        {
            int x;
            scanf("%d", &x);
            if(x == 0)
            {
                int i, j;
                scanf("%d%d", &i, &j);
                // 询问
                printf("%d\n", solve(i, j, n));
            }
            else
            {
                int i, v;
                scanf("%d%d", &i, &v);
                // 单点修改
                update(1, 1, n, dfn[i], v);
            }
        }
    }
    return 0;
}

题单

题号 题目 标签 难度 题解
洛谷-P3379 【模板】最近公共祖先(LCA) LCA 1

标签:结点,剖分,int,树链,maxn,&&,LCA,now
来源: https://www.cnblogs.com/dgsvygd/p/16586730.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有