风和日丽 2019-06-28
BST存在的主要问题是,数在插入的时候会导致树倾斜,不同的插入顺序会导致树的高度不一样,而树的高度直接的影响了树的查找效率。理想的高度是logN,最坏的情况是所有的节点都在一条斜线上,这样的树的高度为N。
基于BST存在的问题,一种新的树——平衡二叉查找树(Balanced BST)产生了。平衡树在插入和删除的时候,会通过旋转操作将高度保持在logN。其中两款具有代表性的平衡树分别为AVL树和红黑树。AVL树由于实现比较复杂,而且插入和删除性能差,在实际环境下的应用不如红黑树。
红黑树(Red-Black Tree,以下简称RBTree)的实际应用非常广泛,比如Linux内核中的完全公平调度器、高精度计时器、ext3文件系统等等,各种语言的函数库如Java的TreeMap和TreeSet,C++ STL的map、multimap、multiset等。
RBTree也是函数式语言中最常用的持久数据结构之一,在计算几何中也有重要作用。值得一提的是,Java 8中HashMap的实现也因为用RBTree取代链表(当链表长度8时),性能有所提升。
RBTree的定义如下(3结点+2路径,插入时受到威胁就是2路径):
定义中的这些约束确保了红黑树的关键特性:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。结果是这个树大致上是平衡的(平衡的复杂度为O(lgN),简略可以说红黑树也是O(lgN)),严格的所需要数学证明
。因为操作比如插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树。
要知道为什么这些性质确保了这个结果,注意到性质4导致了路径不能有两个毗连的红色节点就足够了。最短的可能路径都是黑色节点,最长的可能路径有交替的红色和黑色节点。因为根据性质5所有最长的路径都有相同数目的黑色节点,这就表明了没有路径能多于任何其他路径的两倍长。
RBTree在理论上还是一棵BST树,但是它在对BST的插入和删除操作时能通过旋转操作来保持树的平衡,即保证树的高度在[logN,logN+1](理论上,极端的情况下可以出现RBTree的高度达到2*logN,但实际上很难遇到)。这样RBTree的查找时间复杂度始终保持在O(logN)从而接近于理想的BST。RBTree的删除和插入操作的时间复杂度也是O(logN)。由于RBTree结构上就是一颗二叉查找树,所以其查找操作就是BST的查找操作。所以就是RBTree的查找、插入、删除在最坏情况下的复杂度都是O(lgN)的。
关于红黑树的旋转还好在算法导论上有伪代码,注释讲解也很通透,没什么问题。网上关于红黑树的插入和删除讨论很多,但其中各种错误都有的,把左倾红黑树当成原版红黑树也有之,每个人都有各种不同的情况分析你分三种我分五种这样,也让人看了心烦,很难找到一份靠谱或者说权威的。
美团技术团队知乎上那篇红黑树深入剖析及Java实现里面一些点总结的不错,但也搞错了几个小地方,导致源码也是有错误的地方,评论里也有人指出来了。后来觉得这种各种不能够完全靠谱的源码与分析看着实在费劲,因为java8当中的TreeMap就是采用的红黑树,所以干脆自己看TreeMap的源码了,当时觉得源码自己看不懂再去google一些英文靠谱点的文章吧。但最后通过一步步分析源码明白了二叉红黑树的插入原理(虽然搞到了凌晨三点多...),画出流程图并进行对照总结之后发现就是美团技术团队中所总结的三种,所以说它总结的不错但它的那个少写了个黑色结点的情况包括给出的源码也是。所以对于插入操作的参考源码就是TreeMap上的源码,为了方便看我把一些保证安全的泛型去掉了,将Entry改成了Node,具体完整的实现还是要看TreeMap源码。
对于删除应该是更为复杂的操作了,所以暂时还没怎么看,后面可能会补上吧。
红黑树中的结点增加了一个属性color,来表示结点的颜色,可以是RED或者BLACk,所以共包含6个属性:color、key、value、left、right和parent。
private static final boolean RED = false; private static final boolean BLACK = true; static final class Node { K key; V value; Node left; Node right; Node parent; boolean color = BLACK; Node(K key, V value, Node parent) { this.key = key; this.value = value; this.parent = parent; }
描述的更准确些,不是红黑树的旋转操作,而是二叉树树的旋转操作,也就是在二叉树中的一种子树调整操作, 每一次旋转并不影响对该二叉树进行中序遍历的结果,也就是旋转前后两棵树上的结点直线投影来的序列一样的,这样旋转不会违反结点的大小关系(即左小右大)。树旋转通常应用于需要调整树的局部平衡性的场合。旋转分为左旋转和右旋转,其实直白点应该说成是把示意图中展现出的结点多的一方向左旋转和向右旋转。在写旋转代码的时候可以配合示意图来写(对于旋转的两个根对象,写完每一行指针指向相应的对象)。图中所画出来的结点也就是我们旋转的时候会涉及到的结点。
为了更加针对红黑树(结点有父指针),我画出了下面的这张图,双向箭头代表着左或右指针指向和父指针指向,红色部分代表进行旋转时需要改变的链接关系。
private void rotateLeft(Node p) { if (p != null) { Node r = p.right; p.right = r.left; if (r.left != null) r.left.parent = p; r.parent = p.parent; if (p.parent == null) root = r; else if (p.parent.left == p) p.parent.left = r; else p.parent.right = r; r.left = p; p.parent = r; } } private void rotateRight(Node p) { if (p != null) { Node l = p.left; p.left = l.right; if (l.right != null) l.right.parent = p; l.parent = p.parent; if (p.parent == null) root = l; else if (p.parent.right == p) p.parent.right = l; else p.parent.left = l; l.right = p; p.parent = l; } }
旋转的过程如代码所示,可以划分为下面的四个过程:
关键注意的就是y在旋转的时候会丢失一个与旋转方向一致的结点,然后用x来填补这个位置(赋值给与旋转方向一致的指针)。x则会用与旋转方向相反的那个指针来改指向这个结点,然后就是这个结点如果存在的话就把它的父指针也改指为x。程序流程中先处理了那个丢失的结点(y结点本身是逆贼上位,在旋转方向上有失有得,x在旋转方向相反方向上有失有得),再处理了父结点,最后处理了x、y之间的关系
我们首先以二叉查找树的方法增加节点并标记它为红色
。(如果设为黑色,就会导致根到叶子的路径上有一条路上,多一个额外的黑节点,这个是很难调整的。但是设为红色节点后,可能会导致出现两个连续红色节点的冲突,那么可以通过颜色调换(color flips)和树旋转来调整。)下面要进行什么操作取决于其他临近节点的颜色。其次每次插入以及插入修复后将根节点置为黑色。最后是空节点实现上处理为颜色为黑。这两条保证了性质123始终是成立的。同人类的家族树中一样,我们将使用术语叔节点来指一个节点的父节点的兄弟节点。注意:
红色
节点、重绘黑色节点为红色
,或做旋转时受到威胁。即:每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)红色
节点为黑色,或做旋转时受到威胁。即:从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。
为了方便操作,TreeMap红黑树中还设置了如下几个辅助函数
//返回结点的颜色,空结点返回黑色 private static boolean colorOf(Node p) { return (p == null ? BLACK : p.color); } //返回结点的父结点或者null private static Node parentOf(Node p) { return (p == null ? null: p.parent); } //设置结点的颜色 private static void setColor(Node p, boolean c) { if (p != null) p.color = c; } //返回该结点的左节点 private static Node leftOf(Node p) { return (p == null) ? null: p.left; } //返回该结点的右节点 private static Node rightOf(Node p) { return (p == null) ? null: p.right; }
首先讲下插入与插入修复,顾名思义插入修复是对插入后进行的一种修复,为什么要进行修复,因为你的插入可能会造成上面提到的性质4和性质5被破坏。下面提到的重绘(不包括每次将结束将根节点设置为黑色)实际上相当于将结点的颜色进行了反转
,先说下不会造成性质破坏的插入:
首先我们要明确一个事实,在我们插入之前,红黑树是严格遵守上面5条性质的。性质13是不会被破坏的以上三种情况,
其中1是根节点,我们只需要将它在插入后重绘为黑色即可,更不会破坏性质4和性质5。
对于2,既然能够在根节点进行插入也就是说在插入前空节点到根节点的黑色结点路径为0,那么插入后新结点的两个子节点(空节点)及其新结点本身到根节点黑色距离也是0(因为插入结点本身是红色),不会破坏性质5,性质4因为新插入结点的两个子节点(空节点都是黑色),所以也不会破坏。
对于3,与2同理,不会破坏性质4,对于性质5,任何一个节点到新结点及其两个子节点的距离是相同的,因为新结点是红色,此外任何节点到新结点及其子节点的距离肯定与该结点到新结点所替换的那个空节点的距离相同,所以不会破坏性质5。
所以以上这三种情况的插入是被排除在插入后修复的。那么那些需要修补的插入情况是什么呢?首先要满足不是上面那几种情况之一,所以即必须有祖父节点(不为空)并且父结点是红色的,这是下面说的几种情况的前提条件(并且下面的情况是过滤式的,即if 、else if 、else的关系
)
下面图中的虚线段表明子节点可能为父结点的左节点或者父结点的右节点两种情况,绿色虚线圆圈包围着的结点表示在即将的修复操作后会被重绘的。
一张图中的所有的黑色外圈数字结点
(可能是一颗子树,因为情况1可能会迭代修复)表示它们是内含有相同的黑色长度的,黑色外圈指的是它的根节点必须是黑色的,不然就不满足性质4。内含有相同的黑色长度就是从它们的根节点到各自子树的空节点的路过的黑色结点个数相同(这是根据性质5推出的)。当它们其中之一是null即空节点,即内含长度是0,而它们的根节点本身黑色的,所以他们所有黑色外圈数字节点都是null即空节点(这就是插入新结点的第一次插入修复的时候)。
一张图中的所有红黑外圈数字节点
(可能是一颗子树,因为情况1可能会迭代修复)都是挂在叔结点下面的,表示它们内含长度一定是比黑色外圈数字结点少1(根据性质5推出),当黑色外圈数字已经是null空节点时,因为内含长度不可能是-1(根据性质5推出),这时表示的是叔结点为null即空节点(根据性质3空节点是黑色的,仍满足叔结点为黑色)(这就是插入新结点的第一次插入修复的时候)。**红黑外圈表示它们的根节点可能是红色或者黑色的。
叔结点为红色的时候,则需要进行插入修复,如上图表示的四种情况所示,修复操作统一为:将叔、父重绘为黑色,祖绘为红色,这种修复并不是一次性的,修复完毕需要继续进行从头开始的插入后修复判断(即从判断是否需要插入修复开始往下也要能又回到这种情况)。
叔结点为黑色并且子父祖在同侧斜线上时,修复操作为:将父绘黑,祖绘红,然后以祖为枢纽进行向相反侧的方向旋转,同在右侧斜线上的就往左旋转了,同在左侧斜线上的就往右旋转了。注意我们这个时候绘黑的是即将成为这颗子树新的根节点的的父,绘红的是不再是根节点的祖,此种情况是修复停止的,因为该子树的根节点还是黑色没有发生变化,而字数内部又满足了性质三四五,并且没有破坏有序性。
当叔结点为黑色并且子父祖异侧的时候,情况如图所示,修复操作分为了两步:第一步是根据子父所在的那一侧的方向,以父为枢向相反方向旋转,这样就实现了将子父祖异侧转换为了父子祖同侧(并且是子父原所在侧的相反侧),第二步是发现它符合上一种情况了,那就将现在的父(原来的子)绘黑,祖(原来的祖)绘红,因为上种情况是修复停止的所以修复结束
在上面三种修补情况当中后两种是不会改变所在子树的黑色路径长度的,只有第一种情况可能改变黑色路径长度,因为它可能子树根节点涂红,如果子树根节点就是整棵树的根节点时,在修补结束后会被重新设置成黑色,黑色路径长度就加1了,但这个时候黑色路径增加针对的不再是这个子树了,而是整颗红黑树,所以是不会破坏性质5的。至于性质4看修补后的图中结点的颜色就知道是不会破坏了。
插入结束之后,这颗子树的满足了性质4(不能有两个连续的红色节点)和性质5(从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点),又是一颗新的红黑树了。