为了理解 TreeMap 的底层实现,必须先介绍排序二叉树和平衡二叉树,然后继续介绍红黑树。平衡二叉树和红黑树又是一种特殊的二叉排序树。二叉排序树是一种特殊结构的二叉树,可以非常方便地对树中所有节点进行排序和检索。
1、排序二叉树
1.1 排序二叉树之插入操作
已知一个关键字值为key的结点s,若将其插入到二叉排序树中,只要保证插入后仍符合二叉排序树的定义即可。插入可以用下面的方法进行:
(1)若二叉排序树是空树,则key成为二叉排序树的根;
(2)若二叉排序树非空,则将key与二叉排序树的根进行比较。如果key的值等于根结点的值,则停止插入;如果key的值小于根结点的值,则将key插入左子树,如果key的值大于根结点的值,则将key插入右子树。
(3)重复步骤2,直到找到合适的插入位置。
1.2 排序二叉树之删除操作
当程序从排序二叉树中删除一个节点之后,为了让它依然保持为排序二叉树,程序必须对该排序二叉树进行维护。维护可分为如下几种情况:
(1)被删除的节点是叶子节点,则只需将它从其父节点中删除即可。
(2)如果待删除节点左子树存在右子树不存在,或者左子树不存在右子树存在。直接将其子树中存在的一边候补上来即可。
图 2 显示了被删除节点只有左子树的示意图:
图 3 显示了被删除节点只有右子树的示意图:
图 4 显示了被删除节点左右子节点不为空的情形,采用到是第一种方式维护:
图 5显示了被删除节点左右子节点不为空的情形,采用到是第二种方式维护:
TreeMap 删除节点采用图 5 所示右边的情形进行维护——也就是用被删除节点的右子树中最小节点与被删节点交换的方式进行维护。
在使用第二种方式进行维护时,如果使用前驱节点代替被删除的节点,则前驱节点可能还存在左子树(因为前驱节点是根节点左子树中最右边的节点),而如果是后继节点的话,这个后继节点可能还存在右子树。他们的处理方法相同,直接将子树移上去即可。
一定要理解,无论是前驱还是后继节点,不可能同时具有左子树或右子树,这就为删除替代节点后的操作带来了方便。
1.3 排序二叉树之查找操作
从二叉排序树中进行查找时,根据树的性质,节点的左子树必定小于根节点,右子树必定大于根结点。如果查找的节点值小于根节点,则进入左子树,大于进入右子树,重复这个比较步骤直到找到这个节点或者这个节点不存在。
1.4 总结
排序二叉树虽然可以快速检索,但在最坏的情况下:如果插入的节点集本身就是有序的,要么是由小到大排列,要么是由大到小排列,那么最后得到 的排序二叉树将变成链表:所有节点只有左节点(如果插入节点集本身是大到小排列);或所有节点只有右节点(如果插入节点集本身是小到大排列)。在这种情况 下,排序二叉树就变成了普通链表,其检索效率就会很差。
2、平衡二叉树(AVL)
ALV树中任何两个结点的高度差值最大不超过1。树在查找、插入和删除时平均和最坏的情况下都是O(logn)。在一颗二叉搜索树查找一个值的平均时间复杂度为log(n),但是若查找树的所有的节点向一边倾斜,这时候的查找就退化为线性查找,复杂度为n。为了获得更高的查找效率,就有了AVL树的概念,对于一颗非平衡的AVL树,可以通过旋转变换为AVL树。
2.2 LR 插入一个新节点到根节点的左子树的右子树,导致根节点的平衡因子由1变为2.需要先左旋后右旋转来解决。
3、Java中的红黑树
红黑树是一个更高效的检索二叉树,因此常常用来实现关联数组。典型地,JDK 提供的集合类 TreeMap 本身就是一个红黑树的实现。排序二叉树的深度直接影响了检索的性能,当插入节点本身就是由小到大排列时,排序二叉树将变成一个链表,这种排序二叉树的检索性能最低:N 个节点的二叉树深度就是 N-1。但是红黑树通过上面这种限制来保证它大致是平衡的——因为红黑树的高度不会无限增高,这样保证红黑树在最坏情况下都是高效的,不会出现普通排序二叉树的情况。
红黑树在原有的排序二叉树增加了如下几个要求:
图 6. Java 红黑树的示意
备注:本文中所有关于红黑树中的示意图采用白色代表红色。黑色节点还是采用了黑色表示。
下面来看一下几个约定:
(1)性质 3 中指定红黑树的每个叶子节点都是空节点,而且叶子节点都是黑色 Java 实现的红黑树将使用 null 来代表,因此遍历红黑树时将看不到黑色的叶子节点,反而看到每个叶子节点都是红色的。非叶子节点,也就是非null节点称为红黑树中的儿子节点。
(2) 红黑树从根节点到每个叶子节点的路径都包含相同数量的黑色节点,因此从根节点到叶子节点的路径中包含的黑色节点数被称为树的“黑色高度”。
对于给定的黑色高度为 N 的红黑树,从根到叶子节点的最短路径长度为 N-1,最长路径长度为 2 * (N-1)。
假如有一棵黑色高度为 3 的红黑树:从根节点到叶节点的最短路径长度是 2,该路径上全是黑色节点(黑节点 - 黑节点 - 黑节点)。最长路径也只可能为 4,在每个黑色节点之间插入一个红色节点(黑节点 - 红节点 - 黑节点 - 红节点 - 黑节点),性质 4 保证绝不可能插入更多的红色节点。由此可见,红黑树中最长路径就是一条红黑交替的路径。
3.1 读取操作
由于红黑树只是一个特殊的排序二叉树,因此对红黑树上的只读操作与普通排序二叉树上的只读操作完全相同,只是红黑树保持了大致平衡,因此检索性能比排序二叉树要好很多。但在红黑树上进行插入操作和删除操作会导致树不再符合红黑树的特征,因此插入操作和删除操作都需要进行一定的维护,以保证插入节点、删除节点后的树依然是红黑树。
为了在每次插入删除节点时,满足红黑树的如上一些性质,需要进行节点颜色的变换和进行旋转(向平衡二叉树一样,只是比平衡二叉树更加严格要求),下面就来看一下Java 中实现的红黑树。
3.2 插入节点后修复
每次插入节点后必须进行简单修复,使该排序二叉树满足红黑树的要求。插入操作按如下步骤进行:
(1)以排序二叉树的方法插入新节点,并将它设为红色。
(2)进行颜色调换和树旋转。
在插入操作中,红黑树的性质 1 和性质 3 两个永远不会发生改变,因此无需考虑红黑树的这两个特性。
而颜色调用和树旋转就比较复杂了,下面将分情况进行介绍。在介绍中,我们把新插入的节点定义为 N 节点,N 节点的父节点定义为 P 节点,P 节点的兄弟节点定义为 U 节点,P 节点父节点定义为 G 节点(参见图7)
下面分成不同情形来分析插入操作。
情形 1:新节点 N 是树的根节点,没有父节点
在这种情形下,直接将它设置为黑色以满足性质 2。
情形 2:新节点的父节点 P 是黑色
在这种情况下,新插入的节点是红色的,因此依然满足性质 4。而且因为新节点 N 有两个黑色叶子节点;但是由于新节点 N 是红色,通过它的每个子节点的路径依然保持相同的黑色节点数,因此依然满足性质 5。
情形 3:如果父节点 P 和父节点的兄弟节点 U 都是红色
在这种情况下,程序应该将 P 节点、U 节点都设置为黑色,并将 P 节点的父节点设为红色(用来保持性质 5)。现在新节点 N 有了一个黑色的父节点 P。由于从 P 节点、U 节点到根节点的任何路径都必须通过 G 节点,在这些路径上的黑节点数目没有改变(原来有叶子和 G 节点两个黑色节点,现在有叶子和 P 两个黑色节点)。
经过上面处理后,红色的 G 节点的父节点也有可能是红色的,这就违反了性质 4,因此还需要对 G 节点递归地进行整个过程(把 G 当成是新插入的节点进行处理即可)。
图 7 显示了这种处理过程:
虽然图7 绘制的是新节点 N 作为父节点 P 左子节点的情形,其实新节点 N 作为父节点 P 右子节点的情况与图 7 完全相同。
情形 4:父节点 P 是红色、而其兄弟节点 U 是黑色或缺少;且新节点 N 是父节点 P 的右子节点,而父节点 P 又是其父节点 G 的左子节点。
如上的情况下,需要对新节点和其父节点进行左旋转操作,接着按情形 5 处理以前的父节点 P(也就是把 P 当成新插入的节点即可)。这导致某些路径通过它们以前不通过的新节点 N 或父节点 P 的其中之一,但是这两个节点都是红色的,因此不会影响性质 5。
图 8. 插入节点后的树旋转
备注:图 8 中 P 节点是 G 节点的左子节点,如果 P 节点是其父节点 G 节点的右子节点,那么上面的处理情况应该左、右对调一下。
情形 5:父节点 P 是红色、而其兄弟节点 U 是黑色或缺少;且新节点 N 是其父节点的左子节点,而父节点 P 又是其父节点 G 的左子节点。
在这种情形下,需要对节点 G 的一次右旋转,在旋转产生的树中,以前的父节点 P 现在是新节点 N 和节点 G 的父节点。由于以前的节点 G 是黑色,否则父节点 P 就不可能是红色,我们切换以前的父节点 P 和节点 G 的颜色,使之满足性质 4,性质 5 也仍然保持满足,因为通过这三个节点中任何一个的所有路径以前都通过节点 G,现在它们都通过以前的父节点 P。在各自的情形下,这都是三个节点中唯一的黑色节点。
图 9. 插入节点后的颜色调整、树旋转
备注:图 9 中 P 节点是 G 节点的左子节点,如果 P 节点是其父节点 G 节点的右子节点,那么上面的处理情况应该左、右对调一下。
3.3 删除节点后修复
如果需要删除的节点有两个儿子,那么问题可以被转化成删除另一个只有一个儿子的节点的问题,也就是典型的二叉排序树的删除操作。 对于二叉查找树,在删除带有两个非叶子儿子的节点的时候,我们找到要么在它的左子树中的最大元素、要么在它的右子树中的最小元素,并把它的值转移到要删除 的节点中。我们接着删除替换节点。因为只是复制了一个值而不违反任何属性,这就把问题简化为如何删除最多有一个儿子的节点的问题。
删除只有一个儿子的节点(如果它两个儿子都为空,即均为叶子,我们任意将其中一个看作它的儿子)是删除节点的关键:
把删除的节点替换为树的后继节点(当然也可以是前驱节点)这个后继节点假设为 N,它的兄弟节点为S。P节点为N节点的父亲,SL为S的左子树,SR为S的右子树。如下图。
情形 1:删除节点是树的根节点
从所有路径去除了一个黑色节点,而新根是黑色的,所以属性都保持着。举个例子如下:
注意: 在情况2、5和6下,我们假定 N 是它父亲的左儿子。如果它是右儿子,则在这些情况下的左和右应当对调。
情形2. 删除节点的兄弟节点S是红色
在这种情况下在N的父节点P上做左旋转操作,接着对调 N 的父节点和S节点的颜色。尽管所有的路径仍然有相同数目的黑色节点,现在 N 有了一个黑色的兄弟和一个红色的父亲,所以我们可以接下去按 4、5或6情况来处理。(它的新兄弟是黑色因为它是红色S的一个儿子)
情形3: N 的父亲、S 和 S 的儿子都是黑色的
在这种情况下,我们简单的重绘 S 为红色。结果是通过S的所有路径, 它们就是以前不通过 N 的那些路径,都少了一个黑色节点。因为删除 N 的初始的父亲使通过 N 的所有路径少了一个黑色节点,这使事情都平衡了起来。但是,通过 P 的所有路径现在比不通过 P 的路径少了一个黑色节点,所以仍然违反属性4。要修正这个问题,我们要从情况 1 开始,在 P 上做重新平衡处理。
情形4. S 和 S 的儿子都是黑色,但是 N 的父亲是红色
在这种情况下,我们简单的交换 N 的兄弟和父亲的颜色。这不影响不通过 N 的路径的黑色节点的数目,但是它在通过 N 的路径上对黑色节点数目增加了一,添补了在这些路径上删除的黑色节点。
情形5. S 是黑色,S 的左儿子是红色,S 的右儿子是黑色,而 N 是它父亲的左儿子
在这种情况下我们在 S 上做右旋转,这样 S 的左儿子成为 S 的父亲和 N 的新兄弟。我们接着交换 S 和它的新父亲的颜色。所有路径仍有同样数目的黑色节点,但是现在 N 有了一个右儿子是红色的黑色兄弟,所以我们进入了情况 6。N 和它的父亲都不受这个变换的影响。
情形6. S 是黑色,S 的右儿子是红色,而 N 是它父亲的左儿子
在这种情况下我们在 N 的父亲上做左旋转,这样 S 成为 N 的父亲和 S 的右儿子的父亲。我们接着交换 N 的父亲和 S 的颜色,并使 S 的右儿子为黑色。子树在它的根上的仍是同样的颜色,所以属性 3 没有被违反。但是,N 现在增加了一个黑色祖先: 要么 N 的父亲变成黑色,要么它是黑色而 S 被增加为一个黑色祖父。所以,通过 N 的路径都增加了一个黑色节点。
此时,如果一个路径不通过 N,则有两种可能性:
它通过 N 的新兄弟。那么它以前和现在都必定通过 S 和 N 的父亲,而它们只是交换了颜色。所以路径保持了同样数目的黑色节点。
它通过 N 的新叔父,S 的右儿子。那么它以前通过 S、S 的父亲和 S 的右儿子,但是现在只通过 S,它被假定为它以前的父亲的颜色,和 S 的右儿子,它被从红色改变为黑色。合成效果是这个路径通过了同样数目的黑色节点。
在任何情况下,在这些路径上的黑色节点数目都没有改变。所以我们恢复了属性 4。在示意图中的白色节点可以是红色或黑色,但是在变换前后都必须指定相同的颜色。
同样的,函数调用都使用了尾部递归,所以算法是就地的。此外,在旋转之后不再做递归调用,所以进行了恒定数目(最多 3 次)的旋转。
3.4 红黑树的优势
红黑树能够以O(log2(N))的时间复杂度进行搜索、插入、删除操作。此外,任何不平衡都会在3次旋转之内解决。这一点是AVL所不具备的。