平衡树学习笔记

从线段树开始

众所不周知,线段树作为一种非常好用的 DS,本质上其实是排序加二分,只不过我们需要看你排序的对象是什么。如果是对下标排序,就是普通的线段树。如果按照权值排序,就是权值线段树。

所以线段树构建的本质就是排序,只不过排序的对象不一样,我们得到的线段树也就不一样。至于查找,就是一个在线段树上跳儿子的过程,每跳一次就相当于一次二分,这一点从代码里能很轻松的看出来。

所以大多数树形结构的 DS 的本质都是排序(我是说大多数,至少目前我学到的都是),只不过它们的排序方式和排序对象略有差异而已。

二叉查找树

二叉查找树(Binary Search Tree,简称 BST),是一种高效有用的数据结构。它的特征是:

  1. 对于任意一个节点,它的左儿子所在的子树中的最大值比它小。
  2. 对于任意一个节点,它的右儿子所在的子树中的最小值比它大。
  3. BST 上没有相同的两个数,如果有,那么可以合并成一个节点。

BST 的这种特性赋予了它一种特殊的排序方式:一棵 BST 的中序遍历的权值一定是单调递增的。因此我想要查找一个值,首先就可以和当前节点比较,如果比当前节点的值要小,那么就往左走,反之就往右走。

我们来算一算一棵 BST 的时间复杂度:假设 n n n 个数都不一样,在最优情况下,建出来的 BST 高度正好为 log ⁡ n \log n logn,时间复杂度 O ( log ⁡ n ) O(\log n) O(logn)。但是如果退化成了一条链,那么时间复杂度直接退化为 O ( n ) O(n) O(n),这时时间直接爆炸。

为了解决这个问题,我们可以选择使用平衡树

普通平衡树

我们首先来看这样一组数据:

复制代码
1 2 3 4 5 6 7 8 9 10

显然这是一组单调递增的数据,如果我们直接建 BST 就会变成这样:

但是显然我们如果以 5 5 5 为根节点的话建出来的数就长这样:

(图片都小了好多)

显然树的高度大幅减少。

但是如果按照正常的 BST 去建的话,最终会直接变成第一幅图的样子,但是显然我们可以把这棵树"旋转"成第二幅图的样子从而减小树的高度。于是平衡树出现了。

平衡树(英文名 Treap)是 BST 的一种变体。大致作用就是通过旋转原本的 BST 来平衡整棵树的高度,从而使树的高度尽可能的小。

首先我们来看一看这个旋转规则。

旋转规则

旋转一棵树,显然有两种方法:往左边旋转和往右边旋转。简称左旋右旋

假设 x x x 为当前子树的根,左旋的规则如下:

  1. 把 x x x 的右儿子变成 x x x 原本的右儿子的左儿子。
  2. 把 x x x 原本的右儿子的左儿子变成 x x x。

画个图看一下:

首先把 x x x 的右儿子变成 x x x 原本的右儿子的左儿子:

然后把 x x x 原本的右儿子的左儿子变成 x x x:

看着有点诡异,我们稍微调整一下:

于是我们便完成了一次左旋。用字母表示出来就是这样:

复制代码
  A             C
 / \           / \
B   C ------> A   E
   / \       / \
  D   E     B   D

我们来看一下这棵旋转后的 BST 的中序遍历是否发生改变:

原本的中序遍历:BADCE。

修改后的中序遍历:BADCE。一模一样!

这样我们就做到了在不改变原 BST 的中序遍历下修改了树的结构,也就是完成了一次旋转。

那么右旋就是把上面箭头的方向反过来。规则也很好想,大家可以自己手推一下。

当然因为在旋转之后树的结构发生了改变,如果你有维护什么与子树相关的信息,需要重新更正。这里明显只有 x x x 与 x x x 原本的右儿子受到了影响,所以只需要重新更新它俩的值就行。

代码:

cpp 复制代码
void rotate(int &pos,int x)
{
	if(x==0)//右旋
	{
		int t=node[pos].ls;
		node[pos].ls=node[t].rs;
		node[t].rs=pos;
		pushup(pos);
		pushup(t);
		pos=t;//这里是更新根节点
	}
	else//左旋
	{
		int t=node[pos].rs;
		node[pos].rs=node[t].ls;
		node[t].ls=pos;
		pushup(pos);
		pushup(t);
		pos=t;
	}
}

现在旋转的问题被我们完美解决了,但是我们什么时候旋转呢?

如果你直接用原本 BST 上的信息显然是不足以支持判断是否旋转的。这里用了一个很"不靠谱"的方法:随机数。

就是说:我给每个节点随机赋一个值,如果我的儿子节点的值比我的值大,那么就把相应的儿子节点旋上去作为根节点。也就是按照大根堆的方式旋转。这里就是靠脸吃饭的时候了,你运气好能完美的平衡下来,你运气不好可能正好给你搞成一条链。不过在大部分情况下你都能平衡这棵树(虽然不一定非常完美)。因此时间复杂度 O ( log ⁡ n ) O(\log n) O(logn)(当然不排除你是怨种的情况,正好给你平衡成了一条链)。这一部分涉及到概率,比较玄学啊,我也不知道怎么证明这个东西,反正记着就对了。

为了让你更好的理解平衡树,我们来看一道例题:洛谷 P6136 【模板】普通平衡树(数据加强版)

首先我们知道平衡树的本质就是排序,那么这道题要我们对什么排序呢?稍微思考一下就能想到是按照值的大小排序。如果你想不到那么可以先想想如果写普通的线段树该怎么写,然后你就能想到是按照什么排序了。

现在我们来对每种操作逐个分析:

插入

首先我们直接用 BST 的查找方式找到当前插入的值应该在的位置。如果说原本没有这个数,那我们就新加入一个点,然后各种初始化。如果有,那我们直接对个数加一就行。当然不要忘记旋转。

代码:

cpp 复制代码
void update(int &pos,int x)
{
	if(pos==0)//如果没有这个数字
	{
		pos=++cnt;//新建一个节点
		node[pos].siz=1;
		node[pos].num=1;
		node[pos].val=x;
		node[pos].s=rd();//rd() 是随机数函数
		return;
	}
	if(node[pos].val==x)//如果有这个点
	{
		node[pos].num++;//个数加一
		node[pos].siz++;//子树大小加一
		return;
	}
	if(node[pos].val>x)//如果 x 比根节点小,说明它应该在根节点的左子树上
	{
		update(node[pos].ls,x);
		if(node[node[pos].ls].s>node[pos].s)//旋转,跟上面讲的一样
		{
			rotate(pos,0);
		}
	}
	else//反之就是右子树
	{
		update(node[pos].rs,x);
		if(node[node[pos].rs].s>node[pos].s)
		{
			rotate(pos,1);
		}
	}
	pushup(pos);//更新信息
}

删除

这一步跟插入操作很像。我们先找到这个数所在的点,那么如果这个数的出现次数大于 1 1 1,直接个数减一就行。但是如果个数正好等于 1 1 1,那我们就需要去掉这个点,而且我们还要重构这棵树。不过重构整棵树太麻烦了,这里我们分类讨论一下:

  • 如果当前节点是叶子结点,直接删去。
  • 如果当前节点有一个子节点,把这个子节点转上来作为新的根,然后原本的根就变成了叶子结点,然后按照上面的操作进行。
  • 如果当前节点有两个子节点,随意转上来一个子节点(当然我们为了保持原树大根堆的性质,通常会选择子树中随机数更大的那一个为根),那么原本的根节点就只有一个子节点了,然后按照上面的操作进行。

代码:

cpp 复制代码
void erase(int &pos,int x)
{
	if(pos==0)
	{
		return;
	}
	if(x<node[pos].val)
	{
		erase(node[pos].ls,x);
	}
	else if(x>node[pos].val)
	{
		erase(node[pos].rs,x);
	}
	else
	{
        if(node[pos].num>1)
        {
            node[pos].num--;
            node[pos].siz--;
        }
        else
        {
            if(!node[pos].ls&&!node[pos].rs)
			{
				node[pos].num--;
				node[pos].siz--;
				if(!node[pos].num)
				{
					pos=0;
				}
			}
			else if(node[pos].ls&&!node[pos].rs)
			{
				rotate(pos,0);
				erase(node[pos].rs,x);
			}
			else if(!node[pos].ls&&node[pos].rs)
			{
				rotate(pos,1);
				erase(node[pos].ls,x);
			}
			else
			{
				if(node[node[pos].ls].s>node[node[pos].rs].s)
				{
					rotate(pos,0);
					erase(node[pos].rs,x);
				}
				else
				{
					rotate(pos,1);
					erase(node[pos].ls,x);
				}
			}
        }
	}
	pushup(pos);
}

查询排名

这个事情很好办了啊,用类似于线段树的方式:如果当前节点的值正好就是输入进来的值,那么直接返回左子树的大小加一,反之就继续查找。这个会线段树的基本都能写。

代码:

cpp 复制代码
int rank(int pos,int x)
{
	if(pos==0)
	{
		return 1;
	}
	if(node[pos].val==x)
	{
		return node[node[pos].ls].siz+1;
	}
	else if(node[pos].val>x)
	{
		return rank(node[pos].ls,x);
	}
	else
	{
		return node[node[pos].ls].siz+node[pos].num+rank(node[pos].rs,x);
	}
}

对应排名上的数

这个也是线段树的经典操作,我觉得会线段树的基本都会这个操作。

代码:

cpp 复制代码
int find(int pos,int x)
{
	if(pos==0)
	{
		return 0;
	}
	if(node[node[pos].ls].siz>=x)
	{
		return find(node[pos].ls,x);
	}
	else if(node[node[pos].ls].siz+node[pos].num<x)
	{
		return find(node[pos].rs,x-node[node[pos].ls].siz-node[pos].num);
	}
	else
	{
		return node[pos].val;
	}
}

查询前驱

这里稍微有点细节,因为我查询前驱的数不一定在之前出现过,所以不能直接查找这个数所在的位置然后返回前面一个数,而是要在不断查找的过程中把所有比给定的数小的数求最大值。简单来说就是如果当前这个位置可能是给定的数的前驱,那么就记录下来,然后把所有的数全部取最大值就是前驱。

代码:

cpp 复制代码
int front(int pos,int x)
{
	if(pos==0)
	{
		return -1e18;
	}
	if(node[pos].val>=x)
	{
		return front(node[pos].ls,x);
	}
	else
	{
		return max(node[pos].val,front(node[pos].rs,x));
		//当前这个数比 x 小,因此有可能成为前驱,所以我们把这个数和其他可能成为前驱的数取最大值
		//当然 node[pos].ls 里面的数也可能是前驱,只不过都比 node[pos].val 小所以可以不考虑
	}
}

后继

与前驱类似,取最小值就行。

代码:

cpp 复制代码
int back(int pos,int x)
{
	if(pos==0)
	{
		return 1e18;
	}
	if(node[pos].val<=x)
	{
		return back(node[pos].rs,x);
	}
	else
	{
		return min(node[pos].val,back(node[pos].ls,x));
	}
}

然后把上面的东西全部合在一起,再写个 pushup 函数就是正解了。


现在你已经初步掌握了平衡树的知识,让我们来再做一道题:洛谷 P3391 【模板】文艺平衡树

看完题面,然后思考......好大脑宕机了。

我们会发现普通平衡树在这道题中完全无法应用,因为颠倒顺序这件事实在是太......难了。

因此,我们不得不引出另一个东西:无旋平衡树

无旋平衡树

无旋平衡树(FHQ-Treap,FHQ 好像是发明人的拼音首字母),就是不会旋转的平衡树,但我们知道平衡树被发明出来的初衷就是通过旋转平衡树的高度,从而使时间复杂度最优,因此旋转是平衡树里最重要的一环。现在又不旋转了,这又是咋回事呢?

别慌,虽然我们不旋转了,但是我们可以用其他的操作来替代旋转啊。像替罪羊树就是当树不满足结构时,就直接重构。这里我们肯定不能直接整棵树全部重构,不过我们可以选择把它------分裂合并,然后在这两个操作中维护这棵树。

分裂

分裂也分成两种:按值分裂和按个数分裂。我们先来看一下按值分裂。

按值分裂

按值分裂,就是说给定一个数 x x x,把 [ m n , x ] [mn,x] [mn,x] 的部分分成一棵树, [ x + 1 , m x ] [x+1,mx] [x+1,mx] 的部分分成另外一棵树。其中 m n mn mn 和 m x mx mx 代表所有数中的最小值和最大值。

现在我们来讲一下这种操作怎么进行。

假设我当前到了一个点 p o s pos pos,我们考虑这个点对应的值。因为我们知道 BST 其中一条性质就是每个点左子树内的点的值都一定比这个点的值小,右子树内的所有点的值都一定比这个点的值小(虽然在 FHQ-Treap 中我们为了方便,在一个数出现多次时,会选择构造多个值相同的点,但是不太影响这个结论,只需要改成还可能等于就行),那么如果 v a l p o s < = x val_{pos}<=x valpos<=x( v a l p o s val_{pos} valpos 就是 p o s pos pos 这个点对应的值),就说明 p o s pos pos 的左子树中的所有点对应的值全都小于等于 x x x,那么我们保留左子树,然后更新右子树就行。反过来一样。

可以看一下代码:

cpp 复制代码
void split2(int pos,int x,int &pos1,int &pos2)//pos1 和 pos2 就是我分裂的两棵树的两个根节点
{
	if(pos==0)//如果没有点了
	{
		pos1=pos2=0;
		return;
	}
	if(node[pos].val<=x)
	{
		pos1=pos;//首先把整棵树扔过去
		split2(node[pos].rs,x,node[pos1].rs,pos2);//然后分裂右子树,左子树保持完好
		pushup(pos1);//更新
	}
	else
	{
		pos2=pos;//同上
		split2(node[pos].ls,x,pos1,node[pos2].ls);//同上
		pushup(pos2);//更新
	}
}

按个数分裂

这一部分和上面很像,不过就是改一下判断,剩下的看代码就行:

cpp 复制代码
void split1(int pos,int x,int &pos1,int &pos2)
{
	if(pos==0)
	{
		pos1=pos2=0;
		return;
	}
	if(node[node[pos].ls].siz+1<=x)//如果当前点以及其左子树都被包含了
	{
		pos1=pos;//把整棵树移过去
		split1(node[pos].rs,x-node[node[pos].ls].siz-1,node[pos1].rs,pos2);//修改一下右子树
	}
	else
	{
		pos2=pos;//同上
		split1(node[pos].ls,x,pos1,node[pos2].ls);
	}
	pushup(pos);//这里的写法其实和上面一样,因为不管 pos 最终归为哪棵树,根一定都是 pos
}

合并

合并就没有那么多讲究了,直接把两棵树合并在一起就行。不过我们需要保证放在左边的那棵树的最大值一定小于等于放在右边的那棵树的最小值。

看一下代码就行:

cpp 复制代码
int merge(int pos1,int pos2)
{
	if(!pos1||!pos2)
	{
		return pos1|pos2;
	}
	if(node[pos1].num<=node[pos2].num)
	{
		node[pos1].rs=merge(node[pos1].rs,pos2);
		pushup(pos1);
		return pos1;
	}
	else
	{
		node[pos2].ls=merge(pos1,node[pos2].ls);
		pushup(pos2);
		return pos2;
	}
}

现在回到上面那个题:我们首先很容易注意到题目实际上是要对当前这个数所在的位置排序,至于这个数是什么只是附带的一个值。首先考虑最基本的插入点操作。因为是对位置排序,所以我们只需要把在这个位置左边的数和右边的数拆成两棵树,然后在中间插入这个点就行。

代码:

cpp 复制代码
int new_node(int x)
{
	cnt++;
	node[cnt].val=x,node[cnt].siz=1,node[cnt].num=rd();
	return cnt;
}
void update(int x)
{
	int pos1=0,pos2=0;
	split1(rt,x,pos1,pos2);
	rt=merge(merge(pos1,new_node(x)),pos2);
}

接着我们来说一说翻转区间。显然翻转区间操作就可以看成把两边的编号组互换了一下,在 BST 上的体现就是交换左右子树。

因此我们可以对于每个区间,首先把整棵树成三部分: [ 1 , l − 1 ] , [ l , r ] , [ r + 1 , n ] [1,l-1],[l,r],[r+1,n] [1,l−1],[l,r],[r+1,n],然后把中间那一部分打上标记(区间操作),把左右儿子互换,最后再拼回去。

当然因为分裂和合并操作中涉及到左右儿子的重构,所以我们需要先 pushdown 之后在进行接下来的操作。

无旋平衡树代码:

cpp 复制代码
mt19937 rd(time(nullptr));
struct FHQ_Treap{
	struct Node{
		int ls,rs,val,num,siz,lt;
	}node[100006];
	int rt=0,cnt=0;
	void pushdown(int pos)
	{
		if(node[pos].lt)
		{
			swap(node[pos].ls,node[pos].rs);
			node[node[pos].ls].lt^=1,node[node[pos].rs].lt^=1;
			node[pos].lt=0;
		}
	}
	void pushup(int pos)
	{
		node[pos].siz=node[node[pos].ls].siz+node[node[pos].rs].siz+1;
	}
	void split1(int pos,int x,int &pos1,int &pos2)
	{
		if(pos==0)
		{
			pos1=pos2=0;
			return;
		}
		pushdown(pos);
		if(node[node[pos].ls].siz+1<=x)
		{
			pos1=pos;
			split1(node[pos].rs,x-node[node[pos].ls].siz-1,node[pos].rs,pos2);
		}
		else
		{
			pos2=pos;
			split1(node[pos].ls,x,pos1,node[pos].ls);
		}
		pushup(pos);
	}
	void split2(int pos,int x,int &pos1,int &pos2)
	{
		if(pos==0)
		{
			pos1=pos2=0;
			return;
		}
		pushdown(pos);
		if(node[pos].val<=x)
		{
			pos1=pos;
			split2(node[pos].rs,x,node[pos1].rs,pos2);
            pushup(pos1);
		}
		else
		{
			pos2=pos;
			split2(node[pos].ls,x,pos1,node[pos2].ls);
            pushup(pos2);
		}
	}
	int merge(int pos1,int pos2)
	{
		if(!pos1||!pos2)
		{
			return pos1|pos2;
		}
		pushdown(pos1);
		pushdown(pos2);
		if(node[pos1].num<=node[pos2].num)
		{
			node[pos1].rs=merge(node[pos1].rs,pos2);
			pushup(pos1);
			return pos1;
		}
		else
		{
			node[pos2].ls=merge(pos1,node[pos2].ls);
			pushup(pos2);
			return pos2;
		}
	}
	int new_node(int x)
	{
		cnt++;
		node[cnt].val=x,node[cnt].siz=1,node[cnt].num=rd();
		return cnt;
	}
	void update(int x)
	{
		int pos1=0,pos2=0;
		split1(rt,x,pos1,pos2);
		rt=merge(merge(pos1,new_node(x)),pos2);
	}
	void reverse(int l,int r)
	{
		int pos1=0,pos2=0,pos3=0;
		split1(rt,r,pos1,pos2);
		split1(pos1,l-1,pos1,pos3);
		node[pos3].lt^=1;
		rt=merge(merge(pos1,pos3),pos2);
	}
	void print(int pos)
	{
		if(!pos)
		{
			return;
		}
		pushdown(pos);
		print(node[pos].ls);
		write(node[pos].val);
		putchar(' ');
		print(node[pos].rs);
	}
}tr;

当然,无旋平衡树作为区间操作之王,它也是有缺点的:虽然它的时间复杂度平均下来是 O ( log ⁡ n ) O(\log n) O(logn),但是它常数却很大(不过俗话说得好:卡卡就过了嘛)。读者需谨慎使用。

伸展树

最后我们来讲一讲伸展树(Splay 树)。这里我不重点讲,只是提一下。

其实伸展树的原理和 Treap 一样:旋转。不过 Treap 的旋转是为了平衡高度,Splay 树的旋转是为了方便区间操作(同时平衡高度)。因此在 Splay 树里面,我们完全不需要写那个随机数,也就是不需要看脸吃饭了。

Splay 树的原理大致就是通过旋转把我们确定的一个点旋转到它的某个祖先节点的位置上去,至于这里的旋转,分为了 zig-zig,zig-zag 两种旋转方式,分别对应两种情况,感兴趣的同学可以下来自己查阅资料。

然后区间操作就是把两个端点旋转到根节点和根节点的子节点两个位置,那么夹在两点之间的部分就是要进行操作的部分。我们只需要打个标记就行。

相关推荐
爱写代码的小朋友2 小时前
生成式人工智能(AIGC)在中小学生探究式学习中的应用边界与伦理思考
人工智能·学习·aigc
wen__xvn2 小时前
天梯赛L2刷题(也就写写水题骗骗自己了)
算法
EllinY2 小时前
扩展欧几里得算法 exgcd 详解
c++·笔记·数学·算法·exgcd
jiayong232 小时前
第 17 课:任务选择与批量操作
开发语言·前端·javascript·vue.js·学习
AI科技星2 小时前
三维网格—素数对偶性及其严格证明(全域数学·统一基态演化版)
算法·数学建模·数据挖掘
星哥说事2 小时前
开源项目OpenClaw:多AI模型统一调用的技术学习与实践
人工智能·学习
像一只黄油飞2 小时前
第二章-01-字面量
笔记·python·学习·零基础
一个天蝎座 白勺 程序猿2 小时前
零基础AI学习:数学基础要求与补充指南
人工智能·学习·ai
诸葛务农2 小时前
光电对抗:多模复合制导烟雾干扰外场试验及仿真(4)
人工智能·算法·光电对抗