Java Collection框架 HashMap 红黑树与 TreeNode源码浅析
2018年拍摄于京都平安神宫内。
微信公众号:JavaBoy王皓
今天看树型数据结构。
树
上图是一个简单的树形结构,最顶层为一个根节点,向下延伸出树杈和叶子构成一个具有层次关系的集合,例如前端树形插件Ztree,用过的朋友都很熟悉知道这个结构。
一个节点也是一颗树,这个节点就是root根节点,上图中3,6和8没有下级节点,这种节点被称为叶子节点。
任何一个节点下有子节点则构成子树。根节点没有父节点,叶子节点没有子节点,除了根节点没有父节点外其他的节点都只有一个父节点。任何节点可以有n的子节点。
从某一个节点出发,到叶子节点的最少边数为该节点的高度。
例如,root节点的高度为5,2的高度为1,因为到最近的叶子节点3的边数为1。4的高度为2。7的高度为2.
从root节点出发到某一个节点的边数为该节点的深度。
例如,root的深度为0, 2的深度为1, 5的深度为3.
以上就是最基础的树形结构,以此为基础,我们继续向后延伸。
二叉树
每个节点至多有两个子节点的树叫做二叉树。
平衡二叉树
平衡二叉树在二叉树的基础上添加了新的限制,即树的左右高度之差小于等于1。
以7节点为准,左侧高度7到1叶子节点高度为2,7到8的叶子节点高度为2相等,高度差为0,节点4同理左右各两条边高度差为0,节点9左右高度差为1小于等于1,所以上图为一棵标准平衡二叉树。
二叉查找树
二叉查找树(Binary Search Tree),又称二叉排序树(Binary Sort Tree),亦称二叉搜索树。
二叉查找树在平衡二叉树的基础上继续添加了限制,但是会由于不断增加和删除节点失去平衡特性。
首先我们先看一下增加了什么限制,若左子树不空,则左子树上所有节点的值均小于它的根节点的值,若右子树不空,则右子树上所有节点的值均大于它的根节点的值。
查找为简单的判断查找值比节点小的向左,比节点值大的向右,直到找到数据或者查到叶子节点还没有找到。查找平均复杂度为O(log n)。
添加与删除节点,树的结构通常不是一次生成的,而是在查找过程中,当树中不存在关键字等于给定值的结点时再进行插入。新插入的结点一定是一个新添加的叶子结点,并且是查找不成功时查找路径上访问的最后一个结点的左子或右子结点。
不断的添加与删除容易使平衡查找树失去平衡,所以为了保持平衡性,后面又诞生了很多新的算法树。
AVL树
本身首先是一棵二叉查找树,但又与二叉查找树不同,AVL树是最先发明的自平衡二叉查找树,本质上是带了平衡功能的二叉查找树。由苏联数学家G. M. Adelson-Velsky与E.M.Landis发明并以AVL命名。
AVL树的增加与删除节点与普通二叉查找树不同,它是以旋转使树重新达到平衡。分为右旋(顺时针)与左旋(逆时针)两种操作。
右旋(顺时针)
右旋以某个节点为中心上图为15,将17向右沉入其右子节点位置,节点15变为新的根节点,15原右子节点变为17的左子节点。
左旋(逆时针)
左旋为逆时针,以某个基点为中心例如上图18节点,将17节点其沉入其右子节点的位置,18作为新的根节点其原来的左子节点变为17的右子节点。
以上为单纯的旋转,接下来我们看一下添加节点时如何变化。
这是一个AVL树,当我们要添加节点6时如下图。
首先根据普通规则比5大,向右,比8小向左,比7小且7位叶子节点,6被新建到7的左子节点。但是这样8节点下的子树会失去平衡,这时AVL自平衡会开始旋转来使其达到平衡。
以7为中心节点,将8沉入其右子节点位置,7变为子树的根节点,如下图。
通过单次旋转就又使树达到了平衡。
从AVL树中删除可以通过把要删除的节点向下旋转成一个叶子节点,接着直接剪除这个叶子节点来完成。
红黑树
红黑树(Red Black Tree) 是一种自平衡二叉查找树,在1972年由Rudolf Bayer发明的,当时被称为平衡二叉B树(symmetric binary B-trees)。后来,在1978年被 Leo J. Guibas 和 Robert Sedgewick 修改为如今的“红黑树”。
红黑树和AVL树类似,都是在进行插入和删除操作时通过特定操作保持二叉查找树的平衡,从而获得较高的查找性能,它可以在O(log n)时间内做查找,插入和删除。
相对于二叉查找树,红黑树又添加了以下限制。
1、每个树节点会被染色,每个节点只能是红色或者黑色。
2、根节点一定是黑色的。
3、NIL节点为黑色,NIL节点为所有叶子节点的虚拟子节点。
4、不可出现父子节点都为红色的情况。
5、从某一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
以上约束条件保证了之前说的增删查的复杂度均在O(log n)级别。第五点也是保证子树平衡的关键。
接着上一篇HashMap源码接着看,在HashMap类中有一个TreeNode内部静态类该类就是红黑树在代码中定义的样子。
LinkedHashMap.Entry类继承了HashMap.Node<K,V>类,TreeNode继承了LinkedHashMap.Entry类,算是Node类的子类了。
TreeNode只有一个构造方法调用的其实是HashMap类中的静态类Node<K,V>的构造方法,熟悉的配方,放入hash值,key值与val值,及next节点。
自带五个成员变量,红黑树父节点,左树节点,右树节点,需要断开链接的节点,是否为红色。
树化
根据上篇public V put(K key, V value)方法调用putVal(hash(key), key, value, false, true)方法内。
当超过阈值将触发树化方法treeifyBin(tab, hash);
树化前方法,首先判断树化的第二个限制条件,tab数组长度要大于等于64才会真正树化,如果小于则进行resize进行扩容。
开始对原链表进行操作,首先使用replacementTreeNode方法替换所有链表中原Node节点为新TreeNode节点。
形成新的链表。然后进行treeify树化方法。
树化开始遍历刚才新树节点链表,如果root节点为空,首先设置root节点,root节点父节点为空,染色为黑色,设置root左右子节点为空,自己包含原Node节点的信息,hash,key,val,next。
如果root不为空则开始遍历root节点,进行二叉查找树插入操作。与父节点的hash值比较小于放左边为左子节点,大于放右边作为右子节点。如果hash值相等,那么判断k是否实现了comparable接口,如果实现了comparable接口就使用compareTo进行进行比较。
如果仍旧相等或者没有实现comparable接口,则在tieBreakOrder中比较,用两者的类名进行比较,如果相同则使用对象默认的hashcode进行比较。
最后使用root = balanceInsertion(root, x);方法做插入平衡处理,由于这个方法引用了其他方法,我们过会再看。
接下来先看moveRootToFront方法,用来将root节点放入到哈希槽中,保证其处于哈希桶的头部。
index为tab数组头部指针,为第一个节点,如果root不是第一个节点,声明rn节点即root的下一个节点,rp为root的前一个节点,root赋值给哈希桶头部,如果rn不为null则将rn的前一个节点赋值为rp,如果rp不为null,则将rp的next节点赋值为rn,如果原来哈希桶头部的节点不为null,则将它的前一个节点赋值为root节点,root的next节点赋值为first节点,root节点的prev节点赋值为null。
最后使用checkInvariants方法来检查是否满足红黑树的结构。
左旋
旋方法rotateLeft,前面我们已经说过了传入树节点p,代表上图的17节点,r代表节点p的右子节点18,pp表示17的父节点,rl指的是节点18的左子节点19。
第一层判断p节点不为空,p的右子节点不为空,17与18必须存在才能进行左旋,如果不满足条件则直接返回root节点。
第一步将r节点18的左节点19指向p节点17的右子节点,同时将变量rl指向18的左子节点,不为空则将rl节点19的父节点指向p节点17。
第二步将p节点17的父节点为null时,将r节点18设为root节点,染色为黑色。
如果17为其父节点的左子节点,则将r节点18挂到原来17父节点的左子节点,相反挂到右子节点。
最后将p节点17指向到r节点18的左侧,并将p节点17的父节点指向r节点18.
到此左旋操作完成,这段代码其实就是完成了上图的操作。
右旋操作同理,这里不再赘述,右旋代码同样也是完成了上图的操作流程。
balanceInsertion
了解了左旋与右旋方法后我们再来看看刚才提到过的balanceInsertion方法,插入节点后用来保持红黑树平衡特性的代码,比较复杂分很多种情况。
首先我们来看一下新插入的节点首先被染为红色x.red = true;
定义TreeNode<K,V>节点 xp(x的父节点), xpp(x的爷爷节点), xppl(左叔叔节点), xppr(右叔叔节点)。
开始无条件循环,只能从内部跳出,首先看第一个判断A,将x.parent赋值给xp,如果为空(A=true),表示x节点为根节点,x.red = false;染为黑色,直接返回x节点。
如果不为空(A=false),x节点的父节点是黑色或者x的爷爷节点为空,则直接返回root节点。因为x节点已经直接在root节点下了,无需做平衡处理。
接下来我们来看第二个判断B与C两种情况.
如果父节点是爷爷节点的左子节点(B),开始判断B1与B2两种情况,如果x右叔叔节点不为空且为红色(B1)。则将右叔叔节点染为黑色,父节点染为黑色。爷爷节点染为红色,将爷爷节点指向x。
如果x右叔叔节点为空或者为黑色(B2)时,判断当前x节点为父节点的右子节点时(B2_a),父节点左旋,并获得新的爷爷节点,进行下一次循环。再判断当前节点父节点不为空时(B2_b),将xp染色为黑色,xpp不为null时,xpp染色为红色,并将xpp右旋后返回root节点继续循环。
接下来我们来看第二个判断C情况,当前节点的父节点是爷爷节点的右子节点(C)。
开始判断C1与C2两种情况,x的左叔叔节点不为空且为红色(C1).则将左叔叔节点染色为黑色,父节点染色为黑色,爷爷节点染色为红色,xpp指向x并开始下一次循环。
x的左叔叔节点为空或者为黑色(C2),先判断当前节点为父节点的左子节点时(C2_a),对当前节点的父节点进行右旋操作,并重新指定xpp节点。再判断当前节点x父节点不为空时(C2_b),将其父节点染为黑色,如果其爷爷节点为红色时,对其爷爷节点进行左旋操作,返回root节点继续循环,一直到满足A判断条件跳出循环。
接下来我们图解一下流程。
首先插入9,复合A情况染色为黑色直接返回,再插入4,默认为红色,其父节点9为黑色,且爷爷节点为空,同样直接返回root节点9.
再次插入8,按照二叉查找树插入简单判断放到叶子节点4的右子节点上。8的父节点为爷爷节点的左子节点,复合B情况,进入下一层判断,右叔叔节点为空则进入B2场景,由于8为4的右子节点,所以先左旋,8的父节点不为空是9重新赋值给xpp,然后在右旋返回root节点。
继续添加2,复合B1情况2的右叔叔节点不为空切为红色,只需要右叔叔,父节点变黑色,爷爷节点变红色,下一次循环会被变为黑色,继续添加节点5,5的xp节点为黑色!xp.red代码直接返回root节点,继续添加6,父节点为爷爷节点的右子节点,进入C场景,C1判断其左叔叔节点不为null且为红色,开始变色处理,左叔叔与父节点变为黑色,爷爷节点4变为红色,下一次循环开始从节点4处理。
继续插入18,18的父节点为黑色,直接返回root,不做任何变化。继续插入30到18节点的右子节点,其父节点为爷爷节点的右子节点,进入C情况,其左叔叔节点为空,进入C2,其父节点的右子节点且父节点不为空进入C2_b,父节点变为黑色,爷爷节点不为空变为红色,之后进行左旋操作。
接着插入22,22的父节点为爷爷节点的右子节点,且其左叔叔节点为黑色,进入C1情况,开始变色,父节点与左叔叔节点变为黑色,爷爷节点变为红色。
到这里就看完了红黑树插入的流程,接下来我们来看一下删除流程。
红黑树的删除流程分两大块,一块是普通的二叉查找树删除,一块是红黑树删除后的自平衡处理,就想插入一样,插入时先根据二叉查找树性质插入,插入后再进行自平衡和染色处理。
二叉查找树的删除,总共分为三种情况。
1、删除节点下无子树或子节点时,直接剪除即可。
2、删除节点下只有左子树或者右子树,或者只有左子节点或右子节点,保存其子节点或子树信息,删除当前节点,将刚才保存的信息挂到被删除节点的位置。
3、删除带左右子树或者左右子节点的节点时,先保存其左右子树或左右子节点信息,通常获取右子树一直遍历右子树的左子树直到最后一个左叶子节点(比左子树都大,但是时右子树最小的)继承被删除节点的位置,然后将刚才保存的左右子树或子节点挂到该替换节点下。
首先判断tab数组为空或长度为0时直接return。index为当前节点的指针,当前节点赋值给first与root,rl节点为根节点的左子节点,succ为当前节点的下一个节点,prev为当前节点的上一个节点。
当前节点前一个节点为空时,将其下一个节点覆盖到当前节点,如果不是就将下一个节点赋值给当前节点上一个节点的next上,如果succ节点不为空则将其前一个节点的prev指向当前节点的上一个节点,当前节点为空时直接return,其实就是删除了节点后将被删除节点的前后节点链接起来,然后重新把根节点赋值到root上。
如果root为null或者root有右子节点为null,root的左子节点或者左子节点的左子节点为空时,由于该哈希桶中节点太少,需要反树化,重新变为链表。
声明树节点p变量为当前节点,变量pl当前节点的左子节点,pr当前节点的右子节点,变量replacement用来替换被删除节点的位置。
pl != null && pr != null对应刚才提到的第三种情况,左右子节点都不为空的情况。
首先需要继任节点,一直寻找pr即右子节点的最左叶子节点的场景,将这个右子树最左叶子节点s的颜色赋值给c,然后将要被删除节点的颜色赋值给s节点,最后把s节点的原颜色赋值给p节点。
定义sr,s节点的右子节点,pp为被删除节点p的父节点,如果被删除的p节点的右子节点等于s,直接将s赋值成为p节点的父节点,p节点变为s节点的右子节点,直接对调两者位置。
如果不是,则定义sp,s节点的父节点,sp不为null,s为sp的左子节点或者右子节点,则s替换为p,p节点的左子节点设置为null。
s有右子节点,则把p设置为sr的父节点,s的左子节点替换为p的左子节点,s父节点替换为p节点父节点,p为原来pp的左还是右子节点就把s挂到pp的左或者右子节点上,如果sr不为null替换节点为sr,否则为p。
接下来对应删除的第一种情况与第二种情况,相对简单很多。pl不为空直接把replacement赋值为pl,pr不为空直接把replacement赋值为pr,左后就是左右子都为空直接把replacement赋值为p。
replacement不为p则定义pp为被删除p的父节点,同时赋值给replacement的父节点,replacement直接替换p的位置,p原来的左右父被设置为null。
然后使用balanceDeletion方法来做自平衡与染色。
replacement为p,replacement直接替换p的位置,p原来的左右子节点被设置为null直接剪除p。movable通常都为true,把树的root节点放到哈希槽中。
接下来我们看一下红黑树删除节点后的的自平衡颜色处理。
balanceDeletion
接下来看一下balanceDeletion方法。
与balanceInsertion类似,也是进行for循环直到删除节点后保持红黑树特性。首先定义xp,为传入x节点的父节点,xpl为xp节点的左子节点,xpr为xp节点的右子节点(注意这里的x节点为replacement节点并不是被删除的节点而是替代节点)。
循环首先开始判断x是否为空或者为root节点如果是直接return跳出,如果x的xp节点为null则只讲x节点染色为黑色后return x,x为红色时会被染色为黑色并return root节点。
以上为简单情况,接下来我们看一下后面复杂的情况判断。
A 判断当前删除节点是否为左子节点(xpl = xp.left) == x
A1 被删除节点右兄弟节点不为空且为红色节点时。设置右兄弟节点为黑色,父节点为红色,父节点左旋,xpr重新赋值。
A2 xpr为空时,xp赋值给x。
A3 xpr不为空时, sl为左兄弟节点,sr为右兄弟节点。
A3_1 这两个节点都为空或者为黑色时, 将右兄弟节点赋值为红色,xp赋值给x。
A3_2_1 如果sr为空,或者为黑色时,sl不为空时sl设为黑色,右兄弟节点设为红色,进行右旋,重新给xpr赋值。
A3_2_2 如果xpr不为空,xp为空xpr设置黑色,如果不是设置为xp的颜色,如果xpr的右子节点赋值给sr不为空,sr节点设置为红色。
A3_2_3 xp不为空,xp设置为黑色,进行右旋,最后root赋值x。
B情况为A情况的对称代码。
下面我们来举两个例子来看一下流程。
第一个例子比较简单。
第二个例子删除18节点。
>>>复制左子节点9到18的位置后将原来的9节点断开。
>>>自平衡9节点右旋。
>>>最后重新染色完成删除后的平衡与染色处理。
到此TreeNode操作删除完成。
find
最后看一下查询方法final TreeNode<K,V> find(int h, Object k, Class<?> kc)。
h为要查找节点的hash值,k为要查找节点的key值,hash值就是二叉查找树比较大小的值,大的节点放到右边,小的放到左边。
p节点为当前节点,ph为当前节点的hash值,dir为Comparable接口的比较结果,pk为当前节点的key值,pl为当前节点的左子节点,pr为右子节点,q为找到的节点。
开始循环超找直到p节点为空,其实就是轮训所有树节点。
当前节点ph值大于h时,将pl赋值给p继续循环,相反pr赋值给p继续循环。其实就是根据普通二叉查找树原理根据大小向左或右子树继续进行节点遍历。
pk==k或者k不为空且kk.equals(pk)时,就说明找到了,直接返回p。
如果pl为null即左子树遍历完毕,则pr赋值给p继续遍历右子树,相反右子树遍历完毕就去遍历左子树。
正常kc为null,除非有自己定义的比较器比较才会根据自定义比较器比大小根据0,1,-1判断是去左子树遍历还是右子树遍历。
最后以上都不满足,就递归调用find遍历右子树,直到直到q为止。
最后所有节点遍历完毕,还是没有找到,则返回null。
以上就是红黑树的插入,删除,查找在Java代码由TreeNode类的实现。
- END -