引子
在网上的大多数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个数中的最大值。在转移时,我们可以把当前区间分成两个小区间,并分别求最大值,如下图所示:
具体伪代码实现如下:
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值就行了。
整个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),即给定一棵有根树,求树上两个点的最近公共祖先的问题。
有图有真相:
我们一般有三种方法求解,分别为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表有机结合起来呢?
从此,便有了欧拉序。
我们从根节点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)。
比如在该图中,我们连解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,字体之间的大小间隙没有调好,还希望包涵。如果有什么问题,欢迎再评论区中向我提出~~