数据结构与算法(十):红黑树与TreeMap详细解析
本文目录
一、为什么要创建红黑树这种数据结构
在上篇我们了解了AVL树,既然已经有了AVL这种平衡的二叉排序树,为什么还要有红黑树呢?
AVL树通过定义我们知道要求树中每一个结点的左右子树高度差的绝对值不超过1,其是一颗严格的平衡树,这样构建出来的平衡二叉排序树具有很好的查找性能,但是为了保持其每个结点平衡因子绝对值不超过1的特性在插入或者删除的时候需要的维护成本是很大的,插入或者删除需要大量的平衡度计算,比如上一篇在AVL的插入的时候就需要不断回溯其父节点调整平衡因子的值,数据量小没什么问题,但是实际应用中往往数据量是很大的,导致AVL树的插入删除维护成本会很高。
此时,就有一部分前人们思考可不可以发明一种数据结构查找性能和AVL树差不多,但是插入删除的时候不需要那么大的维护开销呢?
1972年,鲁道夫·贝尔老爷子就发明了这样一种数据结构: 平衡二叉B树,后来在1978年的时候经过修改才叫红黑树。
以上了解了AVL树的问题以及红黑树的发明创造,下面正式讲解红黑树。
二、红黑树定义
直接给出:
1.节点是红色或黑色。
2.根节点是黑色。
3.每个叶子节点都是黑色的空节点(NIL节点)。
4.每个红色节点的两个子节点都是黑色(从每个叶子到根的所有路径上不能有两个连续的红色节点)。
5.从任一节点到其每个叶子结点的所有路径都包含相同数目的黑色节点。
这里的叶子结点指的是NIL结点,而不是我们平时所指的叶子结点。
满足以上条件的二叉排序树就是一颗红黑树,AVL树与红黑树前提都是二叉排序树。
以下就是一颗红黑树:
这里标记出了NIL结点,之后就不在标记出了,忽略不计了。
以上定义就保证了红黑树不需要像AVL树一样保证左右子树高度差绝对值不超过1,但是也保证了红黑树左右子树高度差不会相差2倍以上,怎么理解呢?怎么就保证不相差两倍以上呢?
三、红黑树如何保证左右子树高度差不会相差很大的
先跟着我的思路进行下面操作
比如以上面举例的红黑树为例,我们在根结点的右子树再插入结点,那么这个结点的颜色只能是红色,因为如果插入的是黑色,那么违反红黑树中第5条约束,如图:
如图,如果在根结点右子树再插入黑色结点那么左侧8到7有两个给色结点,而右侧8到11则有3个黑色结点,显然违反第5条约束。
所以我们只能插入红色结点:
在上图基础上我们在继续想右子树插入结点,根据约束4:红色结点的孩子只能是黑结点,然而插入黑色结点又违反约束5,这里就形成了死循环,无法继续下去。
所以极端情况下,对于上述一棵树,在不进行平衡操作的前提下,根结点左右子树高度差最大为2,。
这里只是举了一个例子说明红黑树红黑树是如何保证左右子树高度差不相差很大的,上面说的都是在不进行平衡操作的前提下,真正的红黑树结点是随便插入的,在违反上述约束会进行相应调整。
四、红黑树添加结点
讲解红黑树的添加算法之前我们先看看TreeMap添加数据的逻辑。
TreeMap与HashMap比较最大特点就是其是有序的Map集合,并且其底层数据结构为红黑树,我们先来简单了解下TreeMap的存储结构。
TreeMap每个存储结构为TreeMapEntry类,源码如下:
1 static final class TreeMapEntry<K,V> implements Map.Entry<K,V> { 2 K key; 3 V value; 4 TreeMapEntry<K,V> left; 5 TreeMapEntry<K,V> right; 6 TreeMapEntry<K,V> parent; 7 boolean color = BLACK; 8 9 /** 10 * Make a new cell with given key, value, and parent, and with 11 * {@code null} child links, and BLACK color. 12 */ 13 TreeMapEntry(K key, V value, TreeMapEntry<K,V> parent) { 14 this.key = key; 15 this.value = value; 16 this.parent = parent; 17 } 18 19 /** 20 * Returns the key. 21 * 22 * @return the key 23 */ 24 public K getKey() { 25 return key; 26 } 27 28 /** 29 * Returns the value associated with the key. 30 * 31 * @return the value associated with the key 32 */ 33 public V getValue() { 34 return value; 35 } 36 37 /** 38 * Replaces the value currently associated with the key with the given 39 * value. 40 * 41 * @return the value associated with the key before this method was 42 * called 43 */ 44 public V setValue(V value) { 45 V oldValue = this.value; 46 this.value = value; 47 return oldValue; 48 } 49 50 public boolean equals(Object o) { 51 if (!(o instanceof Map.Entry)) 52 return false; 53 Map.Entry<?,?> e = (Map.Entry<?,?>)o; 54 55 return valEquals(key,e.getKey()) && valEquals(value,e.getValue()); 56 } 57 58 public int hashCode() { 59 int keyHash = (key==null ? 0 : key.hashCode()); 60 int valueHash = (value==null ? 0 : value.hashCode()); 61 return keyHash ^ valueHash; 62 } 63 64 public String toString() { 65 return key + "=" + value; 66 } 67 }
其中最重要的信息是每个Entry中都包含一个color属性,并且默认是BLACK的,也就是黑色的,至于其余的,就是自己本身的信息key, value以及左右孩子和父节点的指针(这里我就叫做指针了)。
初始化TreeMap的时候我们可以指定自己的比较器,实现我们自己定制的排序,如下构造函数:
1 public TreeMap(Comparator<? super K> comparator) { 2 this.comparator = comparator; 3 }
如果我们在构造的时候传入自己的比较器(实现Comparator接口),那么TreeMap内部排序的时候用的是我们自己传入的比较器,如果没有则使用的是自然排序,所谓自然排序也就是SDK内部默认使用key的compareTo方法,TreeMap存入的key必须实现Comparable接口,这样才有比较性,这些都是基础了,简单提一下。
TreeMap中put方法分析,源码如下:
1 public V put(K key, V value) { 2 TreeMapEntry<K,V> t = root; 3 if (t == null) { 4 5 if (comparator != null) { 6 if (key == null) { 7 comparator.compare(key, key); 8 } 9 } else { 10 if (key == null) { 11 throw new NullPointerException("key == null"); 12 } else if (!(key instanceof Comparable)) { 13 throw new ClassCastException( 14 "Cannot cast" + key.getClass().getName() + " to Comparable."); 15 } 16 } 17 // END Android-changed: Work around buggy comparators. http://b/34084348 18 root = new TreeMapEntry<>(key, value, null); 19 size = 1; 20 modCount++; 21 return null; 22 } 23 int cmp; 24 TreeMapEntry<K,V> parent; 25 // split comparator and comparable paths 26 Comparator<? super K> cpr = comparator; 27 if (cpr != null) { 28 do { 29 parent = t; 30 cmp = cpr.compare(key, t.key); 31 if (cmp < 0) 32 t = t.left; 33 else if (cmp > 0) 34 t = t.right; 35 else 36 return t.setValue(value); 37 } while (t != null); 38 } 39 else { 40 if (key == null) 41 throw new NullPointerException(); 42 @SuppressWarnings("unchecked") 43 Comparable<? super K> k = (Comparable<? super K>) key; 44 do { 45 parent = t; 46 cmp = k.compareTo(t.key); 47 if (cmp < 0) 48 t = t.left; 49 else if (cmp > 0) 50 t = t.right; 51 else 52 return t.setValue(value); 53 } while (t != null); 54 } 55 TreeMapEntry<K,V> e = new TreeMapEntry<>(key, value, parent); 56 if (cmp < 0) 57 parent.left = e; 58 else 59 parent.right = e; 60 fixAfterInsertion(e); 61 size++; 62 modCount++; 63 return null; 64 }
3-22行,处理root为null情况,也就是TreeMap中没有放入过任何数据。
5-8行,comparator不为null,也就是我们自己在TreeMap的构造方法中指定了我们自己的比较器,那么在key为null的情况下,交给我们自己的比较器处理,SDK不管,需要我们自己在比较器的compare方法中处理,可以来决定要不要抛出异常。
9-16行,没有指定comparator,需要SDK处理,那么,如果传入key为null,则抛出异常,也就是默认情况下(没有指定我们自己的比较器)TreeMap不允许放入key为null的键值对数据。
所以这里我们要明白TreeMap并没有完全禁止放入key为null的数据,我们设置自己的比较器的时候就可以自己决定允不允许放入key为null的数据,而如果没有设置自己比较器,则不允许放入key为null的数据。
18行,就是初始化root结点信息了。
23-59行,就是TreeMap中有其余数据,那么这时候在放入数据就需要先查找出将要放入数据的父结点了(和之前二叉排序树的放入数据逻辑一样),查找到其父结点然后将其设置为父结点的子节点就可以了。
26-38,也就是我们设置了自己的比较器,就用我们自己设置的比较器不断查找其父结点。
39-54,没有设置自己的比较器,则使用SDK默认的比较方式查找其父结点,同样的,没有设置自己比较器是不允许放入key为null的数据的。
55-59,创建结点信息并将其挂载到父结点下。
好了,以上都是比较简单的,主要是有个自己的比较器逻辑判断,之前可能有些同学没有了解过。
通过以上处理,一个结点就加入到已有的红黑树了,那么数据是加入完了,如果新加入的数据影响了红黑树的平衡性,也就是违反了红黑树的约束条件那么我们该怎么调整呢?
五、红黑树添加结点后的修复逻辑
红黑树添加数据后调整的逻辑就是其精华所在了,这部分需要根据不同情况仔细分析。
插入数据大体分为两步:
(1)以排序二叉树的方法插入新结点。
(2)新结点设为红色,进行颜色调换和树旋转。
对于步骤2,颜色的调换和旋转就比较复杂了,需要分不同情况不同处理,具体如下:
第一种情形:新插入的结点是根结点
这种情况处理就比较简单了,直接将结点涂黑即可
第二种情形:新插入结点的父节点颜色为黑色
这种情况也不需要任何处理,不违反任何红黑树原则,在插入的时候我们会将新结点染为红色,其父节点为黑色不违反任何约束原则。
第三种情形:新插入结点的父节点颜色为红色,父节点是祖父节点的左孩子,祖父节点的另一个子节点(也就是叔叔节点)是红色
这种情况需要如下处理:将当前节点的父节点和叔叔节点涂黑,祖父节点涂红,把当前节点指向祖父节点,从新的当前节点开始算法
如图:(图中N标记新插入结点或者调整后的当前结点)
这种情形我们只需要这样处理就可以了,有些同学看完处理完依然不满足红黑树约束条件啊,别急,整个算法是不断循环,这里只是算法的一部分,针对这种情形,我们只需完成自己负责的就可以了,继续向下看
第四种情形:新插入结点的父节点颜色为红色,父节点是祖父节点的左孩子,祖父节点的另一个子节点(也就是叔叔节点)是黑色,当前节点是其父节点的右孩子
这种情形需要如下处理:当前节点的父节点做为新的当前节点,以新当前节点左旋。
如图:
这样操作完依然不满足红黑树的约束条件,别急,还有其余要处理的。
第五种情形:当前结点的父节点颜色为红色,父节点是祖父节点的左孩子,祖父节点的另一个子节点(也就是叔叔节点)是黑色,当前节点是其父节点的左孩子
这种情形需要如下处理:父节点变为黑色,祖父节点变红色,祖父节点进行右旋。
如图:
到此就变成一颗标准的红黑树了。
对于上述五种情形,第一,第二比较好理解,三,四,五情形并不是孤立的,有时需要经过三,四,五情况依次变换才会达到最终的红黑树约束条件,仔细观察情形三变换完的结果证实情形四的情形,而四的结果正是五的情形,所以如果插入结点插入后情形为三,需要经过三,四,五三种变换才会达到最终结果,如果插入后情形为四,则只需要经过四,五变换就可以,如果插入后情形为五,则只需要经过五操作就可以了。
插入结点父结点是红色,父结点是祖父结点左侧经过上述三,四,五情形处理即可,如果父结点是红色,父结点是祖父结点右孩子该怎么处理呢?很简单,只需要将上述三,四,五情形操作中的左旋变为右旋,右旋变为左旋即可。
红黑树插入数据总结如下:
在分析TreeMap的put方法源码的时候并没有分析fixAfterInsertion(e)插入后修复的逻辑,有了上述知识储备我们就可以分析一波了,源码如下:
1 private void fixAfterInsertion(TreeMapEntry<K,V> x) { 2 x.color = RED; 3 4 while (x != null && x != root && x.parent.color == RED) { 5 if (parentOf(x) == leftOf(parentOf(parentOf(x)))) { 6 TreeMapEntry<K,V> y = rightOf(parentOf(parentOf(x))); 7 if (colorOf(y) == RED) { 8 setColor(parentOf(x), BLACK); 9 setColor(y, BLACK); 10 setColor(parentOf(parentOf(x)), RED); 11 x = parentOf(parentOf(x)); 12 } else { 13 if (x == rightOf(parentOf(x))) { 14 x = parentOf(x); 15 rotateLeft(x); 16 } 17 setColor(parentOf(x), BLACK); 18 setColor(parentOf(parentOf(x)), RED); 19 rotateRight(parentOf(parentOf(x))); 20 } 21 } else { 22 TreeMapEntry<K,V> y = leftOf(parentOf(parentOf(x))); 23 if (colorOf(y) == RED) { 24 setColor(parentOf(x), BLACK); 25 setColor(y, BLACK); 26 setColor(parentOf(parentOf(x)), RED); 27 x = parentOf(parentOf(x)); 28 } else { 29 if (x == leftOf(parentOf(x))) { 30 x = parentOf(x); 31 rotateRight(x); 32 } 33 setColor(parentOf(x), BLACK); 34 setColor(parentOf(parentOf(x)), RED); 35 rotateLeft(parentOf(parentOf(x))); 36 } 37 } 38 } 39 root.color = BLACK; 40 }
2行,首先将插入的数据项设置为红色,还记得上面提到过吗,红黑树插入的新结点都被当做红色来处理,这样能减少冲突的情况,如果当做黑色那很可能违反第5条约束啊。
接下来就是不断循环算法了,根据不同情形不同处理。
while循环中会判断父节点的颜色,只有红色才会进入循环,上面已经提到过,父节点是黑色就不需要处理了,不会违反红黑树的约束条件。
5-20行,处理父节点是祖父结点左孩子的情形。
6行,y就是祖父结点的右孩子,也就是其叔叔结点。
7-11行,就是情形三,操作就是将父结点与叔叔结点涂黑,祖父结点涂红,最后11行将当前结点指向祖父结点。
13-20行就是情形四,五的处理,很简单,自己对着思路看一下。
21-36行,处理父节点是祖父结点右孩子的情形,上面已经说了只需要将左旋变为右旋,右旋变为左旋即可。
好了,以上就是红黑树的插入算法,结合TreeMap的put方法分析了一下,红黑树插入为什么按照这几种情形来分类处理呢?其实这些都是前人们总结的经验啊,说白了就是按照这几种情况分类处理就可以了,并且源码中也是按照这思路来编写的。
好了,以上就是红黑树的插入逻辑,主要是了解一下数据插入后的修复操作,一开始接触的同学可能觉得很难,确实不简单,这些玩意很枯燥并且学了也很可能用不到,没办法,内功的修炼就是很难,希望你静下心来自己慢慢弄懂。
六、红黑树删除结点
红黑树删除结点大体分为两步:
①先进行二叉排序树的删除操作。
②然后将已替换节点作为当前节点进行后面的平衡操作
接下来我们直接看TreeMap的删除逻辑,二叉排序树的删除操作如果不熟悉,请先查看我之前讲解的二叉排序树那篇文章,这里我在结合TreeMap的源码讲解一下。
TreeMap删除源码:
1 public V remove(Object key) { 2 TreeMapEntry<K,V> p = getEntry(key); 3 if (p == null) 4 return null; 5 6 V oldValue = p.value; 7 deleteEntry(p); 8 return oldValue; 9 }
getEntry方法也就是查找之前我们是否存储过对应数据,存储过则返回之前存储时key对应的value值,没有存储过则直接返回null了。
具体的getEntry方法可以自己看一下,很简单,有一点就是比较的时候我们如果设置了自己的比较器则使用自己的比较器进行比较,注意一下这里就好了。
真正执行删除的操作是在deleteEntry(p)方法里面,我们看下这个方法源码:
1 /** 2 * Delete node p, and then rebalance the tree. 3 */ 4 private void deleteEntry(TreeMapEntry<K,V> p) { 5 modCount++; 6 size--; 7 // If strictly internal, copy successor's element to p and then make p 8 // point to successor. 9 //首先判断删除的结点是否左右孩子都包含 10 if (p.left != null && p.right != null) { 11 //左右孩子都有则找出其后继结点将其替换 12 TreeMapEntry<K,V> s = successor(p); 13 //当前结点的key,value替换为后继结点的内容 14 //这样当前结点也就算被删除了 15 p.key = s.key; 16 p.value = s.value; 17 //这里一定要注意如果有左右孩子,则删除结点后p指向了其后继结点,后续需要对后继结点进行处理 18 p = s; 19 } // p has 2 children 20 //这里就有一些巧妙的地方了,首先如果删除的结点//p有左右孩子,那么经过上述操作p指向了其后继结//点,这里判断的是后继结点是否有左孩子或者右孩子。如果删除的结点左右孩子只有一个或者都没有,那么p没有经过上述操作,依然指向被删除的结点,这里判断的是删除结点是否有左孩子或者右孩子,这里一定要想明白了,源码中删除操作都是将好几种情况中重合的逻辑进行了整合,而没有像讲解二叉排序树删除操作时那么分的那么清 21 TreeMapEntry<K,V> replacement = (p.left != null ? p.left : p.right); 22 23 //replacement这里也是分两种情况的 24 //1:被删除结点左右孩子都存在,则replacement代表后继结点的左孩子或者右孩子,并且要明白后继结点只有一个孩子结点或者都没有,这里不明白可以自己思考一下? 25 //2:被删除结点左右孩子只有一个或者都没有,则replacement代表被删除结点的左孩子或者右孩子,如果都没有也就是null了。 26 if (replacement != null) { 27 // Link replacement to parent 28 replacement.parent = p.parent; 29 if (p.parent == null)//表示删除的是根结点 30 root = replacement;//替换根结点 31 else if (p == p.parent.left)//删除结点是父结点的左孩子 32 p.parent.left = replacement; 33 else//删除结点是父结点的右孩子 34 p.parent.right = replacement; 35 36 // 将删除结点的左右孩子父结点信息都清空,完全从树中移除 37 p.left = p.right = p.parent = null; 38 39 // 修复操作,后续在分析 40 if (p.color == BLACK) 41 fixAfterDeletion(replacement); 42 } else if (p.parent == null) { 43 //没有父结点并且左右子树也没有,那么其就是根结点了 44 root = null; 45 } else { //执行到这,表明删除结点左右孩子都没有。 46 if (p.color == BLACK) 47 fixAfterDeletion(p); 48 49 if (p.parent != null) { 50 if (p == p.parent.left) 51 p.parent.left = null; 52 else if (p == p.parent.right) 53 p.parent.right = null; 54 p.parent = null; 55 } 56 } 57 }
好了,以上就是TreeMap删除结点的逻辑了,这里一定要先弄明白二叉排序树删除结点的逻辑,否则直接看这里会直接蒙圈了。
上面分析的时候删除后的修复fixAfterDeletion(p)没有分析,别急这里就是核心点了,也就是红黑树的删除后修复的逻辑。
只有在删除结点是黑色的时候才会进行修复操作,也很好理解,黑色删除了很可能影响约束五啊,如果是删除结点为红黑,则不会违反红黑树的任何约束条件。
七、红黑树删除结点后修复操作
接下来看下fixAfterDeletion(p)源码:
1 private void fixAfterDeletion(TreeMapEntry<K,V> x) { 2 //不是根结点,当前结点是黑色 3 while (x != root && colorOf(x) == BLACK) { 4 if (x == leftOf(parentOf(x))) {//被删除节点是父节点的左孩子 5 TreeMapEntry<K,V> sib = rightOf(parentOf(x));//sib为删除结点的兄弟节点 6 if (colorOf(sib) == RED) {//兄弟结点是红色,根据红黑树定义可以判断出其父节点以及兄弟结点的左右孩子结点都是黑色的 7 setColor(sib, BLACK);//兄弟结点染黑 8 setColor(parentOf(x), RED);//父节点染红 9 rotateLeft(parentOf(x));//父节点左旋 10 sib = rightOf(parentOf(x));//左旋后重新设置兄弟结点 11 } 12 //兄弟结点的两个子节点都是黑色 13 if (colorOf(leftOf(sib)) == BLACK && 14 colorOf(rightOf(sib)) == BLACK) { 15 setColor(sib, RED);//兄弟节点染红 16 x = parentOf(x);//父节点设置为当前结点 17 } else { 18 //兄弟结点右孩子是黑色 19 if (colorOf(rightOf(sib)) == BLACK) { 20 setColor(leftOf(sib), BLACK);//兄弟结点左孩子设置为黑色 21 setColor(sib, RED);//兄弟结点设置为红色 22 rotateRight(sib);//兄弟结点右旋 23 sib = rightOf(parentOf(x));//右旋后重新设置兄弟结点 24 } 25 setColor(sib, colorOf(parentOf(x)));//设置兄弟结点的颜色与当前结点x父节点的颜色相同 26 setColor(parentOf(x), BLACK);//设置父节点为黑色 27 setColor(rightOf(sib), BLACK);//设置兄弟结点右孩子为黑色 28 rotateLeft(parentOf(x));//当前结点x父节点左旋 29 x = root;//当前结点指向根结点 30 } 31 } else { //被删除节点是父节点的右孩子,左右互换即可 32 TreeMapEntry<K,V> sib = leftOf(parentOf(x)); 33 34 if (colorOf(sib) == RED) { 35 setColor(sib, BLACK); 36 setColor(parentOf(x), RED); 37 rotateRight(parentOf(x)); 38 sib = leftOf(parentOf(x)); 39 } 40 41 if (colorOf(rightOf(sib)) == BLACK && 42 colorOf(leftOf(sib)) == BLACK) { 43 setColor(sib, RED); 44 x = parentOf(x); 45 } else { 46 if (colorOf(leftOf(sib)) == BLACK) { 47 setColor(rightOf(sib), BLACK); 48 setColor(sib, RED); 49 rotateLeft(sib); 50 sib = leftOf(parentOf(x)); 51 } 52 setColor(sib, colorOf(parentOf(x))); 53 setColor(parentOf(x), BLACK); 54 setColor(leftOf(sib), BLACK); 55 rotateRight(parentOf(x)); 56 x = root; 57 } 58 } 59 } 60 //设置当前节点为黑色 61 setColor(x, BLACK); 62 }
其实和插入后修复的逻辑很像,也是根据不同情况进行不同调整。
好了,红黑树整体删除算法就讲到这里,很多地方需要自己静下来慢慢分析,AVL树和红黑树确实是比较难懂的,没有办法,别人讲出来总归是别人的,需要自己慢慢体会。
八、红黑树与AVL树比较
AVL树要求结点的平衡因子绝对值不大于1,而红黑树则没有那么严格要求,所以红黑树相对于AVL来说平衡度会差一些,这就导致红黑树的查询性能略微逊色于AVL树,但是,红黑树在插入和删除上性能远远好于avl树,avl树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于avl树为了维持平衡的开销要小得多。
红黑树更像是个全能选手,我某一项比你差,但是我综合性能好啊,而AVL更像是某一点特别突出的选手,我综合性能不如你,但是查询这一单项性能比你强。
好了,本文到此为止,希望对你有用。