本文主要介绍以下五个方面,通过本文可以大致掌握红黑树的基础知识,并且有助于你在现实场景中根据需要选择合适的数据结构。
-
什么是红黑树?
-
红黑树与哈希表的区别
-
如何构造红黑树?
-
红黑树在DNS数据存储中的应用
-
其他使用场景
1. 定义
红黑树本身也是一个二叉树结构,对于普通二叉树结构,其本身的树形结构依赖于插入数据的顺序,比如我们插入一个数组(1,2,3,4,5,6)到二叉树中, 普通二叉树的结构如下面所示:
这种情况下,二叉树和链表等价,执行效率也相同,我们使用二叉树的目的就是获得O(log(N))的性能提升,这种情况下只能达到O(N),显然不是我们所期望的。为了实现更加平衡(左右基本高度差不多)的二叉树结构,当前主要的两种平衡树结构是红黑树和AVL树,其中AVL树因其对平衡性要求更高,实现上会更复杂一些,因此在插入效率上相对红黑树更慢一些。这里我们先来看一下一个红黑树的五大特性来了解一下什么样子的二叉树是红黑树。
红黑树五大特性分别指的是:
- 每个节点只能是红色或者黑色
- 根节点是黑色
- 叶子节点是黑色
- 每一个红色节点的子节点是黑色
- 从根节点到任意的叶子节点包含相同的黑色节点数量(简称为:黑高)。
下面就是一个典型的红黑树,为了节省空间占用,由于叶子节点不会实际存储数据,所有的叶子节点设置为为一个即可。而且根节点的父节点也指向这个节点。 大家可以逐一比对上面的条件,可以看到所有条件均满足要求。
在满足上面的五个条件情况下,可以保证在n个节点的红黑树中高度最多为2lg(n+1)! ,在设计节点数据结构的时候一般均包含有五个基本属性,分别是: 代表颜色的Color, 代表左右节点的Left和Right 代表父节点的Parent 以及代表 键的Key, 假如需要按照KV形式存储数据,还可以包含对应的数据Data。
节点的颜色与实际存储的数据没有关系,只是用于平衡使用。 当一个节点增加或者删除的时候, 需要根据实际的情况进行重新平衡来满足上面的两个条件,其中颜色用于确定执行平衡时候需要的操作情况。
我们可以定义每个节点的数据结构如下面的代码所示, 由于颜色只有红黑两种,我们可以使用一个Bool类型来存储即可,当然如果为了达到最佳存储效果,可以使用一个比特位即可,而不是一个字节。
type Node struct{
Left *Node
Right *Node
Parent *Node
Color bool
Key string
Value interface{}
}
2. 与哈希表的区别
红黑树相对于哈希表的优点在于,尽管都可以做到存储键值,但是红黑树具有一些自己的特性:
- 具有顺序性,可以按照键值进行元素的顺序遍历,如果一个数据结构需要排序元素,那么哈希表基本上会被直接淘汰掉。另外这里的顺序性实际包含很多功能实现,比如范围选择,最大最小值,迭代元素等。
- 存储数据方面,红黑树可以做到稳定的增长,但是鉴于哈希表本身的实现,往往需要定期的复制元素到一个更大或者更小的容器,才能实现容量动态的变化。
- 哈希表存储数据需要计算哈希值,尽管O(1)操作,但是性能完全取决于哈希函数实现。在红黑树中这些存储数据,需要的是键与键之间实现比较即可, 另外哈希表还需要处理好可能出现的碰撞问题。
3. 设计红黑树
我们使用下面的初始化函数来创建一个红黑树,RBTree结构本身包含有一个根节点,一个总的叶子节点以及一个计数器统计当前元素数量。
type RBTree struct {
root *Node
leaf *Node
count uint
}
// NewRBTree create a new rbtree
func NewRBTree()*RBTree{
node := &Node{
Left: nil,
Right: nil,
Parent: nil,
Color: ColorBlack,
Key: "",
Value: nil,
}
return &RBTree{
root: node,
leaf: node,
count: 0,
}
}
3.1 旋转操作
为了维持节点在插入和删除的时候保持红黑树的四大规则,在节点插入后需要执行一定的左旋和右旋来完成对于树的修改,使其能够继续满足作为一个红黑树的要求。左旋和右旋节点的方式如下面所示,其实就是要保证作为一个二叉树需要满足的条件,对于旋转后的abc位置进行调整。
如果用程序代码来表示的话,对于左旋的方式如下:
func (rbtree *RBTree) leftRotate(x *Node){
// 对于x执行左旋转的时候必须保证x存在右节点,参考实例图
if x.Right == rbtree.NIL{
return
}
y := x.Right
x.Right = y.Left // y 的左子树现在将成为 x的右子树
if y.Left != rbtree.NIL{
y.Left.Parent = x
}
y.Parent = x.Parent // x 的父元素现在成为y的父元素
// 替换x到y需要处理其父元素中的位置
if x.Parent == rbtree.NIL{
rbtree.root = y
}else if x == x.Parent.Left{
x.Parent.Left = y
}else{
x.Parent.Right = y
}
y.Left = x // x现在成为y的左子节点
x.Parent = y
}
右旋操作同上,对称进行处理即可,这里不再给出相关代码。
3.2 插入操作
了解了旋转后我们可以直接进行红黑树的插入操作了,相对于普通的二叉树的插入,其实多了着色和重新调整的步骤。对于新插入的节点,我们找到其位置后,对其进行着色,设置颜色为红色,然后进行重新调整操作。
func (rbtree *RBTree) Insert(z *Node) *Node{
x := rbtree.root
y := rbtree.NIL
for x != rbtree.NIL{
y = x
if z.Key < x.Key{
x = x.Left
}else if z.Key > x.Key{
x = x.Right
}else{
return x
}
}
z.Parent = y
if y == rbtree.NIL{
rbtree.root = z
}else if z.Key < y.Key{
y.Left = z
}else {
y.Right = z
}
z.Left = rbtree.NIL
z.Right = rbtree.NIL
z.Color = ColorRed
rbtree.count ++
rbtree.insertFixUp(z)
return z
}
前面主要是通过循环来获取对应的二叉树的节点,定位最终获得数据位置比如下面的示例图,我们在一个红黑树中插入数据(4),插入后通过上述代码找到插入的位置并着色为红色。 这样可能破坏的规则只可能是:
- 根节点必须是黑色(如果插入的节点为根节点则违背该规则)
- 红色节点的子节点必须是黑色(如果插入的节点父节点为红色则违背该规则)
在上面的实例中,插入节点完成后由于节点5作为其父节点为红色,而节点4也是红色,违背红黑规则因此需要做调整。调整的方式。这里存在三种情况 分别对应了上面这个节点插入后调整的三个步骤:
- 情况一: 节点的叔叔节点为红色(上述例子中的8节点)
- 情况二: 节点的叔叔节点为黑色,且节点本身是一个右子节点
- 情况三: 节点的叔叔节点为黑色, 且节点本身是一个左子节点
首先对于上面的实例按照情况一的处理方式,先将元素进行重新着色,叔叔节点和父节点重新设置为黑色,爷爷节点设置为红色也就是图中z’节点,执行完成后发现仍旧不满足条件存在两个红色节点相邻的情况,但处于情况二,因此我们可以继续按照情况二的方式进行处理。
情况二的调整直接进行左旋转即可,旋转后符合情况三的说明,我们统一按照情况三来处理。情况三下面需要对于其中的z’节点也就是7节点进行右旋操作完成后检查是否满足条件。
插入操作总计的时间复杂度为O(lgN),其中查询定位元素的操作时间最多为O(lgN), 而执行调整的时间最多为O(lgN),总计的时间也是O(lgN).
整个修复的代码如下:
func (rbtree *RBTree) insertFixUp(z *Node) {
for z.Parent.Color == ColorRed {
if z.Parent == z.Parent.Parent.Left {
y := z.Parent.Parent.Right
if y.Color == ColorRed {
z.Parent.Color = ColorBlack
y.Color = ColorBlack
z.Parent.Parent.Color = ColorRed
z = z.Parent.Parent
} else {
if z == z.Parent.Right {
z = z.Parent
rbtree.leftRotate(z)
}
z.Parent.Color = ColorBlack
z.Parent.Parent.Color = ColorRed
rbtree.rightRotate(z.Parent.Parent)
}
} else {
y := z.Parent.Parent.Left
if y.Color == ColorRed {
z.Parent.Color = ColorBlack
y.Color = ColorBlack
z.Parent.Parent.Color = ColorRed
z = z.Parent.Parent
} else {
if z == z.Parent.Left {
z = z.Parent
rbtree.rightRotate(z)
}
z.Parent.Color = ColorBlack
z.Parent.Parent.Color = ColorRed
rbtree.leftRotate(z.Parent.Parent)
}
}
}
rbtree.root.Color = ColorBlack
}
3.3 删除节点
红黑树中节点的删除分为两个阶段,第一个阶段基本和二叉树删除的相似,第二个阶段则进行颜色和位置的调整。针对第一个阶段二叉树的节点删除又分为三类情况:
- 第一种情况:节点不包含孩子节点
- 第二种情况:节点包含一个孩子节点
- 第三种情况:节点包含两个孩子节点
节点不包含孩子节点的时候,我们可以直接将该节点删除掉即可,节点包含一个的子节点的时候我们将其作为与父节点直接相连,节点包含两个子节点的时候,则需要在其右子树中找到其继任者重新完成父子节点元素的调整。不同于二叉树的删除,我们在红黑树删除中需要根据颜色来判断是否需要进行处理,如果待删除的元素颜色为黑色或者待移动的颜色为黑色,可能会破坏原有的规则,需要额外的处理。
第一种情况最简单,直接删除即可,我们看下第二种情况,这里不管左还是右节点为空,都可以直接将另一方提升到父节点即可。示意图如下面所示:
对于第三种情况又可以分为两类,第一类为右子树中的左节点为空,则可以直接将其右字数提升到父节点,而不需要其他的操作.
而如果不是空节点的话,则需要考虑多个节点的状态,如下面的操作,z的后继节点肯定是右子树的最小节点,至少包含一个左节点为nil(否则该节点的左节点才是最小节点), 这时候需要将其提升为当前节点,假如包含右节点,则右节点上移即可。
该步骤执行的代码如下面所示:
func (rbtree *RBTree) delete(key string) *Node {
z := rbtree.Search(key)
if z == nil{
return rbtree.NIL
}
ret := &Node{rbtree.NIL, rbtree.NIL, rbtree.NIL, z.Color, z.Key, z.Value}
var y *Node
var x *Node
if z.Left == rbtree.NIL || z.Right == rbtree.NIL {
y = z
} else {
y = rbtree.successor(z)
}
if y.Left != rbtree.NIL {
x = y.Left
} else {
x = y.Right
}
x.Parent = y.Parent
if y.Parent == rbtree.NIL {
rbtree.root = x
} else if y == y.Parent.Left {
y.Parent.Left = x
} else {
y.Parent.Right = x
}
if y != z {
z.Key = y.Key
}
if y.Color == ColorBlack {
rbtree.deleteFixUp(x)
}
rbtree.count--
return ret
}
当我们执行的过程中,只有发现被删除的节点或者被移动的节点(y)为黑色的时候才会执行修复操作,只有这种情况才会导致红黑树规则出现问题。第二阶段进入颜色调整和旋转的步骤,也就是上面deleteFixUp函数所表示的过程,主要为了修复已经遭受破坏的红黑树规则。
这里又分为四种情况,循环依次检测属于下面的哪种情况,一旦检测发现x不在是黑色或者x为根节点,则退出循环即可:
第一种情况,X的兄弟w节点为红色如下面的图例所示,x代表已经完成删除后补充到原有位置的节点(可以是子节点或者后继节点两种),由于删除了一个黑色的节点,导致B节点左右不再保持平衡,因此我们需要执行一次颜色的转换,之后再进行一次左旋。从而变为下面的第二,三或四中情况
第二种情况, X的兄弟w节点为黑色,且其两个子节点也是黑色: 如下面图例所示, 其中绿色代表可以为红色或者黑色节点。这是唯一一个需要重复不断执行的情况,执行后x上移一层。假如从情况一转入,则由于x本身的父元素为红色,因此直接满足退出循环的条件,退出即可。否则继续循环执行检查。
第三种情况, X的兄弟w节点为黑色,且其两个子节点中左子节点为红色,右子节点为黑色:这里需要交换一次C和D的颜色,并进行右旋转。此时转换为情况四处理
第四种情况, X的兄弟w节点为黑色,且w两个子节点中右子节点为红色: 此时将通过修改颜色以及对于B执行左旋转。
基本上通过每次循环检测是否属于上述的几种情况,进行对应的处理,从而保证最终到达根节点或者最终x节点变为了红色节点。修复操作需要通过循环进行不断上移指针,但是最多上移logN次,最多执行3次循环即可终止。加上最开始查询的时间O(logN), 因此总的时间复杂度为O(logN). 整个的代码较多,此处不再给出。
3.4 辅助操作
对于红黑树我们经常遇到的一些操作包括查询最大值,查询最小值,搜索节点,获取下一个节点,获取上一个节点等操作,这些与常规的二叉树相同,所以比较简单这里给出查询以及寻找下一个节点操作的代码:
func (rbtree *RBTree)Search(key string) *Node{
p := rbtree.root
for p!=rbtree.NIL{
if p.Key < key{
p = p.Right
}else if p.Key > key{
p = p.Left
}else {
return p
}
}
return nil
}
查询下一个节点的操作函数Successor, 判断是否存在右节点,如果存在返回右节点的最小节点,如果不存在的话,则查找父节点的右节点并进行循环查找直到找到该节点返回。
func (rbtree *RBTree) successor(x *Node) *Node {
if x == rbtree.NIL {
return rbtree.NIL
}
if x.Right != rbtree.NIL {
return rbtree.min(x.Right)
}
y := x.Parent
for y != rbtree.NIL && x == y.Right {
x = y
y = y.Parent
}
return y
}
4. 使用红黑树存储DNS数据
在DNS软件中,可以使用DNS的名称作为存储的键,对应存储的数据可以是任何的类型。红黑树算法与DNS本身的树形结构可以很容易的结合在一起。对于具有相同后缀的名称,存储的时候进行独立的存储,可以使用单独的红黑树完成,并通过指针进行串联在一起。
au
com
de
org
ftp.example.net
www.example.net
bbs.vip.example.net
admin.vip.example.net
login.vip.example.net
我们对于具有相同后缀的域名存储在一个独立的红黑树结构中,这里我们使用了三个独立的红黑树,分别如下面所示:
- 第一个红黑树用来存储au, com, de, org和example.net。因为这里并没有单独的net所以这里将example,net合并到一起。
- 第二个存储具有相同后缀的example.net的相关信息。vip作为一个独立的子域切分出来,使用指针进行关联。
- 第三层存储vip.example.net的相关信息,包含对应的三个子域名
这里可以看到,假如一个节点只有一个特定的标签的时候,多个标签可以合并在一起,减少存储空间和提升查询索引路径。比如这里的example.net, 这里使用红黑树的另外一个原因是,有序性。当我们对于一个区域的域名进行排序的时候(比如NSEC中使用), 可以直接进行二叉树遍历操作。
值得注意的是,我们这里尽管具有example.net但是由于本身并没有存储任何数据,所以实际认为该域名不存在,当我们插入example.net的数据的时候,可以成功的插入节点数据到该节点。
假如我们插入一个新的节点net,则插入后的节点将会存储在第一层,example.net作为其一个子域只保留example的标签,由于不存在其他任何的同级别的net子域名,example作为第二层存储,并通过指针与上面和下面建立关联关系。实际存储的结果如下面:
对于删除节点,也需要考虑其中的重组关系,比如对于最后的三个域名如果删除掉两个(admin, login),这时候导致vip域名仅仅包含一个子域名,因此它在存储的时候会按照聚合规则上移到第三层中,也就是变成了[ftp, www, bbs.vip]三个节点。
搜索域名的时候,一般会返回三个结果,(1)找到对应的Key,(2) 找到了父级域名,(3)找不到任何与之关联的数据。对于第二种结果一般是查询了一个子域名不存在的时候返回,比如查询abc.exmaple.net,元素不存在,此时返回example.net的信息
对于实际的存储DB设计可以参考如下的结构定义, 相对于传统的红黑树,增加一个down指针,以及一些额外的控制属性,数据存储的Data指针指向DNS数据集合 用于存储多种类型的RRSet数据,比如A类型,NS类型等等,这里设计细节不再给出。
type Node struct{
Key string
Left *Node
Right *Node
Parent *Node
Down *RBTree
Data *RRData
Color bool
}
当然数据结构设计与实际可用仍旧存在一定的差距,比如如何实现并发的读写,假如DNS数据进行更新处理,那如何保证数据的并发一致性。当前多核心处理的情况下, 使用读写锁可以保证并发的读,以及串行的序列化的写入数据。但是这个方式最大的问题是,当写入的时候,读取会被锁死。考虑到执行一些IXFR操作的时候,可能会有很长的写操作窗口,导致读取不了因此是不可接受的。
为了解决这个问题,一种方式是控制数据的版本,一个数据可以可以具有多个读版本,Bind软件在实现上就是采用多版本控制来完成对于数据的并发读写访问情况。
5. 红黑树的其他应用
Linux的完全公平调度器(英语:Completely Fair Scheduler,缩写为CFS) ,使用红黑树来完成进程的调度执行,之前使用的是一个类似于优先队列的数据结构完成调度(O(1)调度器)。这种调度器执行方式每次选择最左边的节点来执行任务,任务执行完成则删除节点,如果任务执行超出一定时间,则停止任务,重新插入节点(基于其执行的时间)。整体的调度复杂度为O(logN)。
C++标准库STL中的Set和Map以及Java程序中的HashMap实现均为红黑树,作为底层数据结构实现,其实很多人都在不知不觉的使用红黑树去存储数据。
大家如果对于算法感兴趣,可以关注我的微信公众号: 银河系算法指南,我会定期发布一些算法相关的技术文章,感谢大家的阅读。