我的原则:先会用再说,内部慢慢来。
学以致用,根据场景学源码
一、架构
二、特性
2.1 二叉查找树的特点
任意一个节点所包含的键值,大于等于左孩子的键值,小于等于右孩子的键值。
2.2 红黑树的特点
- 红黑树(Red-Black Tree,简称R-B Tree),它一种特殊的二叉查找树。
- 节点要么黑色,要么红色
- 根节点是黑的
- 叶子节点也是黑的 [注意:这里叶子节点,是指为空的叶子节点!]
- 如果一个节点是红色的,则它的儿子必须是黑色的。
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。(确保没有一条路径会比其他路径长出俩倍。因而,红黑树是相对是接近平衡的二叉树。)
总结: 非黑即红,头尾是黑,红儿是黑,各路同黑。
2.3 红黑树的基本操作
- 添加
- 删除
- 旋转。
- 为什么要旋转?
- 旋转的目的是让树保持红黑树的特性。
- 添加或删除红黑树中的节点之后,红黑树就发生了变化,可能不满足红黑树的5条性质,也就不再是一颗红黑树了,而是一颗普通的树。而通过旋转,可以使这颗树重新成为红黑树。
- 旋转包括两种:左旋 和 右旋。
2.3.1 【左旋】
2.3.1.1 图示与步骤
- 先检查 P 是不是空,并且 P 的右边有东西
- P 的右指针指向了 Y 的左节点
- 原先 Y 的左节点换爸爸
- Y 的爸爸换成了 P 的爸爸。由于 P 的爸爸比较顶部,所以假如原先 root (root 指向的节点的爸爸是 null) 指向了 P 话,那么此时 root 改为指向了 Y ,并且由于红黑树性质“头尾是黑”,此时 Y 的节点的 red 肯定是 false
- 顶部爸爸换儿子(原先儿子是 P, 现在要变成 Y),先判断原先孩子P到底是左孩子,还是右孩子,然后再替换
- Y 的左孩子变成了 P
- P 的爸爸变成了 Y
2.3.1.2 代码(HashMap1.8)
- HashMap中红黑树的代码(jdk1.8)
- java.util.HashMap.TreeNode#rotateLeft
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> y, pp, yl;
// 1. 先检查 P 是不是空,并且 P的右边有东西
if (p != null && (y = p.right) != null) {
// 2. P 的右指针指向了 Y 的左节点
if ((yl = p.right = y.left) != null)
// 3.原先 Y 的左节点换爸爸
yl.parent = p;
// 4. Y 的爸爸换成了 P 的爸爸。
if ((pp = y.parent = p.parent) == null)
// 假如 “原先 root 指向 P”,进入此代码
(root = y).red = false;
// 5. 顶部爸爸换儿子(原先儿子是 P, 现在要变成 Y)
// 先判断原先孩子P到底是左孩子,还是右孩子,然后再替换
else if (pp.left == p)
// 原先是左孩子
pp.left = y;
else
// 原先是右孩子
pp.right = y;
// 6. Y 的左孩子变成了 P
y.left = p;
// 7. P 的爸爸变成了 Y
p.parent = y;
}
return root;
}
2.3.2 【右旋】
2.3.2.1 图示与步骤
- 先检查 P 是否为空,并且检查 P 是否有左孩子
- P 的左指针指向了 X 的右孩子
- 原先 X 的右节点换爸爸
- X 的爸爸换成了 P 的爸爸。由于 P 的爸爸比较顶部,所以假如原先 root (root 指向的节点的爸爸是 null) 指向了 P 话,那么此时 root 改为指向了 Y ,并且由于红黑树性质“头尾是黑”,此时 Y 的节点的 red 肯定是 false
- 顶部爸爸换儿子(原先儿子是 P, 现在要变成 Y),先判断原先孩子P到底是左孩子,还是右孩子,然后再替换
- X 的右儿子变成 P
- P 的爸爸变成了 X
2.3.2.2 代码(HashMap1.8)
- HashMap中红黑树的代码(jdk1.8)
- java.util.HashMap.TreeNode#rotateRight
static <K,V> HashMap.TreeNode<K,V> rotateRight(HashMap.TreeNode<K,V> root,
HashMap.TreeNode<K,V> p) {
HashMap.TreeNode<K,V> x, pp, lr;
// 1. 先检查 P 是否为空,并且检查 P 是否有左孩子
if (p != null && (x = p.left) != null) {
// 2. P 的左指针指向了 X 的右孩子
if ((lr = p.left = x.right) != null)
// 3. 原先 X 的右节点换爸爸
lr.parent = p;
// 4. X 的爸爸换成了 P 的爸爸
if ((pp = x.parent = p.parent) == null)
// 假如 “原先 root 指向 P”,进入此代码
(root = x).red = false;
else if (pp.right == p)
// 原先是右孩子
pp.right = x;
else
// 原先是左孩子
pp.left = x;
// 6. X 的右儿子变成 P
x.right = p;
// 7. P 的爸爸变成了 X
p.parent = x;
}
return root;
}
2.3.3 【插入】
- 添加节点的步骤
- 将红黑树当作一颗二叉查找树,将节点插入。
- 将插入的节点着色为"红色"。
- 通过一系列的旋转或着色等操作,使之重新成为一颗红黑树。
- 第一步: 将红黑树当作一颗二叉查找树,将节点插入。
黑树本身就是一颗二叉查找树,将节点插入后,该树仍然是一颗二叉查找树。也就意味着,树的键值仍然是有序的。此外,无论是左旋还是右旋,若旋转之前这棵树是二叉查找树,旋转之后它一定还是二叉查找树。这也就意味着,任何的旋转和重新着色操作,都不会改变它仍然是一颗二叉查找树的事实。
- 第二步:将插入的节点着色为"红色"。( 为什么着色成红色,而不是黑色呢?为什么呢?)
- 先看红黑树的特点
这里将插入的节点着色为红色,不会违背"特性(6)" !
少违背一条特性,就意味着我们需要处理的情况越少。接下来,就要努力的让这棵树满足其它性质即可;满足了的话,它就又是一颗红黑树了。
- 第三步: 通过一系列的旋转或着色等操作,使之重新成为一颗红黑树。
2.3.3.2 简易代码
- 看先简易的实现,理解完再去看HashMap(jdk1.8)中的红黑树实现。
/*
* 将结点插入到红黑树中
*
* 参数说明:
* node 插入的结点
*/
private void insert(RBTNode<T> node) {
// 1. 设置节点的颜色为红色
node.color = RED;
int cmp; // 节点的比较结果 0 1 -1
RBTNode<T> y = null; // 缓冲使用,记录上一个节点
RBTNode<T> x = this.mRoot;
// 2. 将红黑树当作一颗二叉查找树,将节点添加到二叉查找树中。
while (x != null) { // 遍历到最后,没有左孩子或者右孩子了,退出
y = x;// 缓冲使用,记录上一个节点
cmp = node.key.compareTo(x.key);
if (cmp < 0)
x = x.left; // node 比 X 小,那么往左边遍历
else
x = x.right;// node 比 X 大,那么往右边遍历
}
//已经找到并且退出,此时认爸爸,指向了上一个节点 y
node.parent = y;
if (y!=null) { // 为 null 表明是最顶部了
cmp = node.key.compareTo(y.key); // 看下插入的节点是y的左孩子还是右孩子
if (cmp < 0)
y.left = node;
else
y.right = node;
} else {
this.mRoot = node;
}
// 3. 将它重新修正为一颗二叉查找树
insertFixUp(node);
}
2.3.3.2 代码(HashMap1.8)
- HashMap的数据结构 = 数组 + 链表 + 红黑树,要出现红黑树,必须该链表中节点数量达到一定限度才会进行树化。
- 看上图,能到同一个桶的Node,他们的Hash必然相同,不同的是equals的结果,假如hashcode()与equals()方法相同,那么在HashMap中会进行一个覆盖处理。
- 红黑树是二叉树,所以可以允许key值重复,这一点在看不同数据结构的时候,需要甄别理解。
- java.util.HashMap.TreeNode#putTreeVal
注意,这个方法putTreeVal 上游的调用方是一个TreeNode,也是上面图中数组桶黑色的那个最开始的节点
/**
* 当存在hash碰撞的时候,且元素数量大于8个时候,就会以红黑树的方式将这些元素组织起来
* map 当前节点所在的HashMap对象
* tab 当前HashMap对象的元素数组
* h 指定key的hash值
* k 指定key
* v 指定key上要写入的值
* 返回:指定key所匹配到的节点对象,针对这个对象去修改V(返回空说明创建了一个新节点)
*/
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {
// 定义k的Class对象
Class<?> kc = null;
// 标识是否已经遍历过一次树,未必是从根节点遍历的,但是遍历路径上一定已经包含了后续需要比对的所有节点。
boolean searched = false;
// 父节点不为空那么查找根节点,为空那么自身就是根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
// 从根节点开始遍历,没有终止条件,只能从内部退出
for (TreeNode<K,V> p = root;;) {
int dir; // 对比结果 0 1 -1,用来声明方向
int ph; // 节点p的hash值(当前节点hash值)
K pk; // 节点p的key(当前节点的键对象)
if ((ph = p.hash) > h) // 如果当前节点hash 大于 指定key的hash值
dir = -1; // 要添加的元素应该放置在当前节点的左侧
else if (ph < h) // 如果当前节点hash 小于 指定key的hash值
dir = 1; // 要添加的元素应该放置在当前节点的右侧
// 如果当前节点的键对象 和 指定key对象相同(hashCode 与 equals方法结果一样,那么就得覆盖处理)
else if ((pk = p.key) == k || (k != null && k.equals(pk))) /
return p; // 那么就返回当前节点对象,在外层方法会对v进行写入
// 走到这一步说明 当前节点的hash值 和 指定key的hash值 是相等的,但是equals不等
// 打个比方,16个桶,【1,17,33,39】他们的hash值相同,都位于第一个桶里面,但是equals不等、
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// 走到这里说明:指定key没有实现comparable接口 或者 实现了comparable接口并且和当前节点的键对象比较之后相等(仅限第一次循环)
/*
* searched 标识是否已经对比过当前节点的左右子节点了
* 如果还没有遍历过,那么就递归遍历对比,看是否能够得到那个键对象equals相等的的节点
* 如果得到了键的equals相等的的节点就返回
* 如果还是没有键的equals相等的节点,那说明应该创建一个新节点了
*/
if (!searched) { // 如果还没有比对过当前节点的所有子节点
TreeNode<K,V> q, ch; // 定义要返回的节点、和子节点
searched = true; // 标识已经遍历过一次了
/*
* 红黑树也是二叉树,所以只要沿着左右两侧遍历寻找就可以了
* 这是个短路运算,如果先从左侧就已经找到了,右侧就不需要遍历了
* find 方法内部还会有递归调用。参见:find方法解析
*/
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) ||
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null))
return q; // 找到了指定key键对应的
}
// 走到这里就说明,遍历了所有子节点也没有找到和当前键equals相等的节点
dir = tieBreakOrder(k, pk); // 再比较一下当前节点键和指定key键的大小
}
TreeNode<K,V> xp = p; // 定义xp指向当前节点
/*
* 如果dir小于等于0,那么看当前节点的左节点是否为空,如果为空,就可以把要添加的元素作为当前节点的左节点,如果不为空,还需要下一轮继续比较
* 如果dir大于等于0,那么看当前节点的右节点是否为空,如果为空,就可以把要添加的元素作为当前节点的右节点,如果不为空,还需要下一轮继续比较
* 如果以上两条当中有一个子节点不为空,这个if中还做了一件事,那就是把p已经指向了对应的不为空的子节点,开始下一轮的比较
*/
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 如果恰好要添加的方向上的子节点为空,此时节点p已经指向了这个空的子节点
Node<K,V> xpn = xp.next; // 获取当前节点的next节点
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); // 创建一个新的树节点
if (dir <= 0)
xp.left = x; // 左孩子指向到这个新的树节点
else
xp.right = x; // 右孩子指向到这个新的树节点
xp.next = x; // 链表中的next节点指向到这个新的树节点
x.parent = x.prev = xp; // 这个新的树节点的父节点、前节点均设置为 当前的树节点
if (xpn != null) // 如果原来的next节点不为空
((TreeNode<K,V>)xpn).prev = x; // 那么原来的next节点的前节点指向到新的树节点
moveRootToFront(tab, balanceInsertion(root, x));// 重新平衡,以及新的根节点置顶
return null; // 返回空,意味着产生了一个新节点
}
}
}
2.3.4 【删除】
2.3.4.1 图示与步骤
- 宏观步骤:
- 按照二叉查找树的属性,删除节点
- 按照红黑树的规则,通过左旋或者右旋变成一颗红黑树。
- 删除节点的情形:
- 被删除节点没有儿子,即为叶节点。【那么,直接将该节点删除就OK了。】
- 被删除节点只有一个儿子。【那么,直接删除该节点,并用该节点的唯一子节点顶替它的位置。】
- 被删除节点有两个儿子。【那么,先找出它的后继节点;然后把“它的后继节点的内容”复制给“该节点的内容”;之后,删除“它的后继节点”。】
- 情形三中有俩子节点,思考一下后继节点:二叉树是用中序排序,也就是“左中右”的节点排列顺序,那么右继节点也就是“右子树的最左节点”,也就是说,情形三,最终都是转化为情形一与情形二。
- 那么思考一下:
- 假如删除了上图“4”,那么“4”的右子树的最左节点是“5”,也就是把“4”和“5”换个位置,然后按照“情形1”处理,把节点“5”删了。
- 假如删除了上图“14”,那么“14”的右子树的最左节点是“13”,那么也就是“情形2”处理,把后继节点“11”包括“11”下面整个树,往上拉上去。
2.3.4.2 代码(HashMap1.8)
/**
* @param map 用于树化或者链化
* @param tab 桶数组,该 node 位于某一个桶内
*/
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,
boolean movable) {
// ====== section 1:通过prev和next删除当前节点 ======
int n;
if (tab == null || (n = tab.length) == 0)
return;
// 获取桶 index
int index = (n - 1) & hash; // 用与字符取余,看我之前的文章
// first 指向了桶的第一个元素,可能是链表头,也可能是树的 root节点 ,此处是树的根节点
TreeNode<K,V> first = (TreeNode<K,V>)tab[index], rl;
TreeNode<K,V> root = first; // root 指向了 first
TreeNode<K,V> rl; //
// succ 指向了 Node 节点的后继节点 next,对于二叉查找树来说,就是右子树的最左节点 。
TreeNode<K,V> succ = (TreeNode<K,V>)next;
// pred 指向了 this节点的前驱节点
TreeNode<K,V> pred = prev;
// 如果前驱结点是 null,那么他肯定是root节点
if (pred == null)
// 首先 first先指向 succ,也就是this节点的后继节点next
// 然后 tab[index] 指向了 first指向的位置,也就是指向了 this节点的 next
tab[index] = first = succ; // 此处说明一下,假如 succ为空,那么代表这棵树啥都没了
else
// 不是root节点,那么前驱结点的next后继指针指向了this的后继节点,意思就是断开了this节点
pred.next = succ;
// 看上面 succ 原本指向了 this节点的后继节点 next,如果不为空,说明 succ 是一个节点
if (succ != null)
// this的后继节点的 prev前驱指针指向了 this节点的前驱节点,照样是断开了 this节点
succ.prev = pred;
// 这棵树啥都不剩下了。直接 return
if (first == null)
// ************ 此处有 return ************
return;
// ====== section 2:当节点数量小于7时转换成链栈的形式存储 ======
// root的爸爸不是null,那证明不是根节点,那么继续往上面找上去,直到找到真正的 root
if (root.parent != null)
root = root.root();
// 树是空
if (root == null
// 可以移动 并且
|| (movable
// root节点的右子树是空,
&& (root.right == null
// 或者root节点的左子树是空 ,rl指向了左子树
|| (rl = root.left) == null
// 或者左子树的左子树是空
|| rl.left == null))) {
tab[index] = first.untreeify(map); // too small ,此轮条件不判断树种节点的总数量,只判断根的左右子树是否符合链化的条件
// ************ 此处有 return ************
return;
}
// ====== section 3:判断当前树节点情况 ======
// p 指向当前节点, pl 当前Node的左子树,pr当前Node的右子树
TreeNode<K,V> p = this, pl = left, pr = right, replacement;
// 有俩孩子,属于情形三,那么就必须找到右子树的最左节点,也就是转化为情形1或者情形2
if (pl != null && pr != null) {
// s 指向thisNode 的右子树
TreeNode<K,V> s = pr, sl;
// s 是为了找到最左节点
while ((sl = s.left) != null) // find successor
s = sl;
boolean c = s.red; s.red = p.red; p.red = c; // swap colors todo
// sr 指向了最左节点的右子树,因为 s已经是最左节点了,所以s肯定没有左子树
TreeNode<K,V> sr = s.right;
// pp 指向了 p的父亲(p在上面指向了this节点)
TreeNode<K,V> pp = p.parent;
// s是当前节点右子树的最左节点,pr是当前节点的右子树,s == pr 说明右子树的最左节点就是当前节点的右子树
// demo:看上图的红黑树,假如要删除“18”,那么“18”的s 右子树的最左节点是“19”,pr是当前节点的右子树也是“19”,那么他们俩互相换位置
if (s == pr) { // p was s's direct parent
// 那么互换位置
p.parent = s;
s.right = p;
}
// 如果不是上面那个情况,比如此时要删除的是节点“18”,那么
else {
// s是当前节点右子树的最左节点,此时s是节点“15”
// sp是节点“15”的爸爸节点“16”,那么此时要把 节点“18” 和s 节点“15”换个位置
TreeNode<K,V> sp = s.parent;
// 当前节点与右子树的最左节点换位置
// 当前节点换爸爸
if ((p.parent = sp) != null) {
// 替换元素换儿子
// s 是 sp的左孩子,
if (s == sp.left)
sp.left = p;
// s 是 sp的右孩子
else
sp.right = p;
}
// s的右指针指向了原先节点的右子树,也就是接手他的右子树
if ((s.right = pr) != null)
// 原本的右子树指针换个爸爸
pr.parent = s;
}
// 当前这个要删除的节点已经换到右子树的最左节点了,那么他肯定没有左孩子
p.left = null;
// 假如删除的节点还有右孩子,那么就接住
// 比如要删除的节点是“4”,那么把“4”和“5”换位置之后,原先“4”的右子树还是得接住
if ((p.right = sr) != null)
sr.parent = p;
// s目前已经是替换位置完毕的了,已经上位的了,如果s有左子树,那么接住
if ((s.left = pl) != null)
pl.parent = s;
// s目前已经是替换位置完毕的了,如果他的爸爸是null,那么s就是根节点root
if ((s.parent = pp) == null)
root = s;
// 如果一开始要删除的节点p位于他父亲pp的 左边,那么接住
else if (p == pp.left)
pp.left = s;
// 如果一开始要删除的节点p位于他父亲pp的 右边,那么也接住
else
pp.right = s;
// 如果“替换节点”有右孩子,那么替换他的右边孩子。
// 比如上图,你要删除的是p节点“14”,那么找到右子树的最左节点s是“11”,那么此时要把他的右孩子“13”给换上去
if (sr != null)
replacement = sr;
else
// 如果“替换节点”没有右孩子,那么替换节点就是她自己
// 比如上图,你要删除的p节点是“6”,由于他没有右边的孩子,所以把“5”直接换了即可
replacement = p;
}
// 情形1,只有一个孩子,那么直接替换即可
else if (pl != null)
replacement = pl;
// 情形1,只有一个孩子,那么直接替换即可
else if (pr != null)
replacement = pr;
// 没有左子树 并且没有右子树 ,那么属于情形1,直接删除该节点即可
else
replacement = p;
// ====== section 4:实现删除树节点逻辑 ======
if (replacement != p) {
TreeNode<K,V> pp = replacement.parent = p.parent;
if (pp == null)
root = replacement;
else if (p == pp.left)
pp.left = replacement;
else
pp.right = replacement;
p.left = p.right = p.parent = null;
}
TreeNode<K,V> r = p.red ? root : balanceDeletion(root, replacement);
if (replacement == p) { // detach
TreeNode<K,V> pp = p.parent;
p.parent = null;
if (pp != null) {
if (p == pp.left)
pp.left = null;
else if (p == pp.right)
pp.right = null;
}
}
if (movable)
moveRootToFront(tab, r);
}
未完待续…