浅谈Treap&FHQ-Treap
0.无题

/fad/fad
1.引入
先看一个问题:
\(n\)个数,\(m\)次操作,包括插入、删除、找前驱(后继)、排名
这是一个经典的用BST解决的题目,这里默认读者会BST经典算法,故不再赘述。我们考虑一个极端情况:
1,2,3,4,5,6
此时的BST长这样:

深度达到了\(n\)而不是\(\log n\),复杂度退化,于是平衡树应运而生
2.Treap
我们把上面的BST重构一下:

此时我们发现这棵树被「旋转」了一下,但仍然满足BST的性质,而且深度变浅,复杂度变优了,这个「旋转」就是平衡树的核心思想:满足BST的性质的同时改变树的结构使其深度变浅。这样这棵树看上去就更「平衡」,所以称之为平衡树。
其实平衡树的思想到这里就结束了,接下来才是最难理解的地方
3.旋转
旋转操作用来实现「减小深度」这个目的
具体地说,旋转分为「左旋」和「右旋」两种,先放一张经典的图:

可以发现,「左旋」就是把右儿子旋转到左边去,「右旋」就是把左儿子旋转到右边去。
看上去比较抽象,难以理解,所以我们一步步做(以右旋为例):
一开始树长这样:

我们希望把\(2\)号点转上去,于是我们先把\(2\)号点「提」起来:

此时我们发现\(2\)号点有\(3\)个子节点,是不合法的,所以我们把\(3\)号点「切掉」:

接着再按照构造BST的方法插入:

到此就完成了一次「右旋」,左旋同理
由此我们便可以通过上述过程把树变得更「平衡」
参考代码:
inline void pushup(int p) {//统计数量
siz[p] = siz[son[p][0]] + siz[son[p][1]] + num[p];
//siz为子树大小,num为当前点的副本数量
}
inline void rotate(int& p, int d) {//d为旋转方向(左旋/右旋)
int t = son[p][d];//d = 0 or 1
son[p][d] = son[t][d ^ 1];
son[t][d ^ 1] = p;
pushup(p), pushup(t);
p = t;
}//手模一遍即可发现和上述过程相同
4.插入
我们先按照普通BST的方式插入新的数,然后我们赋予每个节点一个值\(r_i=rand()\),然后我们通过旋转使所有\(r_i\)构成一个堆。因为\(r_i\)随机赋值,所以出现深度较深的情况的概率很小,可以认为平衡
参考代码:
void ins(int& p, int k) {
if (!p) {//新建一个点
p = ++tot;
v[p] = k;
son[p][0] = son[p][1] = 0;
r[p] = rand();
num[p] = siz[p] = 1;
return;
} else if (v[p] == k) {//出现过k这个点
++siz[p];
++num[p];
return;
}
int t = (v[p] < k);
ins(son[p][t], k);//BST常规插入
if (r[son[p][t]] > r[p]) rotate(p, t);//旋转保证平衡
pushup(p);
}
5.删除
平衡树同样支持删除操作
对于一个点:
1.若该节点为叶子节点
我们直接删除即可
2.若该节点有左孩子没有右孩子
我们对该节点进行「右旋」并递归处理直到其成为叶子节点
3.若该节点有右孩子没有左孩子
和2同理
4.若该节点有右孩子也有左孩子
我们根据左右孩子\(r\)的大小决定左旋还是右旋并递归处理
参考代码:
void del(int& p, int k) {
if (!p)
return;
else if (v[p] == k) {//找到这个点
if (!son[p][0] && !son[p][1]) {
--siz[p];
--num[p];
if (!num[p]) p = 0;
} else if (son[p][0] && !son[p][1]) {
rotate(p, 0);
del(son[p][1], k);
} else if (!son[p][0] && son[p][1]) {
rotate(p, 1);
del(son[p][0], k);
} else if (son[p][0] && son[p][1]) {
int t = (r[son[p][0]] < r[son[p][1]]);
rotate(p, t);
del(son[p][t ^ 1], k);
}
pushup(p);
return;
}
//向下递归
int t = (v[p] < k);
del(son[p][t], k);
if (r[son[p][t]] > r[p]) rotate(p, t);//始终保证r的值构成一个堆
pushup(p);
}
6.查询
有了平衡树就可以查询很多东西了,原理和BST基本相同
6.1 k的排名
int rk(int p, int k) {
if (!p)
return 0;
else if (v[p] == k)
return siz[son[p][0]] + 1;
else if (v[p] > k)
return rk(son[p][0], k);
else
return rk(son[p][1], k) + siz[son[p][0]] + num[p];
}
6.2 排名为k的数
int kth(int p, int k) {
if (!p)
return 0;
else if (siz[son[p][0]] >= k)
return kth(son[p][0], k);
else if (siz[son[p][0]] + num[p] < k)
return kth(son[p][1], k - siz[son[p][0]] - num[p]);
else
return v[p];
}
6.3 前驱
int pre(int p, int k) {
if (!p)
return -inf;
else if (v[p] >= k)
return pre(son[p][0], k);
else
return max(pre(son[p][1], k), v[p]);
}
6.4 后继
int suf(int p, int k) {
if (!p)
return inf;
else if (v[p] <= k)
return suf(son[p][1], k);
else
return min(suf(son[p][0], k), v[p]);
}
最后附上一道模板题及代码:
您需要写一种数据结构(可参考题目标题),来维护一些数,其中需要提供以下操作:
\(1.\)插入 \(x\) 数
\(2.\)删除 \(x\) 数(若有多个相同的数,只删除一个)
\(3.\)查询 \(x\) 数的排名(排名定义为比当前数小的数的个数 \(+1\) )
\(4.\)查询排名为 \(x\) 的数
\(5.\)求 \(x\) 的前驱(前驱定义为小于 \(x\),且最大的数)
\(6.\)求 \(x\) 的后继(后继定义为大于 \(x\),且最小的数)
#include <bits/stdc++.h>
using namespace std;
#define MAXN 100005
#define inf (1 << 30)
int tot = 0, rt = 0;
int r[MAXN], v[MAXN], siz[MAXN], num[MAXN], son[MAXN][2];
inline void pushup(int p) { siz[p] = siz[son[p][0]] + siz[son[p][1]] + num[p]; }
inline void rotate(int& p, int d) {
int t = son[p][d];
son[p][d] = son[t][d ^ 1];
son[t][d ^ 1] = p;
pushup(p), pushup(t);
p = t;
}
void ins(int& p, int k) {
if (!p) {
p = ++tot;
v[p] = k;
son[p][0] = son[p][1] = 0;
r[p] = rand();
num[p] = siz[p] = 1;
return;
} else if (v[p] == k) {
++siz[p];
++num[p];
return;
}
int t = (v[p] < k);
ins(son[p][t], k);
if (r[son[p][t]] > r[p]) rotate(p, t);
pushup(p);
}
void del(int& p, int k) {
if (!p)
return;
else if (v[p] == k) {
if (!son[p][0] && !son[p][1]) {
--siz[p];
--num[p];
if (!num[p]) p = 0;
} else if (son[p][0] && !son[p][1]) {
rotate(p, 0);
del(son[p][1], k);
} else if (!son[p][0] && son[p][1]) {
rotate(p, 1);
del(son[p][0], k);
} else if (son[p][0] && son[p][1]) {
int t = (r[son[p][0]] < r[son[p][1]]);
rotate(p, t);
del(son[p][t ^ 1], k);
}
pushup(p);
return;
}
int t = (v[p] < k);
del(son[p][t], k);
if (r[son[p][t]] > r[p]) rotate(p, t);
pushup(p);
}
int rk(int p, int k) {
if (!p)
return 0;
else if (v[p] == k)
return siz[son[p][0]] + 1;
else if (v[p] > k)
return rk(son[p][0], k);
else
return rk(son[p][1], k) + siz[son[p][0]] + num[p];
}
int kth(int p, int k) {
if (!p)
return 0;
else if (siz[son[p][0]] >= k)
return kth(son[p][0], k);
else if (siz[son[p][0]] + num[p] < k)
return kth(son[p][1], k - siz[son[p][0]] - num[p]);
else
return v[p];
}
int pre(int p, int k) {
if (!p)
return -inf;
else if (v[p] >= k)
return pre(son[p][0], k);
else
return max(pre(son[p][1], k), v[p]);
}
int suf(int p, int k) {
if (!p)
return inf;
else if (v[p] <= k)
return suf(son[p][1], k);
else
return min(suf(son[p][0], k), v[p]);
}
int T;
signed main() {
scanf("%d", &T);
int op, x;
while (T--) {
scanf("%d%d", &op, &x);
switch (op) {
case 1:
ins(rt, x);
break;
case 2:
del(rt, x);
break;
case 3:
printf("%d\n", rk(rt, x));
break;
case 4:
printf("%d\n", kth(rt, x));
break;
case 5:
printf("%d\n", pre(rt, x));
break;
default:
printf("%d\n", suf(rt, x));
}
}
return 0;
}
7.FHQ-Treap
fhq 和 splay 应该各有优势。fhq 的优势是可以可持久化,splay 的优势是可以 lct。但是事实是,在我整个生涯中,可持久化平衡树和 lct 这两个东西我都没用到过。——duyi
Treap的旋转操作显然比较难以理解,写起来码量也较大,那有没有一种好写而又好理解的平衡树呢?
FHQ-Treap,又名「非旋Treap」,顾名思义,就是基于Treap但不用旋转的一种平衡树。
FHQ有两个核心操作:分裂(split)和合并(merge)。
7.0 引子
显然当一颗Treap里面所有节点的\(r_i\)都确定时这棵树的结构是唯一确定的,所以我们可以用这棵树的根节点来完全代表这棵树,下文所说「\(x\)子树」者即为以\(x\)为根的子树
7.1 分裂(Split)
split(int cur,int val,int& x,int& y)表示将以cur为根的子树按照val关键字分裂成\(2\)棵树(分别以\(x\)和\(y\)为根节点)。我们称以\(x\)为根节点的子树为「左树」,以\(y\)为根节点的子树为「右树」,具体分裂规则如下:
\(1.\)如果当前节点的权值小于等于\(val\),显然该节点及其左子树的所有节点的权值都小于\(val\),故将这些点都分裂到左树中,递归到右孩子继续处理;
\(2.\)如果当前节点的权值大于\(val\),显然该节点及其右子树的所有节点的权值都大于\(val\),故将这些点都分裂到右树中,递归到左孩子继续处理;
\(3.\)如果该节点为叶子节点,则返回
上图:

(该图来自万万没想到大佬)
显然单次分裂复杂度\(O(\log n)\)
\(Code:\)
inline void pushup(int p) { siz[p] = siz[son[p][0]] + siz[son[p][1]] + 1; }
void split(int cur, int val, int& x, int& y) {
if (!cur) {
x = y = 0;
return;
} else if (v[cur] <= val) {
x = cur;
split(son[cur][1], val, son[cur][1], y);
} else {
y = cur;
split(son[cur][0], val, x, son[cur][0]);
}
pushup(cur);
}
注意:我们不一定要以权值为关键字分裂,有些情况下会以下标为关键字
7.2 合并(Merge)
merge(int x,int y)表示将\(x\)子树和\(y\)子树合并成一颗树,同时返回新树根节点编号
这里要注意\(x\)子树和\(y\)子树必须是split得到的
所以显然\(x\)子树里面的所有节点权值都小于\(y\)子树内所有节点的权值,只需要根据\(r_i\)构造即可。具体地说:
\(1.\)若\(r_x<r_y\),则以\(x\)为新树的根节点,继承\(x\)子树的左子树,递归合并\(x\)子树的右子树和\(y\)子树;
\(2.\)否则,就以\(y\)为新树的根节点,继承\(y\)子树的右子树,递归合并\(x\)子树和\(y\)子树的右子树
上图:

(该图同样来自万万没想到大佬)
\(Code:\)
int merge(int x, int y) {
if (!x || !y)
return x + y;
else if (r[x] < r[y]) {
son[x][1] = merge(son[x][1], y);
pushup(x);
return x;
} else {
son[y][0] = merge(x, son[y][0]);
pushup(y);
return y;
}
}
7.3 插入
令插入的数为\(val\),我们以\(val\)为关键字分裂这棵树,再新建一个权值等于\(val\)的节点,将其与左树、右树合并即可
\(Code:\)
int New(int val) {
++tot;
siz[tot] = 1;
v[tot] = val;
r[tot] = rand();
return tot;
}
void ins(int val) {
split(rt, val, tr1, tr2);
rt = merge(merge(tr1, New(val)), tr2);
}
7.4 删除
令删除的数为\(val\),我们通过split操作使得\(val\)这个节点被单独分裂出来,合并剩下的子树即可
\(Code:\)
void del(int val) {
split(rt, val, tr1, tr3);
split(tr1, val - 1, tr1, tr2);//再次分裂
tr2 = merge(son[tr2][0], son[tr2][1]);//直接合并val的左右子树
rt = merge(merge(tr1, tr2), tr3);//合并剩下的子树
}
7.5 val的排名
我们以\(val-1\)为关键字进行分裂,显然左子树的所有节点权值都小于\(val\),所以左子树的size\(+1\)就是答案
\(Code:\)
int rnk(int val) {
split(rt, val - 1, tr1, tr2);
int ans = siz[tr1] + 1;
rt = merge(tr1, tr2);
return ans;
}
7.6 排名为val的数
直接看代码:
int kth(int cur, int val) {
while (1) {
if (val <= siz[son[cur][0]]) {//该数在左子树
cur = son[cur][0];//向左递归
continue;
} else if (val == siz[son[cur][0]] + 1) {//找到该节点
return v[cur];
} else {//在右子树
val -= (siz[son[cur][0]] + 1);
cur = son[cur][1];//向右递归
}
}
}
7.7 前驱
我们分裂出所有小于\(val\)的数,取其中最大值即可
\(Code:\)
int pre(int val) {
split(rt, val - 1, tr1, tr2);
int ans = kth(tr1, siz[tr1]);
merge(tr1, tr2);
return ans;
}
7.8 后继
同理
\(Code:\)
int suf(int val) {
split(rt, val, tr1, tr2);
int ans = kth(tr2, 1);
merge(tr1, tr2);
return ans;
}
8.例题
[HNOI2004]宠物收养场
凡凡开了一间宠物收养场。收养场提供两种服务:收养被主人遗弃的宠物和让新的主人领养这些宠物。
每个领养者都希望领养到自己满意的宠物,凡凡根据领养者的要求通过他自己发明的一个特殊的公式,得出该领养者希望领养的宠物的特点值\(a\)(\(a\)是一个正整数,而他也给每个处在收养场的宠物一个特点值。这样他就能够很方便的处理整个领养宠物的过程了,宠物收养场总是会有两种情况发生:被遗弃的宠物过多或者是想要收养宠物的人太多,而宠物太少。
被遗弃的宠物过多时,假若到来一个领养者,这个领养者希望领养的宠物的特点值为\(a\),那么它将会领养一只目前未被领养的宠物中特点值最接近\(a\)的一只宠物。(任何两只宠物的特点值都不可能是相同的,任何两个领养者的希望领养宠物的特点值也不可能是一样的)如果有两只满足要求的宠物,即存在两只宠物他们的特点值分别为\(a-b\)和\(a+b\),那么领养者将会领养特点值为\(a-b\)的那只宠物。
收养宠物的人过多,假若到来一只被收养的宠物,那么哪个领养者能够领养它呢?能够领养它的领养者,是那个希望被领养宠物的特点值最接近该宠物特点值的领养者,如果该宠物的特点值为\(a\),存在两个领养者他们希望领养宠物的特点值分别为\(a-b\)和\(a+b\),那么特点值为\(a-b\)的那个领养者将成功领养该宠物。
一个领养者领养了一个特点值为\(a\)的宠物,而它本身希望领养的宠物的特点值为b,那么这个领养者的不满意程度为\(abs(a-b)\)。
一年当中共有\(n\)位领养者或宠物来到收养场,请你计算所有收养了宠物的领养者的不满意程度的总和。这一年初始时,收养所里面既没有宠物,也没有领养者。
\(1\le n\le 8\times 10^4\),\(1\le a< 2^{31}\)
Treap裸题,对宠物和顾客分别开一颗平衡树维护前驱和后继即可
[POI2008]KLO-Building blocks
有 \(n\) 柱砖,每柱砖有一个高度,我们现在希望有连续 \(k\) 柱的高度是一样的。
你可以进行以下两种操作之一:
\(1.\)从某柱砖的顶端拿一块砖出来,丢掉不要了。
\(2.\)从仓库中拿出一块砖,放到另一柱,仓库是无限大的。
现在希望用最小次数的动作完成任务,除此之外你还要求输出结束状态时,每柱砖的高度。
\(1\le n,k\le 10^5,1\le h_i\le 10^6\)
我们考虑枚举一段长度为\(k\)的区间,显然每段区间都改为该区间的中位数最优,平衡树维护区间中位数、插入、删除
紧接着就有个问题:我们怎么计算答案?
我们使用fhq,在更新节点的时候多加上一个sum值,每次计算的时候把区间split为小于中位数,等于中位数,大于中位数的三棵树,显然有:
其中\(w\)为中位数,\(l\)为小于中位数的数的个数,\(r\)为大于中位数的数的个数
上式显然可以通过fhq所维护的值快速求出
CF85D Sum of Medians
有一个集合,初始为空。现有 \(n\) 次操作:
\(1.\)add x:将 \(x\) 添加到集合中。
\(2.\)del x:将 \(x\) 从集合中删除。
\(3.\)sum:将集合内的数从小到大排好序后形成有 \(k\) 个数的序列 \(a\),求\(\sum\limits^{i\le k}_{i\mod5=3} a_i\)
\(1\le n\le 10^5,1\le x\le 10^9\)
我们开一颗fhq,维护一个\(sum_{i,j}\)表示以\(i\)为根的子树里所有下标\(\mod5=j\)的节点的权值和,显然有:
显然插入和删除用fhq都可以完成,查询的时候输出\(sum_{root,3}\)即可
洛谷P3391 【模板】文艺平衡树
您需要写一种数据结构(可参考题目标题),来维护一个有序数列。
其中需要提供以下操作:翻转一个区间,例如原有序序列是 \(5\ 4\ 3\ 2\ 1\),翻转区间是 \([2,4]\) 的话,结果是 \(5\ 2\ 3\ 4\ 1\)。
\(1\le n,m\le 10^5\)
设翻转的区间为\([l,r]\),我们就把树分裂成\([1,l-1],[l,r],[r+1,n]\)三部分,对于\([l,r]\)区间维护swap的懒标记,交换该根节点的左右儿子即可完成一次翻转,每次下传就可以了
核心代码:
void pushdown(int x){
swap(son[x][0], son[x][1]);
if(son[x][0]) rev[son[x][0]] ^= 1;
if(son[x][1]) rev[son[x][1]] ^= 1;
rev[x] = 0;
}
最后的答案其实就是中序遍历的结果
注意这里按照下标分裂而不是权值 原题好像有说\(a_i=i\),但fhq不像splay依赖这个性质

浙公网安备 33010602011771号