引子

在网上的大多数blog中,博主们都是将RMQ问题,ST表,LCA,倍增分成一个一个单独的blog来写(就是为了水),其实,他们之间是有一定的内在联系的,但我们将其合并在一起学习时,就会得到一个特殊的buff,在本篇中,你不仅能习得这些知识点,还能把他们内在的联系弄明白,事不宜迟,现在就开始吧!

RMQ

RMQ,全名为Range Maximum/Minimum Query,中译为区间最大/最小值问题,一般形式都是给定一个(一般都是多个)闭区间[l,r],问你在这个区间中某个量的最大最小值。这类问题都是隐藏在题目深处的,关于一道题是否是用RMQ解,这需要睿智的你来根据RMQ的定义自行判断。

 

ST表

 

先上题目:https://www.luogu.com.cn/problem/P3865

 

对于一个序列,我们要求这个序列中某闭区间的最大值,最原始的暴力方法就是O(n)跑一遍,但加上m次询问后时间复杂度就会暴涨为O(mn),实在太不划算,ST表就应运而生了,他能用O(nlogn)的时间进行一次预处理,此后每一次的查询他只需要用O(1)的时间就可以求解出问题的解了。具体步骤如下:

 

我们用f[i][j]表示从第i个数开始的2^j个数中的最大值,例如f[i][1]就是第i个数与第i+1个数中的最大值。在转移时,我们可以把当前区间分成两个小区间,并分别求最大值,如下图所示:

从RMQ到ST表到倍增到LCA(多个模块一起过)_i++

 

具体伪代码实现如下:

f[i][j] = max(f[i][j-1], f[i+2^(j-1)][j-1]),   (j>0 且 i+2^(j-1)<=n)
= f[i][j-1], (j>0 且 i+2^(j-1)>n) = a[i], (j=0)

查询时,我们将一个目标区间分为两部分,定义一个k=log(r-l+1),为闭区间二进制下的长度,将区间分为l~l+2^k-1和r-2^k+1~r两个区间,这两个区间重合起来一定是覆盖整个目标区间的(如下图所示),所以取其max值就行了。

从RMQ到ST表到倍增到LCA(多个模块一起过)_st表_02

 

  整个ST表的实现如下:

#include<iostream>
#include<cstdio>
#include<cmath>
using namespace std;
int n,m,a,LC;
int f[100001][40];
int bit[1000];
inline void Bit(int LC){ 
    bit[0]=1;
    for(int i=1;i<=LC;i++){
        bit[i]=bit[i-1]*2;//计算2的i次方的值 
    }
}
inline void ST(int LC){//构建ST表 
    for(int j=1;j<=LC;j++){
        for(int i=1;i<=n-bit[j]+1;i++){
            f[i][j]=max(f[i][j-1],f[i+bit[j-1]][j-1]); //前文有讲 
        }
    }
}
inline int search(int l,int r,int LC){
    return max(f[l][LC],f[r-bit[LC]+1][LC]);//查询 
}
int main() {
    scanf("%d %d",&n,&m);
    LC=(int)(log(n)/log(2));//计算ST表的长度 
    Bit(LC);
    for(int i=1; i<=n; i++) {
        scanf("%d",&a);
        f[i][0]=a;//第i个数的2^0(=1)个数即为自己 
    }
    ST(LC);
    for(int i=1;i<=m;i++){
        int l,r;
        scanf("%d %d",&l,&r);
        LC=(int)(log(r-l+1)/log(2));//询问闭区间的长度 
        int ans=search(l,r,LC);
        printf("%d\n",ans);
    }
    return 0;
}

 

难道,你以为这就完了?不,以上内容只是一维的ST表,巨佬们甚至还有二维的st表,但是由于我太菜了,还没有完全理解,等我消化几个月后再附上二维ST表~~(先鸽一段时间,以后再补,咕嘿~)

 

倍增

 

其实,求ST表的过程就是倍增。

 

最简单的倍增就是快速幂算法,原来的快速幂是一个递推公式f[i]=f[i-1]*f[i-1],其思想就是倍增,只不过在经过优化后没有了空间的大量损耗罢了。

 

倍增是一种思想,倘若你要我交于你一种解题的模板方法那还真难弄,因为这在巨佬手中就是一种降低复杂度的工具而非直接的一种算法,因此参悟其中的奥妙还需要你自己多刷题。

附上几个例题,自行摸索吧!

例题1:https://www.luogu.com.cn/problem/P3509

题解:

从题目中我们可以得知,青蛙在第i个点可以向左向右跳,从每一点出发跳到第K远的位置固定,因此我们可以预处理出每个点跳到第K远的位置为多少,时间复杂度为O(nk),实在有点慢,这时候我们可以构建一个滑窗,使其长度为k+1(长度为k+1的原因是其中还需要包含一个点i),并且i被包含在其中。滑窗的左端点为left,右端点为right,我们要维护其left和right端点所处的位置一个是距离i的第k-1远的位置,一个是距离其第k远的位置。维护起来也不难,具体如下:

 

for(ll i=1;i<=n;i++){
        while(i>right)
            left++,right++;//当第i点不在其滑窗中时,强制把滑窗拉到它的位置,此时,i的位置应当位于right的位置 
        pre_l=a[i]-a[left];//计算第i点距离left的距离 
        pre_r=a[right]-a[i];//距离right的距离 
        ll to_l=a[i]-a[left+1];//假如滑窗要移动,那么第i点距离left+1个点的距离 
        ll to_r=a[right+1]-a[i];//距离right+1个点的距离 
        while(to_r<pre_l){//倘若向右有那么一个点,它距离i点更近,那么距离i点第k远的位置就不再是left或right之间了,为了维护滑窗,移动 
            left++,right++;
            pre_l=to_l;
            pre_r=to_r;
            to_r=a[right+1]-a[i];
            to_l=a[i]-a[left+1];
        }
        if(pre_l>=pre_r) next[i]=left;//距离i更远的点就是第k远的点 
        else  next[i]=right;
    }

 

虽然说我们知道了每一步的接下来一步的位置,但是别忘了我们还要跳m步呀!真要一步一步跳还是会炸,因此我们可以用倍增的方法进行优化。对于每个点i,我们维护它跳2^j步后的位置,记做f[i][j],转移方程也很好写,就是f[i][j]=f[f[i][j-1]][j-1],他看上去像什么?诶,,对!像我们前面讲的快速幂的原始版本的变形,那我们思考一下能不能直接将其转为形似快速幂的更优版本,因为我们进行一次装转移后就再也不会用到以前的决策了,所以我们完全可以放手一搏,具体代码实现如下:

while(m){
        if(m&1) for(ll i=1;i<=n;i++) ans[i]=next[ans[i]];
        m>>=1;
        memcpy(next2,next,sizeof(next2)); 
        for(ll i=1;i<=n;i++)
            next[i]=next2[next2[i]];
    }

最终的完整版如下:

#include<iostream>
#include<cstdio>
#include<cmath>
#include<cstring>
using namespace std;
typedef long long ll;
#define maxn 1000001
ll n,k,m,ans[maxn];
ll a[maxn],next[maxn];
ll pre_l,pre_r;
ll lg[maxn];
ll next2[maxn];
int main(){
    memset(a,0x7f7f7f7f,sizeof(a));//为了防止目标外的空位影响到结果 
    scanf("%lld%lld%lld",&n,&k,&m);
    for(ll i=1;i<=n;i++)
        scanf("%lld",&a[i]);
    ll left=1,right=k+1;//构建一个长度为k的滑窗 
    for(ll i=1;i<=n;i++){
        while(i>right)
            left++,right++;//当第i点不在其滑窗中时,强制把滑窗拉到它的位置,此时,i的位置应当位于right的位置 
        pre_l=a[i]-a[left];//计算第i点距离left的距离 
        pre_r=a[right]-a[i];//距离right的距离 
        ll to_l=a[i]-a[left+1];//假如滑窗要移动,那么第i点距离left+1个点的距离 
        ll to_r=a[right+1]-a[i];//距离right+1个点的距离 
        while(to_r<pre_l){//倘若向右有那么一个点,它距离i点更近,那么距离i点第k远的位置就不再是left或right之间了,为了维护滑窗,移动 
            left++,right++;
            pre_l=to_l;
            pre_r=to_r;
            to_r=a[right+1]-a[i];
            to_l=a[i]-a[left+1];
        }
        if(pre_l>=pre_r) next[i]=left;//距离i更远的点就是第k远的点 
        else  next[i]=right;
    }
    lg[0]=1;
    ll limit=log(m)/log(2);
    for(ll i=1;i<=limit;i++)
        lg[i]=lg[i-1]*2;
    for(ll i=1;i<=n;i++)
        ans[i]=i;
    while(m){
        if(m&1) for(ll i=1;i<=n;i++) ans[i]=next[ans[i]];
        m>>=1;
        memcpy(next2,next,sizeof(next2)); 
        for(ll i=1;i<=n;i++)
            next[i]=next2[next2[i]];
    }
    for(ll i=1;i<=n;i++)
        printf("%lld ",ans[i]);
    return 0;
}

 

LCA

LCA(Lowest Common Ancestor),即给定一棵有根树,求树上两个点的最近公共祖先的问题。

有图有真相:

从RMQ到ST表到倍增到LCA(多个模块一起过)_预处理_03

 

我们一般有三种方法求解,分别为tarjan,树链剖分和倍增法,但是,还是因为本蒟蒻太菜了,还是没深入了解其他两种方法,加之本节只是为了深究倍增思想的应用,所以就鸽了吧,咕嘿~~,本节仅讲倍增法。

我们考虑维护 an[i][j] 表示 i 号节点的第 2^j 级祖先,如果没有则为 0。转移即: an[i][j] = an[an[i][j-1]][j-1], j > 0 = fa[i], j = 0 这个可以通过树上 dfs 一遍求得。

 代码如下:

void dfs(int u,int fath){
    deep[u]=deep[fath]+1;
    parent[u][0]=fath;
    for(int i=1;i<=(int)(log(deep[u])/log(2));i++)
        parent[u][i]=parent[parent[u][i-1]][i-1];
    for(int i=head[u];i;i=tree[i].next)
        if(tree[i].to!=fath)
            dfs(tree[i].to,u);
}

就这样,我们完成了预处理,接下来是查询。

考虑两个相同深度的点 x,y 的 lca 怎么求。(假定 x != y) 容易发现它们到 lca 的距离相同,假设距离为 d。 对于任意的 r>=d, 那么 x 的 r 级祖先和 y 的 r 级祖先相同,对于任意的 r<d, x 的 r 级祖先和 y 的 r 级祖先不同。 故可以按照 k 从大往小的顺序,每次看两个点的第 2^k 级祖先是否相同,如果相同则什么也不做,如果不同则将两个点改成它们的第 2^k 级祖先。 这样做下去,直至 k = 0。求出的两个点一定是要求的 lca 的儿子。(为什么?自己看上面的图推去!)

所以我们可以知道,我们控制x,y一齐向上跳直到相遇时不一定是对的,我们要控制其不断地逼近正确的LCA但不重合,最好的位置就是LCA的第一个儿子,这时候两者的父亲一致但不重合,到时候结果只需要输出其中一个的父亲就可以啦!

考虑当 x 和 y 深度不同的时候怎么办? 把 x 和 y 的深度搞成相同的即可。 不妨假设 dep[x]>dep[y],设 x 的 dep[x]-dep[y] 级祖先为 z。那么 lca(z,y)=lca(x,y),dep[z]=dep[y], 于是求 lca(z,y) 即可。 怎么求 z? 考虑将 dep[x]-dep[y] 拆成 log 个 2 的幂暴力跳即可。

具体代码如下:

int lca(int x,int y){
    if(deep[x]<deep[y])
        swap(x,y);
    while(deep[x]>deep[y])
        x=parent[x][(int)(log(deep[x]-deep[y])/log(2))];//两者一起跳 
    if(x==y)//跳到最后深度持平且重合,那就直接输出 
        return x;
    for(int i=lg[deep[x]]-1;i>=0;i--){
        if(parent[x][i]!=parent[y][i]){//一起跳 
            x=parent[x][i];
            y=parent[y][i];
        }
    }
    return parent[x][0];
}

时间复杂度: 预处理 O(nlogn),单词询问 O(logn)。 空间复杂度:O(nlogn)。

最后的完整代码:

#include<iostream>
#include<cmath>
#include<cstdio>
#define maxn 500010
using namespace std;
int n,m,s;
int x,y;
struct abc{
    int to,next;
}tree[maxn<<1];
int head[maxn],top;
void add(int u,int v){
    tree[++top].to=v;
    tree[top].next=head[u];
    head[u]=top;
}
int lg[maxn];
int deep[maxn];
int parent[maxn][40];
void dfs(int u,int fath){
    deep[u]=deep[fath]+1;
    parent[u][0]=fath;
    for(int i=1;i<=lg[deep[u]];i++)
        parent[u][i]=parent[parent[u][i-1]][i-1];
    for(int i=head[u];i;i=tree[i].next)
        if(tree[i].to!=fath)
            dfs(tree[i].to,u);
}
int lca(int x,int y){
    if(deep[x]<deep[y])
        swap(x,y);
    while(deep[x]>deep[y])
        x=parent[x][(int)(log(deep[x]-deep[y])/log(2))];//两者一起跳 
    if(x==y)//跳到最后深度持平且重合,那就直接输出 
        return x;
    for(int i=lg[deep[x]]-1;i>=0;i--){
        if(parent[x][i]!=parent[y][i]){//一起跳 
            x=parent[x][i];
            y=parent[y][i];
        }
    }
    return parent[x][0];
}
int main(){
    scanf("%d%d%d",&n,&m,&s);
    for(int i=1;i<=n-1;i++){
        scanf("%d%d",&x,&y);
        add(x,y),add(y,x);
    }
    lg[1]=1;
    for(int i=2;i<=maxn;i++)
        lg[i]=lg[i-1]+((1<<lg[i-1])==i);//有人喜欢预处理有人不喜欢,在此全部奉上了
    dfs(s,0);
    for(int i=1;i<=m;i++){
        scanf("%d%d",&x,&y);
        printf("%d\n",lca(x,y));
    }
    return 0;
}

那我们能不能把LCA与ST表有机结合起来呢?

从此,便有了欧拉序。

从RMQ到ST表到倍增到LCA(多个模块一起过)_st表_04

 

我们从根节点A进入,记录,以先序遍历的方式进行遍历,去到哪里记录哪里,回来了也记录一下,最终得到了欧拉序,如上图所示。

我们把每一个点的深度写下来,

A B D B E F E G E B A C A

1 2 3 2 3 4 3 4 3 2 1 2 1

那这有什么用呢?别急,我们先试求一下D点与G点的LCA。

由图可知,其LCA就是B,我们看一下欧拉序与其深度表。

A B D B E F E G E B A C A

1 2 3 2 3 4 3 4 3 2 1 2 1

你会发现,在标红的这一闭区间中,深度最小的点B就是其LCA。

说明在欧拉序中,在一闭区间里深度最小的点就是区间端点的LCA。

这是因为我们在构建欧拉序时我们有一个进进出出的过程,在这个过程中,我们肯定是经过了两点的LCA并且在这两点的闭区间中不会意外地碰到别的深度更小的点,如果有意外的话,那就与定义不符了。

求闭区间最小值?那你肯定在行呀!不就ST表嘛!题目也就自然而然地解开了,但是据某巨佬说这样会有点慢,所以代码我就不贴了,自己写去吧!(还是因为懒~~)

 

学会了LCA,我们能用它干什么呢?诸如两点之间最短距离,树上倍增,树上差分都是与其相关哒,在下就勉为其难,打几道题来教你吧!

例题2:https://www.luogu.com.cn/problem/CF609E

 

题解:他要求我们求包含第i条边的树的最小值,我们可以先跑一遍最小生成树,把最小生成树求出来,然后再进行加边删边处理。

当我们求出一个最小生成树后,假如再连边后肯定会形成一个自环,我们考虑在这个自环上删边(不是真删,而是输出减去其权值后的ans)。

从RMQ到ST表到倍增到LCA(多个模块一起过)_#include_05

 

比如在该图中,我们连解4,5两点,这三点会与其LCA成顶点形成自环 ,黄色那条边肯定是不能动的,要动的就是4-2和5-2这两条边,我们在处理LCA时变换一下,改为求解其从x点到其LCA的所有连边上的最大值。我们考虑树上倍增,定义一数组walth[i][j]从第i点开始向上跳2^j个点后第i点与第2^j点的连边中的最大值,然后用类似于求LCA的做法进行处理最终求得一条链边上的max值。

具体实现如下:

#include<iostream>
#include<algorithm> 
#include<cstdio>
#include<cmath>
#define Maxn 200005
using namespace std;
typedef long long ll;
//此部分用于树上倍增 
ll head[Maxn*2],lg[Maxn*4];
struct acb{
    ll next,to,w;
}tree[Maxn*2];
ll cut;
void Add(ll u,ll v,ll w){
    tree[++cut].to=v;
    tree[cut].next=head[u];
    tree[cut].w=w;
    head[u]=cut;
}
//
ll anc[Maxn][30],dept[Maxn],walth[Maxn][30];
ll n,m,fa[Maxn],top,len; 
bool bin[Maxn];
struct abc{
    ll u,v,w,ty,id;
}edge[Maxn*2];
bool cmp(abc x,abc y){
    return x.w<y.w;
}
bool rbq(abc x,abc y){
    return x.id<y.id;
}
ll Find(ll x){
    while(x!=fa[x])
        x=fa[x]=fa[fa[x]];
    return x;
}
void add(ll u,ll v,ll w){
    edge[++top].v=v;
    edge[top].w=w;
    edge[top].u=u;
    edge[top].ty=top;
}
ll kruskal(){//处理出一个最小生成树 
    sort(edge+1,edge+m+1,cmp);
    ll cnt=0,res=0;
    for(ll i=1;i<=m;i++){
        ll u=edge[i].u,v=edge[i].v;
        ll rootx=Find(u),rooty=Find(v);
        if(rootx==rooty)continue;
        bin[edge[i].ty]=true;//记录这一条边在生成树里面了,假如要加的边也在生成树其中,那就不用处理 
        Add(u,v,edge[i].w);//另外将最小生成树上的每一条边存入链式前向星中,以备后面的LCA 
        Add(v,u,edge[i].w);
        res+=edge[i].w;
        fa[rooty]=rootx;
//        printf("%d %d\n",u,v);
        if(++cnt==n-1)break;
    }
    return res;
}
//以上为最小生成树部分 
void dfs(ll u,ll fath,ll w) {
    dept[u]=dept[fath]+1;
    anc[u][0]=fath;
    walth[u][0]=w;
    for(int i=1;(1<<i)<=dept[u];i++){
        anc[u][i]=anc[anc[u][i-1]][i-1];
        walth[u][i]=max(walth[u][i-1],walth[anc[u][i-1]][i-1]);//求解从u点出发直到第2^i个点之间的边的权值的最大值 
    }
    for(int i=head[u];i;i=tree[i].next){
        if(fath!=tree[i].to){
            dfs(tree[i].to,u,tree[i].w);
        }
    }
}
ll LCA(ll x,ll y){
    if(dept[x]<dept[y]) swap(x,y);
    ll maxx=-1,maxy=-1;
    while(dept[x]>dept[y]){
        maxx=max(maxx,walth[x][lg[dept[x]-dept[y]]-1]);//万一两点重合了,我们就只需要一条边上的最大值 
        //哪怕它们没重合,我们仍然需要这一点在往上跳的途中的最大值 
        x=anc[x][lg[dept[x]-dept[y]]-1];//注:顺序不要搞反了 
    }
    if(x==y) return maxx;
    for(int i=lg[dept[x]]-1;i>=0;i--){
        if(anc[x][i]!=anc[y][i]){
            maxx=max(maxx,walth[x][i]); //两点没有重合,那就需要我们两边一起求取最大值 
            maxy=max(maxy,walth[y][i]);
            x=anc[x][i];
            y=anc[y][i];
            
        }
    } 
    maxx=max(walth[x][0],maxx);//注意,我们最后求到的x值是LCA的第一级儿子,还有一条边(LCA与其第一级儿子的连边)没算 
    maxy=max(walth[y][0],maxy);
    return max(maxx,maxy);
}
bool check(ll x){
    if(bin[x]) return true;
    else return false;
} 
ll work(ll x){
    ll res=len;
    if(check(x))return res;//假如这一条边在最小生成树中,那么我们就不处理 
    else res+=edge[x].w; //否则,我们预先加上目标的那一条边的权值 
    ll u=edge[x].u;
    ll v=edge[x].v;
    ll lens=LCA(u,v);
    return res-lens;
}
//以上为代码实践 
int main(){
    scanf("%lld%lld",&n,&m);
    for(ll i=1;i<=n;i++)
        fa[i]=i;
    for(ll i=1;i<=m;i++){
        ll u,v,w;
        edge[i].id=i;
        scanf("%lld%lld%lld",&u,&v,&w);
        add(u,v,w);
    }
    len=kruskal();
    sort(edge+1,edge+m+1,rbq);//注:在求完最小生成树后一定要把数组纠正回来,因为题目要的是原装边加上并处理后的值 
    lg[1]=1;
    for(int i=2;i<=Maxn;i++)
        lg[i]=lg[i-1]+((1<<lg[i-1])==i);
    dfs(1,0,0);
    for(ll i=1;i<=m;i++){
        ll ans=work(i);
        printf("%lld\n",ans);
    }
    return 0;
} 

在此,我们的知识点就讲完了(自然,还是留了一些遗留问题,咕嘿~~),本蒟蒻第一次打blog,字体之间的大小间隙没有调好,还希望包涵。如果有什么问题,欢迎再评论区中向我提出~~