👈👈👈 欢迎点赞收藏关注哟
首先分享之前的所有文章 >>>> 😜😜😜
文章合集 : 🎁 juejin.cn/post/694164…
Github : 👉 github.com/black-ant
CASE 备份 : 👉 gitee.com/antblack/ca…
一. 前言
闲来无事,对 HashMap 其中比较重要的节点做了一下深入,然后尝试用最通俗易懂的说法输出出来。
老规矩 ,给节省时间的小伙伴上菜 :
二. HashMap 的树化
初级入门
之前面试别人的时候,就经常问这问题 ,问的比较多的一个点就是 HashMap 的树化 ,这个问题的标准答案也很简单 :
- 在 Java 1.8 之前 , HashMap 通过数组 + 链表的数据结构来处理哈希冲突。当多个键具有同一个哈希码时,他们会存储在一个哈希桶中,形成一个链表结构
- 在 Java 1.8 之后, 也是数组 + 链表,但是当链表的长度超过一定阈值后,会转变成红黑树结构,这个行为就叫树化
会了这两点,一般就认为对HashMap有一定了解了,初级就算过了,但是如果是中级就会稍微问多点
中级浅谈 :
- 如何实现的链表?
每一个写入 HashMap 的对象会被封装到 Node 对象中,这个对象中有四个属性 ,其中一个属性同样是一个 Node 对象 ,用来指向链表中下一个 Node
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
// next 即为链表中下一个 Node
Node next;
}
- 如何实现的红黑树?
基于 TreeNode 对象实现, 该对象包含了 TreeNode-Left ,TreeNode-Right , TreeNode-Parent对象,用于实现树形结构
// 实际每个 TreeNode 包含了数据信息
TreeNode extends Node {
// 父节点
TreeNode parent;
// 左子节点
TreeNode left;
// 右子节点
TreeNode right;
// 上一节点用于生成双向链表,便于树形操作
TreeNode prev;
boolean red;
}
- 为什么会有 Hash 冲突?
因为 HashMap 的底层是数组,而一个元素放在哪个数组格里面是通过 Hash & 数组长度来实现的,这就意味着哪怕 Hash 算法很厉害 ,但是取模下来得到同一个下标的可能性也非常大。
- 为什么选择红黑树?
红黑树能保证两边元素的平衡 , 基于这个特性 ,红黑树能保证查询的效率,无论元素多少,都能让查询一直在对数级别。
详细的红黑树原理可以看看下面这篇文章 :👇👇👇
算法 : 这可能是你看过最简洁的红黑树笔记
三. 高级原理
当然,像技术要求比较高的兄弟,可能还会继续问下去,这个时候就需要把前后的所有概念串起来了,不多说,直接上代码 :
3.1 手写逻辑
HashMap 的逻辑线性表述不是很清晰,这里手写了一段逻辑,力求逻辑更加清晰 :
- 2个存储对象 :在树化和解除树化之间互相转换
- Node 用来存储Map中的数据, 通过 next 引用指向下一个节点形成链表
- TreeNode 是 Node 的扩展 , 用来形成树结构
- 4个流程 :
- 如果Hash获得的数组下标对应的位点没有对象,则直接放入Node对象
- 如果存在对象,但是小于树化阈值,则 放入链表末尾
- 如果存在对象,且大于 树化阈值(8),则 先转换成树链表 ,在对树链表进行树化
- 如果插入或者删除 触发解除阈值(6),则解除树化
两个存储对象 :
/**
* 基础节点
*/
public static class Node {
protected int hash;
protected String key;
protected String value;
// 基于 next 指向下一个节点从而形成一个链表结构
protected Node next;
}
/**
* 树节点 ,实际上继承了 Node ,意味着每个 TreeNode 实际也包含数据对象
*/
public static class TreeNode extends Node {
// 父节点
TreeNode parent;
// 左子节点
TreeNode left;
// 右子节点
TreeNode right;
// 上一节点用于生成双向链表,便于树形操作
TreeNode prev;
boolean red;
public TreeNode(Node node) {
this.key = node.key;
this.value = node.value;
this.next = node.next;
this.hash = node.hash;
}
}
核心流程操作 :
public static void main(String[] args) {
// 插入一个数据
HashMapDemo mapDemo = new HashMapDemo();
mapDemo.putVal("test", "1");
}
/**
* HashMap 底层数组结构 , 每一个 Node 对应一个实际的对象
*/
Node[] table = null;
/**
* 树化条件,超过该条件则进行红黑树转化
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 解除树化条件,小于该条件则解除树化
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 红黑树的操作一般都是基于插入操作来实现的
*/
public void putVal(String key, String value) {
int hash = getHash(key);
// S0 : 先获取 Hash
Node newNode = new Node(getHash(key), key, value, null);
// S1 : 判断桶中是否存在元素, 如果不存在元素 ,直接添加
if (table[hash] == null) {
table[hash] = newNode;
return;
}
// S2 : 先添加到链表中,判断添加后的长度是否超过最大长度
Node currentNode = table[hash];
boolean isAdd = addNode(currentNode, newNode);
// S3 : 超过最大长度,进行树化操作
if (isAdd) {
treeifyBin(currentNode);
}
}
// 模拟Hash冲突 , 只有 Hash 冲突时才会触发
private static int hash(String key) {
return 10;
}
private static boolean addNode(Node head, Node newNode) {
int length = 0;
Node lastNode = head;
// S2-1 :循环整个链表, 把数据加入链表最后一位
for (; lastNode != null; lastNode = lastNode.next) {
length++;
}
lastNode.next = newNode;
// S2-2 :假代码,模拟当前链表长度超过树化阈值
return length > TREEIFY_THRESHOLD;
}
private static void treeifyBin(Node head) {
TreeNode treeNode = null;
// S3-1 : 执行树华,先将节点转换为树节点 , 本质上形成的还是一个树形链表
for (Node currentNode = head; currentNode != null; currentNode = currentNode.next) {
if (treeNode == null) {
treeNode = new TreeNode(currentNode);
} else {
treeNode.next = new TreeNode(currentNode);
}
}
// S3-2 : 执行树华逻辑
treeify(treeNode);
}
private static void treeify(TreeNode treeNode) {
// S3-3 : 树形转换,此处略过
}
3.2 高级问题集锦
什么时候红黑树会解除树化 ?
- 桶中的元素数量下降到一定阈值以下(UNTREEIFY_THRESHOLD=6)时,如果再触发删除节点时,则会解除树化。
- 当整体容量缩容的时候,Hash 取模会发生变化,(resize)此时也有可能触发解除树华
3.3 红黑树转换精华
红黑树的代码很有意思,但是全记住不容易,掌握其中最重要的几点就足够了 :
树链表转红黑树 : treeify(Node[] tab)
- S1 : 先预设 Root 节点为 null ,然后开始遍历所有的节点
- S2 : 将每个树节点从树链表中抽离出来,为其初始化左右子节点(null)
- S3 : 如果是第一个节点,则直接设置为root ,如果不是第一个节点,则基于大小比较放在对应的节点侧
- S4 : 调用 balanceInsertion 方法对树进行结构调整
- S5 : 返回调整后的root节点,移动到数组中,作为入口的首位
平衡的原理 :
平衡的这一段代码是整个红黑树的精华,单独拉出来写一篇文章都足够了,篇幅有限,只说几个关键点 :
- S1 : 先把插入节点定义为红色 ,然后按照大小插入指定位置
- S2 : 从当前节点开始回到根节点 , 在这个过程中进行节点的位移和染色 ,通常包括2种位置 ,3种情况
- 2 种位置 : 当前节点的父节点是左子节点还是右子节点 (用于确定是左旋还是右旋)
- 3 种情况 : 树节点 (当前父节点的兄弟节点)的颜色和位置
- 叔节点是红色 (修改颜色,上移当前节点)
- 叔节点是黑色,且 x 是右子节点 (进行旋转)
- 叔节点是黑色,且 x 是左子节点 (修改颜色,进行旋转)
如果知道了这个流程,和具体旋转的方式,那么代码就很容易看清楚了 ,无非就像下面的这种方式
再细节就不深入了,等后面出针对性的文章吧,这一块不自己写个始终差点意思。
x.parent.red = false; // 将父节点设为黑色
y.red = false; // 将叔节点设为黑色
x.parent.parent.red = true; // 将祖父节点设为红色
x = x.parent.parent; // 将 x 上移到祖父节点
rotateRight(root, x.parent.parent); // 右旋转
rotateLeft(root, x.parent.parent); // 左旋转
解除树化 :
原因 :节省空间,上面也能看的出来 ,TreeNode 包含了 pre ,next ,parent 等信息,会占用大量内存
解除树化主要在 resize 和 removeNode 逻辑中进行 ,resize 主要为了控制容量,通常在以下场景中可能会触发 Resize 操作 :
- 插入元素时 :在插入数据的时候,如果触发了阈值, 则会触发resize ,包括不限定于插入,合并,等等
- 直接手动调用或者 Map 初始化时
在 untreeify 中进行解除树化操作,处理逻辑很简单 :
- S1 : for 循环节点的 next 属性,把属性转换为 Node 对象
- S2 : 把 Node 按照 Next 属性连接的方式生成链表
PS :哪怕转换成了 TreeNode ,但是由于TreeNode 继承了 Node ,所以 next 属性是保留的,也可以通过 next 循环所有的变量
总结
看完这篇还能被问住,那不怪你,那是面试官的问题。