面试爽文 :开局一张图,花十分钟了解 HashMap 的树化逻辑

2023年 9月 16日 103.4k 0

👈👈👈 欢迎点赞收藏关注哟

首先分享之前的所有文章 >>>> 😜😜😜
文章合集 : 🎁 juejin.cn/post/694164…
Github : 👉 github.com/black-ant
CASE 备份 : 👉 gitee.com/antblack/ca…

一. 前言

闲来无事,对 HashMap 其中比较重要的节点做了一下深入,然后尝试用最通俗易懂的说法输出出来。

老规矩 ,给节省时间的小伙伴上菜 :

image.png

二. 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节点,移动到数组中,作为入口的首位

image.png

平衡的原理 :

平衡的这一段代码是整个红黑树的精华,单独拉出来写一篇文章都足够了,篇幅有限,只说几个关键点 :

  • S1 : 先把插入节点定义为红色 ,然后按照大小插入指定位置
  • S2 : 从当前节点开始回到根节点 , 在这个过程中进行节点的位移和染色 ,通常包括2种位置 ,3种情况
    • 2 种位置 : 当前节点的父节点是左子节点还是右子节点 (用于确定是左旋还是右旋)
    • 3 种情况 : 树节点 (当前父节点的兄弟节点)的颜色和位置
      • 叔节点是红色 (修改颜色,上移当前节点)
      • 叔节点是黑色,且 x 是右子节点 (进行旋转)
      • 叔节点是黑色,且 x 是左子节点 (修改颜色,进行旋转)

左旋.gif

如果知道了这个流程,和具体旋转的方式,那么代码就很容易看清楚了 ,无非就像下面的这种方式

再细节就不深入了,等后面出针对性的文章吧,这一块不自己写个始终差点意思。

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 循环所有的变量

总结

看完这篇还能被问住,那不怪你,那是面试官的问题。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论