大家好,今天给大家介绍一个很厉害的数据结构,它的名字就很厉害,叫SB树,业内大佬往往叫做傻叉树。这个真不是我框你们,而是它的英文缩写就叫SBT。
SBT其实是英文Size balanced tree的缩写,翻译过来可以理解成节点平衡树,这是大牛陈启峰在高中参加算法竞赛时期发明的数据结构。不得不说大牛实在是大牛,在高中的时候就已经难以望其项背了。
二叉搜索树
SBT本质上是一棵二叉搜索树,我们之前介绍过二叉搜索树,但是从来没有真正实现过。我们今天先来复习一下二叉搜索树的概念。
对于一棵二叉树而言,如果它满足对于每一个节点都有,以它右孩子构成的右子树中的所有元素大于它,左孩子为根构成的左子树所有元素都小于它,那么这样一棵二叉树就可以被认为是一棵二叉搜索树。比如下图,就是一棵经典的二叉搜索树。
二叉搜索树有什么好处呢?我们观察一下上图,其实很容易发现,当我们想要查找某个元素是否存在于二叉树当中的时候,我们可以利用刚才提到的性质进行快速地查找。比如我们想要判断15这个元素在不在树当中,我们首先和根节点的11进行判断,由于15大于11,如果15存在一定在11的右子树。所以我们移动到它的右子树16上,继续判断。由于15小于16,所以15要存在一定在它的左子树当中,以此类推,我们只需要经过最多4次比较,就可以找到15。
对于一棵二叉树而言,如果它是完美二叉树,每一层的元素都是满的。我们假设它的层数是k,那么它一共可以存放
个元素。反过来说,如果一个完美二叉树当中存在n个元素,那么它的层数应该是
。换句话说,我们只需要
次操作就可以判断元素是否存在。
但是这是完美的情况,大多数情况下普通方法构建出来的二叉搜索树并不是完美的,其中可能存在倾斜。在极端情况下,甚至可以蜕化成链表。比如这样:
正因为如此,所以我们才需要设置一些机制来保证二叉搜索树的平衡性。平衡性有了,二叉搜索树的查找效率才能得到保障。
关于让二叉树维持平衡的方法,现在有很多种,比如大名鼎鼎的红黑树、AVL树等等,其实本质上都是二叉搜索树。只是它们维护二叉树平衡性的方法不同。今天我们介绍的SBT同样也是一种自平衡二叉搜索树的实现方法,它的核心机制是旋转。
旋转
旋转是二叉搜索树维持平衡的常用机制,说是旋转,其实可以理解成树上的某些节点之间互换位置。当然位置不能随意更换,我们必须要保证更换位置之后不会破坏二叉搜索树的特性,同时让二叉树整体更加趋向于平衡。
旋转分为两种,一种是左旋,另外一种是右旋。我们一个一个来介绍。
左旋
左旋可以理解成逆时针旋转,我们来看一个例子:
是不是看着有点蒙,不知道这个旋转怎么实现的?这里我有一个方法,我们可以想象一下,我们把左边的二叉树以B为轴逆时针旋转90度,之后得到的结果是这样:
我们可以发现B节点拥有三个孩子节点了,这显然就违反了二叉树的规则。那么我们就需要断掉它的一个孩子,重新分配。那么为什么重新分配是把E分配给D而不是把C分配给E或者是D呢?
首先我们观察一下就知道,C子树的元素都是比B要大的,那么把它分配给D显然不合适。对于把C分配给E,看起来似乎没有问题,但其实仔细想下也会发现不妥。不妥的原因在哪里?不妥的地方在于我们不知道E节点的情况,如果E没有右子树还好,如果E存在右子树,那么怎么处理?
所以我们只有一种解法,就是把E分配给D做右子树,因为D原先的右子树是B,旋转之后一定就不存在右子树了。
我们试着写出伪代码:
代码语言:javascript复制def left_rotate(u):
ur = u.right
u.right = ur.left
ur.left = u
u = ur
看起来旋转一通操作猛如虎,但是写成代码也就这么几行。
右旋
左旋理解了,右旋也就好办了,实际上右旋就是左旋的逆操作。左旋刚才是逆时针的旋转,那么右旋自然就是顺时针的旋转。这个不需要死记,你只需要记住是向左旋转或者是向右旋转就可以了。
对于这样一棵子树我们要进行右旋:
之前左旋的时候我们是以右孩子作为旋转轴,那么右旋自然就要以左孩子作为旋转轴了。旋转90度之后,我们得到了这样的结果:
同样我们发现A节点的孩子数量超过了限制,我们需要断开重连。根据刚才一样的判断方法,我们可以发现只有一种重连的方式,就是把C节点作为D的左孩子。因为D的左孩子原本是A,由于旋转,D没有左孩子了,这样连接一定不会引起冲突和问题。最终,我们得到的结果就是:
同样,我们可以写出伪代码:
代码语言:javascript复制def right_rotate(u):
ul = u.left
u.left = ul.right
ul.right = u
u = ul
旋转很好理解,但是我们为什么要旋转呢?我们观察一下会发现旋转最重要的功能就是改变了一些节点的位置,这样可以扭转一些不平衡的情况。
比如在右旋之前,可能E或者C子树当中元素过多,引发了不平衡。当我们旋转之后,我们把E和C分别放到了树的两边。这样旋转之后的树距离平衡也就更接近了一些,但是如何严格地保证完全达到完美平衡呢?这里就需要引入本数据结构的核心概念——size balance了。
Size Balance
前面我们也说过了,实现二叉树平衡的方法有很多,同样定义一棵二叉树是否平衡的标准也有很多。比如在AVL树当中是通过左右两棵子树的树深来判断的,两边的树深差不超过1,那么就认为是平衡了。而在SBT当中,我们对二叉树平衡的定义是基于节点数量的,也就是Size。
这里呢,我们需要定义一下size的概念,对于树上某个节点而言,它的size指的是以它为树根的子树当中节点的数量。接下来,我们就要结合size来讨论平衡树不平衡的情况以及我们让它变得平衡能够使用的办法。
首先,我们先来看看我们认为平衡树达到平衡的条件。这里呢我们一共有两个条件,这两个条件都是对称的。我们先来看下一个一般意义上的平衡树。
我们观察一下上面的图,来思考一下,什么情况下可以认为这棵树达成平衡了呢?是L.size == R.size吗?这其实是有问题的,因为可能L的节点都落在了A上,R的节点都落在了C上。这同样是不平衡的,但这种情况除非我们继续往下递归,否则很难识别。
所以这里换了一种方法,我们判断R和AB的size的关系,以及L和CD size的关系。我们要求L.size >= C.size, D.size, R.size >= A.size, B.size。
也就是说高层的size一定要比底层来的大,这两个条件都很直观,也都很好记。这两个条件理解了,我们再去分析它不平衡的条件就很清楚了。一共有四种情况:
- Size of A > size of R
- Size of B > size of R
- Size of C > size of L
- Size of D > size of L
在这4种情况当中,1和3是对称的,2和4也是对称的。所以我们只需要着重分析其中两种就可以了,另外两种可以通过对称性得到。
由于我们使用递归来维护树的平衡性的时候,是从底往上的。因此我们可以假设ABCDLR这六棵子树都是平衡的,这样可以简化我们的分析。我们假设我们现在有了一个函数叫做maintain,它可以将一棵不平衡的子树旋转到平衡状态。我们先假设已经有了这个函数,再去看看它里面需要实现哪些逻辑。
接下来我们来看看上面四种情况如果不满足的话,我们应该怎么处理。
情况1
情况1当中A.size > R.size,也就是A当中的节点比较多,为了能够趋近于平衡。我们将原子树右旋,得到:
我们右旋之后,A的层级向上提升了一层。我们观察一下旋转之后的结果,会发现R子树的平衡性得到了保持,没有被破坏,A子树本身就是平衡的。所以旋转之后,还有还有两个节点的平衡性没有保证。一个是T节点,一个是L节点。那么,我们递归调用maintain(T)和maintain(L)即可。
我们写成伪代码就是:
代码语言:javascript复制right_rotate(T)
maintain(T)
maintain(L)
情况2
下面我们来看情况2,也就是B.size > R.size的情况。和上面一种情况类似,由于B的节点比较多,我们希望能够把B往上提。但是B节点在内部,我们无论对L左旋还是右旋都
既然对T旋转不行,那么我们可以对L进行旋转啊,这样不就可以影响到B节点了吗?为了展示地更加清楚,我们把B子树的孩子节点也画出来。
接着我们对L进行左旋,这样可以把B往上提升一层,得到:
虽然我们把B往上提了一层,但是对于T子树而言,左重右轻的局面仍然没有改变。要想改变T的不平衡,我们还需要对T进行右旋,得到:
对于这个结果而言,除了L、T和B这三棵树而言,其他所有的子树都满足平衡了。所以我们按顺序维护L、T和B即可。
我们写成代码就是:
代码语言:javascript复制left_rotate(L)
right_rotate(T)
maintain(L)
maintain(T)
maintain(B)
情况3和情况1刚好相反,我们把左旋和右旋互换即可,情况4和情况2也一样。
所以我们可以写出所有的情况来了:
代码语言:javascript复制def maintain(t):
if t.left.left.size > t.right.size:
right_rotate(t)
maintain(t.right)
maintain(t)
elif t.left.right.size > t.right.size:
left_rotate(t.left)
right_rotate(t)
maintain(t.left)
maintain(t.right)
maintain(t)
elif t.right.right.size > t.left.size:
left_rotate(t)
maintain(t.left)
maintain(t)
else:
right_rotate(t.right)
left_rotate(t)
maintain(t.left)
maintain(t.right)
maintain(t)
这里的四种情况罗列出来当然就可以,但是有很多代码重复了,我们可以设置一个flag标记,表示我们判断的是左子树还是右子树。这样我们可以把一些情况归并在一起,让代码显得更加简洁:
代码语言:javascript复制def maintain(t, flag):
if flag:
if t.left.left.size > s.right.size:
right_rotate(t)
elif s.left.right.size > t.right.size:
left_rotate(t.left)
right_rotate(t)
else:
return
else:
if t.right.right.size > t.left.size:
left_rotate(t)
elif t.right.left.size > t.left.size:
right_rotate(t.right)
left_rotate(t)
else:
return
maintain(t.left, False)
maintain(t.right, True)
maintain(t, False)
maintain(t, True)
这里其实我们省略了maintain(t.left, True)和maintain(t.right, False)这两种情况,这两种情况我们稍微分析一下会发现其实已经被包含了。
我们搞清楚了这些之后,还有一个疑问没有解开,就是为什么旋转操作可以让二叉树趋向于平衡呢,而不是无穷无尽地旋转下去呢?
尽管我们已经知道了不会,但是还是想要来证明一下。我们以情况一举例,我们右旋之后的结果是:
我们对比一下旋转之前的结果,会发现T、R、C、D的高度增加1,而L和A的高度减小了1。由于A.size > R.size,A的size最小等于R.size 1,也就刚好是T加上R子树的size。这两个部分一增一减互相抵消之后,至少还有L这个节点的深度减小了1。也就是说旋转之后的所有元素的深度和是在减小的,不仅是情况1如此,其他的情况也是一样。
既然深度和是在减小的,那么maintain这个操作就一定不是无限的。并且它也的确可以让树趋向于稳定,因为完美平衡的情况下所有元素的深度和才是最小的。
实现细节
到这里我们就已经把SBT的原理都讲解完了,但是还存在一些细节上的问题。由于我们是使用Python是引用语言,所以当我们在旋转的时候进行赋值只是指针之间改变了引用的目标, 并没有实际对原本的结构进行改变。
我们来看下刚才上面的伪代码:
代码语言:javascript复制def right_rotate(u):
ul = u.left
u.left = ul.right
ul.right = u
u = ul
由于我们把u的左孩子右旋,代替了u本来的位置。当我们执行u = ul的时候,只是u这个指针改变了指向的位置。至于原本的数据结构当中的内容,并没有发生变化。因为u、ul这些变量都是临时变量,都是拷贝出来的,我们随便更改,也不会影响类当中的值。
在C 当中我们可以传入引用,这样我们修改引用就是修改原值了。但是Python当中不行,想要解决这个问题,只有一种方法,就是对于每个节点我们都记录它父节点的位置。当我们旋转完了之后,我们需要去更新它父节点中储存的孩子节点的地址,这样的话,我们就不只是局部变量之间互相修改了,就真正落实到了数据结构上了。
我们以右旋为例:
代码语言:javascript复制 def reset_child(self, node, child, left_or_right='left'):
"""
Since Python pass instance by reference, in order to rotate the node in tree, we need to reset the child of father node
Otherwise the modify won't be effective
"""
if node is None:
self.root = child
self.root.father = None
return
if left_or_right == 'left':
node.lchild = child
else:
node.rchild = child
if child is not None:
child.father = node
def rotate_right(self, node, left_or_right='left'):
"""
Right rotate operation of Treap.
Example:
D
/
A B
/
E C
After rotate:
A
/
E D
/
C B
"""
father = node.father
lchild = node.lchild
node.lchild = lchild.rchild
if lchild.rchild is not None:
lchild.rchild.father = node
lchild.rchild = node
node.father = lchild
# 要重新reset父节点的孩子节点,这样整个改动才是真的生效了。
self.reset_child(father, lchild, left_or_right)
# 更新节点买的size
node.size = node_size(node.lchild) node_size(node.rchild) 1
lchild.size = node_size(lchild.lchild) node_size(lchild.rchild) 1
return lchild
由于每个节点的孩子节点有两个,所以我们还需要一个变量来记录我们当前要改变的节点究竟是它父亲节点的左孩子还是右孩子,这样我们才能在reset的时候正确地修改。不仅是旋转如此,删除和添加也是一样的,我们都需要修改父节点当中的信息,否则我们修改来修改去,改的都只是局部变量而已。
另外一点是我们旋转之后还需要更新每个节点的size,这个逻辑如果忘记了,那么后面的maintain就无从谈起了。
最后我们思考一个问题,我们在什么情况下需要maintain操作呢,也就是什么情况下会破坏树的平衡性呢?其实很简单,就是当树中的元素数量发生改变的时候。无论是增多或者是减少都有可能破坏树的平衡。所以我们在完成了插入和删除之后都需要maintain一次树的平衡。
论文当中对于maintain这个操作还有详细的分析,可以证明maintain的均摊复杂度是
,也就是常数级的操作,这也是为什么SBT运行效率高的原因。
论文的最后还附上了SBT和其他常用平衡树数据结构的比较,我们可以看出SBT无论是运行效率还是质量都是其中佼佼者。
最后,我们聊一聊SBT的实现。关于SBT这类复杂数据结构的实现还是C 要更方便一些,主要原因就是因为C 当中带有引用和指针的传递操作。我们可以在函数内部修改全局的值,而Python当中则不行。参数传递默认传递的是拷贝,我们在函数内部赋值并不会影响结果。所以如果使用Python实现会更加复杂一些,并且需要一些修改父节点的额外操作。
因此网上关于SBT的Python实现非常非常少,我有自信说我的代码目前是我能找到的实现得比较好的一个。相关代码很长,足足有五百多行,不适合放在文章当中。 - END -