引言
Kruskal重构树主要用于解决在线查询问题,询问通常涉及的条件为边权小于某值。
原理
考虑利用Kruskal算法构建一个最小生成树,但在此过程中稍稍改动一下。Kruskal算法的过程主要是按照边权由小到大的顺序对边上的两点做合并操作。现在假设正在处理 u − v \mathbf{u-v} u−v这条边,Kruskal重构树将不会直接在新图建立边 u − v \mathbf{u-v} u−v,而是建立一个新的节点 t \mathbf{t} t,然后让该节点连向 u 和 v \mathbf{u和v} u和v各自连通块的根节点,并给 t \mathbf{t} t节点设置一个点权 a [ t ] \mathbf{a[t]} a[t],大小为 u − v \mathbf{u-v} u−v的边权大小,一般来说会强制让 t \mathbf{t} t节点成为 u \mathbf{u} u和 v \mathbf{v} v两个点所在连通块的根节点的父亲,也就是说重构树最终是一颗有根树。然后并查集合并的操作需要变化一下,为了能够迅速找到 u 和 v \mathbf{u和v} u和v各自连通块的根节点,我们强制让 u \mathbf{u} u和 v \mathbf{v} v的根节点连向 t \mathbf{t} t,即并查集操作为 f a [ u ] = f a [ v ] = f a [ t ] = t \mathbf{fa[u]=fa[v]=fa[t]=t} fa[u]=fa[v]=fa[t]=t即可,这与Kruskal重构树上的连边操作很相似,不同的是并查集会在查找根节点的时候进行路径压缩。
以上就是关于重构树的全部操作,但通过文字并不能直观地感受Kruskal重构树的作用,下面用图来演示。
先随便给出一张图:
现在按照边权有小到大进行操作。
一开始我们让所有节点所在连通块的根节点为它自己。
第一步我们找到最小的边权为1,对应的边是
e
−
d
\mathbf{e-d}
e−d,然后先建立一个新节点
f
\mathbf{f}
f,再让
f
\mathbf{f}
f节点成为
e
和
d
\mathbf{e和d}
e和d各自连通块根节点的父亲节点(不过此时
e
和
d
\mathbf{e和d}
e和d节点各自连通块的根节点就是它们自己),然后让
a
[
f
]
\mathbf{a[f]}
a[f]为原图中边
e
−
d
\mathbf{e-d}
e−d的边权大小,即
a
[
f
]
=
1
\mathbf{a[f]=1}
a[f]=1。如果画成图就是如下形式(红边代表最小生成树中的一条边):
上图中展示了原图、Kruskal重构树上发生的变化,然后并查集上的操作没有画出来,但也要清楚并查集上也会将
f
\mathbf{f}
f节点成为
e
和
d
\mathbf{e和d}
e和d的根节点,注意到Kruskal重构树上的边是有方向的,深度更浅的节点是深度更深的节点的父亲。
第二步我们找到下一个边权最小的边,有两条边的边权都是2,那么随便选一条,这里以选择
a
−
b
\mathbf{a-b}
a−b边为例,还是像第一步一样,我们在Kruskal重构树上建立
g
→
a
,
g
→
c
\mathbf{g\rightarrow a,g\rightarrow c}
g→a,g→c两条边即可,并令
a
[
g
]
=
2
\mathbf{a[g]=2}
a[g]=2,然后并查集完成相应的合并操作,也就是下图。
第三步我们找到下一个边权最小的边,边权依然为2,对应着边
a
−
b
\mathbf{a-b}
a−b,于是建立新点
h
\mathbf{h}
h,不过这时候不再是连接边
h
→
a
\mathbf{h\rightarrow a}
h→a,由于
a
\mathbf{a}
a节点所在连通块的根节点是
g
\mathbf{g}
g,因此我们需要连接的是
h
→
g
和
h
→
b
\mathbf{h\rightarrow g和h\rightarrow b}
h→g和h→b,其中查找
a
\mathbf{a}
a连通块的根节点的操作由并查集完成,然后令
a
[
h
]
=
2
\mathbf{a[h]=2}
a[h]=2,连边效果如下图所示:
第四步找到下一个最小的边权3,由于有两条边边权都为3,可随便选一条,这里选择
a
−
e
\mathbf{a-e}
a−e,我们依然建立新节点
i
\mathbf{i}
i,然后找到
a
和
e
\mathbf{a和e}
a和e节点各自所在连通块的根节点,也就是
h
和
f
\mathbf{h和f}
h和f,然后连接边
i
→
h
,
i
→
f
\mathbf{i\rightarrow h,i\rightarrow f}
i→h,i→f并令
a
[
i
]
=
3
\mathbf{a[i]=3}
a[i]=3,连边效果如下图所示:
好了!现在我们发现已经没有边可以选择了,因为现在原图中任何一条黑边的两个端点都在同一个连通块中。那么右图就是我们建立的Kruskal重构树,我们把每个点的点权标记上去(除了叶子结点),然后单独拎出来也就是下图所示:
性质
我们不难得出Kruskal重构树的一些性质:
-
每个节点的点权是其子树所有节点(包括本身)中的最大点权(不考虑叶子节点)。
证明:这个性质也挺好证明的,大的边权总是在后面被合并,而边权又转化为了非叶子结点的点权,故Kruskal重构树会满足如上性质。 -
原树上任意两个节点之间路径上最大边权的最小值(最小瓶颈路)是它们在Kruskal重构树上最近公共祖先(lca)的点权。
证明:考虑Kruskal加边的过程,假设在加上边 e \mathbf{e} e后导致 u \mathbf{u} u与 v \mathbf{v} v连通(即 e \mathbf{e} e是第一个也是最后一个让 u \mathbf{u} u与 v \mathbf{v} v连通的边),那么 e \mathbf{e} e的边权一定是最小生成树上的 u \mathbf{u} u到 v \mathbf{v} v的路径上边权的最大值,根据Kruskal重构树的构建过程不难发现e的边权就是 u \mathbf{u} u节点与 v \mathbf{v} v节点在重构树上的lca的点权。现在要证明不可能有另一种构建树的方式导致 u \mathbf{u} u到 v \mathbf{v} v的路径上最大边权小于 e \mathbf{e} e的边权。
不过这一点是很显然的,假如有这么一条 u 到 v \mathbf{u到v} u到v的路径,上面的每条边的边权都小于 e \mathbf{e} e,那么根据Kruskal构建最小生成树的过程不难发现这些边都会先于 e \mathbf{e} e边被处理,而我们知道只有当连接同一连通块的边被处理的时候会被忽略(即不实施连接操作),说人话就是,只要有一堆边可能将一堆点连成同一个连通块,那么这些边经过处理以后这些点就一定会被连成同一个连通块,因此当发现一堆小于 e \mathbf{e} e的边能连接 u , v \mathbf{u,v} u,v时,并且这些边会先于 e \mathbf{e} e被处理,那么 u , v \mathbf{u,v} u,v就一定会被连通,于是 e \mathbf{e} e也就不是第一个让 u , v \mathbf{u,v} u,v连通的边了,与最初的假设矛盾,故 e \mathbf{e} e就是最小的最大边权。
而有因为这个 e \mathbf{e} e的边权对应着 u 和 v \mathbf{u和v} u和v的lca的点权,故原命题得证。 -
Kruskal重构树是一颗二叉树。
证明:这很显然,因为每次都只有新点向旧点连边,而且是连两条。 -
原图中的所有节点与Kruskal重构树上的叶子节点一一对应。
应用
那么讲了这么多Kruskal重构树到底有什么用呢?它主要是方便我们处理一些变态的询问操作,比如说让你求从某个点出发经过的边权不大于x能到达的满足性质xxx的点,这些询问一般都能与边权扯上关系,此外还可以用于求最小瓶颈路(性质2)。
下面用一些例题来增加对Kruskal重构树的理解。
习题
例题一
题目来源:LuoGuP4197 Peaks
题面:
题解:本题简而言之就是询问从v点出发经过的边权不大于x所能到达的点中第k大的点权。由于Kruskal重构树的叶子节点就是原图中的所有节点,我们可以在这些叶子结点中找到v节点,对于任意其它节点u而言,它到达v节点的路径中最大边权的最小值必需满足不大于x才行。而根据性质2,我们只需要保证
l
c
a
(
u
,
v
)
的
点
权
≤
x
\mathbf{lca(u,v)的点权\le x}
lca(u,v)的点权≤x即可,又因为lca必定是v的祖先节点,我们只需要从v节点出发不断向上跳,找到不大于x的最大点权的一个祖先节点f即可,v到达f子树中的所有叶子结点的lca都必定是f的子孙,根据性质1我们知道祖先点权不小于子孙点权,换句话说,v到达f子树中所有叶子节点的最大边权的最小值都不大于x,而除此之外的节点,即非f的子孙节点并且非f节点,这些节点与x的lca都必定是f的祖先节点,由于f已经是满足不大于x的点权最大节点,故f的祖先节点的点权都一定大于x,故这些节点都是v无法到达的节点。
综上所述,我们先通过倍增找到f节点,然后f的所有子孙(包括自己)都是符合题意的v可以到达的所有节点。但是怎么迅速锁定这些节点中的第k大值呢,很容易想到主席树,不过由于是在树上,因此我们可以在dfs序上建立主席树,不过不需要给每个节点建dfs序,因为查询的时候只会查询Kruskal重构树上的叶子结点(对应着原图中的所有节点),故只需要在dfs时判断一下当前节点是否为叶子结点,是的话就增加dfs序即可。
具体代码中还有许多细节,详见代码:
int n,m,q;
int a[maxn],la[maxn],lasz,h[maxn],ee=1,df[maxn],tot,root[maxn],
l[maxn],r[maxn],dfn,fa[maxn][maxlog];
struct EDGE{
int u,v,w;
bool operator<(const EDGE a)const{
return w<a.w;
}
}E[maxm];
struct Edge{
int v,nxt;
}e[maxm];
void addedge(int u,int v){
e[ee]=Edge{v,h[u]};
h[u]=ee++;
}
struct ZXT{//主席树
int rtsz,ch[maxn<<4][2],sum[maxn<<4];//主席树一般要乘2^5大小,这里由于maxn已经乘2
void build(int &rt,int l,int r){
rt=++rtsz;
if(l==r)return;
int mid=l+r>>1;
build(ls(rt),l,mid);
build(rs(rt),mid+1,r);
}
void add(int rt1,int &rt2,int l,int r,int x){
rt2=++rtsz;
ls(rt2)=ls(rt1),rs(rt2)=rs(rt1);
sum[rt2]=sum[rt1]+1;
if(l==r)return;
int mid=l+r>>1;
if(x<=mid)add(ls(rt1),ls(rt2),l,mid,x);
else add(rs(rt1),rs(rt2),mid+1,r,x);
}
int qry(int rt1,int rt2,int l,int r,int k){
if(k>sum[rt2]-sum[rt1])return -1;
int d=sum[rs(rt2)]-sum[rs(rt1)];
if(l==r)return r;
int mid=l+r>>1;
if(k<=d)return qry(rs(rt1),rs(rt2),mid+1,r,k);
return qry(ls(rt1),ls(rt2),l,mid,k-d);
}
}t;
int fd(int rt){//并查集
return df[rt]==rt?rt:(df[rt]=fd(df[rt]));
}
int id(int x){//离散化坐标映射
return lower_bound(la+1,la+1+lasz,x)-la;
}
void dfs(int u){//在Kruskal重构树的dfs序上构建主席树
l[u]=dfn;
FOR(i,1,maxlog)fa[u][i]=fa[fa[u][i-1]][i-1];
if(!h[u]){
++dfn;
t.add(root[dfn-1],root[dfn],1,lasz,id(a[u]));
}
for(register int i=h[u];i;i=e[i].nxt)dfs(e[i].v);
r[u]=dfn;
}
void Kruskal(){//构建Kruskal重构树
sort(E,E+m);
FOR(i,1,n+1)df[i]=i;
tot=n;
FOR(i,0,m){
int u=E[i].u,v=E[i].v,w=E[i].w;
int fu=fd(u),fv=fd(v);
if(fu==fv)continue;
++tot;
df[fu]=df[fv]=df[tot]=tot;
addedge(tot,fu),addedge(tot,fv);//构建重构树
fa[fu][0]=tot,fa[fv][0]=tot;
a[tot]=w;
}
t.build(root[0],1,lasz);//主席树初始化
dfs(tot);//在dfs序上建立主席树
}
int main(){
rd(&n,&m,&q);
FOR(i,1,n+1){
rd(&a[i]);
la[i]=a[i];
}
sort(la+1,la+1+n);
lasz=unique(la+1,la+1+n)-(la+1);//离散化
FOR(i,0,m){
int u,v,w;rd(&u,&v,&w);
E[i]=EDGE{u,v,w};
}
Kruskal();//构建Kruskal重构树
while(q--){
int v,x,k;
rd(&v,&x,&k);
ROF(i,maxlog-1,0){//倍增查找满足条件的祖先
if(fa[v][i] && a[fa[v][i]]<=x)v=fa[v][i];
}
int ans=t.qry(root[l[v]],root[r[v]],1,lasz,k);
if(ans==-1)wrn(ans);else wrn(la[ans]);
}
}
例题二
题目来源:LuoGuP4768 [NOI2018]归程
题面:
题解:思路与例题一差不多,只不过这次不是求第k大的点,而是求到1点距离最小的点。我们先预处理出距离数组d,它相当于是每个点的权值,然后要求权值最小,很容易想到用st表,于是在dfs序上建st表即可。不过本题还有个差异是要求海拔大于p,这里有个小技巧,可以直接让p变成负数,并在读入海拔高度的时候就置负,那么就变成求海拔小于p的所有点了。当然你也可以直接求最大生成树的Kruskal重构树,这样树的性质与最小生成树相反,也很容易求解。代码中为了方便,Kruskal重构树上新点的权值也是存在d数组里的。
代码:
struct ST{//st表
int st[maxn][maxlog],n,lgn[maxn];//关于1~n的st表
void set(int x,int v){
st[x][0]=v;
}
void init(){//初始化lgn数组
lgn[1]=0;
FOR(i,2,maxn){
lgn[i]=lgn[i/2]+1;
}
}
void create(){//创建st表
FOR(i,1,maxlog)
FOR(j,1,n+1){
if(j+(1<<i)-1>n)break;
st[j][i]=min(st[j][i-1],st[j+(1<<(i-1))][i-1]);
}
}
int qry(int l,int r){//查询[l,r]最小值
int lg=lgn[r-l+1];
return min(st[l][lg],st[r-(1<<lg)+1][lg]);
}
}st;
struct EDGE{
int u,v,w;
bool operator<(const EDGE a)const{
return w<a.w;
}
}e[maxm];
struct Edge{
int v,w,next;
}e1[maxm*2],e2[maxm*2];
int n,m,h1[2*maxn],d[maxn*2],h2[maxn*2],ee1=1,ee2=1,df[maxn*2],fa[maxn*2][maxlog],
tot,dfn,l[maxn*2],r[maxn*2];
void init(){
ee1=ee2=1;
tot=dfn=0;
st.n=n;
FOR(i,1,2*n+1)h1[i]=h2[i]=0;
}
void addedge1(int u,int v,int w){
e1[ee1]=Edge{v,w,h1[u]};
h1[u]=ee1++;
}
void addedge2(int u,int v){
e2[ee2]=Edge{v,0,h2[u]};
h2[u]=ee2++;
}
set<pi >s;
void Dijkstra(int ori){//预处理每个点到1的距离
FOR(i,1,n+1)d[i]=inf;
s.insert(mk(d[ori]=0,ori));
while(!s.empty()){
int u=s.begin()->se,dd=s.begin()->fi;
s.erase(s.begin());
if(dd>d[u])continue;
for(register int i=h1[u];i;i=e1[i].next){
int v=e1[i].v,w=e1[i].w;
if(1ll*d[u]+1ll*w<d[v]){
d[v]=d[u]+w;
s.insert(mk(d[v],v));
}
}
}
}
int fd(int u){
return u==df[u]?u:(df[u]=fd(df[u]));
}
void dfs(int u,int dep){
l[u]=dfn;
FOR(i,1,maxlog){
if((1<<i)>dep)fa[u][i]=0;
else fa[u][i]=fa[fa[u][i-1]][i-1];
}
if(!h2[u]){
st.set(++dfn,d[u]);
}
for(register int i=h2[u];i;i=e2[i].next)dfs(e2[i].v,dep+1);
r[u]=dfn;
}
void Kruskal(){
sort(e,e+m);
FOR(i,1,n+1)df[i]=i;
tot=n;
FOR(i,0,m){
int u=e[i].u,v=e[i].v,w=e[i].w;
int fu=fd(u),fv=fd(v);
if(fu==fv)continue;
d[++tot]=w;
df[fu]=df[fv]=df[tot]=tot;
addedge2(tot,fu),addedge2(tot,fv);
fa[fu][0]=fa[fv][0]=tot;
fa[tot][0]=0;
}
dfs(tot,1);
}
int qry(int v,int p){//注意是小于p
ROF(i,maxlog-1,0){
if(fa[v][i] && d[fa[v][i]]<p)v=fa[v][i];
}
return st.qry(l[v]+1,r[v]);
}
int main(){
int t;
st.init();
rd(&t);
while(t--){
rd(&n,&m);
init();
FOR(i,0,m){
int u,v,w1,w2;
rd(&u,&v,&w1,&w2);
addedge1(u,v,w1);
addedge1(v,u,w1);
e[i]=EDGE{u,v,-w2};
}
Dijkstra(1);
Kruskal();
st.create();
int q,k,s,lastans=0;
rd(&q,&k,&s);
while(q--){
int v,p;
rd(&v,&p);
v=((1ll*v+1ll*k*lastans-1ll)%n+n)%n+1;
p=((1ll*p+1ll*k*lastans)%(s+1)+s+1)%(s+1);
p=-p;//取反
wrn(lastans=qry(v,p));
}
}
}
例题三
题目来源:CFGraph and Queries
题面:
题解:本题其实是求从v点出发经过的边被删除的时间点大于当前时间点所能到达的所有节点中的最大权值,这样一描述就发现是Kruskal重构树的模板题了,在本题中可以把边权设置为被删除时间点,那些没有被删除的边的边权显然是
i
n
f
\mathbf{inf}
inf,然后在最大生成树的基础上构造Krusakl重构树(因为本题是要大于时间点,当然你也可以采取时间变负数的形式)。询问的时候,在Kruskal重构树上从v点开始跳,直到遇到距离最远的一个祖先
f
\mathbf{f}
f满足它的权值大于当前时间点,那么
f
\mathbf{f}
f的子孙就是v可以到达的所有节点,用线段树查询最大值并单点修改即可。
代码:
int n,m,a[maxn*2],h[maxn*2],df[maxn*2],fa[maxn*2][maxlog],tot,ee=1,dfn,
l[maxn*2],r[maxn*2],c[maxn],vis[maxn*2];
struct Node{
int v,id;
};
struct SegTree{
Node t[maxn*4];
Node merge(Node a,Node b){
if(a.v>b.v)return a;
return b;
}
void build(int rt,int l,int r){
if(l==r){
t[rt]={c[l],l};
return;
}
int mid=l+r>>1;
build(rt<<1,l,mid);
build(rt<<1|1,mid+1,r);
t[rt]=merge(t[rt<<1],t[rt<<1|1]);
}
void cg(int rt,int l,int r,int x){
if(l==r){
int vv=t[rt].v;
t[rt].v=0;
return;
}
int mid=l+r>>1;
if(x<=mid)cg(rt<<1,l,mid,x);else cg(rt<<1|1,mid+1,r,x);
t[rt]=merge(t[rt<<1],t[rt<<1|1]);
}
Node qry(int rt,int l,int r,int ql,int qr){
if(ql<=l && r<=qr)return t[rt];
int mid=l+r>>1;
Node tl={0,-1},tr={0,-1};
if(ql<=mid)tl=qry(rt<<1,l,mid,ql,qr);
if(qr>=mid+1)tr=qry(rt<<1|1,mid+1,r,ql,qr);
if(tl.id!=-1 && tr.id!=-1)return merge(tl,tr);
else if(tl.id!=-1)return tl;
return tr;
}
}t;
struct EDGE{
int u,v,w;
bool operator<(const EDGE a)const{
return w>a.w;
}
}E[maxm];
struct QRY{
int op,v;
}qy[maxm+maxn];
struct Edge{
int v,next;
}e[maxn*2];
void addedge(int u,int v){
e[ee]=Edge{v,h[u]};
h[u]=ee++;
}
int fd(int u){
return u==df[u]?u:(df[u]=fd(df[u]));
}
void dfs(int u){
vis[u]=1;
l[u]=dfn;
FOR(i,1,maxlog)fa[u][i]=fa[fa[u][i-1]][i-1];
if(!h[u])c[++dfn]=a[u];
for(register int i=h[u];i;i=e[i].next)dfs(e[i].v);
r[u]=dfn;
}
void Kruskal(){
sort(E,E+m);
FOR(i,1,n+1)df[i]=i;
tot=n;
FOR(i,0,m){
int u=E[i].u,v=E[i].v,w=E[i].w;
int fu=fd(u),fv=fd(v);
if(fu==fv)continue;
a[++tot]=w;
fa[fu][0]=fa[fv][0]=df[fu]=df[fv]=df[tot]=tot;
addedge(tot,fu),addedge(tot,fv);
}
ROF(i,tot,1)if(!vis[i])dfs(i);
t.build(1,1,dfn);
}
int work(int u,int x){
ROF(i,maxlog-1,0){
if(fa[u][i] && a[fa[u][i]]>x)u=fa[u][i];
}
Node ans=t.qry(1,1,dfn,l[u]+1,r[u]);
t.cg(1,1,dfn,ans.id);
return ans.v;
}
int main(){
int q;
rd(&n,&m,&q);
rd(a+1,n);
FOR(i,0,m){
int u,v;
rd(&u,&v);
E[i]={u,v,inf};
}
FOR(i,0,q){
rd(&qy[i].op,&qy[i].v);
if(qy[i].op==2)E[qy[i].v-1].w=i;
}
Kruskal();
FOR(i,0,q)if(qy[i].op==1)wrn(work(qy[i].v,i));
}
总结
其实也没啥好总结的
Kruskal重构树解决的题其实挺模板化的,一般都可以转化为询问 从v点出发经过不小于(不大于或小于或大于)边权的路径所能到达的所有点中满足xxx性质的xxx。然后Kruskal主要是能够迅速找出从v点出发不小于(不大于或小于或大于)边权的路径能够到达的所有点,方法是从v点出发在Kruskal树上倍增到一个满足条件的最远祖先f,那么f的子孙都是符合条件的点,至于要求解满足xxx性质的xxx这个需要根据具体题目来选择不同的数据结构,可能是主席树(解决区间第k大问题),可能是线段树(解决区间最值、求和带修改问题),可能是st表(不带修改的最值问题),还可能是树状数组等等…不过一般情况下这些数据结构都是区间上的数据结构,因此还需要为Kruskal重构树的节点建立dfs序才行,一般只会用到叶子结点,故只需要为叶子结点建dfs序即可。
这一套流程相对比较固定,所以说说最难的地方还是在于题意的转化,比如例三将删边时间转化为边权,整道题难度瞬间骤减。总之得找到一种转化方式使得变为边权大小限制的模型即可。
不难发现Kruskal重构树最大的优势是能够在线处理询问,且数据量能高达1e5的数量级。