平衡树详解和运用

0.总言

平衡树是一种十分有用的数据结构,它能支持以下操作:

1、插入一个数x

2、删除一个数x

3、查询一个数x(其排名,其前驱后继)

4、查询排名为k的数x

5、快速合并与分裂

6、维护区间修改、查询、翻转

7、维护其它信息

了解平衡树,先从最普通的\(\text{Treap}\)开始。(注:下文的平衡树实现均用指针)

1.平衡树 && Treap

平衡树是一种特殊的二叉查找树,所谓二叉查找树,就是满足所有子树中,根节点的权值大于(小于)左子树中任一结点,小于(大于)右子树中任一结点,而特殊就特殊在它是平衡的——任一结点的左右子树高度差\(\leq 1\),换句话说满足中序遍历递增。比如下图就是一个平衡的二叉查找树。

当然处理序列问题时只考虑中序遍历的有序性。这样的二叉查找树在插入、删除、查询的过程中都能够保证复杂度为\(\text{O}(\log{N})\)\(N\)表示当前二叉树内结点数。

但事实上我们在正常操作过程中,无法保证二叉查找树一定是平衡的。如果需要插入的数据是单调递增或单调递减的,得到的二叉查找树将会退化成一条链,就像这样:

所以有关于平衡树的算法就诞生了,比如下面的\(\text{Treap}\)\(\text{Treap}=\text{Tree}+\text{Heap}\),即拥有二叉搜索树的结构,也拥有大根堆的性质。事实上\(\text{Treap}\)和大多数平衡树并不能做到完全平衡,但能尽可能保持平衡保证复杂度。不过再说这个算法前,需要做一些准备。

数据结构的定义

struct Node *nil; // 自定义的空指针,防止翻车(RE)
struct Node {
	Node *ch[2]; // 结点的左右孩子。为什么不分开写成lc,rc呢?往后就知道了
	int v, s, c; // v表示该结点的值,s表示以该结点为根的子树大小,c表示权值v的结点数量(合并了相同权值的结点),即v的副本数量
	int r; // Treap专有的随机数,是大根堆的关键字
	void maintain() { // 维护当前结点的信息
		s = ch[0]->s + ch[1]->s + c;
	}
	Node(int v) : v(v), c(1), s(1), r(rand()) { ch[0] = ch[1] = nil; } // 新建结点
} *root;

void init() {
	srand(0); // 随机数初始化
	nil = new Node(0); // 空指针初始化
	root = nil->ch[0] = nil->ch[1] = nil; // 左右孩子指向自身
	nil->s = nil->c = 0; // 防止空指针影响后面的操作
}

这里操作的是可重复集合,为了方便,将重复出现的数合并成一个结点。

旋转

旋转分为左旋和右旋。旋转是大多数平衡树的核心,因为调节树的平衡全靠它。且调节平衡后,二叉查找树的性质没有变。

先看左旋:\(k=o->ch[1]\)\(x=k->ch[0]\),为什么先看\(o,k,x\)呢?因为它们三个点是旋转过程中非常重要的三个点,这三个点换了爸爸(对父亲结点亲切的称呼),而另外两个并没有。

再看这三个结点连接的变化。选择恰当的顺序很重要。

①将\(o\)的右儿子指针由\(k\)改指向\(k\)的左儿子\(x\),即\(o->ch[1]=k->ch[0]\)

②将\(k\)的左儿子指针由\(x\)改到\(o\),即\(k->ch[0]=o\)

③将根\(o\)提到\(k\)的位置,即\(o=k\)

这样左旋就完成了。等等,还没!因为\(o,k\)的儿子有变动,所以它们的信息要及时维护,需要在\(o\)提到\(k\)之前执行\(o->maintain()\)\(k->maintain()\),这两句更新顺序不能写错,因为\(o\)的儿子是\(k\),所以必须等\(k\)更新完后\(o\)才能更新数据。

再看右旋:\(k=o->ch[0]\)\(x=k->ch[1]\)...别急,左右儿子和左旋正好相反!\(0\)变成了\(1\)\(1\)变成了\(0\)!继续发现①将\(o\)的左儿子指针由\(k\)改指向\(k\)的右儿子\(x\),即\(o->ch[0]=k->ch[1]\),相反;②将\(k\)的左儿子指针由\(x\)改到\(o\),即\(k->ch[1]=o\),相反!而且\(\text{!}0=1\)\(\text{!}1=0\),如果我们给左旋和右旋标一个号\(d\),分别为\(0\)\(1\),那么通过上面分析的特征,我们可以把左旋和右旋的代码合并,而不用分别写左右旋的过程了!

代码要细细对照上面来看。

// d=0代表左旋,d=1代表右旋
void rotate(Node* &o, int d) { // 注意这里o需要加上引用,因为旋转过后换的根要传出去的,否则还是原来的o
	Node *k = o->ch[!d];
	o->ch[!d] = k->ch[d]; // STEP 1
	k->ch[d] = o; // STEP 2
	o->maintain(); k->maintain(); // STEP 2.5
	o = k; // STEP 3
}

一次旋转的复杂度为\(\text{O}(1)\)

插入

对于插入操作,我们采取这样的方式:从根开始一直根据结点大小关系选择向左还是向右走,直到走到了空结点(\(\text{nil}\)),或者走到了一个权值相等的结点,此时我们将其合并,否则插入的数据在这里作为叶子结点代替(\(\text{nil}\))。

比如在这样的一棵平衡树中插入元素\(2\),首先从根开始。

\(2<6\),所以会往左边走,到了\(4\)这个结点。

\(2<4\),继续往左走,到达\(3\)

\(2<3\),继续往左走,到达\(1\)

\(2>1\),往右走到达了空结点,于是将\(2\)作为\(1\)的右儿子。

但光这样做可不行,没法保证复杂度,所以还需要通过一路向上旋转来保证大根堆堆的性质。而关键字是随机的,可以证明这样做的复杂度为期望\(\text{O}(\log N)\)。所以代码就出来了。

void insert(Node* &o, int v) {
	if (o == nil) { o = new Node(v); return; } // 走到了空结点要新建
	if (o->v == v) { o->c++, o->s++; return; } // 如果遇到了相同权值的结点要合并&&维护信息
	int d = v < o->v ? 0 : 1; // d表示方向,0表示左,1表示右
	insert(o->ch[d], v); // 递归插入
	if (o->r < o->ch[d]->r) rotate(o, !d); // 维护大根堆性质,旋转方向与递归的方向相反
	o->maintain(); // 注意哦!o不一定参与rotate,所以要及时维护信息
}

删除

对于要删除的数值,如果它有别的副本,就保留该结点,否则将其移至叶子结点处删除。细细来说,当找到目标结点时,有\(4\)种情况:

①目标结点存有大于\(1\)的副本数,那么只需要减去一个副本即可。

②目标结点只有\(1\)个副本。考虑将其移至叶子结点,而其本身就是叶子。直接删除即可。

③目标结点只有\(1\)个副本。考虑将其移至叶子结点,而其只有一个儿子。将儿子提上来代替目标结点,然后删除。

④目标结点只有\(1\)个副本,而其有两个儿子,为满足堆性质,比较两个儿子的随机关键字的大小关系,选择大的将其右旋上去,目标结点就会被转下去,然后又会变成这几种可能,递归处理即可。

(这是个栗子,假设\(ch[0]->r > ch[1]->r\)

(然后就变成情况③了)

在实际实践中,一般操作②和③合并起来,因为操作②相当于将空结点(\(\text{nil}\))提上来再删。无论如何,最后对于根节点都需要维护下信息,但一定要注意判断是否为空结点。

void remove(Node* &o, int v) {
	if (o == nil) return; // 没找到
	if (o->v == v) { // 找到了关键字为v的结点
		if (!o->c || !--o->c) { // 如果o->c = 0,说明这个结点要移到叶子处删除,否则减去1且仅一次且同时判断是否要移动
			if (o->ch[0] == nil || o->ch[1] == nil) { // 如果只有一个儿子(与没有儿子的情况合并),直接提上来代替当前的根
				Node *p = o;
				o = o->ch[o->ch[0] == nil ? 1 : 0];
				delete p;
			} else { // 否则要满足堆性质,随机关键字大的提上来
				int d = o->ch[0]->r > o->ch[1]->r ? 0 : 1;
				rotate(o, !d); // 注意旋转的方向
				remove(o->ch[!d], v); // 注意递归方向
			}
		}
	}
	else remove(o->ch[v < o->v ? 0 : 1], v); // 继续找v
	if (o != nil) o->maintain(); // 同理移除过程中也要维护信息,但一定要判其是否为nil!
}

排名与数值之间的查询

根据二叉搜索树的性质以及维护的信息,可以很方便简单地完成该操作。

考虑查找数值\(v\)的排名,我们从树根开始找。根据二叉搜索树的性质,我们可以将\(v\)与根\(o\)中的\(v\)比较,判断出\(v\)的位置:在左子树中(\(v < o->v\)),在右子树中(\(v > o->v\)),在根中(\(v == o->v\))。

比如以下图中:

这种情况直接递归左子树。

这种情况直接返回左子树大小\(+1\)

这种情况递归右子树,之后加上左子树大小和\(o\)的副本数。

// 注意:这里查找的排名为相同数值v中最小的排名
int get_rank(Node* o, int v) {
	if (o->v == v) return o->ch[0]->s + 1; // 找到v
	if (v < o->v) return get_rank(o->ch[0], v); // v在左子树中
	return get_rank(o->ch[1], v) + o->ch[0]->s + o->c;
	// v在右子树中,此时要将左子树中的所有元素以及根的元素数统计起来
}

根据排名查值几乎就是上面反过来,注意\(3\)中可能的界线。这里直接放代码了。

int get_val(Node* o, int k) {
	if (k <= o->ch[0]->s) return get_val(o->ch[0], k); // 排名为k的数在左子树中
	else if (k <= o->ch[0]->s + o->c) return o->v; // 为o
	else return get_val(o->ch[1], k - o->ch[0]->s - o->c); // 在右子树中
}

查前驱后继

这里的前驱后继不与待查找的数相等(当然相等也可以),例如\(\{1,2,2\}\)中任一的\(2\)的前驱为\(1\)而不为\(2\)。前驱后继的查询很简单、极其相似。查\(v\)的前驱等于\(\text{get_val}(\text{get_rank}(v)-1)\),但常数较大,根据这个启发我们要找一个数的排名尽可能大(或者小),但这个数要小于\(v\)(或者大于),所以就有了以下的代码。

int get_pre(Node *o, int v) { // 查前驱
	if (o == nil) return -INF; // 找到空结点返回无穷小目的是不干扰其它解
	if (o->v >= v) return get_pre(o->ch[0], v); // v大于当前结点o的值,向左走找小一点的值
	return max(o->v, get_pre(o->ch[1], v)); // 否则向右走找更大的值并与当前值比较
}

int get_next(Node *o, int v) { // 查后继,与上面完全相反
	if (o == nil) return INF;
	if (o->v <= v) return get_next(o->ch[1], v);
	return min(o->v, get_next(o->ch[0], v));
}

以上即为\(\text{Treap}\)的大部分内容,可以过掉洛谷P3369【模板】普通平衡树

下面的操作涉及序列操作,就不进行结点合并了。我不会告诉你这个是无旋\(\text{Treap}\)的核心操作哦QwQ

分裂与合并

分裂分为两种:按值分裂、按排名(或序列位置)分裂。分裂指从某两个元素之间切开,分成左右两部分。先说按值分裂。

按值分裂指从第一个元素为\(v\)的结点前面划分,前面的在左边,后面的在右边。比如下面的一棵树以关键字\(5\)进行分裂。

但是右边的不是一棵树啊。

我们采取这样的策略:对于当前的根结点,如果结点的值\(<\)关键字,说明该结点以及左子树的所有结点的值都小于关键字,所以不用考虑,而右子树不确定,所以左半边就为当前的根,而其右子树需要递归分裂;反之说明右子树不用考虑,往左子树递归。直到递归到空结点。

放上面的栗子。首先走到整棵树的根结点,发现\(5<6\),所以根和右子树全部划入右边,向左子树递归。

接着发现\(5>4\),根和左子树划入左边,向右子树递归。

然后\(5=5\),根和右子树(空树)划入右边,向左子树递归。

最后走到了空结点,结束。

代码如下:

void split(Node *o, int v, Node* &l, Node* &r) {
	if (o == nil) { l = r = nil; return; } // 到了空结点两边附上空后结束
	if (o->v < v) { l = o; split(o->ch[1], v, l->ch[1], r); l->maintain(); } // 第一种情况。注意分裂后要及时维护信息
	else { r = o; split(o->ch[0], v, l, r->ch[0]); r->maintain(); } // 第二种情况。同上
}

不过这样做两棵树并不太平衡,不过不影响大局的复杂度(深度没变深)。

按照序列位置分裂其实只跟排名有关,这里指将前\(k\)个元素分在左边,其余在右边,大体上与按值分裂相似,直接放代码了。

void split(Node *o, int k, Node* &l, Node* &r) {
	if (o == nil) { l = r = nil; return; }
	if (k >= o->ch[0]->s + 1) { l = o; split(o->ch[1], k, l->ch[1], r); l->maintain(); }
	else { r = o; split(o->ch[0], k - o->ch[0]->s - 1, l, r->ch[0]); r->maintain(); }
}

合并顾名思义就是将两棵平衡树合并起来,在序列上的意义就是两段序列拼起来。方法也不难:从两棵树的顶端开始,为了保证堆性质,先确定随机关键字大的,再递归子树合并,最后直到空结点停止。如果是左边的树先(加重为了区分),可以确定的是其左子树一定不会有右边的树的结点,否则不满足中序遍历,所以递归合并右子树;反之亦然。

比如这个栗子,蓝色数字表示随机关键字。先比较左右两棵树根的随机关键字,发现\(7<9\),所以先选右边的,而右子树所有内容已确定,只需递归左子树了。

继续,\(7>6\),再选左边的,左子树的所有内容已确定,只需递归右子树了。

左边的数空了,直接选上右边的就结束了。

Node* merge(Node *a, Node *b) {
	if (a == nil) return b; // 如果a空返回b
	if (b == nil) return a; // 如果b空返回a
	if (a->r > b->r) { a->ch[1] = merge(a->ch[1], b); a->maintain(); return a; } // 比较两者的随机关键字,大的先合并
	else { b->ch[0] = merge(a, b->ch[0]); b->maintain(); return b; }
}

事实上提前生成随机数再根据其合并可以被卡(不过也懒得卡,本人只被卡过一次),所以不生成随机数,在合并时根据两边树的大小分配概率随机合并这样更稳。代码上就是

	if (a->r > b->r) { ... }

改成

	if (rd() % (a->s + b->s) < a->s) { ... }

这里用\(\text{rd}()\)

ll rd() { return rand() * RAND_MAX + rand(); }

不用\(\text{rand}()\),原因在于\(\text{rand}()\)的最大值可能不够。

以上两个操作即为无旋\(\text{Treap}\)的核心操作,利用它可以完成其他操作。

插入:就是找对位置一次拆分再两次合并。

void insert(Node* &rt, int v) {
	Node *l, *r;
	split(rt, v, l, r);
	rt = merge(merge(l, new Node(v)), r);
}

删除:就是找对位置两次拆分删除中间的一个结点再全部依次合并。

void remove(Node* &rt, int v) {
	Node *l, *mid, *r;
	split(rt, v, l, r);
	split(r, v+1, mid, r);
	rt = merge(merge(l, merge(mid->ch[0], mid->ch[1])), r);
	delete mid;
}

其他操作类似。是不是简单了好多?

所以对于上面的那题,相当于维护一个有序的序列,支持插入删除查询第\(k\)位等等。是不是辣样\(nie\)

而它的优势不仅仅是简单,还能用来可持久化呢!

可持久化

平衡树的可持久化与主席树的思想很相像,实现其实不难:不去破坏原有结点,遇到修改操作新建结点。

但是带旋\(\text{Treap}\)的可持久化十分复杂:涉及了不少结点的修改,下面要说的\(\text{Splay}\)和替罪羊树复杂度是均摊\(\text{O}(\log N)\)的,某些对过去版本进行操作的单次复杂度又可能太高,所以不能够完全做到可持久化,而无旋\(\text{Treap}\)就能够很好地胜任这一工作。

void split(Node *o, int v, Node* &l, Node* &r) {
	if (o == nil) { l = r = nil; return; }
	if (v > o->v) { l = cpynode(o); split(o->ch[1], v, l->ch[1], r); l->maintain(); } // 需要修改的结点新建
	else { r = cpynode(o); split(o->ch[0], v, l, r->ch[0]); r->maintain(); }
}

Node* merge(Node *a, Node *b) {
	if (a == nil) return b;
	if (b == nil) return a;
	if (a->r > b->r) { a->ch[1] = merge(a->ch[1], b); a->maintain(); return a; }
	else { b->ch[0] = merge(a, b->ch[0]); b->maintain(); return b; }
}
(这种写法的常数比数组版要大,如果有常数更小的指针写法,望大家告知!)

合并可以不用新建,直接套用上面的代码即可。为啥?因为我们分裂的目的就是要合并成新的平衡树,而分裂的版本无需保留,所以直接合并。

悄悄地告诉你线段树能做的可持久化平衡树全能做!而且支持的操作更多更强,就是无旋\(\text{Treap}\)的常数很大!(跑的贼慢空间贼大)

最强的操作就是复制啦!不用将待复制结点全部新建,只要——拷贝指针即可!

到这里可以切掉洛谷P3835【模板】可持久化平衡树辣。

带下传操作

跟线段树一样,区间修改难免要\(\text{pushdown}\),而平衡树也有下传,比如区间翻转。这里给出带区间翻转的平衡树的\(\text{pushdown}\)

void pushdown() {
	if (!rev) return;
	ch[0]->rev ^= 1, ch[1]->rev ^= 1;
	swap(ch[0], ch[1]);
	rev = 0;
}

然后认真思考下该在何时下传标记呢?发现应该在被操作结点操作之前下传,具体的看下面的代码。

void split(Node *o, int k, Node* &l, Node* &r) {
	if (o == nil) { l = r = nil; return; }
	o->pushdown(); // 在o被修改之前下传下标记
	if (k >= o->ch[0]->s + 1) { l = o; split(o->ch[1], k - o->ch[0]->s - 1, l->ch[1], r); l->maintain(); }
	else { r = o; split(o->ch[0], k, l, r->ch[0]); r->maintain(); }
}

到这里可以切掉洛谷P3391【模板】文艺平衡树\(\text{AND}\)洛谷P5055【模板】可持久化文艺平衡树辣。

2.Splay

\(\text{Splay}\)又叫作伸展树,也基于旋转,它的核心也就是把某个结点旋转到根,普通的旋转到根无法保证复杂度,而通过某种策略的旋转可以保证复杂度为均摊\(\text{O}(\log N)\)。这也就说\(\text{Splay}\)不太适合可持久化(上文也分析了)。而\(\text{Splay}\)\(\text{LCT}\)搭配简直天衣无缝,其它的平衡树就做不到了。

下文的代码可能为了干练牺牲了可读性。

旋转&&核心操作Splay

旋转大体上相似,只不过在结点的定义上多出个指向父节点的指针\(fa\),旋转时注意维护。这里旋转有另一个直观的解释:将待旋转结点旋转成父亲。比如像下面。

不难发现左旋实际上是将\(k\)变成父亲,右旋将\(o\)变成了父亲。

所以数据结构体改成以下的代码(详见注释)。

struct Node {
	Node *fa, *ch[2]; // 指向父亲和孩子的指针
	int v, s, c; // 与上面一样
	int id() {
		return fa->ch[1] == this; // 返回该结点对于父节点是左儿子还是右儿子(左0右1)
	}
	void maintain() {
		s = ch[0]->s + ch[1]->s + c; // 维护子树大小
	}
	void rotate() { // 以下顺序有讲究,请细细体会
		int d = id(); Node *f = fa;
		(f->fa->ch[f->id()] = this)->fa = f->fa; // 改连接this和f->fa
		(f->ch[d] = ch[!d])->fa = f; // 改连接ch[!d]和f
		(ch[!d] = f)->fa = this; // 改连接f和this
		f->maintain(); // 在Splay中其实只要维护原先父亲的信息即可(除自身以外其它信息不会变)
	}
	void splay(Node *top) ; // 这个核心下文会讲到
	Node(int v, Node *f) : v(v), s(1), c(1), fa(f) { ch[0] = ch[1] = nil; } // 新建结点传入参数
} *rt;

void init() { // 切记一定要调用
	nil = new Node(0, nil);
	rt = nil->fa = nil->ch[0] = nil->ch[1] = nil;
	nil->s = nil->c = 0;
}

\(\text{Splay}\)操作就是将某一个结点\(o\)一直旋转到根。也许你已经想到要一直调用\(o->\text{rotate}()\),直到\(o->fa = \text{nil}\),但这样太过简单了,没办法保证复杂度,采取下面的策略就能保证复杂度为均摊\(\text{O}(\log N)\)了(具体证明我不会

对于结点\(o\),我们看它的爸爸的爸爸(爷爷咯)(目光要长远啊——)

\(o\)的爸爸是\(\text{nil}\)。得了吧已经是根了就结束了。

\(o\)的爷爷是\(\text{nil}\),这种情况\(o\)只要单旋上去就成了根,结束。

\(o\)的爷爷不是\(\text{nil}\),且从爷爷到\(o\)的方向全部相同,这种情况先将爸爸转上去(此时\(o\)的深度减小了)再把\(o\)转上去\(o\)就成了爷爷,情况又变成了①②③④。

\(o\)的爷爷不是\(\text{nil}\),且从爷爷到\(o\)的方向不相同,这种情况就将\(o\)连转两次就成了爷爷,情况又变成了①②③④。

很复杂?其实代码很简单。

void splay(Node *top) { // 核心操作splay,其中传参目的是将其旋转到top的下面。当top=nil时即为旋转到根。LCT中可以不传
	for (/* 这里可以加上标记下传,下文将提到 */; fa != top; rotate()) // ①循环会结束;rotate()旋转自己是②③④操作的共同点
		if (fa->fa != top) (id() == fa->id() ? fa : this)->rotate(); // 极简的三种情况写法:if为false则是②,id()==fa->id()成立即为③,反之为④
	maintain(); // 补上对自己信息的维护
}

\(o\)旋转到根只需调用\(o->\text{splay(nil)}\)即可。

插入和删除

插入其实没什么特别的,类似\(\text{Treap}\)的插入,只是要把结果旋转到根。注意要连接父亲即可。

删除会有小区别。找到待删除结点,先旋转到根,如果删完后副本数大于\(0\)就没啥事了,否则将左子树的最后一位伸展上来连接右子树。

void insert(Node *&rt, int v) {
	if (rt == nil) { rt = new Node(v, nil); return; } // 特判空树
	for (Node *o = rt;;) {
		if (o->v == v) {
			o->c++, o->s++; // 加一个副本
			(rt = o)->splay(nil); // 旋转到根保证复杂度
			return;
		}
		int d = v > o->v;
		if (o->ch[d] == nil) {
			(rt = o->ch[d] = new Node(v, o))->splay(nil);  // 先新建结点,再变成新的根旋转上去
			return;
		}
		o = o->ch[d]; // 继续找
	}
}

void remove(Node *&rt, int v) {
	for (Node *o = rt; o != nil; o = o->ch[v > o->v]) // 一直找v,找不到v会退出
		if (o->v == v) { // 找到了
			o->splay(nil); // 先将o旋转到根
			if (!(--o->s, --o->c)) { // 同时减维护信息,并判断副本数是否减完了
				if (o->ch[0] == nil || o->ch[1] == nil) // 类似于带旋Treap删除的情况
					(rt = o->ch[o->ch[0] == nil])->fa = nil;
				else {
					Node *p = o->ch[0];
					while (p->ch[1] != nil) p = p->ch[1]; // 将o的左子树中权值最大的结点找出来,再旋转到根,保证o的左孩子无右子树
					(rt = p)->splay(o); // 旋转到根
					((p->ch[1] = o->ch[1])->fa = p)->fa = nil; // 操作有点多:左孩子与o的右子树建立联系,左孩子的父节点设成空(成根结点了)
					delete o; // 删除
				}
			}
			else rt = o; // 否则副本未删完的o结点为根
			return;
		}
}

其它查询类操作

也没什么特别的,只是要把含有答案的结点旋转到根,否则有可能一直找一个深度很大很大的点许多次而被卡复杂度。

// 值与排名之间的查询
// 以下两个操作为非递归写法,但有少许不同之处
int get_rank(Node *&rt, int v) {
	int rk = 0; Node *o = rt;
	while (1) {
		if (o->v == v) {
			rk += o->ch[0]->s + 1;
			(rt = o)->splay(nil); // 找到答案要旋转到根
			break;
		}
		if (v < o->v) o = o->ch[0];
		else rk += o->ch[0]->s + o->c, o = o->ch[1];
	}
	return rk;
}

int get_val(Node *&rt, int rk) {
	Node *o = rt;
	while (1) {
		if (rk <= o->ch[0]->s) o = o->ch[0];
		else if (rk <= o->ch[0]->s + o->c) {
			(rt = o)->splay(nil); // 同上
			return rt->v;
		}
		else rk -= o->ch[0]->s + o->c, o = o->ch[1];
	}
}

这两个操作不方便\(\text{Splay}\)就没有写了。

// 查前驱后继
// 以下两个操作将递归改成了非递归的写法
int get_pre(Node *o, int v) {
	int res = -INF;
	while (o != nil) {
		if (o->v >= v) o = o->ch[0];
		else res = max(res, o->v), o = o->ch[1];
	}
	return res;
}

int get_next(Node *o, int v) {
	int res = INF;
	while (o != nil) {
		if (o->v <= v) o = o->ch[1];
		else res = min(res, o->v), o = o->ch[0];
	}
	return res;
}

序列类操作

3.SBT

\(\text{SBT}\)即为重量平衡树。主要的区别就在于它的平衡原理是每棵树的大小不小于其兄弟树的子树的大小。记\(x\)为根的结点的子树大小为\(size[x]\),那么满足:

\[size[x->lc] \geq size[x->rc->lc] \&\& size[x->rc->rc] \]

\[size[x->rc] \geq size[x->lc->lc] \&\& size[x->lc->rc] \]

这里为表示直观,用\(lc,rc\)代替\(ch[0]\)\(ch[1]\)。如果不满足上面的条件,通过左旋或右旋来进行调整。通过这样能够保证单次操作复杂度为\(\text{O}(\log N)\)。在\(\text{OI}\)中不太常见,具体实现不再赘述,下文详讲一下比较常见、写法简单、不基于旋转的一种根据重量调节平衡的平衡树:替罪羊树

结构定义&&核心:拍扁重构

const int maxn = 111111;
const double alpha = 0.7; // 平衡因数

int n, opt, x;

struct Node *nil;
struct Node {
	Node *lc, *rc;
	int v, s, c, size;
	void maintain() {
		s = lc->s + rc->s + c;
		size = lc->size + rc->size + (c ? 1 : 0);
	}
	bool bad() { return max(lc->size, rc->size) > size * alpha; }
	Node(int v) : v(v), s(1), c(1), size(1) { lc = rc = nil; }
} *rt;

void init() {
	nil = new Node(0);
	rt = nil->lc = nil->rc = nil;
	nil->s = nil->c = nil->size = 0;
}

Node **id;

Node *cur[maxn];
int pos;

void dfs(Node *o) {
	if (o == nil) return;
	dfs(o->lc);
	if (o->c) cur[pos++] = o;
	dfs(o->rc);
	if (!o->c) delete o;
}

Node *build(int l, int r) {
	if (l > r) return nil;
	int mid = l+r>>1;
	cur[mid]->lc = build(l, mid-1);
	cur[mid]->rc = build(mid+1, r);
	cur[mid]->maintain();
	return cur[mid];
}

void rebuild(Node *&rt) {
	pos = 0;
	dfs(rt);
	rt = build(0, pos-1);
}

修改类操作

和普通\(\text{BST}\)插入很像?只是要注意信息维护,以及在最高的地方拍扁重建即可。

void insert(Node *&o, int v) {
	if (o == nil) { o = new Node(v); return; }
	if (o->v == v) o->c++;
	else insert(o->v > v ? o->lc : o->rc, v);
	if (o->bad()) id = &o;
	o->maintain();
}

void remove(Node *&o, int v) {
	if (o->v == v) { o->c--; o->maintain(); return; }
	remove(o->v > v ? o->lc : o->rc, v);
	if (o->bad()) id = &o;
	o->maintain();
}

查询

没什么特别的吧。并不是!注意到使用的是惰性删除,所以——要有些改动。比如说前驱后继就不能用了,要改用查数值排名再查数值的办法得到前驱后继。这里不放代码了。

对了说下替罪羊树最大的作用:解决旋转无法维护或者不方便维护的问题。详见论文【陈立杰-重量平衡树和后缀平衡树在信息学奥赛中的应用】。

对比

平衡树实现算法及细节 耗时(无O2)
带旋Treap不带结点合并 210ms
带旋Treap带结点合并 216ms
无旋fhq-Treap无合并 280ms
Splay 327ms
替罪羊树 291ms
红黑树 Unknown

P.S.这里用【洛谷P3369-普通平衡树】测试,结果为通过所有测试点的时间总和

P.P.S.支持操作:\(\text{insert(), remove(), get_rank(), get_val(), get_pre(), get_next()}\)

posted @ 2019-10-28 20:35  AC-Evil  阅读(4152)  评论(0编辑  收藏  举报