一般我们考虑的树都是无向树,即联通非循环图(也就是联通无向图中没有环),如果为非联通非循环图,我们称之为森林。
而在树形结构中,二叉树具有很多很好的性质,也有很多有用的应用,所以一般都以二叉树为基础进行讨论。
二叉搜索树
一种中序遍历为原序列的树形结构,对于每一个子树,左儿子比自己小,右儿子比自己大,如果必要的话,可以给每个结点加权,记录每个数出现次数。
一个二叉搜索树:

基于这种结构,二叉搜索树支持以下功能:
- 插入:从根节点开始不断比较,小往左,大往右,最后放到合适位置;
- 删除:对于叶子结点直接删除;对于有一个儿子的结点,删除后把儿子放到该结点位置;否则,寻找右子树中最小的值(即以右儿子为根,不断往左寻找)进行替换;
- 查找:这个就很简单了;
- 查询排名:带有 size 的深度搜索;
- 前继后继:跟查找差不多;
堆
堆是一种完全二叉树,分为大根堆和小根堆,大根堆即所有子树的根节点都比儿子要大,小根堆同理。
一个大根堆:

基于这种数据结构,堆支持以下功能:
- 查询最值:即堆顶;
- 插入:我们将新的值放在最底层最右边,然后不停向上更新,直到新插入的值小于父亲;
- 弹出:将最后的值放到堆顶,然后不停向下更新更大的儿子,直到该值大于所有儿子;
Treap
尽管对于随机数来说二叉搜索树非常高效,但是在一些特殊情况下,它可能会退化成一条较长的链。所以,随机平衡二叉搜索树——Treap,用于解决平衡问题。Treap=Tree+Heap,Treap结合了二叉搜索树和堆的性质,为每一个结点分配一个随机数,在满足二叉搜索树的性质的前提下,对随机数满足堆的性质。这样由于随机数是随机的,所以二叉搜索树是平衡的。
Treap的核心操作是旋转,从以下例子可以看出,不管是左旋还是右旋,都不会影响Treap的二叉搜索树的性质:

我们可以看出,旋转的规律就是:把当前结点的反儿子(左旋右儿子,右旋左儿子)的正儿子(左旋左儿子,右旋右儿子)拿出来,然后把待旋转的两个结点对应移一下,再把拿出来的结点放到原来结点的反方向。
由于考虑到Treap要满足堆的性质,所以我们在插入、删除结点的时候,还要考虑满足大根堆或者小根堆,这通过旋转来实现。插入和删除的操作跟普通二叉搜索树差不多,只是对于插入操作,某个结点插入了新的儿子时,要对随机数数组 rd 进行 rotate(旋转)操作,以满足堆性质;对于删除操作,可以将待删除的结点 rotate 到叶子结点处,这样方便处理,中途也要考虑满足堆的性质。
其它的功能,凡是二叉搜索树能满足和实现的,Treap也能同样地实现。
Treap模板如下(普通平衡树):
// t为每个结点的值;num为每个值出现次数;ch为儿子指针;rd为结点随机数;siz为结点子树大小
// root记录树根,tot记录结点个数
struct treap
{
int tot,root;
int *t,*num,(*ch)[2],*rd,*siz;
treap(int maxn)
{
t=new int[maxn](); num=new int[maxn]();
rd=new int[maxn](); siz=new int[maxn]();
ch=new int[maxn][2](); root=tot=0;
}
void push_up(int k) {siz[k]=siz[ch[k][0]]+siz[ch[k][1]]+num[k];}
void rotate(int &k,int d)
{
int x=ch[k][d^1];
ch[k][d^1]=ch[x][d]; ch[x][d]=k;
push_up(k); push_up(x);
k=x;
}
void insert(int &k,int x)
{
if(!k) {k=++tot; t[k]=x; num[k]=siz[k]=1; rd[k]=rand(); return;}
else if(t[k]==x) {num[k]++; siz[k]++; return;}
else
{
int d=x>t[k];
insert(ch[k][d],x);
if(rd[k]<rd[ch[k][d]]) rotate(k,d^1);
push_up(k);
}
}
void insert(int x) {insert(root,x);}
void del(int &k,int x)
{
if(!k) return;
if(x!=t[k]) del(ch[k][x>t[k]],x);
else
{
if(!(ch[k][0]|ch[k][1])) {num[k]--; siz[k]--; if(!num[k]) k=0;}
else if(!ch[k][0] && ch[k][1]) {rotate(k,0); del(ch[k][0],x);}
else if(ch[k][0] && !ch[k][1]) {rotate(k,1); del(ch[k][1],x);}
else {int d=rd[ch[k][0]]>rd[ch[k][1]]; rotate(k,d); del(ch[k][d],x);}
}
push_up(k);
}
void del(int x) {del(root,x);}
int ranking(int k,int x)
{
if(!k) return 0;
if(x==t[k]) return siz[ch[k][0]]+1;
if(x<t[k]) return ranking(ch[k][0],x);
return ranking(ch[k][1],x)+siz[ch[k][0]]+num[k];
}
int ranking(int x) {return ranking(root,x);}
int who_ranking(int k,int r)
{
if(!k) return 0;
if(r<=siz[ch[k][0]]) return who_ranking(ch[k][0],r);
if(r>siz[ch[k][0]]+num[k]) return who_ranking(ch[k][1],r-siz[ch[k][0]]-num[k]);
return t[k];
}
int who_ranking(int r) {return who_ranking(root,r);}
int pre(int k,int x)
{
if(!k) return -1e9;
if(t[k]>=x) return pre(ch[k][0],x);
return max(t[k],pre(ch[k][1],x));
}
int pre(int x) {return pre(root,x);}
int suf(int k,int x)
{
if(!k) return 1e9;
if(t[k]<=x) return suf(ch[k][1],x);
return min(t[k],suf(ch[k][0],x));
}
int suf(int x) {return suf(root,x);}
};
Splay(伸展树)
也是一种可以让二叉搜索树平衡的数据结构,其中核心操作是旋转(rotate),与Treap类似(其实我们可以想象,二叉搜索树退化是因为一边太大一边太小,而旋转操作就是解决这个问题的)。Splay树的独特之处在于有一个splay操作,该操作把某一结点旋转为根结点(或其它结点),通过不停地splay保证树的平衡性,同时便于各种操作。
旋转操作和Treap差不多,但是在这里旋转的对象是和其父亲关联的(Treap中和儿子关联),我们执行一次rotate操作相当于把目标结点向上进了一步,同时不影响二叉搜索树的性质。
而伸展操作本质上就是不停地rotate,直到根结点为止。这里需要注意,如果目标结点和父亲和祖父三点一线的话,要先rotate父亲结点(其实不特殊化也可以,但是先旋转父亲的话可以让树更加平衡)。
下面列举一下常见操作的实现:
- 旋转:略;
- splay:略;
- 插入:同普通二叉搜索树差不多,但是要注意维护相关数组,以及插入的结点需要splay;
- 排名:同普通二叉搜索树差不多,但是找到目标结点后要splay;
- pre:根结点前驱,因为限定为根结点,所以直接从根结点左儿子开始不停往右到底就行了;
- suf:根结点后继,同上,直接从根结点右儿子开始不停往左到底就行了;
- 删除:ranking(x) 一下将目标结点splay至根结点,然后分类讨论即可;
Splay树模板如下(普通平衡树):
// t为每个结点的值;num为每个值出现次数;ch为儿子指针;siz为结点子树大小;f为父亲指针
// root记录树根,tot记录结点个数
struct splay_tree
{
int root,tot;
int *t,*num,(*ch)[2],*siz,*f;
splay_tree(int maxn)
{
t=new int[maxn](); num=new int[maxn]();
siz=new int[maxn](); f=new int[maxn]();
ch=new int[maxn][2](); root=tot=0;
}
int which(int k) {return k==ch[f[k]][1];}
void clear(int k) {t[k]=num[k]=ch[k][0]=ch[k][1]=siz[k]=f[k]=0;}
void push_up(int k) {siz[k]=siz[ch[k][0]]+siz[ch[k][1]]+num[k];}
void rotate(int k)
{
int fa=f[k],gf=f[fa],w=which(k);
ch[fa][w]=ch[k][w^1]; f[ch[fa][w]]=fa;
ch[k][w^1]=fa; f[fa]=k; f[k]=gf;
if(gf) ch[gf][ch[gf][1]==fa]=k;
push_up(fa); push_up(k);
}
void splay(int k,int goal) // 将k旋转到goal的儿子(goal为0时旋转到根)
{
for(int fa;(fa=f[k])!=goal;rotate(k))
if(f[fa]!=goal) rotate(which(k)==which(fa)?fa:k);
if(!goal) root=k;
}
void insert(int x)
{
if(!root) {clear(root=++tot); num[root]=siz[root]=1; t[root]=x; return;}
int k=root,fa=0;
while(1)
{
if(x==t[k]) {num[k]++; push_up(k); push_up(fa); splay(k,0); return;}
fa=k; k=ch[fa][t[fa]<x];
if(!k) {clear(++tot); num[tot]=siz[tot]=1; ch[f[tot]=fa][t[fa]<x]=tot; t[tot]=x; push_up(fa); splay(tot,0); return;}
}
}
int ranking(int x) // x必须在里面
{
int k=root,ans=0;
while(1)
{
if(x<t[k]) {k=ch[k][0]; continue;}
if(ch[k][0]) ans+=siz[ch[k][0]];
if(t[k]==x) {splay(k,0); return ans+1;}
ans+=num[k]; k=ch[k][1];
}
}
int who_ranking(int r)
{
int k=root;
while(1)
{
if(ch[k][0] && r<=siz[ch[k][0]]) {k=ch[k][0]; continue;}
int temp=num[k];
if(ch[k][0]) temp+=siz[ch[k][0]];
if(r<=temp) return t[k];
r-=temp; k=ch[k][1];
}
}
int pre() // 当前根结点前继
{
int k=ch[root][0];
while(ch[k][1]) k=ch[k][1];
return k;
}
int suf() // 当前根结点后继
{
int k=ch[root][1];
while(ch[k][0]) k=ch[k][0];
return k;
}
void del(int x)
{
ranking(x);
if(num[root]>1) {num[root]--; push_up(root); return;}
if(!ch[root][0] && !ch[root][1]) {clear(root); root=0; return;}
if(!ch[root][1]) {int k=root; f[root=ch[root][0]]=0; clear(k); return;}
if(!ch[root][0]) {int k=root; f[root=ch[root][1]]=0; clear(k); return;}
int k=root,p=pre();
splay(p,0);
ch[root][1]=ch[k][1]; f[ch[k][1]]=root;
clear(k); push_up(root);
}
};
写成结构体是为了方便调用,同时将内部变量与外面隔离开来。
Treap是利用自己构造的随机数据去保证二叉搜索树的平衡性,而Splay通过特殊的伸展操作保证平衡性;在平衡性条件下,不论是堆操作还是splay操作,复杂度都是 O(logn) 级别的;实际应用中,splay的功能还是更加强大一些(比如区间处理),它几乎所有操作都会把目标结点splay到根结点上去,这保证了平衡性,同时在根结点上进行分析也更加简洁。
用splay进行区间处理的思路一般是:t数组记录1-n,也就是原本下标,我们要处理区间 [l,r] ,可以把 l-1 splay到根结点,把 r+1 splay到根结点的右儿子,这样根结点的右儿子的左子树就是待处理的区间了。为了避免左右特殊处理,往往可以添加两个虚拟结点。
进行区间翻转的维护时,我们借鉴线段树 lazy标签的思路,对对应区间结点打标记,需要访问的时候往下传即可。要注意这里区间翻转的实现是交换儿子,而这样的操作之后事实上该树已经不满足二叉搜索树的性质了,我们说的比如说把 r+1 splay到根结点,实际上是指在树上排名为 r+1 的结点。
区间翻转维护代码如下(文艺平衡树):
const int maxn=1e5+5;
int root=1,tot,n,m;
int t[maxn],ch[maxn][2],siz[maxn],f[maxn],rev[maxn];
int which(int k) {return k==ch[f[k]][1];}
void clear(int k) {t[k]=ch[k][0]=ch[k][1]=siz[k]=f[k]=0;}
void push_up(int k) {siz[k]=siz[ch[k][0]]+siz[ch[k][1]]+1;}
void rotate(int k)
{
int fa=f[k],gf=f[fa],w=which(k);
ch[fa][w]=ch[k][w^1]; f[ch[fa][w]]=fa;
ch[k][w^1]=fa; f[fa]=k; f[k]=gf;
if(gf) ch[gf][ch[gf][1]==fa]=k;
push_up(fa); push_up(k);
}
void splay(int k,int goal)
{
for(int fa;(fa=f[k])!=goal;rotate(k))
if(f[fa]!=goal) rotate(which(k)==which(fa)?fa:k);
if(!goal) root=k;
}
void push_down(int k)
{
if(!rev[k]) return;
rev[ch[k][0]]^=1; rev[ch[k][1]]^=1;
swap(ch[k][0],ch[k][1]);
rev[k]=0;
}
int findd(int x)
{
int k=root;
while(1)
{
push_down(k);
if(x<=siz[ch[k][0]]) k=ch[k][0];
else
{
x-=siz[ch[k][0]]+1;
if(!x) return k;
k=ch[k][1];
}
}
}
int build(int l,int r,int fa)
{
if(l>r) return 0;
int mid=(l+r)>>1,k=++tot;
t[k]=mid; f[k]=fa;
ch[k][0]=build(l,mid-1,k);
ch[k][1]=build(mid+1,r,k);
push_up(k);
return k;
}
void dfs(int k)
{
if(!k) return;
push_down(k);
dfs(ch[k][0]);
if(t[k]>1 && t[k]<n+2) printf("%d ",t[k]-1);
dfs(ch[k][1]);
}
int main()
{
//freopen("input.txt","r",stdin);
n=read(),m=read();
build(1,n+2,0);
while(m--)
{
int l=read(),r=read();
int a=findd(l),b=findd(r+2);
splay(a,0); splay(b,a);
rev[ch[ch[root][1]][0]]^=1;
}
dfs(root);
return 0;
}
LCT
Link-cut Tree,动态树的一种,用来处理可添加边和删除边的树上问题。
LCT其实就是用 splay树去维护实链剖分后的实链,实链剖分和重链剖分类似,重链剖分是根据重儿子决定重边和轻边,而实链剖分是随意指定实边和虚边,这里说“随意”并不是真的随意,只是说实边虚边是可以变动的,而不是像重链剖分那样根据树形去决定。结合了 splay树维护实链信息,LCT变得非常灵活。
LCT中有两个重要的概念:原树和辅助树。原树指的是原本森林中一颗一颗的树,辅助树是由若干个 splay树组成的用于维护原树的树,所以,LCT是若干个辅助树的集合,用于维护整个森林。辅助树上的每个 splay 维护一条实链,且这个 splay 根据结点在原树中的深度满足二叉搜索性,每个 splay 的父亲为其最小结点在原树上的父亲。
鉴于实链剖分和重链剖分的相似特征,LCT也是用于维护树上区间可合并值的数据结构,同时支持加边和删边操作。
LCT的一些重要操作:
- is_root(k):判断 k 是否为所在 splay 的根;
- which(k):判断 k 是 splay 上哪个儿子;
- push_up/push_down:结点维护的信息更新;
- rotate/splay: splay 树的基本操作(这里的 splay 是将当前结点旋转至当前 splay树的根),但要注意这里和基础的 splay树不太一样;
- access(k):将结点 k 到原树树根的这一条链变成实链,组成一个 splay树(这个操作非常重要!),这个操作过后,结点 k 是该实链中深度最深的结点;
- make_root(k):使结点 k 成为其所在树的树根,我们只用先 access(k)、splay(k)(这时由 splay树的性质,k没有右儿子),然后我们将 k 的儿子翻转,结点 k 就成为了当前 splay树中最小的结点,也就是原树中这条实链中深度最浅的结点,也就是根结点;
- find_root(k):寻找结点 k 所在树的根结点,同样 access(k)、splay(k) 后,根结点就是当前 splay树中最小的结点;
- link(x,y):如果 x 和 y 不在同一棵树中,就把 x 变成其所在树的树根(make_root(x)),然后令其父亲为 y;
- cut(x,y):把 x 变成树根,然后 access(y)、splay(y) 后,如果 x 和 y 有连边,那么 x 此时一定是 y 的左儿子,删除该边即可;
对于 洛谷P3690 Link Cut Tree ,要求维护森林中路径权值异或和,并且支持加边、删边和单点修改。代码如下:
const int maxn=3e5+5;
int n,m;
int f[maxn],rev[maxn],a[maxn],t[maxn],Q[maxn],ch[maxn][2];
bool is_root(int k) {return ch[f[k]][0]!=k && ch[f[k]][1]!=k;}
bool which(int k) {return k==ch[f[k]][1];}
void push_up(int k) {t[k]=t[ch[k][0]]^t[ch[k][1]]^a[k];}
void push_down(int k) {if(rev[k]) rev[ch[k][0]]^=1,rev[ch[k][1]]^=1,rev[k]=0,swap(ch[k][0],ch[k][1]);}
void rotate(int k)
{
int fa=f[k],gf=f[fa],w=which(k);
if(!is_root(fa)) ch[gf][ch[gf][1]==fa]=k;
ch[fa][w]=ch[k][w^1]; f[ch[k][w^1]]=fa;
ch[k][w^1]=fa; f[fa]=k; f[k]=gf;
push_up(fa); push_up(k);
}
void splay(int k)
{
int top=1; Q[0]=k;
for(int i=k;!is_root(i);i=f[i]) Q[top++]=f[i];
REP_(i,top-1,0) push_down(Q[i]);
for(int fa=f[k];!is_root(k);rotate(k),fa=f[k])
if(!is_root(fa)) rotate(which(k)==which(fa)?fa:k);
}
void access(int k) {int t=0; while(k) splay(k),ch[k][1]=t,push_up(k),t=k,k=f[k];}
void make_root(int k) {access(k); splay(k); rev[k]^=1;}
int find_root(int k) {access(k); splay(k); while(ch[k][0]) k=ch[k][0]; return k;}
void link(int x,int y) {if(find_root(x)!=find_root(y)) make_root(x),f[x]=y;}
void cut(int x,int y) {make_root(x); access(y); splay(y); if(ch[y][0]==x) f[x]=ch[y][0]=0;}
int main()
{
n=read(),m=read();
REP(i,1,n) a[i]=t[i]=read();
while(m--)
{
int op=read(),x=read(),y=read();
if(!op) make_root(x),access(y),splay(y),printf("%d\n",t[y]);
else if(op==1) link(x,y);
else if(op==2) cut(x,y);
else make_root(x),a[x]=y,push_up(x);
}
return 0;
}
对于维护树上区间的问题,一般都是要求询问结点 x 到结点 y 路径上点的权值和、异或和等等信息,而LCT支持 access 操作,所以可以 make_root(x)、access(y)、splay(y) 后,在 x 和 y 所在的 splay树中,结点 y 就存储了这条路径信息,结点 x 是其中的最小结点。
对于 洛谷P2147 洞穴勘测 ,要求判断加边删边后的连通性,那就更容易了,判断 x 和 y 所在树的根结点是不是相同即可。