竞赛最好用的平衡树-Size Balanced Tree(SBT)【建议收藏】

大家好。

前段时间我更新过AVL、红黑树以及搜索二叉树的简单讲解。今天我们还是围绕着平衡树的话题,来讲解一个很牛逼的平衡树结构。这种结构是我国的一位大牛:陈启峰。在2006年,他还在读高中阶段,所发明的一种平衡树结构,叫Size Balanced Tree(SBT),根据节点的个数,来调整平衡性。一直到今天,这种平衡树结构,在算法竞赛领域是非常常用的,虽然SBT的时间复杂度跟AVL、红黑树这些平衡树一样,但是SBT是比较好写,比较好改的。所以在算法竞赛时,是最常用的一种算法。陈启峰SBT论文(译)

本期文章源码:GitHub

img

前期文章

二叉树的概念以及搜索二叉树

AVL平衡树

浅析红黑树

一、左右旋转

还是老样子,为了维持平衡性,SBT也是需要进行旋转操作的。只是说,调用旋转操作的时机,跟其他的平衡树有点区别而已。讲解旋转之前,我们先来认识SBT的节点:

//可以直接改为泛型,这里为了理解这种结构,就忽略了
public class SBTNode {
    public int val; //值域
    public SBTNode left, right; //左右孩子
    public int size; //节点数
    
    public SBTNode(int val) {
        this.val = val;
        size = 1; //默认节点大小是1
    }
}

SBT的节点,只是多了一个size域,用于表示以当前节点为根节点时,这颗子树的节点数。整棵树的平衡性,就是根据这个size来调整的。

右旋转:(LL型)

image-20211023155252766

旋转操作的指针,相互之间的转换,我相信以前了解过平衡树的同学,应该是知道的(如果不知道,请翻阅前期文章)。问题就在于如何,如何修改这些节点的size?

看上图,T1-T3,表示子树,另外两个表示节点,旋转之后,我们会发现,T1和T2和T3这三颗子树,他们下面的节点是没有发生改变的,也就是说T1-T3,这三个是不需要再次计算size的,我们只需要计算上图那两个白色的节点的size即可!

又因为,旋转之后,新的根节点的节点数,还应该是原根节点的节点数,所以最后我们只需要计算旋转之后,原根节点的节点数即可,也就等于该节点的左子树节点数加上右子树的节点数,再加自己本身这个节点。

//SBT右旋转
private SBTNode R_Rotate(SBTNode node) {
    SBTNode newRoot = node.left;
    node.left = newRoot.right;
    newRoot.right = node; 
    
    //计算size
    newRoot.size = node.size; //新根节点的节点数,等于原根节点的节点数
    node.size = (node.left != null? node.left.size : 0) + 
        (node.right != null? node.right.size : 0) + 1;
    return newRoot; //返回新的根节点
}

左旋转:(RR型)

跟上面的旋转一样,每个节点之间的指针指向,这里就不深究了,同学可以看看我前期的文章,有讲解。主要还是size的计算,同样的,T1~T3的节点数,都是没有变,所以不用管。只需计算原根节点和新根节点的节点数,切新根节点的节点数,就是原根节点的节点数。

//SBT左旋转操作
private SBTNode L_Rotate(SBTNode node) {
    SBTNode newRoot = node.right;
    node.right = newRoot.left;
    newRoot.left = node;
    
    //计算size
    newRoot.size = node.size; //继承原根节点的节点数
    node.size = (node.left != null? node.left.size : 0) + 
        (node.right != null? node.right.size : 0) + 1;
    return newRoot; //返回新的根节点
}

以上两种就是最基本的LL型和RR型,在SBT中,也是有LR型和RL型的,跟AVL中一样,不需要再写额外的代码,只需要调用两次左旋转或者右旋转。如下:

  • 假设A节点需要进行LR型的旋转,则只需A.left = 左旋转一次,再A= 右旋转一次。
  • 同理,假设B节点需要进行RL型的旋转,则只需B.right = 右旋转一次,再B = 左旋转一次。

具体在什么时候需要调用旋转函数,我们在接下来的Maintain方法里讲解。

img

二、Maintain方法

Maintain方法,就是SBT树,最核心的地方,也就是这个方法,能够保证一棵树具有平衡性的。

首先,我们需要知道,在什么时候,才需要进行旋转操作。在AVL中,是通过判断平衡因子来处理平衡性;在左倾红黑树中,则是根据每个节点颜色来处理平衡性。而SBT是:

首先看这张图:

image-20211023163650786

上面的四种旋转(LL、LR、RR、RL),分别对于一下四点:

  • LL型:T1的size > 二叔的size
  • LR型:T2的size > 二叔的size
  • RL型:T3的size > 大叔的size
  • RR型:T4的size > 大叔的size

以上四点,就是触发机关,只要满足这四点的某一个条件,则需要进行旋转操作。总结起来就一句话:叔叔的节点数,必须大于等于侄子的节点数。不然的话,就需要进行平衡调整。具体是为什么这种机制,就能够保证平衡性,请翻阅文章开头,陈启峰的那篇论文。

到目前为止,我们就知道了SBT的核心,代码写起来就简单多了,分别计算叔叔节点和侄子节点的节点数,if判断即可。值得注意的是,旋转操作之后,还需要递归调用Maintain方法。递归调用的对象,就是:哪个节点的子树被换了,则需要调用这个Maintain(新子树);举个例子:原先A节点的右子树是T2,旋转操作之后,A节点的右子树变为了T3,那么就需要递归调用Maintain(T3)

//Maintain方法
private SBTNode maintain(SBTNode cur) {
    if (cur == null) {
        return null;
    }

    //计算对应节点的节点数,null的话,就是0
    int leftSize = cur.left != null ? cur.left.size : 0;
    int rightSize = cur.right != null ? cur.right.size : 0;
    int leftLeftSize = cur.left != null ? (cur.left.left != null ? cur.left.left.size : 0) : 0;
    int leftRightSize = cur.left != null ? (cur.left.right != null ? cur.left.right.size : 0) : 0;
    int rightLeftSize = cur.right != null ? (cur.right.left != null ? cur.right.left.size : 0) : 0;
    int rightRightSize = cur.right != null ? (cur.right.right != null ? cur.right.right.size : 0) : 0;

    if (leftLeftSize > rightSize) { //LL型
        cur = R_Rotate(cur);//右旋
        
        cur.right = maintain(cur.right);
        cur = maintain(cur);
    } else if (leftRightSize > rightSize) { //LR型
        cur.left = L_Rotate(cur.left);
        cur = R_Rotate(cur);
        
        cur.left = maintain(cur.left);
        cur.right = maintain(cur.right);
        cur = maintain(cur);
    } else if (rightLeftSize > leftSize) { //RL型
        cur.right = R_Rotate(cur.right);
        cur = L_Rotate(cur);
        
        cur.left = maintain(cur.left);
        cur.right = maintain(cur.right);
        cur = maintain(cur);
    } else if (rightRightSize > leftSize) { //RR型
        cur = L_Rotate(cur);
        
        cur.left = maintain(cur.left);
        cur = maintain(cur);
    }
    return cur;
}

切记:递归调用Maintain时,一定是先调用cur的左右子树,然后才是调用cur。因为cur的处理,是依赖于他的左右孩子的。

可能有同学就会疑惑了,这么多递归函数,这个代码能跑完吗?

当然能够跑完,因为旋转操作之后,递归调用Maintain,能够在O(1)的时间内完成。

三、add方法和delete方法

Maintain方法之后,SBT的就算掌握大部分的代码了,其余的添加和删除代码,完全跟搜索二叉树的增加删除,一模一样。只是需要在添加删除之后,调用Maintain方法,用于调整平衡性即可。

//add方法
public void add(int val) {
    this.root = add(this.root, val);
}
//方法重载
private SBTNode add(SBTNode cur, int val) {
    if (cur == null) {
        return new SBTNode(val);
    } else {
        cur.size++; //沿途节点的节点数加1
        if (val < cur.val) {
            cur.left = add(cur.left, val);
        } else {
            cur.right = add(cur.right, val);
        }
    }
    //添加之后,需要调用maintain,进行平衡操作
    return maintain(cur); 
}

在SBT中,delete的时候,在大多时候,是不需要进行平衡调整的。why?

是因为没必要。假设当前SBT树的高度是H,现在删除一个节点后,高度可能还是H,又或者是H- 1。此时调整平衡与不调整平衡,都不影响后续的操作。比如删除之后,高度还是H,下次查找或者添加时,时间复杂度还是O(H),影响因素还是高度值。所以在一些比赛中,为了达到优化,在delete中,不进行平衡性的调整。而是把平衡性的调整,放在了add方法里。

//delete方法
public void delete(int val) {
    if (contains(val)) { //包含当前值得话,就递归删除
        root = delete(root, val);
    }
}

private SBTNode delete(SBTNode cur, int val) {
    cur.size--; //沿途节点的节点数-1
    if (cur.val > val) {
        cur.left = delete(cur.left, val);
    } else if (cur.val < val) {
        cur.right = delete(cur.right, val);
    } else {
        if (cur.left == null && cur.right == null) { //没有左右子树
            cur = null;
        } else if (cur.left == null && cur.right != null) { //只有右子树
            cur = cur.right;
        } else if (cur.left != null && cur.right == null) { //只有左子树
            cur = cur.left;
        } else {
            //左右两个子树都有的情况
            SBTNode pre = null;
            SBTNode des = cur.right; //向右子树查找最左节点
            des.size--;
            while (des.left != null) {
                pre = des;
                des = des.left;
                des.size--; //最终的des节点,会重新计算节点数
            }
            if (pre != null) {
                pre.left = des.right;
                des.right = cur.right; //将des节点,替换cur节点
            }
            des.left = cur.left;//连接原先的左子树
            des.size = des.left.size + (des.right != null ? des.right.size : 0) + 1;
            cur = des;
        }
    }
    
    //cur = maintain(cur); //平衡调整
    return cur;
}

特别需要注意的是,就是被删除节点的左右子树都不为null时,需要找一个节点来替换当前被删除的节点。一般都是在被删除节点的右子树上,查找最小(最左)的节点进行替换。这一点,也是搜索二叉树的删除操作,最容易出错的一个点,值得重点关注。

还有一些简单的方法没写,大家自主实现即可。比如contains、isEmpty等等。

好啦。本期更新就到此结束啦!SBT树学好之后,可以帮助你在一些算法题上得到更好的帮助,这种结构也算比较好改。可能根据SBT改其他的结构。总之,这种结构,值得好好学习。

我们下期见吧!!!
在这里插入图片描述

  • 20
    点赞
  • 32
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 12
    评论
首先,SizeBalancedTree是一种基于平衡树的数据结构,它的特点是除了具备平衡树的特性之外,还能够在节点中记录子树大小。这样可以方便地查询某个节点的排名或者寻找某个排名的节点。 以下是Java实现SizeBalancedTree的完整源码: ```java import java.util.Random; public class SizeBalancedTree { private static final int MAXN = 100005; private static final Random random = new Random(); static class Node { int key, size, cnt; Node ch[] = new Node[2]; Node(int key) { this.key = key; size = cnt = 1; } } private Node nullNode = new Node(0); private Node root = nullNode; private Node newNode(int key) { Node x = new Node(key); x.ch[0] = x.ch[1] = nullNode; return x; } private void pushup(Node x) { x.size = x.ch[0].size + x.ch[1].size + x.cnt; } private Node rotate(Node x, int k) { Node y = x.ch[k^1]; x.ch[k^1] = y.ch[k]; y.ch[k] = x; pushup(x); pushup(y); return y; } private Node maintain(Node x, boolean flag) { if (x == nullNode) return x; if (flag && x.ch[0].size > x.ch[1].size * 3) { x = rotate(x, 1); } else if (!flag && x.ch[1].size > x.ch[0].size * 3) { x = rotate(x, 0); } return x; } private Node insert(Node x, int key) { if (x == nullNode) return newNode(key); if (key == x.key) { x.cnt++; pushup(x); return x; } boolean k = key > x.key; x.ch[k] = insert(x.ch[k], key); pushup(x); return maintain(x, k); } private Node remove(Node x, int key) { if (x == nullNode) return x; if (key == x.key) { if (x.cnt > 1) { x.cnt--; pushup(x); return x; } if (x.ch[0] == nullNode && x.ch[1] == nullNode) { return nullNode; } else if (x.ch[0] == nullNode) { x = x.ch[1]; } else if (x.ch[1] == nullNode) { x = x.ch[0]; } else { boolean k = x.ch[0].size > x.ch[1].size; x = rotate(x, k ? 0 : 1); x.ch[k^1] = remove(x.ch[k^1], key); pushup(x); x = maintain(x, k^1); } } else { boolean k = key > x.key; x.ch[k] = remove(x.ch[k], key); pushup(x); x = maintain(x, k); } return x; } private Node getKth(Node x, int k) { if (x == nullNode) return nullNode; int rank = x.ch[0].size + 1; if (k < rank) { return getKth(x.ch[0], k); } else if (k > rank + x.ch[1].size) { return getKth(x.ch[1], k - rank - x.cnt); } else { return x; } } public void insert(int key) { root = insert(root, key); } public void remove(int key) { root = remove(root, key); } public int getRank(int key) { int rank = 0; Node x = root; while (x != nullNode) { if (key == x.key) { return rank + x.ch[0].size + 1; } else if (key < x.key) { x = x.ch[0]; } else { rank += x.ch[0].size + x.cnt; x = x.ch[1]; } } return rank; } public int getKth(int k) { Node x = getKth(root, k); return x.key; } public static void main(String[] args) { SizeBalancedTree sbt = new SizeBalancedTree(); sbt.insert(1); sbt.insert(2); sbt.insert(3); sbt.insert(4); sbt.insert(5); System.out.println(sbt.getRank(3)); // output: 3 System.out.println(sbt.getKth(3)); // output: 3 sbt.remove(3); System.out.println(sbt.getRank(3)); // output: 0 System.out.println(sbt.getKth(3)); // output: 4 } } ``` 在这里,我们定义了一个内部类Node来表示SizeBalancedTree的节点,包含了键值key、子树大小size和数量cnt。同时,我们也定义了一个nullNode,用于代表空节点。在这里,我们将SizeBalancedTree视作一个二叉搜索树,因此左子树的值都小于当前节点,右子树的值都大于当前节点。 在插入和删除节点的时候,我们需要维护平衡和子树大小,因此需要编写maintain和pushup方法。maintain方法用于在插入或删除节点后,对树进行平衡调整。如果左子树的大小大于右子树的大小的三倍,就进行右旋转;如果右子树的大小大于左子树的大小的三倍,就进行左旋转。pushup方法用于更新节点的子树大小。 在插入节点的时候,我们首先查找要插入的位置,如果该节点已经存在,则只需要增加它的数量即可。如果要插入的节点不存在,则将其插入到相应的位置,并对树进行平衡调整。 在删除节点的时候,我们同样需要查找要删除的节点,如果该节点数量大于1,则只需要减少其数量即可。如果要删除的节点数量为1,则需要考虑该节点的子树情况。如果节点没有子树,则直接删除即可;如果节点只有一个子树,则将子树接到该节点的父节点上;如果节点有两个子树,则选择左子树的大小大于右子树的大小,则在左子树中找到最大值,将其替换为当前节点,并从左子树中删除该最大值。 在查询排名和寻找第k小的节点时,我们需要使用getRank和getKth方法。getRank方法首先查找要查询的节点,如果该节点存在,则返回左子树的大小加上1,否则继续向下查找。getKth方法则根据当前节点的左子树大小和数量,以及右子树的大小,来判断第k小的节点在哪个子树中,然后递归到相应的子树中查找。 最后,我们在main方法中进行测试,插入一些节点并查询排名和第k小的节点。
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

听雨7x

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值