平衡树详解和运用

0.总言

  平衡树是一种十分有用的数据结构,它能支持以下操作:

1、插入一个数x
2、删除一个数x
3、查询一个数x(其排名,其前驱后继)
4、查询排名为k的数x
5、快速合并与分裂
6、维护区间修改、查询、翻转
7、维护其它信息

  了解平衡树,先从最普通的\(\text{Treap}\)开始。(注:下文的平衡树实现均用指针)

1.平衡树 && Treap

  平衡树是一种特殊的二叉查找树,所谓二叉查找树,就是满足所有子树中,根节点的权值大于(小于)左子树中任一结点,小于(大于)右子树中任一结点,而特殊就特殊在它是平衡的——任一结点的左右子树高度差\(\leq 1\),换句话说满足中序遍历递增。比如下图就是一个平衡的二叉查找树。

1508633-20190714205110751-360454824.png

  当然处理序列问题时只考虑中序遍历的有序性。这样的二叉查找树在插入、删除、查询的过程中都能够保证复杂度为\(\text{O}(\log{N})\)\(N\)表示当前二叉树内结点数。
  但事实上我们在正常操作过程中,无法保证二叉查找树一定是平衡的。如果需要插入的数据是单调递增或单调递减的,得到的二叉查找树将会退化成一条链,就像这样:

1508633-20190714205826920-756170467.png

  所以有关于平衡树的算法就诞生了,比如下面的\(\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; // 防止空指针影响后面的操作
}

  这里操作的是可重复集合,为了方便,将重复出现的数合并成一个结点。

旋转

  旋转分为左旋和右旋。旋转是大多数平衡树的核心,因为调节树的平衡全靠它。且调节平衡后,二叉查找树的性质没有变。

1508633-20190802135259099-467427818.png

  先看左旋:\(k=o->ch[1]\)\(x=k->ch[0]\),为什么先看\(o,k,x\)呢?因为它们三个点是旋转过程中非常重要的三个点,这三个点换了爸爸(对父亲结点亲切的称呼),而另外两个并没有。
  再看这三个结点连接的变化。选择恰当的顺序很重要。
  ①将\(o\)的右儿子指针由\(k\)改指向\(k\)的左儿子\(x\),即\(o->ch[1]=k->ch[0]\)

1508633-20190714230619345-129416180.png

  ②将\(k\)的左儿子指针由\(x\)改到\(o\),即\(k->ch[0]=o\)

1508633-20190714230628763-1933062794.png

  ③将根\(o\)提到\(k\)的位置,即\(o=k\)

1508633-20190714230637528-816025897.png

  这样左旋就完成了。等等,还没!因为\(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\),首先从根开始。

1508633-20190801001322223-477513798.png

  \(2<6\),所以会往左边走,到了\(4\)这个结点。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值