平衡树,是一种数据结构,可以实现一类元素在线性结构中动态变化,基于二叉搜索树,满足二叉搜索树的所有性质。
二叉搜索树(BST)
二叉搜索树是一种二叉树形结构,它满足以下性质:
-
空树是二叉搜索树。
-
若二叉搜索树的左子树不为空,则其左子树上所有点的附加权值均小于其根节点的值。
-
若二叉搜索树的右子树不为空,则其右子树上所有点的附加权值均大于其根节点的值。
-
二叉搜索树的左右子树均为二叉搜索树。
简单来讲,就是 左 < 中 < 右。
二叉搜索树上的基本操作所花费的时间与这棵树的高度成正比。对于一个有 n 个结点的二叉搜索树中,这些操作的最优时间复杂度为 \(\mathcal O(\log n)\),最坏为 \(\mathcal O(n)\)。随机构造这样一棵二叉搜索树的期望高度为 \(\mathcal O(\log n)\)。[1]
平衡树
平衡树,字面意思,就是平衡的树。它是一种优化的二叉搜索树,能够使二叉搜索树的深度尽量小,使树趋于平衡,从而优化操作的时间复杂度。
在这里,平衡指所有叶子的深度趋于平衡,更广义的是指在树上所有可能查找的均摊复杂度偏低。[2]
几乎所有平衡树的实现方法都是基于树旋转操作[3],但由于笔者水平有限,本文仅介绍一种相对简单,且无需旋转的平衡树——无旋 Treap。
Treap
Treap 是一种弱平衡二叉搜索树,它同时满足二叉搜索树和堆的性质,因此得名也为 Tree(树) 和 Heap(堆)的组合。
在 Treap 中,我们引入了一个随机附加域,每一个结点有一个随机的附加值,满足堆的性质,结点原权值满足二叉搜索树的性质。
下文称随机附加值为 \(rnk\)。
使用一个随机附加域来满足堆的性质,可以使 Treap 处于一个期望的平衡状态,使 Treap 单次操作的期望复杂度为 \(\mathcal O(\log n)\)。由于笔者水平有限,并不清楚如何严格证明 Treap 的期望平衡,因此这里给出 OI Wiki 中的一种理解方式(略有改动):
首先,我们需要认识到一个节点的 \(rnk\) 是和它所在的层数有直接关联的。再回忆堆的性质:
- 子节点 \(rnk\) 比父节点大或小(取决于是小根堆还是大根堆)
我们发现层数低的节点,比如整个树的根节点,它的 \(rnk\) 也会更小(在小根堆中)。并且,在朴素的搜索树中,先被插入的节点,也更有可能会有比较小的层数。我们可以把这个 \(rnk\) 和被插入的顺序关联起来理解,这样,也就理解了为什么 Treap 可以把节点插入的顺序通过随机附加域打乱。
无旋 Treap
无旋 Treap,是一种无需旋转,且满足 Treap 性质的平衡树。
它的核心操作有两种,分别是分裂和合并。通过这两种操作,无旋 Treap 可以比有旋 Treap 更灵活、方便地实现操作。无旋 Treap 的大部分操作都基于这两个操作。
分裂(Split)
分裂操作将一棵 Treap 分裂成两颗树 \(a, b\),一颗树的所有结点权值小于等于分裂的权值 \(key\),一颗大于 \(key\)。因为二叉搜索树的性质,所以 \(a\) 树右下角的结点 \(\le key\),\(b\) 树左下角的结点 \(> key\),类似下图:
这样我们就可以使用分裂出来的两棵树,依靠上面的性质进行各种操作。
至于具体的分裂过程,同样依靠二叉搜索树的性质,进行递归分裂。对于当前搜到的结点 \(cur\),设它的权值为 \(val\),若 \(cur \le key\),根据二叉搜索树的性质,左子树的结点权值均小于根节点,那么 \(cur\) 的左子树以及 \(cur\) 均属于 \(a\) 树,然后继续分裂右子树,反之亦然。下图可以更清晰地理解:
void split(int cur, int &a, int &b, int val)
//此处的 val 即为 key;这里的a、b加取址符是为了方便将a与b的内部连接
{
if(!cur)//叶子结点
return a = b = 0, void();//停止分裂
if(tree[cur].val <= val)//val小于等于key
a = cur, split(rs, rs, b, val);//左子树与cur均属于a,继续分裂右子树
else//val > key
b = cur, split(ls, a, ls, val);//右子树与cur均属于a,继续分裂左子树
pushup(cur);//更新当前结点信息
return;
}
合并(Merge)
合并操作将两棵 Treap 合并。设它们的根分别为 \(a\) 和 \(b\),那么 \(a\) 中所有点的权值必须小于等于 \(b\) 中的权值。在合并操作中,我们通过维护 \(rnk\) 的值来维护合并后 Treap 的平衡,不再维护 \(val\),因此为了使合并后的 Treap 是 BST,\(a\) 中的权值必须小于等于 \(b\) 中的权值。
由于维护的是平衡,所以需要考虑哪一棵放在上面,哪一棵放在下面作另一棵的子树。考虑递归逐层合并,设当前两棵树合并后的根为 \(cur\),根据堆的性质(小、大根堆皆可,此处为小根堆),将 \(rnk\) 小的放在 \(cur\) 的位置。若 \(rnk_a < rnk_b\),则 \(a\) 在 \(b\) 上面, \(b\) 成为 \(a\) 的子树,并于 \(a\) 原来的子树继续合并。由于 \(a\) 的权值都小于 \(b\),所以将 \(b\) 与 \(a\) 的右子树继续合并,反之亦然。
void merge(int &cur, int a, int b)
//cur加取址符是为了方便直接取出合并后的根
{
if(!a || !b)
return cur = a + b, void();
if(tree[a].rnk < tree[b].rnk)//a在b上方
cur = a, merge(rs, rs, b);//b与a的右儿子继续合并,争夺a右儿子
else//b在a上方
cur = b, merge(ls, a, ls);//a与b的左儿子继续合并,争夺b左儿子
pushup(cur);//更新当前结点信息
return;
}
插入(Insert)
无旋 Treap 的插入、删除等基本操作都可以利用分裂和合并简短地实现。
根据分裂操作的性质,分裂出来的两颗 Treap 一棵小于等于 \(key\),一棵大于 \(key\)。设 \(a\) 为小于等于 \(key\) 的 Treap,那么 \(b\) 大于 \(val\)。
设要插入的点权值为 \(val\)。将 Treap 按照 \(val\) 分裂,那么 \(a\) 中所有点权值都小于等于 \(val\)。
新建一个结点 \(c\),权值为 \(val\)。显然,一个单独的结点也是一颗 Treap。因为 \(a\) 中点权都小于等于 \(val\),而 \(c\) 点权为 \(val\),所以此时 \(a\) 与 \(c\) 可以合并。
将 \(a\) 与 \(c\) 合并,设根结点仍然为 \(a\)。此时 \(a\) 中点权仍然小于等于 \(val\),所以仍然可以与 \(b\) 合并。\(a\) 与 \(b\) 合并后的 Treap 即为插入后的 Treap。
void insert(int &cur, int val)//由于分裂、合并后根可能会变,因此cur加取址符
{
int a = 0, b = 0, c = add_node(val);//新建一个权值为val的结点
split(cur, a, b, val);//按照val分裂为a,b
merge(a, a, c), merge(cur, a, b);//将a,c合并,再与b合并
return;
}
删除(Delete)
删除与插入类似,用分裂找到权值等于 \(val\) 的结点,并删除。但是需要注意题目要求是删一个还是全删。
void delate(int &cur, int val)
{
int a = 0, b = 0, c = 0;
split(cur, a, b, val), split(a, a, c, val - 1);//a树<val,c树=val,b树>val
merge(c, tree[c].lt, tree[c].rt), merge(a, a, c), merge(cur, a, b);//根据题目要求判断删一个还是全删,此处只删一个
//c树中的结点都=val,这里是将根节点c的左右子树合并,那么c就删掉了
return;
}
根据值查询排名(Query Ranking)
这里的排名定义为比当前数小的数的个数 \(+1\)。
将树分裂为小于 \(val\) 的树 \(a\) 和大于等于 \(val\) 的树 \(b\),那么小于 \(val\) 的树的个数即为 \(a\) 树的大小。
当排名的定义有变化时,方法也需要变化。
int find_rnk(int &cur, int x)
{
int a = 0, b = 0;
split(cur, a, b, x - 1);//a树<=x-1,b树>x-1,也就是a树<x,b树>=x
int ans = tree[a].siz + 1;//排名就是a的大小+1
merge(cur, a, b);
return ans;
}
根据排名查询值(Query Number)
这个操作并不建议使用分裂与合并,因为需要根据树的大小分裂。
所以使用更为简单的 dfs。由于 BST 的性质,左右子树有序,所以可以根据左子树的大小来判断排名。
int find_num(int cur, int x)
{
if(tree[ls].siz + 1 == x)//比cur小的结点数+1为x,说明cur的排名为x
return tree[cur].val;
if(tree[ls].siz < x)//比cur小的结点数小于x,说明排名还要变大,查询右子树
return find_num(rs, x - tree[ls].siz - 1);//注意这里要将左子树的大小与cur剪掉
else//反之则是说明排名还要减小,查询左子树
return find_num(ls, x);
}
查询前驱(Query Previous)
前驱定义为小于 \(x\),且最大的数,那么将树分裂为小于 \(x\) 的树 \(a\) 和 大于等于 \(x\) 的树 \(b\),那么 \(x\) 的排名就是 \(a\) 的大小,于是可以用前面的 find_num()
来根据排名查询值。
int find_prev(int &cur, int val)
{
int a = 0, b = 0;
split(cur, a, b, val - 1);//树a<val,树b>=val
int ans = find_num(a, tree[a].siz);//a的大小就是val的前驱的排名,直接查询
merge(cur, a, b);
return ans;
}
查询后继(Query Next)
后继定义为大于 \(x\),且最小的数,那么与查询前驱类似,找到 \(x\) 的后继的排名,并调用 find_num()
查找。
int find_next(int &cur, int val)
{
int a = 0, b = 0;
split(cur, a, b, val);//树a<=val,树b>val
int ans = find_num(b, 1);//b中的最小的数就是比val大的第一个数
merge(cur, a, b);
return ans;
}
模板题 P3369 【模板】普通平衡树
您需要写一种数据结构(可参考题目标题),来维护一些数,其中需要提供以下操作:
- 插入一个数 \(x\)。
- 删除一个数 \(x\)(若有多个相同的数,应只删除一个)。
- 定义排名为比当前数小的数的个数 \(+1\)。查询 \(x\) 的排名。
- 查询数据结构中排名为 \(x\) 的数。
- 求 \(x\) 的前驱(前驱定义为小于 \(x\),且最大的数)。
- 求 \(x\) 的后继(后继定义为大于 \(x\),且最小的数)。
对于操作 3,5,6,不保证当前数据结构中存在数 \(x\)。
对于 \(100\%\) 的数据,\(1\le n \le 10^5\),\(|x| \le 10^7\)。
解法
这道题的操作就是上面分析的所有操作,整合一下即可。
Code
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e5 + 5;
int n, num, root;
struct Tree
{
int lt, rt, val, siz, rnk;
}tree[N];
mt19937 rnd(time(0));
#define ls tree[cur].lt
#define rs tree[cur].rt
void pushup(int cur)
{
tree[cur].siz = tree[ls].siz + tree[rs].siz + 1;
return;
}
void split(int cur, int &a, int &b, int val)
//此处的 val 即为 key;这里的a、b加取址符是为了方便将a与b的内部连接
{
if(!cur)//叶子结点
return a = b = 0, void();//停止分裂
if(tree[cur].val <= val)//val小于等于key
a = cur, split(rs, rs, b, val);//左子树与cur均属于a,继续分裂右子树
else//val > key
b = cur, split(ls, a, ls, val);//右子树与cur均属于a,继续分裂左子树
pushup(cur);//更新当前结点信息
return;
}
void merge(int &cur, int a, int b)
//cur加取址符是为了方便直接取出合并后的根
{
if(!a || !b)
return cur = a + b, void();
if(tree[a].rnk < tree[b].rnk)//a在b上方
cur = a, merge(rs, rs, b);//b与a的右儿子继续合并,争夺a右儿子
else//b在a上方
cur = b, merge(ls, a, ls);//a与b的左儿子继续合并,争夺b左儿子
pushup(cur);//更新当前结点信息
return;
}
int add_node(int val)//新建结点
{
return tree[++num] = {0, 0, val, 1, (int)rnd()}, num;
}
void insert(int &cur, int val)//由于分裂、合并后根可能会变,因此cur加取址符
{
int a = 0, b = 0, c = add_node(val);//新建一个权值为val的结点
split(cur, a, b, val);//按照val分裂为a,b
merge(a, a, c), merge(cur, a, b);//将a,c合并,再与b合并
return;
}
void delate(int &cur, int val)
{
int a = 0, b = 0, c = 0;
split(cur, a, b, val), split(a, a, c, val - 1);//a树<val,c树=val,b树>val
merge(c, tree[c].lt, tree[c].rt), merge(a, a, c), merge(cur, a, b);//根据题目要求判断删一个还是全删,此处只删一个
//c树中的结点都=val,这里是将根节点c的左右子树合并,那么c就删掉了
return;
}
int find_rnk(int &cur, int x)
{
int a = 0, b = 0;
split(cur, a, b, x - 1);//a树<=x-1,b树>x-1,也就是a树<x,b树>=x
int ans = tree[a].siz + 1;//排名就是a的大小+1
merge(cur, a, b);
return ans;
}
int find_num(int cur, int x)
{
if(tree[ls].siz + 1 == x)//比cur小的结点数+1为x,说明cur的排名为x
return tree[cur].val;
if(tree[ls].siz < x)//比cur小的结点数小于x,说明排名还要变大,查询右子树
return find_num(rs, x - tree[ls].siz - 1);//注意这里要将左子树的大小与cur剪掉
else//反之则是说明排名还要减小,查询左子树
return find_num(ls, x);
}
int find_prev(int &cur, int val)
{
int a = 0, b = 0;
split(cur, a, b, val - 1);//树a<val,树b>=val
int ans = find_num(a, tree[a].siz);//a的大小就是val的前驱的排名,直接查询
merge(cur, a, b);
return ans;
}
int find_next(int &cur, int val)
{
int a = 0, b = 0;
split(cur, a, b, val);//树a<=val,树b>val
int ans = find_num(b, 1);//b中的最小的数就是比val大的第一个数
merge(cur, a, b);
return ans;
}
signed main()
{
cin >> n;
while(n--)
{
int opt, x;
cin >> opt >> x;
if(opt == 1)
insert(root, x);
else if(opt == 2)
delate(root, x);
else if(opt == 3)
cout << find_rnk(root, x) << "\n";
else if(opt == 4)
cout << find_num(root, x) << "\n";
else if(opt == 5)
cout << find_prev(root, x) << "\n";
else
cout << find_next(root, x) << "\n";
}
return 0;
}
序列上的无旋 Treap
无旋 Treap 是一种可以维护序列操作的平衡树。
建树
在序列无旋 Treap 中,分裂操作与普通无旋 Treap 不同。在这里,我们不按照权值 \(val\) 来分裂,而是按照子树大小 \(siz\) 分裂。具体来说,对于一个结点 \(cur\),它的左子树的大小 \(+1\) 要为 \(cur\) 在原序列中的编号。
于是在分裂操作中,每个点按照左子树的大小分裂,由于 BST 性质,这样就可以保证 Treap 的中序遍历为原序列的顺序。
插入时,若插入结点在原序列中的编号为 \(i\),按照 \(i\) 分裂,就可以得到一棵表示区间 \([1, i]\) 的树和一棵表示区间 \([i+1, n]\) 的树,将点合并进去即可。
void split(int cur, int &a, int &b, int val)
{
if(!cur)
return a = b = 0, void();
if(tree[ls].siz + 1 <= val)
a = cur, split(rs, rs, b, val - tree[ls].siz - 1);//注意这里要减去cur与左子树的大小
else
b = cur, split(ls, a, ls, val);
return pushup(cur);
}
void merge(int &cur, int a, int b)
{
if(!a || !b)
return cur = a + b, void();
if(tree[a].rnk < tree[b].rnk)
cur = a, merge(rs, rs, b);
else
cur = b, merge(ls, a, ls);
return pushup(cur);
}
int add_node(int val)
{
tree[++num] = {0, 0, val, 1, 0, (int)rnd()};
return num;
}
void insert(int &cur, int val, int id)
{
int a = 0, b = 0, c = add_node(val);
split(cur, a, b, id);
merge(a, a, c), merge(cur, a, b);
return;
}
模板题 P3391 【模板】文艺平衡树
您需要写一种数据结构(可参考题目标题),来维护一个有序数列。
其中需要提供以下操作:翻转一个区间,例如原有序序列是 \(5\ 4\ 3\ 2\ 1\),翻转区间是 \([2,4]\) 的话,结果是 \(5\ 2\ 3\ 4\ 1\)。
对于 \(100\%\) 的数据,$1 \le n, m \leq 100000 $,\(1 \le l \le r \le n\)。
解法
这是一个序列问题,所以要用维护序列的平衡树。
对于区间翻转操作,由于树中每一个结点的中序遍历都是原序列中的一段区间,那么它的左右子节点也表示一段区间,并且合并后也是一段区间,所以只需要分裂出需要修改的区间,交换所有结点左右子节点即可。
但是这样复杂度显然不能接受,于是可以类似线段树,对结点打懒标记,这样复杂度即可接收。
Code
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 1e5 + 5;
int n, m, num, root;
struct Tree
{
int lt, rt, val, siz, tag, rnk;
}tree[N];
mt19937 rnd(time(0));
#define ls tree[cur].lt
#define rs tree[cur].rt
void pushup(int cur)
{
tree[cur].siz = tree[ls].siz + tree[rs].siz + 1;
return;
}
void pushdown(int cur)//标记下传
{
if(!tree[cur].tag)
return;
swap(ls, rs);
tree[ls].tag ^= 1, tree[rs].tag ^=1;
tree[cur].tag = 0;
return;
}
void split(int cur, int &a, int &b, int val)
{
if(!cur)
return a = b = 0, void();
pushdown(cur);//标记下传
if(tree[ls].siz + 1 <= val)
a = cur, split(rs, rs, b, val - tree[ls].siz - 1);
else
b = cur, split(ls, a, ls, val);
return pushup(cur);
}
void merge(int &cur, int a, int b)
{
if(!a || !b)
return cur = a + b, void();
pushdown(a), pushdown(b);//标记下传
if(tree[a].rnk < tree[b].rnk)
cur = a, merge(rs, rs, b);
else
cur = b, merge(ls, a, ls);
return pushup(cur);
}
int add_node(int val)
{
tree[++num] = {0, 0, val, 1, 0, (int)rnd()};
return num;
}
void insert(int &cur, int val)
{
int a = 0, b = 0, c = add_node(val);
merge(cur, cur, c);
return;
}
void update(int &cur, int l, int r)
{
int a = 0, b = 0, c = 0;
split(cur, a, b, r), split(a, a, c, l - 1);//分裂出修改的区间
tree[c].tag ^= 1;//打标记
merge(a, a, c), merge(cur, a, b);
return;
}
void dfs(int cur)
{
if(!cur)
return;
pushdown(cur);
dfs(ls);
cout << tree[cur].val << " ";
dfs(rs);
return;
}
signed main()
{
cin >> n >> m;
for(int i = 1; i <= n; i++)
insert(root, i);
while(m--)
{
int l, r;
cin >> l >> r;
update(root, l, r);
}
dfs(root);
return 0;
}