【学习笔记】平衡树
平衡树的原理与实现
\(\text{BST}\)
给定一棵二叉树,树上的每个节点带有一个权值,称为节点的 “关键码”。 “ \(\text{BST}\) 性质” 是指,对于树中的任意一个节点:
- 该节点的关键码不小于它的左子树中任意节点的关键码。
- 该节点的关键码不大于它的右子树中任意节点的关键码。
满足上述性质的二叉树,就是一棵 “二叉查找树”。显然,二叉查找树的中序遍历是一个关键码单调递增的节点序列。
因为节点权值之间的关系,我们可以通过在其结构上进行二分去找到每一个权值的前驱后继以及查询排名。
在随机数据中,\(\text{BST}\) 一次操作的期望复杂度为 \(O(\log n)\) 。然而,\(\text{BST}\) 很容易退化,例如在 \(\text{BST}\) 中依次插入一个有序序列,将会得到一条链,平均每次操作的复杂度为 \(O(n)\) 。我们称这种左右子树大小相差很大的 \(\text{BST}\) 是 “不平衡”的。有很多方法可以维持 \(\text{BST}\) 的平衡,从而产生了各种平衡树。
\(\text{Treap}\)
在随机数据下,普通的 \(\text{BST}\) 就是趋近平衡的。 \(\text{Treap}\) 的思想就是利用 “随机” 来创造平衡条件。因为在旋转过程中必须维持 \(\text{BST}\) 性质,所以 \(\text{Treap}\) 就把 ”随机“ 作用在堆性质上。
带旋 \(\text{Treap}\) 每次在插入节点时,给该节点随机生成一个额外权值。然后像二叉堆插入的过程一样,自底向上依次检查,当某个节点不满足大(小)根堆的性质时,就执行单旋,使该节点与其父节点的关系发生对换。
对于删除则可以将一个点单旋到叶子节点,直接删除。
无旋 \(\text{Treap}\)
无旋 \(\text{Treap}\) 的两个核心操作是 split
和 merge
void split(int p,int val,int &x,int &y) {
if (!p) {x = y = 0; return;}
if (a[p].val <= val) {
x = p; split(a[p].r,val,a[p].r,y);
} else {
y = p; split(a[p].l,val,x,a[p].l);
} pushup(p);
}
split(int p,int val,int &x,int &y)
意为将以 p
为根的子树,分别拆成以 x
, y
为根的两棵子树满足以 x
为根的子树内所有节点的权值 \(\le val\) ,以 y
为根的子树内的所有节点的权值均 \(> val\) 。
int merge(int x,int y) {
if (!x || !y) return x + y;
if (a[x].dat > a[y].dat) {
a[x].r = merge(a[x].r,y); pushup(x); return x;
} else {
a[y].l = merge(x,a[y].l); pushup(y); return y;
}
}
merge(int x,int y)
意为合并以 x
为根的子树和以 y
为根的子树 (且 x
的关键值小于 y
) ,返回合并后的根节点的节点编号。
很容易通过 split
和 merge
操作实现维护值域的基本操作:
void insert(int val) {
int x,y,z; split(root,val,x,z); split(x,val-1,x,y);
if (y) {
++a[y].cnt; pushup(y); root = merge(merge(x,y),z);
} else {
root = merge(merge(x,New(val)),z);
}
}
void erase(int val) {
int x,y,z; split(root,val,x,z); split(x,val-1,x,y);
if (a[y].cnt > 1) {
--a[y].cnt; pushup(y); root = merge(merge(x,y),z);
} else {
root = merge(x,z);
}
}
int GetPre(int val) {
int x,y; split(root,val-1,x,y);
int p = x;
while (a[p].r) p = a[p].r;
root = merge(x,y);
return a[p].val;
}
int GetNext(int val) {
int x,y; split(root,val,x,y);
int p = y;
while (a[p].l) p = a[p].l;
root = merge(x,y);
return a[p].val;
}
int GetRankByVal(int val) {
int x,y; split(root,val-1,x,y);
int res = a[x].size; root = merge(x,y);
return res;
}
int GetValByRank(int p,int rank) {
if (!p) return inf;
if (a[a[p].l].size >= rank) return GetValByRank(a[p].l,rank);
if (a[a[p].l].size + a[p].cnt >= rank) return a[p].val;
return GetValByRank(a[p].r,rank-a[p].cnt-a[a[p].l].size);
}
无旋平衡树维护序列
与线段树维护序列只有叶子节点维护位置上的信息,其余节点都是 ”虚点“ 不同,无旋 \(\text{Treap}\) 中每个节点都为 ”实点“,即每个节点都维护了一个序列中的位置的信息,满足其中序遍历的节点顺序所对应的序列为我们所维护的序列。
即,我们除了要维护的信息外,我们要 ”维护“ (一般不直接维护)一个决定 ”节点“位置的权值 (一般来说是 \(n\) 的一个排列) ,使得该权值满足 \(\text{BST}\) 性质。
而对于无旋 \(\text{Treap}\) 来说,维护序列的分裂方式与维护权值的分裂方式不大相同。
因为其中序遍历对应着序列,所以我们考虑分裂出 中序遍历的前 \(k\) 个位置构成的子树。
void split(int p,int sz,int &x,int &y) {
if (!p) {x = y = 0 ;return;}
pushdown(p);
if (a[a[p].l].size >= sz) {
y = p; split(a[p].l,sz,x,a[p].l);
} else {
x = p; split(a[p].r,sz-1-a[a[p].l].size,a[p].r,y);
} pushup(p);
}
split(int p,int sz,int &x,int &y)
意为从以 p
为根的子树中,分裂出 **p
的中序遍历的前 k
个位置 **构成的子树(根为 x
,其余部分的子树根为 y
) 。
这样,对于区间 \([l,r]\) 我们按如下方式分裂,以 y
为根的子树就是区间 \([l,r]\) 所对应的子树 :
void modify(){
int x,y,z;
split(root,r,y,z);
split(y,l-1,x,y);
}
我们在操作之后,再按顺序 merge
回去即可。
需要注意,因为平衡树上每个节点都是实点,所以 pushup
时需要考虑节点本身的贡献。
P3391 【模板】文艺平衡树
题目描述
您需要写一种数据结构(可参考题目标题),来维护一个有序数列。
其中需要提供以下操作:翻转一个区间,例如原有序序列是 \(5\ 4\ 3\ 2\ 1\),翻转区间是 \([2,4]\) 的话,结果是 \(5\ 2\ 3\ 4\ 1\)。
数据范围
\(1\le n,m\le 10^5,1\le l\le r\le n\) 。
题解
对于本题来说,我们每个节点维护一个值 \(\text{val}\) 表示该节点(对应位置)上的值。
同时,对于每个节点维护一个翻转标记 reverse
,若 reverse = 1
表示以 x
为根的子树所代表的区间需要翻转。不难写出标记下传:
void pushdown(int p) {
if (a[p].tag) {
std::swap(a[p].l,a[p].r); a[p].tag = 0;
a[a[p].l].tag ^= 1; a[a[p].r].tag ^= 1;
}
}
核心代码:
void pushup(int p) {
a[p].size = a[a[p].l].size + a[a[p].r].size + 1;
}
void pushdown(int p) {
if (a[p].tag) {
std::swap(a[p].l,a[p].r); a[p].tag = 0;
a[a[p].l].tag ^= 1; a[a[p].r].tag ^= 1;
}
}
int New(int val) {
a[++tot].val = val; a[tot].dat = rand(); a[tot].size = 1;
a[tot].tag = a[tot].l = a[tot].r = 0; return tot;
}
void split(int p,int sz,int &x,int &y) {
if (!p) {x = y = 0 ;return;}
pushdown(p);
if (a[a[p].l].size >= sz) {
y = p; split(a[p].l,sz,x,a[p].l);
} else {
x = p; split(a[p].r,sz-1-a[a[p].l].size,a[p].r,y);
} pushup(p);
}
int merge(int x,int y) {
if (!x || !y) return x + y;
pushdown(x); pushdown(y);
if (a[x].dat > a[y].dat) {
a[x].r = merge(a[x].r,y); pushup(x); return x;
} else {
a[y].l = merge(x,a[y].l); pushup(y); return y;
}
}
void reverse(int l,int r) {
int x,y,z;
split(root,r,y,z);
split(y,l-1,x,y);
a[y].tag ^= 1; root = merge(merge(x,y),z);
}
P2042 [NOI2005] 维护数列
题目描述
请写一个程序,要求维护一个数列,支持以下 \(6\) 种操作:
编号 | 名称 | 格式 | 说明 |
---|---|---|---|
1 | 插入 | \(\operatorname{INSERT}\ posi \ tot \ c_1 \ c_2 \cdots c_{tot}\) | 在当前数列的第 \(posi\) 个数字后插入 \(tot\) 个数字:\(c_1, c_2 \cdots c_{tot}\);若在数列首插入,则 \(posi\) 为 \(0\) |
2 | 删除 | \(\operatorname{DELETE} \ posi \ tot\) | 从当前数列的第 \(posi\) 个数字开始连续删除 \(tot\) 个数字 |
3 | 修改 | \(\operatorname{MAKE-SAME} \ posi \ tot \ c\) | 从当前数列的第 \(posi\) 个数字开始的连续 \(tot\) 个数字统一修改为 \(c\) |
4 | 翻转 | \(\operatorname{REVERSE} \ posi \ tot\) | 取出从当前数列的第 \(posi\) 个数字开始的 \(tot\) 个数字,翻转后放入原来的位置 |
5 | 求和 | \(\operatorname{GET-SUM} \ posi \ tot\) | 计算从当前数列的第 \(posi\) 个数字开始的 \(tot\) 个数字的和并输出 |
6 | 求最大子列和 | \(\operatorname{MAX-SUM}\) | 求出当前数列中和最大的一段子列,并输出最大和 |
数据范围
任何时刻数列中最多含有 \(5 \times 10^5\) 个数,任何时刻数列中任何一个数字均在 \([-10^3, 10^3]\) 内,\(1 \le M \le 2 \times 10^4\),插入的数字总数不超过 \(4 \times 10^6\)。
题解
我们需要对于一个节点维护以下信息:
- 推平标记
assign
- 翻转标记
reverse
- 对应位置上的值
val
- 对应区间最大非空前缀和
lmax
- 对应区间最大非空后缀和
rmax
- 对应区间最大非空子段和
max
需要注意的是,我们需要 正确预处理空节点的信息 ,使得某个节点左右儿子为空的时候其节点信息不会受到影响。
对于标记我们需要再做明确约束:其意为 根节点的信息为进行过标记的操作后的信息,但子树内其余节点还未进行标记所对应的操作(即根节点的信息已经更新,但字数内其余节点还未更新信息) 。
P4036 [JSOI2008]火星人
题目描述
火星人最近研究了一种操作:求一个字串两个后缀的公共前缀。
比方说,有这样一个字符串:
madamimadam
,我们将这个字符串的各个字符予以标号:
序号 1 2 3 4 5 6 7 8 9 10 11
字符 m a d a m i m a d a m
现在,火星人定义了一个函数 \(LCQ(x, y)\),表示:该字符串中第 \(x\) 个字符开始的字串,与该字符串中第 \(y\) 个字符开始的字串,两个字串的公共前缀的长度。
比方说,\(LCQ(1, 7) = 5, LCQ(2, 10) = 1, LCQ(4, 7) = 0\)
在研究 \(LCQ\) 函数的过程中,火星人发现了这样的一个关联:如果把该字符串的所有后缀排好序,就可以很快地求出 \(LCQ\) 函数的值;同样,如果求出了 \(LCQ\) 函数的值,也可以很快地将该字符串的后缀排好序。
尽管火星人聪明地找到了求取 \(LCQ\) 函数的快速算法,但不甘心认输的地球人又给火星人出了个难题:在求取 \(LCQ\) 函数的同时,还可以改变字符串本身。
具体地说,可以更改字符串中某一个字符的值,也可以在字符串中的某一个位置插入一个字符。地球人想考验一下,在如此复杂的问题中,火星人是否还能够做到很快地求取 \(LCQ\) 函数的值。
数据范围
-
所有字符串自始至终都只有小写字母构成。
-
\(M\leq150,000\)
-
字符串长度L自始至终都满足\(L\leq100,000\)
-
询问操作的个数不超过 \(10,000\) 个。
题解
如果没有插入操作,我们可以使用线段树维护区间字符串哈希,二分答案。
而平衡树可以很好的处理插入操作,因此可以直接用平衡树维护字符串哈希。
复杂度 \(O(n\log ^2 n)\) 。
P2596 [ZJOI2006]书架
题目描述
小 T 有一个很大的书柜。这个书柜的构造有些独特,即书柜里的书是从上至下堆放成一列。她用 \(1\) 到 \(n\) 的正整数给每本书都编了号。
小 T 在看书的时候,每次取出一本书,看完后放回书柜然后再拿下一本。由于这些书太有吸引力了,所以她看完后常常会忘记原来是放在书柜的什么位置。不过小 T 的记忆力是非常好的,所以每次放书的时候至少能够将那本书放在拿出来时的位置附近,比如说她拿的时候这本书上面有 \(x\) 本书,那么放回去时这本书上面就只可能有 \(x-1\)、\(x\) 或 \(x+1\) 本书。
当然也有特殊情况,比如在看书的时候突然电话响了或者有朋友来访。这时候粗心的小 T 会随手把书放在书柜里所有书的最上面或者最下面,然后转身离开。
久而久之,小 T 的书柜里的书的顺序就会越来越乱,找到特定的编号的书就变得越来越困难。于是她想请你帮她编写一个图书管理程序,处理她看书时的一些操作,以及回答她的两个提问:
-
编号为 \(x\) 的书在书柜的什么位置。
-
从上到下第 \(i\) 本书的编号是多少。
-
若 \(op\) 为
Top
,则后有一个整数 \(s\),表示把编号为 \(s\) 的书放在最上面。 -
若 \(op\) 为
Bottom
,则后有一个整数 \(s\),表示把编号为 \(s\) 的书放在最下面。 -
若 \(op\) 为
Insert
,则后有两个整数 \(s, t\),表示若编号为 \(s\) 的书上面有 \(x\) 本书,则放回这本书时他的上面有 \(x + t\) 本书。 -
若 \(op\) 为
Ask
,则后面有一个整数 \(s\),表示询问编号为 \(s\) 的书上面有几本书。 -
若 \(op\) 为
Query
,则后面有一个整数 \(s\),询问从上面起第 \(s\) 本书的编号。
数据范围
- \(3 \leq n, m \leq 8 \times 10^4\)。
- \(p_i\) 是一个 \(1 \sim n\) 的排列。
- \(1 \leq s \leq n\),\(-1 \leq t \leq 1\),\(op\) 只可能是输入的五种字符串之一。
- 当编号为 \(s\) 的书上面没有书的时候,不会对它进行
Insert s -1
操作。 - 当编号为 \(s\) 的书下面没有书的时候,不会对它进行
Insert s 1
操作。
题解
考虑使用平衡树维护一个序列 \(a[ ]\) 若 \(a[i]=x\) 表示从上到下第 \(i\) 本书是 \(x\) 。
注意到,\(p_i\) 是 \(1\sim n\) 的一个排列,因此权值和平衡树的节点之间是 一一对应(即双射) 的关系,对于这种情况我们有两种对点的编号方法以方便我们通过权值查找其对应的平衡树上的节点:
- 用一个数组 \(num\) ,平衡树上编号为 \(num[i]\) 的节点的权值为 \(i\) 。
- 用权值当平衡树的节点编号,编号为 \(i\) 的节点,其权值是 \(i\) 。
Solution 1
接下来的讨论,我们采用第一种编号方法。
我们考虑我们要维护的操作:
-
Ask s
相当于查询 \(num[s]\) 在中序遍历中有多少个点排在 \(num[s]\) 的前面,我们令 \(x\gets num[s]\) 。- 注意到,若点 \(y\) 在中序遍历中在 \(x\) 的前面,令 \(lca\gets LCA(x,y)\) ,等价于 \(y\) 在 \(lca\) 的左子树中,\(x=lca\) 或 \(x\) 在 \(lca\) 在又子树中 。 第一种办法就是我们考虑中序遍历在点 \(x\) 前面的一个点 \(y\) ,我们在 \(\text{LCA(x,y)}\) 处统计答案 。我们对于每个节点记录一个父亲 \(fa\) 。
- 初始时令 \(p\gets x,res \gets size[ls[x]]\) 。
- 令 \(p’ \gets fa[p]\) ,若 \(p=rs[p']\) ,\(res\gets res + size[ls[p']]+1\) 。
- 令 \(p\gets p'\) 。
- 重复执行 \(2,3\) 直至 \(p=root\) 。
- 注意到,若点 \(y\) 在中序遍历中在 \(x\) 的前面,令 \(lca\gets LCA(x,y)\) ,等价于 \(y\) 在 \(lca\) 的左子树中,\(x=lca\) 或 \(x\) 在 \(lca\) 在又子树中 。 第一种办法就是我们考虑中序遍历在点 \(x\) 前面的一个点 \(y\) ,我们在 \(\text{LCA(x,y)}\) 处统计答案 。我们对于每个节点记录一个父亲 \(fa\) 。
-
Query s
把第 \(s\) 本书split
出来即可。 -
Top s
查询编号为 \(s\) 的树在中序遍历中的位置,将其split
出来之后再把树重新merge
回去。 -
Bottom s
同Top
。 -
Insert s t
将对应的位置split
出来之后再把树merge
回去。
因为 split
和 merge
操作会影响树的形态,从而影响 \(fa\) ,所以在 split
和 merge
的时候要更新 \(fa\) 。
Solution 2
我们注意到,我们第一种做法之所以要记 \(fa\) ,是因为 Ask
,这里我们采用另一种方法处理 Ask
。
我们在维护值域的时候,因为权值满足 \(\text{BST}\) 性质,所以可以很方便地查询节点在中序遍历中的排名。
而维护序列,因为我们不直接维护满足 \(\text{BST}\) 性质的权值,因此难以二分出排名。而维护序列的本质其实就是以数组下标为满足 \(\text{BST}\) 性质的权值,但因为我们只关心下标的大小关系而不关心下标具体是几,所以我们不维护出具体的权值。
为了方便的在中序遍历中查询排名,我们对于平衡树上的每个节点维护一个权值 ,同时维护权值的值域 \([Mn,Mx]\) ,初始时权值的值域是 \([1,n]\) 。
那么三个修改操作其实就是对于权值的修改,直接改权值之后按权值 merge
回去即可。
平衡树杂题
P3765 总统选举
题目描述
黑恶势力的反攻计划被小 C 成功摧毁,黑恶势力只好投降。
秋之国的人民解放了,举国欢庆。此时,原秋之国总统因没能守护好国土,申请辞职,并请秋之国人民的大救星小C钦定下一任。
作为一名民主人士,小 C 决定举行全民大选来决定下一任。为了使最后成为总统的人得到绝大多数人认同,小 C 认为,一个人必须获得超过全部人总数的一半的票数才能成为总统。
如果不存在符合条件的候选人,小 C 只好自己来当临时大总统。为了尽可能避免这种情况,小C决定先进行几次小规模预选,根据预选的情况,选民可以重新决定自己选票的去向。由于秋之国人数较多,统计投票结果和选票变更也成为了麻烦的事情,小 C 找到了你,让你帮他解决这个问题。
秋之国共有 \(n\) 个人,分别编号为 \(1,2,…,n\) ,一开始每个人都投了一票,范围 \(1\sim n\) ,表示支持对应编号的人当总统。
共有 \(m\) 次预选,每次选取编号 \(l_i,r_i\) 内的选民展开小规模预选,在该区间内获得超过区间大小一半的票的人获胜,如果没有人获胜,则由小 C 钦定一位候选者获得此次预选的胜利(获胜者可以不在该区间内),每次预选的结果需要公布出来,并且每次会有 \(k_i\) 个人决定将票改投向该次预选的获胜者。
全部预选结束后,公布最后成为总统的候选人。
数据范围
\(1\le n,m\le 5\times 10^5,\sum k_i\le 10^6,1\le s_i\le n\) 。
题解 (摩尔投票法,线段树,平衡树)
考虑一个简化的问题:给定一个长度为 \(n\) 的序列,询问是否存在一个数 \(x\) 满足 \(x\) 在其中的出现次数 \(> \dfrac{n}{2}\) 。
对于该问题的一种解决办法是摩尔投票法:
- 每次选定两个不相同的数 \(x,y\) 将其从序列中删除直到序列中所以元素均相同(如果序列中不存在元素那么),剩下的元素 \(res\) 有可能 是答案。
并且对于摩尔投票法所维护的结果,具有区间可加性 。
因此我们可以使用线段树维护选民进行摩尔投票法后的结果。
同时,我们需要查询 \(x\) 在区间 \([l,r]\) 的出现次数。
我们对每个 \(x\) 维护一棵平衡树,平衡树中的元素为选民的投票序列中投 \(x\) 的位置,每次查询 \(l,r\) 的 \(rk\) 即可。