前言
之前的博客HashMap源码分析一(jdk8)已经分析了HashMap的一部分,现在主要来分析HashMap中涉及到红黑树的部分。
一、HashMap的红黑树应用
在jdk1.8以后,HashMap才用到了红黑树,主要为了改善链表过长的效率问题。因为红黑树是一种特殊的二叉树,所以我们从二叉树开始讲起。
1、二叉树
二叉树
二叉树是每个结点最多有两个子树的树结构。通常子树被称作“左子树”(left subtree)和“右子树”(right subtree)。
满二叉树
除了叶结点外每一个结点都有左右子叶且叶子结点都处在最底层的二叉树。
完全二叉树
在一棵二叉树中,除最后一层外,若其余层都是满的,并且最后一层或者是满的,或者是在右边缺少连续若干节点,则此二叉树为完全二叉树。
平衡二叉树
平衡二叉树又称为AVL树,它是一种二叉排序树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。
2、二叉树的遍历方式
分为前序遍历、中序遍历和后序遍历。具体的概念可以参考该博客关于二叉树的前序、中序、后序三种遍历。
3、红黑树
红黑树是一种弱平衡的二叉查找树,它的统计性能要好于平衡二叉树,它的查找、插入和删除的时间复杂度都为O(logn)。红黑树是每个节点都带有颜色属性的二叉查找树,非红即黑。它除了满足二叉查找树的一些属性外,还有一些特定的属性:
- 性质1. 节点是红色或黑色。
- 性质2. 根节点是黑色。
- 性质3 每个叶节点(NIL节点,空节点)是黑色的,注:有的图中经常把叶节点省略。
- 性质4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 性质5. 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
从以上的约束可以得出红黑树的一个重要的特性:从根节点到叶节点的最长路径不大于最短路径的2倍。这个保证了红黑树最坏情况下查找、插入和删除的效率。
在对树进行插入或删除操作时,将破坏红黑树的特性,为了维持这些特性,我们将对其进行旋转和重新配色的操作。旋转分为左旋和右旋,如下图所示:
左旋
如上图所示,当在某个结点pivot上做左旋操作时,我们假设它的右孩子y不是叶节点。左旋以pivot到Y之间的链为“支轴”进行,它使Y成为该子树的新根,而Y的左孩子b则成为pivot的右孩子。
右旋:
同理,只是跟左旋的过程相反。
4、HashMap中红黑树的实现
以上讲了红黑树的相关概念,现在看看HashMap中的相关代码。
4.1、基本结构与属性
TreeNode为HashMap的静态内部类:
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 父节点
TreeNode<K,V> left;//左孩子
TreeNode<K,V> right;//右孩子
TreeNode<K,V> prev; // 前一个节点
boolean red; //是否为红色
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
上面就是红黑树的基本结构,它包括父节点、左孩子、右孩子还有颜色属性,由于继承了LinkedHashMap.Entry<K,V>,所以还拥有如下的一些属性:
//继承至LinkedHashMap.Entry<K,V>的属性
Entry<K,V> before, after;
//继承至HashMap.Node<K,V>的属性
final int hash;
final K key;
V value;
Node<K,V> next;
4.2、与红黑树相关的三个重要参数
- TREEIFY_THRESHOLD:树化阈值,当桶中链表的长度大于8时将其转换为红黑树。
- UNTREEIFY_THRESHOLD:链表化阈值,当resize或删除操作时,桶中的元素数低于该值,将把红黑树转换成链表。
- MIN_TREEIFY_CAPACITY:容器可以被树形化的最小容量,当hash表中的容量大于这个值时才会树形化,否则只是扩容。它的值应该至少是4 * TREEIFY_THRESHOLD,以避免调整大小和树状化阈值之间的冲突。
4.3、TreeNode中一些重要的方法
getTreeNode:根据hash值和键获取树的节点。
final TreeNode<K,V> getTreeNode(int h, Object k) {
//从根节点遍历查找
return ((parent != null) ? root() : this).find(h, k, null);
}
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
TreeNode<K,V> p = this;
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
//以下是根据二叉树的特性来查找
//如果当前的hash值大于要查找的hash值,从左子树中查找
if ((ph = p.hash) > h)
p = pl;
//如果当前的hash值小于要查找的hash值,从右子树中查找
else if (ph < h)
p = pr;
//如果键值相等,直接返回当前树节点
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null)
p = pr;
else if (pr == null)
p = pl;
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) &&
(dir = compareComparables(kc, k, pk)) != 0)
p = (dir < 0) ? pl : pr;
else if ((q = pr.find(h, k, kc)) != null)
return q;
else
p = pl;
} while (p != null);
return null;
}
由于遍历是通过左右子树来折半查找,若hash值小于遍历对象,就从左子树查找;若hash值大于遍历对象,就从右子树查找。当hash值相等后,再判断键值是否相等,若相等则返回当前树节点。整个查找的效率不错,时间复杂度为O(logn)。
treeifyBin:当满足树化阈值时,将链表转换为红黑树。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//当小于容器可以被树形化的最小容量时,只是进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//以当前链表节点新建树节点
TreeNode<K,V> p = replacementTreeNode(e, null);
//将链表的节点值依次赋值给树节点
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
//让桶的第一个元素指向树,也就是将原来的链表替换掉了
if ((tab[index] = hd) != null)
//对树进行左旋、右旋及重新配色操作,以维持红黑树的特性
hd.treeify(tab);
}
}
// For treeifyBin
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
以上链表树化的过程可以总结如下:
1.判断链表的容量是否大于树形化的最小容量,若小于,进行扩容操作。
2.若大于,遍历当前链表,将链表中的值依次赋值给树。
3.遍历完后,将桶中的第一个元素指向新建的树,将原来的链表替换掉。
4.为了维持红黑树的‘一些特性,将对树进行左旋、右旋和重新配色等复杂操作。’
对树进行左旋、右旋和重新配色等复杂见以下代码:
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
//给树设置根节点的特性,颜色为黑色
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
//当前树节点不是跟节点的情况
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//再次从跟节点遍历,与当前x树节点比较来调整位置
for (TreeNode<K,V> p = root;;) {
int dir, ph;
K pk = p.key;
//如果当前x树节点小于遍历节点,将dir设置为-1
if ((ph = p.hash) > h)
dir = -1;
//如果当前x树节点小于遍历节点,将dir设置为-1
else if (ph < h)
dir = 1;
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
//如果hash值和键值都相等时或键值无法比较时,将通过特定的规则比较
//这时并不要求完全有序,只需要插入时使用相同的规则保持平衡即可
dir = tieBreakOrder(k, pk);
//把遍历的节点变成x的父节点
//若x小于它,就当做左子树,否则就当做右子树
TreeNode<K,V> xp = p;
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//当插入新节点时,为了维持红黑树的特性,进行左旋,右旋以及配色等复杂操作
root = balanceInsertion(root, x);
break;
}
}
}
}
//确保红黑树的根节点是桶的第一个节点
moveRootToFront(tab, root);
}
以上涉及到了核心的点,当红黑树插入新节点时,为了维持其的平衡特性,需要进行红黑树的左旋、右旋以及配色操作。接下来看下代码是怎么实现的?
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
//新插入的节点标记为红色 TreeNode<K,V> x) {
x.red = true;
//xp:父节点 xpp:祖父节点 xppl:坐叔叔节点 xppr:右叔叔节点
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) {
//若x的父节点为空,说明x为根节点,将x节点标记为黑色,并返回x节点
if ((xp = x.parent) == null) {
x.red = false;
return x;
}
//若x节点的父节点为黑色或者x节点的祖父节点为空,则返回跟节点root
else if (!xp.red || (xpp = xp.parent) == null)
return root;
//若x的父节点是祖父节点的左孩子,即它等于左叔叔节点
if (xp == (xppl = xpp.left)) {
//若右叔叔节点不为空且为红色
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;//右叔叔节点置为黑色
xp.red = false;//父节点置为黑色
xpp.red = true;//祖父节点置为红色
x = xpp;//把当前节点x设置为祖父节点,作为下次循环的值
}
//若右叔叔节点为空或为黑色
else {
//若x节点为父节点的右子树
if (x == xp.right) {
root = rotateLeft(root, x = xp);//父节点进行左旋操作,参考下文的左旋方法rotateLeft解析
xpp = (xp = x.parent) == null ? null : xp.parent;//获取左旋后的祖父节点
}
if (xp != null) {//若父节点不为空,
xp.red = false;//将父节点置为黑色
if (xpp != null) {//若祖父节点不为空
xpp.red = true;//将祖父节点置为红色
root = rotateRight(root, xpp);//将祖父节点右旋,参考下文的右旋方法rotateRight解析
}
}
}
}
//若x的父节点是祖父节点的右孩子,即它等于右叔叔节点
else {
//若左叔叔节点不为空且为红色
if (xppl != null && xppl.red) {
xppl.red = false;//将左叔叔节点置为黑色
xp.red = false;//父节点置为黑色
xpp.red = true;//祖父节点置为红色
x = xpp;//把当前节点x设置为祖父节点,作为下次循环的值
}
//若左叔叔节点为空或为黑色
else {
//若x节点为父节点的左子树
if (x == xp.left) {
root = rotateRight(root, x = xp);//父节点进行右旋操作,参考下文的右旋方法rotateRight解析
xpp = (xp = x.parent) == null ? null : xp.parent;//获取右旋后的祖父节点
}
if (xp != null) {//若父节点不为空
xp.red = false;//将父节点置为黑色
if (xpp != null) {//若祖父节点不为空
xpp.red = true;//将祖父节点置为红色
root = rotateLeft(root, xpp);//将祖父节点左旋,参考下文的左旋方法rotateLeft解析
}
}
}
}
}
}
rotateLeft:左旋
大体可以理解为要旋转的节点至右子树为轴进行逆时针旋转。
//root 根节点
//p 要左旋的节点
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> r, pp, rl;
if (p != null && (r = p.right) != null) { // 要左旋的节点以及要左旋的节点的右孩子不为空
if ((rl = p.right = r.left) != null) // 要左旋的节点的右孩子的左节点 赋给 要左旋的节点的右孩子 节点为:rl
rl.parent = p; // 设置rl和要左旋的节点的父子关系【之前只是爹认了孩子,孩子还没有答应,这一步孩子也认了爹】
// 将要左旋的节点的右孩子的父节点 指向 要左旋的节点的父节点,相当于右孩子提升了一层,
// 此时如果父节点为空, 说明r 已经是顶层节点了,应该作为root 并且标为黑色
if ((pp = r.parent = p.parent) == null)
(root = r).red = false;
else if (pp.left == p) // 如果父节点不为空 并且 要左旋的节点是个左孩子
pp.left = r; // 设置r和父节点的父子关系【之前只是孩子认了爹,爹还没有答应,这一步爹也认了孩子】
else // 要左旋的节点是个右孩子
pp.right = r;
r.left = p; // 要左旋的节点 作为 他的右孩子的左节点
p.parent = r; // 要左旋的节点的右孩子 作为 他的父节点
}
return root; // 返回根节点
}
以上左旋的过程可以结合图来了解:
rotateRight:右旋
大体可以理解为要旋转的节点至左子树为轴进行顺时针旋转。
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> l, pp, lr;
if (p != null && (l = p.left) != null) { // 要右旋的节点不为空以及要右旋的节点的左孩子不为空
if ((lr = p.left = l.right) != null) // 要右旋的节点的左孩子的右节点 赋给 要右旋节点的左孩子 节点为:lr
lr.parent = p; // 设置lr和要右旋的节点的父子关系【之前只是爹认了孩子,孩子还没有答应,这一步孩子也认了爹】
// 将要右旋的节点的左孩子的父节点 指向 要右旋的节点的父节点,相当于左孩子提升了一层,
// 此时如果父节点为空, 说明l 已经是顶层节点了,应该作为root 并且标为黑色
if ((pp = l.parent = p.parent) == null)
(root = l).red = false;
else if (pp.right == p) // 如果父节点不为空 并且 要右旋的节点是个右孩子
pp.right = l; // 设置l和父节点的父子关系【之前只是孩子认了爹,爹还没有答应,这一步爹也认了孩子】
else // 要右旋的节点是个左孩子
pp.left = l; // 同上
l.right = p; // 要右旋的节点 作为 他左孩子的右节点
p.parent = l; // 要右旋的节点的父节点 指向 他的左孩子
}
return root;
}
参考博客:
https://blog.csdn.net/u011240877/article/details/53358305
https://blog.csdn.net/weixin_42340670/article/details/80550932