BST和平衡树

在二叉树中,有两组非常重要的性质。第一种是“堆性质”,即每个节点的值都比他的左右子节点的值更优。第二种就是“BST性质”,就是每个节点的值大于左子树上所有节点的值,小于右子树上所有节点的值。它是二叉查找树以及所有平衡树的基础。

二叉查找树(BST)

BST的建立

为了避免越界,一般在BST中先插入一个值为正无穷一个值为负无穷的节点。

-∞
  \
   +∞
int size[N]//以这个节点为根的整棵子树的大小
int cnt[N]//这个节点的值出现的次数
int val[N]//节点的值
int ch[N][2]//左右子节点,0为左孩子,1为右孩子
int tot,root=0;//节点数量、根结点编号
int newnode(int v){//新建一个值为v的节点
    val[++tot]=v;
    cnt[tot]=size[tot]=1;
    return tot;
}
void pushup(int x){//更新x节点的size值
    size[x]=size[ch[x][0]]+size[ch[x][1]]+cnt[x];
}

BST的检索

目标:在二叉查找树中查找值为 \(val\) 的点的编号。

过程如下:

  1. \(x\) 的初始值设为根结点编号
  2. 比较 \(x\) 点的值和 \(val\) 的大小关系
  3. 如果 \(x\) 点的值等于 \(val\),找到答案,返回x
  4. 如果 \(x\) 点的值小于 \(val\),则答案如果有,一定在右子树中,把 \(x\) 赋值成右子节点的编号,继续操作步骤2
  5. 如果 \(x\) 点的值大于 \(val\),则答案如果有,一定在左子树中,把 \(x\) 赋值成左子节点的编号,继续操作步骤2
  6. 如果 \(x=0\),说明没找到,返回0
   (5)
   / \
  2  (9)         查找6,找到了
 /   / \
-1 (6) 10
 \
  1

    (5)
   /   \
 (2)    9        查找3,不存在
 / \   / \
-1  ? 6  10
 \
  1
int find(int cur,int x){
	if(!cur) return 0;//没找到
	if(val[cur]==x) return cur;//找着了
	return find(ch[cur][x>val[cur]],x);
}

BST的插入

目标:在树中插入一个值为 \(val\) 的点。

此过程与检索过程相似。在当前节点的子节点为空时,直接新建一个节点作为当前节点的子节点。

      5
    /   \
   2     9        插入3和8
  /\     /\
-1 (3)  6  10
 \       \
  1      (8)
void insert(int &x,int v){
    if(!x) x=newnode(v);
    else if(v>val[x]) insert(ch[x][1],v);
    else if(val[x]==v) cnt[x]++;
    else insert(ch[x][0],v);
    return pushup(x);
}

BST求前驱后继

前驱:\(val\) 的前驱为小于 \(val\) 的值中最大的。

后继:\(val\) 的后继为大于 \(val\) 的值中最小的。

以前驱为例,一个点的值的前驱只有可能在的这个节点右子树内,或在这个点到根的路径上。如果在右子树内,只有可能是沿着右子节点往左走,走到头的那个节点的值(右子树内最大值)。

后继同理。

前驱:

int pre(int x){
	int cur=root,res=1;//res初值为负无穷的节点编号 
	while(1){
		if(!cur) return res;
		if(val[cur]<x && val[cur]>val[res]) res=cur;
		cur=ch[cur][x>val[cur]];
	}
}

后继:

int succ(int x){
	int cur=root,res=2;//res初值为正无穷的节点编号
	while(1){
		if(!cur) return res;
		if(val[cur]>x && val[cur]<val[res]) res=cur;
		cur=ch[cur][x>=val[cur]];
	}
}

求第 \(x\) 大的数

目标:求排名为第 \(x\) 名的数值。(从小往大排)

排名的定义:小于当前数的个数总和加1。

这个和线段树求排名有些类似。这里就需要用到我们维护的 \(size\) 了。

因为对于一个节点 \(p\) 来说,左子树上节点的值一定小于节点 \(p\) 的值,节点 \(p\) 的值一定小于右子树上节点的值。

所以当一个节点 \(p\) 左子树的数字个数不小于x,即 \(x<=size[ch[p][0]]\) 时,答案如果有就一定在左边。

\(x>size[ch[p][0]]+1\) 时,相当于左子树所有元素数完了以后再数完了根节点还没数到到 \(x\),答案如果有就一定会在右边。然后递归重复上述操作。

\(x\) 正好等于 \(size[ch[p][0]]+1\) 时,答案就是 \(p\) 节点。

int kth(int k){
	int cur=root;
	while(1){
		if(ch[cur][0]&&k<=size[ch[cur][0]]){
			cur=ch[cur][0];
		}else if(k>size[ch[cur][0]]+cnt[cur]){
			k-=size[ch[cur][0]]+cnt[cur];
			cur=ch[cur][1];
		}else {
			return cur;
		}
	}
}

给值求排名

这个操作和上面的是相反的操作,也就是已知一个值,求这个值在所有数中的排名。

和上面的方法差不多,过程如下:

  • 从根节点出发,比较目标的值 \(v\) 和当前节点 \(p\) 的大小关系。

  • 如果 \(val[p]<v\),则累加左子节点的 \(size+1\),并递归至右子树。

  • 如果 \(val[p]>v\),则递归至左子树。

  • 如果 \(val[p]=v\),则返回左子节点的 \(size+1\)

int getrank(int x,int v){
    if(!x) return 1;
    else if(v>val[x]) return size[ch[x][0]]+cnt[x]+getrank(ch[x][1],v);
    else if(val[x]==v) return size[ch[x][0]]+1;
    else return getrank(ch[x][0],v);
}

删除

目标:删除值为 \(x\) 的节点。

对于这个操作来说,首先要找到那个值为 \(x\) 的节点 \(p\)。然后看看这个节点的 \(cnt\) 是否大于1,(\(cnt\) 代表这个节点上的数出现的次数),如果大于1,则直接 \(cnt--\),否则就要讨论 \(p\) 的子节点个数。

如果 \(p\) 的子节点个数为0,则直接删除这个节点。

如果 \(p\) 的子节点个数为1,则删除这个节点,并让 \(p\) 的子节点代替 \(p\) 的位置,即让 \(p\) 的父节点指向 \(p\) 的子节点。

如果 \(p\) 的子节点个数为2,则找到这个节点的后继,用这个节点后继的值代替这个节点,并删除这个节点的后继节点。

因为后继节点一定至多有一个孩子(后继的定义使得它满足这个性质)删除后继是很好处理的,而后继节点挪到当前位置是不破坏BST性质的,很巧妙。

void remove(int &x,int v,int flag){
	if(!x) return;
	if(val[x]==v){
		if(cnt[x]>1&&!flag) cnt[x]--;
		else if(!ch[x][0]||!ch[x][1]) x=ch[x][0]+ch[x][1];
		else{
			int nex=succ(val[x]);
			remove(ch[x][1],val[nex],1);
			ch[nex][0]=ch[x][0],ch[nex][1]=ch[x][1],x=nex;
		}
	}else if(v>val[x]) remove(ch[x][1],v,flag);
	else remove(ch[x][0],v,flag);
	return pushup(x);
}

时间复杂度分析:

程序的时间复杂度为 \(\mathcal O(nlogn)\text{至}\mathcal O(n^2)\),期望的时间复杂度是 \(\mathcal O(nlogn)\)。对于随机数据,BST的复杂度很优,为 \(\mathcal O(nlogn)\)。但对于构造卡的数据,例如,所有数据是按升序或降序输入的,二叉查找树就会变成一条链,时间复杂度就会退化到 \(\mathcal O(n^2)\)。因此需要对其进行优化。

平衡的维持

平衡树有很多种,但它们本质上的不同其实就是维持平衡的方式不同,我们大致可以将他们分为三类:

  • 基于旋转操作的平衡树:Treap、Splay

  • 基于分裂合并操作的平衡树:非旋Treap(无旋Treap)

  • 基于重构操作的平衡树:替罪羊树


\(\LARGE\textbf{旋转}\)

Treap

Treap是Tree和heap(堆)的合成词,通过堆性质与“随机”维护平衡树的平衡性

Treap给每一个节点一个随机的优先级 \(dat\),要使得整棵树的优先级满足大根堆(或小根堆)性质——也就是每个节点的父节点的优先级都要大于自身的优先级。当某一节点与它的孩子不符合大根堆性质时,会把它和它的孩子进行旋转,使得它的孩子成为它的父亲,以维护堆性质,若原树满足堆性质,插入一个节点时,一次旋转只会改变两个节点之间的堆性质(\(p\)\(q\) 之间传递的那个子树一定比 \(p\)\(q\) 都小,所以没有改变那个子树的堆性质,而 \(p\)\(fa\) 一定比子树里面任何一个值都大,所以也不会改变它的堆性质)

因此新建一个节点的时候,要加上一句 dat[tot]=rand()(记得在main里面调用一下srand随机种子)

与左子节点旋转(zig)(即右旋)

对于 \(p\) 节点的旋转,有模式图:

image.png

\(p\) 是我们要操作的节点,要与 \(q\) 交换,\(fa\) 连向 \(p\) 的边是不确定的,因此画成“中子节点”,ABC都代表子树(为空则指向的是0节点)

试想,如果 \(p\)\(q\) 交换了,那么 \(p\) 一定得成为 \(q\) 的右孩子,这样B子树该放在哪里呢?由于 \(p\) 的左子树是 \(q\),交换以后 \(p\) 就没有左子树了,因此直接把B接在 \(p\) 的左子树处即可,如图:

image.png

不难发现,我们只需要将 \(p\) 的右子树改为B,\(q\) 的左子树改为p,\(fa\) 的某一子树改为 \(q\) 即可(这里可以用引用)

这里要注意一个问题,旋转操作改变了 \(p\)\(q\) 的子树,因此需要更新它们的size值,pushup函数应运而生:

void pushup(int x){
    size[x]=size[ch[x][0]]+size[ch[x][1]]+cnt[x];
}

而且要注意 \(p\) 在旋转之后是孩子,所以要先更新 \(p\) 再更新 \(q\)

因此有:

void zig(int &p){//右旋 
	int q=ch[p][0];
	ch[p][0]=ch[q][1],ch[q][1]=p,p=q;
	pushup(ch[p][1]),pushup(p);
	return;
}

与右子节点旋转(zag)(即左旋)

同上,我们要从左边的图转换到右边的图:

image.png

image.png

与zig操作一样,只要把A从 \(q\) 的左子树搬到 \(p\) 的右子树即可

因此有:

void zag(int &p){//左旋 
	int q=ch[p][1];
	ch[p][1]=ch[q][0],ch[q][0]=p,p=q;
	pushup(ch[p][0]),pushup(p);
	return; 
}

这样,最基础的zig和zag就写完了。

除了建树,可能会改变节点的操作只有插入和删除,所以在进行这两个操作的时候,Treap会检查是否满足堆性质,并对不满足的情况进行旋转

至于其他操作,和BST是一致的

下文均以大根堆Treap为例

插入(insert)

在一个优先级满足堆性质的Treap上插入一个节点以后,这个节点一定是没有左右子树的,这时候如果它的优先级比它的父亲大,就把他和他的父亲交换,一直向上交换直到他的父亲优先级大于等于它的优先级

因为是要回溯着和他的父亲交换,所以最好用递归

当然不能忘记最后及时pushup当前节点

void insert(int &x,int v){//用引用维护父节点的连接
	if(!x){
		x=newnode(v);//没有该节点,新建
		return;
	}else if(val[x]==v) cnt[x]++;//如果有这个节点,把这个节点的cnt++
	else if(val[x]<v){
		insert(ch[x][1],v);
		if(dat[ch[x][1]]>dat[x]) zag(x);//没有插入则不会不满足堆性质,因为走的是右子树,所以只有右子节点可能是新插入的节点,不满足堆性质,因此把x(引用)与x的右子节点交换
	}else{
		insert(ch[x][0],v);
		if(dat[ch[x][0]]>dat[x]) zig(x);//同理,走左边则与左子节点交换
	}
	pushup(x);//别忘了维护节点的size(旋转函数里面也要记得维护size)
} 

删除(remove)

删除操作可以不用像上面那样繁琐了,直接把删除节点翻到叶子即可

由于要向上更新size和记录父节点情况,所以还是用递归

因此有:

void remove(int &x,int v){
	if(!x) return;//没找到,直接返回
	if(val[x]==v){
		if(cnt[x]>1) cnt[x]--;//有多个剩余,cnt--
		else if(ch[x][0]||ch[x][1]){//存在子树
			if(!ch[x][1]||dat[ch[x][0]]>dat[ch[x][1]])//右子树为0(左子树一定不为零)或者左子树的优先级比右子树大,则把x和左子节点交换
				zig(x),remove(ch[x][1],v);//交换以后x变成了要删除节点的父节点,要删除节点是x的右子节点,因此递归remove右子节点
			else zag(x),remove(ch[x][0],v);//同上
		}else x=0;//一直递归到x没有子树,这时候把x设成0即可(x引用)
	}else if(val[x]<v) remove(ch[x][1],v);
	else remove(ch[x][0],v);
	pushup(x);//最后更新每个节点的size值
}

这样整个Treap就完成了

Splay

Splay树,即伸展树。据说是一种除了LCT没有什么用的平衡树

Splay的核心就是splay——伸展操作,通过一系列旋转,把每次任何操作查询到的节点旋转到根节点位置,基本原理是实际应用中上一次操作的结果更有可能成为下一次操作的结果 ~~但显然OI并不是“实际应用”,而是卡常大赛 ~~ 当然,无论如何splay操作一定要可以使一棵树趋向平衡的。

由于splay需要记录每个节点的父亲,因此需要修改部分操作

与父节点的关系(rls/relationship)

为了方便以后的操作,可以定义一个rls函数:

bool rls(int x){
    return ch[fa[x]][1]==x;
}

这样相当于返回0则为左子树,返回1则为右子树

与左子节点旋转(zig)

zig的时候要注意修改对应的fa的值

image.png

例如这个图,把p和q旋转时,先用rls函数判断一下p的fa的哪个子树指针连向q,然后p的左孩子变为B,B的父亲变为p,p的父亲变为q,q的父亲变为fa,q的右孩子变为p

void zig(int p){
    int q=ch[p][0],f=fa[p];
    if(rls(p))ch[f][1]=q;
    else ch[f][0]=q;
    ch[p][0]=ch[q][1],fa[ch[q][1]]=p;
    ch[q][1]=p,fa[p]=q,fa[q]=f;
    pushup(p),pushup(q);//注意最后up两个节点,注意up的顺序
}

与右子节点旋转(zag)

与zig相似,有:

void zag(int p){
    int q=ch[p][1],f=fa[p];
    if(rls(p))ch[f][1]=q;
    else ch[f][0]=q;
    ch[p][1]=ch[q][0],fa[ch[q][0]]=p;
    ch[q][0]=p,fa[p]=q,fa[q]=f;
    pushup(p),pushup(q);
}

伸展(splay)

终于到了splay操作,共有两个参数,x和to,表示要把x伸展到to的位置

由于to是有可能和x交换的,那么怎样才能说明“x伸展到了to的位置”呢?,可以判断x的fa等于to的fa即可(to的fa要提前设成一个变量)

我们有两种旋转方式:

  1. 单旋,能向上旋转就把x向上旋转,一直到to

  2. 双旋,判断一下x的父节点和祖父节点是否处于一条线上,如果是,那么先旋转父亲和祖父,再旋转x,否则两次单旋

为什么不能只用单旋呢?

只用单旋会有这样一个问题,如图:

image.png

image.png

image.png

image.png

如果从x向上一段距离是一条链的话,x移动到这一条链顶端以后这条链并没有发生变化,ABCD四个子树的深度也没发生变化,因此最终树的深度并没改变,不能保证平衡,会被特殊数据卡掉

我们再来看看双旋过后这棵树的形态

image.png

image.png

image.png

image.png

最后虽然123形成了一条链,但子树的高度改变了,可以想象,如果上面一直是一条链,第三张图片中位于x的右子树的那一条链会进入某个节点的左子树中,这样长度就大概减少了一半,可证明一条链经过一次splay长度差不多会减半,因此用双旋可以保证树的平衡

void splay(int x,int to){
    int tofa=fa[to];//提前记录to的fa
    while(fa[x]!=tofa){//判断是否到达原来to的位置
        int f=fa[x];
        if(fa[f]==tofa)turn(x);//只剩一步,就直接上旋
        else if(rls(x)==rls(f))turn(f),turn(x);//共线,先上旋fa再上旋x
        else turn(x),turn(x);//不共线,两次上旋
    }if(root==to)root=x;//注意更新根的信息
    return;
}

删除(Delete)

splay的删除简单很多,只需要先用splay把要删除的节点翻到根上,然后:

  • 如果只有一个子节点,直接删除这个节点(把root指向它的存在的那个子节点)
  • 如果有两个子节点,则把x的前驱用splay搬到x的左子节点位置,则这个前驱一定没有右子节点(比前驱大比x小的数不存在),因此直接把x的右子节点接到前驱的右子节点处即可,最后x就只有左子节点了,直接删除即可(把root指向前驱)

因此有:

void Delete(ll val){
    int x=find(val);//find函数找到以后会自动splay到根
    if(x==-1)return;
    if(cnt[x]>1)cnt[x]--,size[x]--;//多个副本,删除一个
    else if(ch[x][0]==0)root=ch[x][1],fa[root]=0;//没有左子节点,root指向右子节点
    else if(ch[x][1]==0)root=ch[x][0],fa[root]=0;//没有右子节点,root指向左子节点
    else{
        int pre=ch[x][0];
        while(ch[pre][1])pre=ch[pre][1];//前驱一定是左子树中最右边的节点
        splay(pre,ch[x][0]);//把前驱搬到x的左子节点处
        fa[ch[x][1]]=pre,ch[pre][1]=ch[x][1];//把x的右子节点连接上前驱
        root=pre;//root指向前驱
        pushup(pre);//别忘了更新size
        fa[root]=0;//代替根的fa为0
    }return;
}

其他操作

对于其他操作,只要在查询到某一节点以后splay到根即可(注意x为0的时候不能splay)


\(\LARGE\textbf{分裂合并}\)

fhq Treap(非旋Treap)

前面已经讨论过了treap,那已经是一种非常好写而优秀的算法了,但是下面要讨论的非旋treap比普通treap更好写,时间也差不多,最重要的,非旋treap可以像splay一样维护区间信息,因此除了LCT,所有的splay几乎都可以被非旋Treap代替

(非旋Treap又名fhq Treap,是因为这个算法是由神犇范浩强在大约十年前发明的)

非旋Treap的核心操作有二:split(分裂)和merge(合并),树的结构只有在这两种情况下才会改变,而这两种情况可以做到维护堆性质等随机化操作,从而改善树的平衡性

分裂(split)

不妨考虑下图这棵BST树:

image.png

分裂操作有两种方式:按值分裂和按排名分裂 具体含义就是把值小于等于分裂值的节点或者排名小于分裂排名的节点都分到树的一边,剩下节点分到另一边

接下来我们尝试对这棵树进行按22这个值分裂:

设我们目前走到的节点为p,由于要分成两个树,所以用两个指针,首先p走到根,根的值20小于22,因此应在A树中,而根的左子树都比根小,因此都在A树中,因此有:

image.png

然后考虑指针的转移,由于x的左子树已经都是A树中的节点了,因此ch[p][0]是不用修改的,有可能修改的就只有ch[p][1],因此把ch[p][1]作为指针x传递下去,然后走根的右子树

image.png

p走到25节点处,发现25大于22,因此25应分到B树中,而25的右子树都比25大,所以25的右子树都会分到B树中,因此要修改ch[p][0],所以指针y设置为ch[p][0],并且p走25的左子树

image.png

p走到22节点处,由于我们的判断是小于等于分裂值的值都分到A树中,所以22也分到A树中,这时就可以使x(也就是ch[root][1])指向22了,然后这时就可以向右子树走,并且把x设为ch[p][1],p向右子树走

image.png

p走到24节点处,根据刚才的原理,y代表的25的左子树会连上24,24会被分到B树中,p再走x的左子树,却会走到0,这说明底下没有节点可以再分了,因此x代表的22的右子树和y代表的24的左子树都会指向空,这样,AB两棵树就分离完成了

还有另外一种情况——一条链

image.png

如果以6为分裂值,x指针就会一直下传一直到7,然后y会指向7,接着搜7的左子树为0,因此x(6的右指针)和y(7的左指针)都会指向空,这样整个树就被划分成两个了

因此有代码:

void split(int p,ll v,int &x,int &y){//这里的引用相当于上文中的指针(引用相当于指针常量)
	if(!p) {
		x=y=0;//搜完了,把x和y两个引用指向空
		return;
	}
	else if(val[p]<=v) x=p,split(ch[p][1],v,ch[p][1],y);
	//当前节点值小于等于分裂值,要分到A树中,上一个A树节点的右孩子要指向当前节点。继续递归的时候把这个节点当做上一个A树节点,用a[p].r代替x,这样后面可以直接修改当前节点的右子树指针
	else y=p,split(ch[p][0],v,x,ch[p][0]);
	//同上,分到B树,上一个B树节点的左孩子要指向当前节点,用a[p].l代替y,理由同上
	pushup(p);//分裂以后只有经过节点的左右子树会改变,因此回溯的时候只要把它们up即可
}

调用split的时候,可以找两个变量填充在x和y的引用位置,相当于运行以后这两个变量会自动指向两个树的根

合并(merge)

合并相当于分裂的逆过程,因此只需要用x,y两个指针检查,由于根据分裂的原理,A树中的元素总比B树中的元素小,因此直接判断x节点接在y的左子树还是y接在x的右子树上就行了

那么这两种情况的选择怎么办呢?其实这里就是Treap随机化的核心,通过某些操作把这一步随机化,就可以保证树的平衡

第一种随机方法:优先级/修正值

这个值和旋转Treap里面那个完全一样,就是为了保证合并的时候整棵树的堆性质。

例如维护大根堆,AB两树都是满足堆性质的,因此合并时如果x的值大,x应放在原树这一位置,否则y应放在原树这一位置

例如x放在这一位置后,由于y树的值都比x树大,因此合并后y树节点应都在x的右子树中,应继续递归x的右子树,看看如何和y合并

y放在原树的这一位置同理

int merge(int x,int y) {
	if(!x||!y) return x+y;//有一个为空,直接返回另一个,或者两个都为空时返回空
	if(dat[x]>dat[y]){//x的优先级比y大,把x留在原树的这一位置
		ch[x][1]=merge(ch[x][1],y);//继续地柜合并x的右子树和y,并接到x的右子节点上
		pushup(x);//更新x
		return x;//返回x
	}else{//y的优先级比x大,把y留在原树的这一位置
		ch[y][0]=merge(x,ch[y][0]);//继续递归合并y的左子树和x,并接到y的左子节点上
		pushup(y);//更新x
		return y;//返回y
	}
}

第二种随机方法:随机合并

这种方法不需要rand出优先级:

int merge(int x,int y){
    if(!x||!y)return x+y;//先返回,避免后面size取模出问题
    int rd=std::rand()%(siz[x]+siz[y]);//rand出一个在0~合并后树的大小的值
    if(rd<siz[y]){//如果rand出的值在y树的大小内,就把y放在x上面(可以这么想:如果y树的大小占总共的比例大,那么rd就更有可能比y的大小小,因此rd比y树大小小的时候,y更有可能比x更大,为了保证平衡y要在上面先放进原树)

ps.随机合并非常卡常,会导致性能大幅下降,一般不要使用

有了merge和split操作,剩下的操作就可以直接完成了

建树与加入节点

非旋Treap有一个特性——可以加入多个值相同的点

因此不用考虑cnt的情况了,插入的时候直接新来一个节点,删除的时候也直接把其中一个删掉即可

插入(insert)

可以用插入值split一下,分成小于等于插入值和大于插入值的两棵树AB,把插入值插入到A树以后再把两个树合并即可

因此有:

void insert(ll v){
	int x,y;
	split(root,v,x,y);//把x、y放在引用位置相当于自动变成两个分树的根
	root=merge(merge(x,newnode(v)),y);//先合并新建的val值节点和A树,再把A树和B树合并
}

删除(remove)

fhqTreap的一大好处就是删除非常简单

若删除值为val:

  • 把树用val分裂为两个分树AB,要删除的节点一定在A树中

  • 把A树用val-1分裂为两个分树CD,要删除的节点一定在D中

  • 这时候D中的节点的值一定都等于删除值,但是由于非旋Treap可重,所以D树中可能有多个节点,因此先把D树的根的两个孩子合并,然后把根删掉即可

  • 最后把CDB三个树按顺序合并即可

因此有:

void remove(ll v){
	int x,y,z;
	split(root,v,x,z);//分成两个树AB
	split(x,v-1,x,y);//A树分成两个树CD
	y=merge(ch[y][0],ch[y][1]);//D树中节点全都是val,因此删根(y指向两个孩子合并形成的树的根)
	root=merge(merge(x,y),z);//先合并CD两个树形成A',然后合并A'B
}

用值查询排名(rank)

由于我们分裂用的就是值,因此直接用 val-1 分裂成两棵树AB,A树中任意节点都比val要小,B树中任意节点都大于等于val,因此rank就是A树的大小+1

因此有:

int getrank(ll v){
	int x,y,res;
	split(root,v-1,x,y);
	res=siz[x]+1;
	root=merge(x,y);
	return res;
} 

用排名查询值(rerank)

可以仿照上面,写一个按排名分裂的split函数,不过这样更麻烦,不如直接用以前的rerank就可以了

int rerank(int x,int k){
	if(k<siz[ch[x][0]]+1){
		return rerank(ch[x][0],k);
	}else if(k>=siz[ch[x][0]]+1+1){
		return rerank(ch[x][1],k-siz[ch[x][0]]-1);
	}else return x;
}

查询前驱(pre)

先把树按照查询值val-1分裂成两棵树,前驱一定是小树中排名最大的那一个,直接用rerank函数即可

因此有:

int pre(ll v){
	int x,y,an;
	split(root,v-1,x,y);//按val-1分裂
	an=rerank(x,siz[x]);//查找小树中有小树大小-1个节点值比他小的节点,就是val的前驱
	root=merge(x,y);
	return an;
}

查询后继(succ)

先把树按照查询值val分裂成两棵树,然后在大树中找最小的那一个即可

因此有

int succ(ll v){
	int x,y,an;
	split(root,v,x,y);//按val分裂
	an=rerank(y,1);//查找大树中值最小的节点,就是val的后继
	root=merge(x,y);
	return an;
}

至此,整个非旋Treap就构造完成了

平衡树维护区间(非旋Treap)

这里只讲一下最好写的非旋Treap维护区间,其他的维护方式原理也是相同的

如果要对一个区间的数进行修改的话,我们显然不能把原数都从平衡树里面删掉,然后把新数插入

因此想到了平衡树的另一个性质:如果用排名维护平衡树,一个节点在平衡树中的排名是不变的

也就是说,如果我们维护一个这样的BST,用序列下标当关键值,每个位置再单独维护一个该下标处的序列元素的值即可。

但是这样只能处理对值操作的情况,如果出现改变序列顺序的操作(如翻转某一区间),怎样处理呢?

可以发现,如果不进行任何操作,这棵BST的某一节点序列下标(关键值)就是它在这棵树中的排名位置(rank函数得到的值),如果对这棵BST的树形结构进行修改,虽然不能满足依赖于某个关键值的BST性质,但是我们可以在进行操作的过程中保证某一节点在这棵树中的排名位置就是它对应的节点下标

例如,对于序列5,4,3,2,1,如果我们翻转下标2~4的区域,我们可以直接把树中排名为3的节点的左右子树(下标为2值为4的节点和下标为4值为2的节点)调换位置,这样原来下标为4(值为2)的节点的新的下标就变成它的新排名2了,同理原来下标为4(值为2)的节点的新的下标就变成它的新排名4了,在这个过程中我没法很快改变下标,索性就不记录每个节点对应的下标了,直接用每个点的排名代替下标也是正确的。

这个方法的核心在于我们要保证以下性质:

  1. 平衡树的中序遍历就是对整个区间的遍历
  2. 通过性质1可以得到平衡树中每个节点的排名等于它对应的下标(中序遍历会先把一个节点的下标前面所有的下标对应的节点先遍历,而rank函数返回的就是一个节点前面会有多少节点先被中序遍历)
    因此开始建树的时候要把下标按在序列当中的位置插入(按排名插入),这样保证了新树中每个节点的排名就等于它对应的下标

而splay,split,merge这些平衡树内操作并不会改变一个节点在树中的排名,因此不必担心出问题(当然split的时候要按排名split因为我们建的是排名平衡树)

还有一点,如果我们每次操作都要把平衡树中操作涉及到的节点都处理一遍,显然是一次就需要 \(O(n)\) 的时间复杂度,因此借用线段树的方法——设置lazy tag,这样需要操作的时候打一个标记,下次遇到这个节点的时候pushdown即可

\(\LARGE\textbf{重构}\)

替罪羊树

替罪羊树的一个显著特点是:对于每一个节点,它的左儿子和右儿子的子树大小都不超过本身子树大小的 \(0.75\) 倍,那么树的高度为 \(O( \log_2 n)\)

但是如何做到这一点呢?

重构(rebuild)

插入(insert)

对于插入的过程,像普通二叉排序树一样插入,完成后从新节点开始向父亲查找,找到深度最小的“不平衡”的节点,“不平衡”的意思是左儿子或右儿子的子树大小超过本身子树大小的 \(0.75\) 倍。当然,如果没有这样的点,直接结束即可。否则记录这个点为 \(x\)

在实现时我们可以写一个重构函数,在递归插入前先重构。

void insert(int &x,int v){
	rebuild(x,x);//重构函数
	if(!x) x=newnode(v);
	else if(val[x]==v) cnt[x]++;
	else if(val[x]<v) insert(ch[x][1],v);
	else insert(ch[x][0],v);
	pushup(x);
}

删除(remove)

对于删除的过程同样先重构,再递归删除。

void remove(int &x,int v){
	rebuild(x,x);
	if(!x) return;
	else if(cnt[x]!=0&&val[x]==v) cnt[x]--;
	else if(val[x]<v) remove(ch[x][1],v);
	else remove(ch[x][0],v);
	pushup(x);
}

重构(rebuild)

若我们要重构以节点 \(x\) 为根的子树,我们首先要中序遍历整颗子树,得到这棵子树对应的序列。再 \(O(n)\) 进行一次暴力重构,将 \(x\) 的子树改造成最平衡的样子(只需每次取最中间的数字当根即可)。用这棵子树取代原子树的位置。

void TtoL(int x,int &count){ Tree to Line 即把树上数据存在序列中
	if(!x) return;
	if(ch[x][0]) TtoL(ch[x][0],count);//递归存储左子树
	if(cnt[x]) tmp[++count]=x;//存储当前节点
	if(ch[x][1]) TtoL(ch[x][1],count);//递归存储右子树
	return;
}
void LtoT(int &x,int l,int r){ Line to Tree 即把序列数据重构为树
	if(l>r){//叶子结点的孩子,即不存在的节点
		x=0;
		return;
	}int mid=(l+r+1)>>1;
	x=tmp[mid];//取最中间的数字当根
	LtoT(ch[x][0],l,mid-1);//递归重构左子树
	LtoT(ch[x][1],mid+1,r);//递归重构右子树
	pushup(x);
	return;
}
void rebuild(int x,int &fa){
	if(!x||size[x]*alpha>size[ch[x][0]]&&
	size[x]*alpha>size[ch[x][1]]){//如果平衡则无须重构
		return;
	}
	int count=0;
	TtoL(x,count);//先打散
	LtoT(fa,1,count);//再重构
	return;
}

其他操作

对于其他操作与BST基本一致

关于时间复杂度

每次重构的方法看上去时间复杂度很高,但实际上,用“均摊分析”等较高级的方法进行分析,可以得到一个结论:无论是怎样的输入数据,替罪羊树都可以在 \(O(n \log_2 n)\) 的时间内完成给定的 \(n\) 次操作(证明略),这样做效率确实很高。

posted @ 2022-10-06 16:37  「ycw123」  阅读(87)  评论(0编辑  收藏  举报