平衡树 & 线段树
搜索树
满足左子树权值比自己小、右子树权值比自己大(感觉操作很像线段树/树状数组上二分)
rank(x)
即 < x <x <x 的元素个数,按照根节点与 x x x 的大小关系递归到左右子树中去kth
求第 k k k 小元素,对于每个节点维护子树大小,与求rank(x)
类似
需要支持插入/删除元素
- 对于插入元素也是不断与根节点比较,递归到左右子树中,跑到叶子节点时构造一个新点,然后更新子树大小即可
- 如果有重复元素就记录一个 c n t cnt cnt 表示每个值的出现次数即可
如果树构成一条链那么就会退化成 O ( n ) O(n) O(n) 的操作复杂度。由此出现了平衡树。
平衡树
思路是不断改变树的形态使树的深度尽量小(达到 O ( log n ) O(\log n) O(logn))
Treap
每个点随机分配一个权值,期望时间复杂度 O ( log n ) O(\log n) O(logn)。Treap 上的每个点总是有有 ( k e y , r a n d ) (key,rand) (key,rand) 两个权值,后者为附加的随机值,则 Treap 上节点满足:
- 二叉搜索树的两个性质( k e y key key);
- 如果 v v v 是 u u u 的子节点,那么有 r a n d ( u ) > r a n d ( v ) rand(u)>rand(v) rand(u)>rand(v)。堆的性质,保证平衡性。
struct Treap {
int l,r,siz,v,rnd,w;
// 左儿子 右儿子 子树大小 关键字 随机值 出现次数
// 根据需要添加 tag、区间和等,和线段树类似
} tr[maxn];
void update(int k) {
tr[k].siz = tr[tr[k].l].siz + tr[tr[k].r].siz + tr[k].w;
}
维护序列时的 k e y key key 总是设为下标,优势在于可以插入/删除元素。
FHQ-Treap
基本操作只有分裂和合并,实现思路主要通过将需要操作的部分分裂出来,进行修改后再使用合并操作粘回去。每个操作都是 O ( log n ) O(\log n) O(logn) 的,实现简单故更为主流,对于整段区间的操作也很适合维护(如区间翻转、区间删除、区间插入)。
普通平衡树
维护以下操作:
- 插入一个数
- 删除一个数
- 查询 x x x 的排名
- 查询排名为 x x x 的数
- 求 x x x 前驱
- 求 x x x 后继
平衡树的直接应用,实现时总是以分裂 & 合并为主要思路。FHQ-Treap 记忆套路:对于实现中对左右子树不同的操作,总是截然不同甚至完全相反的。
-namespace FHQ_Treap {
struct Node {
int L, R;
int key, pri, siz;
} T[maxn << 1];
int ntot = 0, root = 0;
// 可以加入一个栈存储删除的节点,新创建节点时可以考虑使用之前空置的编号。
int Create(int x) {
T[++ ntot].siz = 1;
T[ntot].L = T[ntot].R = 0;
T[ntot].key = x, T[ntot].pri = rand();
return ntot;
}
void update(int rt) {
T[rt].siz = T[T[rt].L].siz + T[T[rt].R].siz + 1;
}
// l, r 表示分裂后的两颗子树的根节点。
// 类似于二分,由于平衡树满足搜索树的性质,故按照自己的权值与目标权值进行比较,从而决定向哪颗子树继续分裂。
void split(int rt,int val,int &l,int &r) { // 这是按照权值进行分裂。
if (rt == 0) return l = r = 0, void(0); // 边界情况,分裂到了叶节点之外。
if (T[rt].key <= val) { // 这一刀砍在哪里
l = rt; split(T[rt].R,val,T[rt].R,r);
} else {
r = rt; split(T[rt].L,val,l,T[rt].L);
} return update(rt);
} // 按照子树大小进行分裂是类似的,不过递归解决时需要更改目标的子树大小
int merge(int l,int r) {
if (l == 0 || r == 0) return l + r;
if (T[l].pri < T[r].pri) { // 按照优先级维护 FHQ-Treap 的平衡性
T[l].R = merge(T[l].R, r);
return update(l), l;
} else {
T[r].L = merge(l, T[r].L);
return update(r), r;
}
}
int insert(int val) {
int l, r; split(root, val, l, r); // 把 val 的地方分裂出来
int rt = Create(val);
root = merge(merge(l,rt),r); // 逐步合并
return rt;
}
int Delete(int val) {
int l, mid, r;
// 先得到包含待删除节点的根节点的子树,然后将子树中不属于待删除节点子树的节点进一步剥离
split(root, val, l, r); split(l, val - 1, l, mid);
mid = merge(T[mid].L, T[mid].R); // 此时 mid 就是待删除的节点,将 mid 两颗子树直接合并以湮灭 mid。
// 如果需要保存删除节点的空置位,那就将 mid 的编号递扔进栈里。
return root = merge(merge(l, mid), r); // 最后粘回去。
}
int findkth(int rt, int rnk) {
if (rnk == T[T[rt].L].siz + 1) // 如果找到了
return rt;
// 类似于线段树上二分,讨论左右子树的大小,判断目标节点的方向
if (rnk <= T[T[rt].L].siz)
return findkth(T[rt].L, rnk);
else return findkth(T[rt].R, rnk - T[T[rt].L].siz - 1);
}
}
区间操作
文艺平衡树
实现一颗支持区间翻转的平衡树。其中序列长度 n ≤ 1 0 5 n\le 10^5 n≤105,操作次数 m ≤ 1 0 5 m\le 10^5 m≤105。
区间反转
区间翻转就是平衡树专属的操作。我们将区间下标作为第一键值建树,那么对于 FHQ-Treap 上的每个节点,都可以代表一段区间信息。具体地,我们存储这个节点自己的信息、左子树的信息和右子树的信息。不同于线段树的是,Treap 上的每个点不仅代表区间,在计算这个点的贡献时自己的值也需要被考虑。
考虑把每次操作的区间分裂出来,类似于线段树打个旋转 tag。在分裂与合并等基本操作时应该及时下传旋转标记并进行旋转操作。
维护数列
给定一个数列,维护以下操作:
- 区间插入,即在指定位置插入一段区间
- 区间删除
- 区间修改
- 区间翻转
- 求区间和
- 询问整体最大子段和
区间翻转操作同上。
区间插入
对于待加入的数列,我们考虑将它们先建成一个 Treap,然后再将它粘到主 Treap 里去。类似于线段树的建树操作,每次分治操作先将 [ L , m i d ] [L,mid] [L,mid] 和 [ m i d + 1 , R ] [mid+1,R] [mid+1,R]中的点建好树,然后粘在一起。一开始对初始序列建树也可以这么做。
区间删除
由于我们将下标作为第一键值,所以对于一段连续的区间 [ l , r ] [l,r] [l,r] 一定可以被一个节点的子树表示出来,我们要做的就是把这个子树拆出来。将 FHQ-Treap 中前 l − 1 l-1 l−1 个元素和后 n − r n-r n−r 个元素剥离出来,对于中间的元素我们递归地删除它们,并将它们的编号扔进栈中。
区间修改/求区间和/求最大子段和
这一类在线段树上好实现的操作可以几乎照搬到平衡树上来。不同的是,合并区间信息时需要考虑根节点的信息。剩下的就和线段树操作基本相同了。
Splay
每次操作都考虑把操作的点转到根上,但是旋转时要判断三点共线和折线两种情况进行旋转,这样操作复杂度就正确了(?noip 说不重要所以短时间内不打算学了。
替罪羊树
考虑左右孩子大小的比例,保持平衡的方法:定一个平衡因子 α \alpha α,操作与 bst 相同,但是插入时如果认为不平衡就拍扁重构。意思就是先中序遍历得到序列,然后用一个分治,每次把 m i d mid mid 作为根节点,左右区间递归下去作为左右子树。据说卡常专用。
文文的摄影布置
给出 n n n 个元素,第 i i i 个元素有两个权值 A i A_i Ai 和 B i B_i Bi,令 f ( i , j ) f(i,j) f(i,j) 表示 A i + A j + min i < k < j B k A_i+A_j+\min_{i<k<j}B_k Ai+Aj+mini<k<jBk,其中 i + 1 < j i+1<j i+1<j。共 m m m 次操作:
1 x y
使 A x ← y A_x\gets y Ax←y;2 x y
使 B x ← y B_x\gets y Bx←y;3 l r
求max l ≤ i < i + 1 < j ≤ r f ( i , j ) \max_{l\le i<i+1<j\le r}f(i,j) l≤i<i+1<j≤rmaxf(i,j)
分类讨论 ( i , j , k ) (i,j,k) (i,j,k) 三元组的位置,然后线段树维护需要的值即可。
火星人
维护一个字符串序列,支持以下操作:
- 单点插入;
- 单点修改;
- 查询两个区间的 LCP 即最长公共前缀的长度。
不管前面两个平衡树操作,考虑第三个询问。LCP 的一个做法即为二分答案,前缀哈希比对前面一段的长度。先考虑线段树,维护区间的前缀哈希,询问就做线段树上二分。最后把线段树操作改为平衡树即可支持插入操作。神奇的是这题直接用 STL 的 string
能过,均摊下来复杂度正确。
查找 Search
给定 n n n 个垃圾桶,你需要维护一个数据结构,支持以下操作:
1 pos val
表示将 第 p o s pos pos 个垃圾桶里的垃圾的编号换成 v a l val val;
2 l r
询问在 [ l , r ] [l, r] [l,r] 内是否存在垃圾编号和为 w w w 的 两个 垃圾桶。对于每个操作 2,若存在请输出
Yes
,不存在请输出No
。强制在线。
只考虑询问:记录前驱,即 p r e ( i ) pre(i) pre(i) 表示 i i i 左边最近的元素使得和为 w w w,预处理开一个桶从左往右扫一遍 O ( n ) O(n) O(n) 就能算完。考虑区间 [ l , r ] [l,r] [l,r],若对于所有的 l ≤ i ≤ r l\le i\le r l≤i≤r, p r e ( i ) < l pre(i)<l pre(i)<l 那么就是无解的。于是记录 p r e pre pre 的区间最大值即可。
现在考虑修改,仍然考虑
p
r
e
pre
pre 数组。注意到对于序列
{
1
,
2
,
2
}
\{1,2,2\}
{1,2,2},最后一个
2
2
2 对答案的贡献被离
1
1
1 最近的那个
2
2
2 覆盖了。于是我们每次只用修改最左边的
p
r
e
pre
pre 即可。将
x
x
x 替换为
y
y
y 相当于删去
x
x
x 然后原地插入
y
y
y。对于删除,我们要找到分别找到左右第
1
1
1 个的
x
x
x 和
w
−
x
w-x
w−x,即支持找到
i
i
i 位置右边第一个
=
x
=x
=x 的值。考虑对于每个值用一个平衡树维护它的下标,那么查询就到对应的平衡树中去。实现时完全可以用 set
的 lower_bound
实现。