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\)这个结点。