最近公共祖先、RMQ

一、前言

以下,所有 LCA 题目、RMQ 题目分别均为洛谷模板 https://www.luogu.com.cn/problem/P3379 和 https://www.luogu.com.cn/problem/P3865  

在一棵树上,一个节点的祖先定义为从根节点到这个节点的父亲节点的链上的所有点。

两个节点的公共祖先为这两个节点对应的链重合部分上的所有点。

两个节点的最近公共祖先为这两个节点对应的链重合部分上的所有点中深度最大的那个

容易知道树上任意两个点一定有且仅有一个最近公共祖先。

它可以帮助我们解决很多树上问题,例如树上两个点之间的距离。

二、计算方法

1.在线算法

(1) 暴力跳

要计算 x 和 y 的最近公共祖先,容易想到可以直接暴力跳:先 dfs ,记录所有点的深度,再将 x 、 y 向上跳到同样深度,再逐步往上跳直到 x 、 y 为同一个点,那么这个点就是 x 、 y 的最近公共祖先。

但是有时候树的深度很大,每次查询最坏要跳 2*树的深度 次。

复杂度:预处理 O(n) ,查询 O(mn)

(2) 倍增跳

考虑优化上述算法。我们可以学习二进制拆分的思想,每次向上跳 2的次幂 次,每次查询最坏跳 2*log(树的深度) 次。

dx 代表点 x 的深度。

fxi 代表 x 向上跳 2i 步后到哪个点。

时间复杂度:预处理 O(nlogn),查询 O(mlogn),在一般题目中可以通过。

代码:

void dfs(int x,int fa){
	f[x][0]=fa;d[x]=d[fa]+1;
	for(int i=1;i<=19;i++)f[x][i]=f[f[x][i-1]][i-1];
	for(int i=0;i<v[x].size();i++)
		if(v[x][i]!=fa)dfs(v[x][i],x);
}
int lca(int x,int y){
	if(d[x]>d[y])swap(x,y);
	for(int i=19;i>=0;i--)
		if(d[f[y][i]]>=d[x])y=f[y][i];
	if(x==y)return x;
	for(int i=19;i>=0;i--)
		if(f[x][i]!=f[y][i])x=f[x][i],y=f[y][i];
	return f[x][0];
}

AC记录,最慢的点 901ms ,可以通过大部分题目。

(3) 树链剖分

使用树链剖分的方法优化暴力跳。

● 如果 x、y 在同一条链上,那么它们的最近公共祖先就是深度比较小的那个。

○ 如果 x、y 不在同一条链上,让跳了之后深度更小的那个跳到当前链的端点的父亲节点上。

最坏跳 2*log(树的深度) 次,而且跑不满,常数很小。

时间复杂度:预处理 O(n) ,查询 O(mlogn) ,常数比倍增法小。

void dfs1(int x,int fa){//树剖板子
  d[x]=d[fa]+1;f[x]=fa;sz[x]=1;
  for(int i=0;i<v[x].size();i++)
    if(v[x][i]!=fa){
      dfs1(v[x][i],x);
      if(sz[v[x][i]]>sz[son[x]])son[x]=v[x][i];
      sz[x]+=sz[v[x][i]];
    }
}
void dfs2(int x,int top){
  t[x]=top;
  if(!son[x])return;
  dfs2(son[x],top);
  for(int i=0;i<v[x].size();i++)
    if(v[x][i]!=f[x]&&v[x][i]!=son[x])dfs2(v[x][i],v[x][i]);
}
int lca(int x,int y){
  while(t[x]!=t[y]){
    if(d[t[x]]>=d[t[y]])x=f[t[x]];
    else y=f[t[y]];
  }
  return (d[x]<d[y])?x:y;
}

AC记录,最慢的点 395ms ,可以通过绝大部分题。

(4) dfs 序 + ST 表

使用 ST 表,维护 dfs 序数组的区间深度最小值。

如果有多个相同的,默认是最右侧的那一个。

查询时,如果 x=y ,直接输出 x 。

否则假设 x 的 dfs 序小于 y 的 dfs 序。

那么在 dfs 序列里, x 在 y 的前面。

对于 x 、y 的最近公共祖先 z ,dfnz<=dfnx ( 注意 z 可能是 x,但不可能是 y )。

考虑 z 的子节点中唯一是 y 节点的祖先的节点,设为 w 。

容易知道 w 不是 x 的祖先,而且 dfnx<=dfnw 。

由于 z 以及 z 的祖先以及 z 的儿子中在 y 右侧的节点的 dfs 序都不属于 [dfnx+1,dfny] ,

所以我们查询 dfs 序列中 [dfnx+1,dfny] 中 dep 最小的节点 w 的父亲节点 z 就行了。

数组含义:

dfnx 代表编号为 x 的节点的 dfs 序。

depx 代表编号为 x 的节点的深度。

fax 代表编号为 x 的节点的父亲节点编号。

lgi 代表 i 的关于 2 的对数。

fji 代表 dfs 序 为 j~j+2i-1 的 2i 个节点中深度最小的节点的编号。

时间复杂度:预处理 O(nlogn) ,查询 O(m) 

int get(int x,int y){//比较 x、y 哪个 dep 更小
	return dep[x]<dep[y]?x:y;//默认相等时取最右边的那个
}
void dfs(int x,int fat){//预处理出 dfs 序
	dfn[x]=++cnt;f[cnt][0]=x;fa[x]=fat;dep[x]=dep[fat]+1;
	for(int i=0;i<v[x].size();i++)
		if(v[x][i]!=fat)dfs(v[x][i],x);
}
void ST(){//维护 dep 最小的位置
	for(int i=2;i<=n;i++)lg[i]=lg[i>>1]+1;
	for(int i=1;i<=18;i++)
		for(int j=1;j<=n-(1<<(i-1));j++)f[j][i]=get(f[j][i-1],f[j+(1<<(i-1))][i-1]);
}
int lca(int x,int y){
	if(x==y)return x;
	if(dfn[y]<dfn[x])swap(x,y);
	int len=lg[dfn[y]-dfn[x]];
	return fa[get(f[dfn[x]+1][len],f[dfn[y]-(1<<len)+1][len])];
}

AC记录,最慢的点 529 ms,可以通过绝大部分题

(4) 黑科技

搞一个 O(n) 的 RMQ。

时间复杂度:预处理 O(n) ,查询期望 O(1) ( 当然还有严格 O(1) ),查询一共 O(m) 。

模板:(思路来自 OI-WIKI ,以下全部是原文,代码是自己写的 ( 终于不是贺的了,感动…… ) )

由于 Four russian 算法以 ST 表为基础,而算法竞赛一般没有非常高的时间复杂度要求,所以 Four russian 算法一般都可以被 ST 表代替,在算法竞赛中并不实用。这里提供一种在算法竞赛中更加实用的 Four russian 改进算法。

我们将块大小设为 sqrt(n),然后预处理出每一块内前缀和后缀的 RMQ,再暴力预处理出任意连续的整块之间的 RMQ,时间复杂度为 O(n) 

查询时,对于左右端点不在同一块内的询问,我们可以直接 O(1) 得到左端点所在块的后缀 RMQ,左端点和右端点之间的连续整块 RMQ,和右端点所在块的前缀 RMQ,答案即为三者之间的最值。

而对于左右端点在同一块内的询问,我们可以暴力求出两点之间的 RMQ,时间复杂度为 O(sqrt(n)),但是单个询问的左右端点在同一块内的期望为 sqrt(n)/n ,所以这种方法的时间复杂度为期望 O(n)

而在算法竞赛中,我们并不用非常担心出题人卡掉这种算法,因为我们可以通过在 sqrt(n) 的基础上随机微调块大小,很大程度上避免算法在根据特定块大小构造的数据中出现最坏情况。并且如果出题人想要卡掉这种方法,则暴力有可能可以通过。

这是一种期望时间复杂度达到下界,并且代码实现难度和算法常数均较小的算法,因此在算法竞赛中比较实用。

出处:由乃救爷爷

void RMQ(){
	int sz=sqrt(n);
	for(int i=1;i<=n;i++)id[i]=(i-1)/sz+1;
	for(int i=1;i<=n;i++)
		if(id[i]!=id[i-1])p[i]=a[i];
		else p[i]=max(p[i-1],a[i]);
	for(int i=n;i>=1;i--)
		if(id[i]!=id[i+1])s[i]=a[i];
		else s[i]=max(s[i+1],a[i]);
	for(int i=1;i<=id[n];i++)g[i][i]=p[min(i*sz,n)];
	for(int i=1;i<=id[n];i++)
		for(int j=i+1;j<=id[n];j++)g[i][j]=max(g[i][j-1],p[min(j*sz,n)]);
}
int query(int x,int y){
	if(x>y)swap(x,y);
	if(id[x]==id[y]){
		int res=0;
		for(int i=x;i<=y;i++)res=max(res,a[i]);
		return res;
	}else if(id[x]+1==id[y])return max(s[x],p[y]);
	return max(max(s[x],g[id[x]+1][id[y]-1]),p[y]);
}

AC记录,最慢的点 210 ms,可以通过绝大部分题

但是在本题中,因为 get(x,y)≠get(y,x) ,所以处理每一块前后缀时要注意 get 的顺序。

按理说应该比 维护欧拉序+普通 Four Russian 快多了,但是居然只是和树剖差不多!

int get(int x,int y){
	return dep[x]<dep[y]?x:y;
}
void dfs(int x,int fat){
	dfn[x]=++cnt;f[cnt]=x;fa[x]=fat;dep[x]=dep[fat]+1;
	for(int i=0;i<v[x].size();i++)
		if(v[x][i]!=fat)dfs(v[x][i],x);
}
void RMQ(){
	int sz=sqrt(n);
	for(int i=1;i<=n;i++)id[i]=(i-1)/sz+1;
	for(int i=1;i<=n;i++)
		if(id[i]!=id[i-1])p[i]=f[i];
		else p[i]=get(p[i-1],f[i]);
	for(int i=n;i>=1;i--)
		if(id[i]!=id[i+1])s[i]=f[i];
		else s[i]=get(f[i],s[i+1]);
	for(int i=1;i<=id[n];i++)g[i][i]=p[min(i*sz,n)];
	for(int i=1;i<=id[n];i++)
		for(int j=i+1;j<=id[n];j++)g[i][j]=get(g[i][j-1],p[min(j*sz,n)]);
}
int lca(int x,int y){
	if(x==y)return x;
	if(dfn[y]<dfn[x])swap(x,y);
	if(id[dfn[x]+1]!=id[dfn[y]]){
		if(id[dfn[x]+1]+1==id[dfn[y]])return fa[get(s[dfn[x]+1],p[dfn[y]])];
		return fa[get(get(s[dfn[x]+1],g[id[dfn[x]+1]+1][id[dfn[y]]-1]),p[dfn[y]])];
	}else{
		if(dfn[x]+1==dfn[y])return fa[y];
		int res=get(f[dfn[x]+1],f[dfn[x]+2]);
		for(int i=dfn[x]+3;i<=dfn[y];i++)res=get(res,f[i]);
		return fa[res];
	}
}

AC记录,最慢的点 423 ms,可以通过绝大部分题

于是我决定拿出压箱底的严格 O(n) !( 没错,还是在 OI-WIKI 贺的 )

将原序列 a[1...n] 分成每块长度为 logn 的 n/logn 块。

听说令块长为 1.5logn 时常数较小。

记录每块的最大值,并用 ST 表维护块间最大值,复杂度 

记录块中每个位置的前、后缀最大值 p[1...n],s[1...n]( 即  到其所在块的块首的最大值),复杂度 O(n) 。

若查询的 x,y 在两个不同块上,分别记为第 idx,idy 块,则最大值为 idx+1~idy-1 块间的最大值,以及 sx 和 py 这三个数的较大值。

现在的问题在于若 x,y 在同一块中怎么办。

将 sz*(idy-1)+1~y 依次插入单调栈中,记录下标和值,满足值从栈底到栈顶递减,则 [x,y] 中的最大值 z 为从栈底往上,单调栈中第一个满足其下标 pos≥x 的值。

由于 z 是 [x,y] 中的最大值,因而 [x...z-1] 在插入 z 时,都被弹出,且在插入 [z+1,...,y] 时不可能将 z 弹出。

而如果用 0 和 1 表示每个数是否在栈中,就可以用整数状压,则 z 的位置为第 x 位后的第一个 1 的位置。

由于块大小为 1.5logn,因而最多不超过 30 位,可以用一个整数存下(即隐性条件的原因)。

出处:一个RMQ问题的快速算法,以及区间众数 - 知乎 (zhihu.com)

void RMQ(){
	int sz=1.5*log2(n);
	for(int i=1;i<=n;++i){
		id[i]=(i-1)/sz+1;pos[i]=(i-1)%sz;
		f[id[i]][0]=max(f[id[i]][0],a[i]);
	}
	for(int i=2;i<=n;i++)lg[i]=lg[i>>1]+1;
	for(int i=1,pw=2;pw<=id[n];++i,pw<<=1)
		for(int j=1;j<=id[n]-pw+1;++j)f[j][i]=max(f[j][i-1],f[j+(pw>>1)][i-1]);
	for(int i=1;i<=n;++i)p[i]=(id[i-1]==id[i])?max(p[i-1],a[i]):a[i];
	for(int i=n;i>=1;--i)s[i]=(id[i+1]==id[i])?max(s[i+1],a[i]):a[i];
	for(int i=1;i<=n;++i){
		if(id[i-1]!=id[i])t=0;
		else g[i]=g[i-1];
		while(t&&a[st[t]]<=a[i])g[i]&=~(1<<pos[st[t--]]);
		st[++t]=i;g[i]|=(1<<pos[i]);
	}
}
int query(int x,int y){
	if(x>y)swap(x,y);
	if(id[x]==id[y])return a[x+__builtin_ctz(g[y]>>pos[x])];
	else if(id[x]+1==id[y])return max(s[x],p[y]);
	int len=lg[id[y]-id[x]-1];
	return max(max(max(s[x],f[id[x]+1][len]),f[id[y]-(1<<len)][len]),p[y]);
}

AC记录,最慢的点 216 ms,居然与期望 O(n) 的 RMQ 差不多?

扔进 LCA 

int get(int x,int y){
	return dep[x]<dep[y]?x:y;
}
void dfs(int x,int fat){
	dfn[x]=++cnt;a[cnt]=x;fa[x]=fat;dep[x]=dep[fat]+1;
	for(int i=0;i<v[x].size();i++)
		if(v[x][i]!=fat)dfs(v[x][i],x);
}
void RMQ(){
	dep[0]=0x3f3f3f3f;int sz=1.5*log2(n);
	for(int i=1;i<=n;++i){
		id[i]=(i-1)/sz+1;pos[i]=(i-1)%sz;
		f[id[i]][0]=get(f[id[i]][0],a[i]);
	}
	for(int i=2;i<=n;i++)lg[i]=lg[i>>1]+1;
	for(int i=1,pw=2;pw<=id[n];++i,pw<<=1)
		for(int j=1;j<=id[n]-pw+1;++j)f[j][i]=get(f[j][i-1],f[j+(pw>>1)][i-1]);
	for(int i=1;i<=n;++i)p[i]=(id[i-1]==id[i])?get(p[i-1],a[i]):a[i];
	for(int i=n;i>=1;--i)s[i]=(id[i+1]==id[i])?get(a[i],s[i+1]):a[i];
	for(int i=1;i<=n;++i){
		if(id[i-1]!=id[i])t=0;
		else g[i]=g[i-1];
		while(t&&dep[a[st[t]]]>=dep[a[i]])g[i]&=~(1<<pos[st[t--]]);
		st[++t]=i;g[i]|=(1<<pos[i]);
	}
}
int lca(int x,int y){
	if(x==y)return x;
	x=dfn[x];y=dfn[y];if(x>y)swap(x,y);x++;
	if(id[x]==id[y])return fa[a[x+__builtin_ctz(g[y]>>pos[x])]];
	else if(id[x]+1==id[y])return fa[get(s[x],p[y])];
	int len=lg[id[y]-id[x]-1];
	return fa[get(get(get(s[x],f[id[x]+1][len]),f[id[y]-(1<<len)][len]),p[y])];
}

AC记录,最慢的点 453 ms,居然比期望 O(n) 的 RMQ 还慢一点。

总结:在线 LCA 使用树链剖分最优,编码复杂度也不大,别整那些 useless algorithms 。

2.离线算法

考虑使用 Tarjan 。

先将所有询问存储,并且拆成两个。

从根节点开始 DFS 。遍历 x 的所有子节点。

遍历完后,处理与 x 有关的所有询问,对于每一对 (x,y) ,如果 y 被访问过,那么 lca(x,y) = find(y) 。

时间复杂度: O(nα(n)+m) ( 反阿克曼函数 ) ,α(n) 极小可忽略不计 (?)

int find(int x){
	return fa[x]==x?x:fa[x]=find(fa[x]);
}
void tarjan(int x){
	vis[x]=1;
	for(int i=0;i<v[x].size();i++)
		if(!vis[v[x][i]]){
			tarjan(v[x][i]);
			fa[v[x][i]]=find(x);
		}
	for(int i=0;i<q[x].size();i++)
		if(vis[q[x][i].first])lca[q[x][i].second]=find(q[x][i].first);
}

AC记录,最慢的点 610 ms,居然比 O(nlogn) 的 ST 表和 O(mlogn) 的 树链剖分 还慢!

三、总结

以后 LCA 只用树剖。

posted @ 2023-04-09 15:15  lrxQwQ  阅读(78)  评论(0编辑  收藏  举报