浅显易懂HashMap的数据结构

news2025/2/27 13:21:19

HashMap 就像一个大仓库,里面有很多小柜子(数组),每个小柜子可以挂一串链条(链表),链条太长的时候会变成更高级的架子(红黑树)。下面用超简单的例子解释:


​壹. 排列方式

  • 数组:一排固定的小柜子(比如柜子0、柜子1、柜子2...)。
  • 链表:如果多个东西要放在同一个柜子里,它们会串成一条链子。
  • 红黑树:当某个柜子的链子太长(比如超过8个),链子会变成一个小架子(树结构),找东西更快。

​贰. 存数据的过程

假设我们要存一个键值对 ("name", "小明")

  1. 找柜子:先算 "name" 的哈希值(类似身份证号),比如得到柜子3。
  2. 放数据
    • 如果柜子3是空的,直接放进去。
    • 如果柜子3已经有东西,检查链条上的每个元素:
      • 如果有相同的键(比如已经有 "name"),替换它的值。
      • 如果没有,把新键值对挂到链子末尾。
  3. 链条转架子:如果链子长度超过8,就把链子变成红黑树架子。

​叁. 取数据的过程

假设要取 "name" 对应的值:

  1. 找柜子:算 "name" 的哈希值,确定是柜子3。
  2. 找数据
    • 如果柜子3是链子,遍历链子找 "name"
    • 如果柜子3是架子(红黑树),用树的快速查找方法。

​肆. 代码简化版(Java)​

// 小柜子(数组)
Node[] table = new Node[16];

// 链表节点(如果链子太长,会变成 TreeNode)
class Node {
    String key;
    String value;
    Node next; // 下一个节点
}

// 红黑树节点(架子结构)
class TreeNode extends Node {
    TreeNode parent;
    TreeNode left;
    TreeNode right;
}

// 存数据示例
void put(String key, String value) {
    int hash = key.hashCode();
    int index = hash % table.length; // 计算柜子位置
    
    if (table[index] == null) {
        // 柜子是空的,直接放进去
        table[index] = new Node(key, value);
    } else {
        // 柜子非空,挂到链子末尾或更新值
        // 如果链子太长,转成红黑树
    }
}

​伍. 一句话总结

HashMap​ 的数组是主干,链表解决哈希冲突,红黑树解决链表过长时的性能问题!

陆. 源码:HashMap的putVal()方法


    /**
     * Implements Map.put and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @param value the value to put
     * @param onlyIfAbsent if true, don't change existing value
     * @param evict if false, the table is in creation mode.
     * @return previous value, or null if none
     */
    final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

柒、我们来拆解 ​​“哈希冲突时,链表如何挂到数组上”​​ 的详细过程,用大白话 + 例子解释:


1. 核心原理:用链表串联冲突的键值对

  • 当两个不同的键(比如 "name" 和 "age")通过哈希计算后,分配到 ​同一个数组位置(同一个柜子)​​ 时,就会发生 ​哈希冲突
  • 此时,HashMap 会用 ​链表​ 把这些冲突的键值对串起来,挂在数组的这个位置上(类似挂一串钥匙)。

2. 具体挂链表的步骤(逐行分析)​

假设数组的某个位置 index 已经有数据,现在要存入新的键值对 (key2, value2)

  1. 检查是否重复:遍历链表,对比每个节点的 key

    • 如果发现某个节点的 key 和 key2 ​相同​(用 equals() 判断),直接覆盖它的值。
    • 如果链表中没有相同的 key,则把新节点 ​挂到链表末尾​(Java 8 之后是尾插法)。
  2. 链表挂载示意图

    // 数组的某个位置 index 已经有一个节点 Node1
    table[index] = Node1(key1, value1) -> null;
    
    // 存入新节点 Node2(冲突)
    Node1.next = Node2(key2, value2); // 挂到链表尾部
    table[index] = Node1 -> Node2 -> null;
  3. 代码逻辑简化版

    Node existingNode = table[index]; // 获取数组当前位置的链表头
    
    // 遍历链表,检查是否已有相同的 key
    while (existingNode != null) {
        if (existingNode.key.equals(newKey)) {
            existingNode.value = newValue; // 覆盖旧值
            return;
        }
        existingNode = existingNode.next;
    }
    
    // 如果没有重复 key,把新节点挂到链表末尾
    Node newNode = new Node(newKey, newValue);
    newNode.next = table[index]; // Java 8 之前是头插法(新节点变成链表头)
    table[index] = newNode;      // Java 8 之后是尾插法(直接挂在链表尾部)

3. 关键细节:头插法 vs 尾插法

  • Java 8 之前:新节点插入链表头部(头插法)。
    • 问题:多线程下可能导致链表成环(死循环)。
    • 示例:table[index] = 新节点 -> 旧节点 -> null
  • Java 8 之后:新节点插入链表尾部(尾插法)。
    • 改进:避免多线程下的链表成环问题。
    • 示例:旧节点 -> 新节点 -> null

4. 链表转红黑树的条件

  • 当链表长度 ​超过 8,且 ​数组总长度 ≥ 64​ 时,链表会转换成红黑树。
  • 如果数组长度 < 64,即使链表长度 > 8,HashMap 也会优先选择 ​扩容数组​(而不是转树),因为扩容能直接减少哈希冲突的概率,成本更低。
  • 这正说明 HashMap 的设计是 ​先尝试扩容解决冲突,实在不行再转树。😄
    • 为什么这样设计?

      • 小数组时扩容更高效

        • 数组较小时,哈希冲突可能只是因为数组容量不足,直接扩容能快速缓解问题。
        • 红黑树结构复杂,维护成本高,小规模数据下不如扩容划算。
      • 大数组时优化查询

        • 数组足够大(≥64)后,如果仍有长链表,说明哈希冲突难以通过扩容解决(如多个 key 哈希值相同)。
        • 此时转为红黑树,将查询复杂度从 O(n) 降为 O(logn)

5. 完整流程示例

假设存入 ("name", "小明") 和 ("age", 18),它们的哈希值冲突(都映射到数组位置 3):

  1. 存入第一个节点

    table[3] = Node("name", "小明") -> null;
  2. 存入第二个节点(冲突)​

    // 检查链表,发现没有重复 key,挂到链表末尾
    table[3] = Node("name", "小明") -> Node("age", 18) -> null;
  3. 查找时:遍历链表,逐个对比 key,找到对应值。


​6.总结

  • 链表挂在数组上:通过节点的 next 指针串联冲突的键值对。
  • 插入位置:Java 8 之后用尾插法,避免多线程问题。
  • 转红黑树:链表过长时优化查找速度(从 O(n) 优化到 O(log n))。

捌、再帮你用 ​​“仓库管理员” 的比喻​ 总结一下,确保彻底理解:


终极傻瓜版总结

  1. 仓库(数组)​:管理员有一排柜子(比如16个),每个柜子有编号(0到15)。
  2. 存东西(键值对)​
    • 管理员用 ​哈希函数​(比如 key.hashCode() % 16)算出东西应该放哪个柜子。
    • 如果柜子空,直接放进去。
  3. 柜子冲突(哈希冲突)​
    • 如果柜子已经有东西,管理员会拿一根 ​链条(链表)​​ 把新东西和旧东西绑在一起,挂在柜子里。
    • 链条的挂法:新东西挂在链条的尾部(Java 8之后)。
  4. 链条太长怎么办
    • 如果链条长度超过8,管理员会把链条换成 ​高级货架(红黑树)​,这样找东西更快。
    • 但如果仓库的柜子总数太少(比如小于64个),管理员会直接 ​扩建仓库(扩容数组)​,而不是换货架。

关键逻辑图示

// 数组(柜子列表)
Node[] table = [柜子0, 柜子1, ..., 柜子15];

// 柜子里的内容(链表或树)
柜子3 -> Node1("name", "小明") -> Node2("age", 18) -> null  // 链表形式
柜子5 -> TreeNode("id", 1001)  // 树形式(如果链表转成了红黑树)

容易混淆的点

  1. 覆盖值:如果两次存同一个 key(比如两次存 "name"),会直接覆盖旧值。
  2. 哈希函数hashCode() 决定柜子位置,但不同对象可能算出相同的哈希值(冲突)。
  3. 扩容:当仓库的柜子被填满超过一定比例(默认75%),管理员会扩建仓库(数组长度翻倍),重新分配所有东西的位置。

玖、结合 : 面试被问 Java中hashmap数据结构 

        URL: 地基:Java中hashmap数据结构(面试被问)-CSDN博客

兄弟们,理解好了。rt.jar包里的hashmap源码的putval方法的方式,有厉害的同学可以学以致用哈!向大家致敬!

(望各位潘安、各位子健/各位彦祖、于晏不吝赐教!多多指正!🙏)

-----分界线----------------------------------------------------------------------------------------------------

兄弟们,上面的足以应付面试了,如何还想更深入,可以看下面的。

拾. 为什么数组长度 ≥64 时不扩容,而是转树?

​1 扩容的局限性**

  • 扩容的本质:通过扩大数组长度,将节点分散到新数组中,减少哈希冲突。
  • 局限性:如果多个键的哈希值本身就冲突(例如哈希算法导致碰撞),即使扩容也无法分散它们。
    // 例如:键A和键B的哈希值不同,但取模后仍落在同一个桶
    hash(A) % 64 = 5;
    hash(B) % 64 = 5;  // 即使数组扩容到128,依然可能 hash(A) % 128 = 5,hash(B) % 128 = 5

​2) 转树的优势**

  • 解决哈希碰撞:当哈希冲突无法通过扩容避免时(如多个键哈希值相同),红黑树将查询复杂度从链表O(n)降到O(logn)
  • 成本权衡
    • 扩容成本高:需新建数组、重新哈希所有节点(时间复杂度O(n))。
    • 转树成本低:只处理单个冲突严重的桶,其他桶不受影响。

​3) 阈值为什么是64?**

  • 经验值:基于测试和性能权衡:
    • 数组长度较小时(<64),哈希冲突可能因数组容量不足,优先扩容更高效。
    • 数组长度≥64时,认为哈希冲突更可能是哈希算法导致(而非数组容量问题),转树更合理。

4. 源码逻辑验证

HashMap的treeifyBin()方法中会先检查数组长度是否≥64,再决定转树或扩容:

// HashMap 源码片段
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) { // MIN_TREEIFY_CAPACITY=64
        resize(); // 数组长度<64时,选择扩容
    } else {
        // 数组长度≥64时,将链表转为红黑树
        TreeNode<K,V> hd = null, tl = null;
        // ...(具体转树逻辑)
    }
}

5. 举例说明

场景1:数组长度=16,链表长度=9
  • 数组长度16 < 64 → ​触发扩容​(数组扩大到32),而不是转树。
场景2:数组长度=64,链表长度=9
  • 数组长度≥64 → ​链表转为红黑树,不再扩容。

6、​总结

  • 转树的条件:链表长度>8 ​​ 数组长度≥64。
  • 转树的目的:针对哈希算法导致的不可分散冲突,用空间换时间(红黑树优化查询)。
  • 扩容的优先级:数组较小时,扩容是更经济的解决方案。

拾壹、数组扩容是否重新计算哈希值?

在 HashMap 中,当数组长度小于 64 时触发扩容,哈希值本身不会重新计算,但元素在数组中的位置(索引)会根据新的数组长度重新确定。以下是具体机制:


1. 哈希值如何生成?

  • 哈希值来源
    HashMap 中每个键(Key)的哈希值由以下两步生成:

    1. 调用键对象的 hashCode() 方法,得到原始哈希值。
    2. 通过 ​扰动函数​(位运算)对原始哈希值进行二次处理,增加随机性,减少哈希冲突。
      // HashMap 的扰动函数实现
      static final int hash(Object key) {
          int h;
          return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
      }
  • 哈希值的存储
    这个最终哈希值会在键值对被插入 HashMap 时计算一次,并存储在 Node 或 TreeNode 对象中,后续不再重新计算


2. 扩容时如何确定新位置?

当数组长度从 oldCap(例如 16)扩容到 newCap(例如 32)时,元素的索引位置会按以下规则重新分配:

  1. 索引计算公式
    新索引 = hash & (newCap - 1)
    newCap - 1 是新的掩码,例如 32 → 0b11111)。

  2. 优化计算技巧
    由于扩容是翻倍(newCap = oldCap << 1),新掩码比旧掩码多出一个高位 1(例如 16 → 0b1111,32 → 0b11111)。
    因此,新索引只需判断哈希值中新增的高位是 0 还是 1

    • 如果高位是 0:新索引 = ​原位置
    • 如果高位是 1:新索引 = ​原位置 + oldCap

    源码逻辑

    // 遍历旧数组中的每个桶
    for (int j = 0; j < oldCap; j++) {
        Node<K,V> e;
        if ((e = oldTab[j]) != null) {
            // 检查哈希值高位是 0 还是 1
            if ((e.hash & oldCap) == 0) {
                // 高位为 0 → 留在原位置(j)
            } else {
                // 高位为 1 → 移动到新位置(j + oldCap)
            }
        }
    }

3. 示例说明

假设原数组长度 oldCap = 16(二进制 0b10000),扩容后 newCap = 32(二进制 0b100000)。

  • 键的哈希值:假设为 0b10101
  • 旧索引计算
    hash & (oldCap - 1) = 0b10101 & 0b1111 = 0b00101 → 索引 5。
  • 新索引计算
    hash & (newCap - 1) = 0b10101 & 0b11111 = 0b10101 → 索引 21。
    或者直接通过高位判断:
    hash & oldCap = 0b10101 & 0b10000 = 0b10000 ≠ 0 → 高位为 1,新索引 = 5 + 16 = 21。

4. 为什么哈希值不重新计算?

  • 性能优化:哈希值的计算涉及 hashCode() 方法和扰动函数,重新计算会带来额外开销。
  • 一致性保障:哈希值在键的生命周期内应保持不变(除非键对象被修改,但这违反 HashMap 的设计前提)。

5、​总结

  • 哈希值固定:在键插入时计算一次,后续不再变更。
  • 索引重新分配:扩容时利用原哈希值的高位判断新位置,无需重新计算哈希值。
  • 设计目标:以最小成本重新分布元素,同时减少哈希冲突。

兄弟辛苦,🙇‍♂️。 点烟!

(望各位潘安、各位子健/各位彦祖、于晏不吝赐教!多多指正!🙏)

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2306898.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

02_linux系统命令

一、绝对路径与相对路径 1.以 ./ 开始的路径名是相对路径 2.以 / 开始的路径是绝对路径. 相对路径:会随着用户当前所在的目录发生改变. 绝对路径:不会根据用户所在的路径而改变. 3.gcc 编译器 编译器把高级语言(C语言/JAVA语言/C语言)生成二进制代码的一种工具.gcc 是专用…

【leetcode hot 100 11】移动零

一、暴力解法&#xff1a;两个 for 循环&#xff0c;外层循环遍历所有可能的左边界&#xff0c;内层循环遍历所有可能的右边界 class Solution {public int maxArea(int[] height) {int max_area0;for(int i0; i<height.length; i){for(int ji1; j<height.length; j){in…

AI绘画软件Stable Diffusion详解教程(2):Windows系统本地化部署操作方法(专业版)

一、事前准备 1、一台配置不错的电脑&#xff0c;英伟达显卡&#xff0c;20系列起步&#xff0c;建议显存6G起步&#xff0c;安装win10或以上版本&#xff0c;我的显卡是40系列&#xff0c;16G显存&#xff0c;所以跑大部分的模型都比较快&#xff1b; 2、科学上网&#xff0…

轨迹控制--odrive的位置控制---负载设置

轨迹控制 此模式使您可以平滑地使电机旋转&#xff0c;从一个位置加速&#xff0c;匀速和减速到另一位置。 使用位置控制时&#xff0c;控制器只是试图尽可能快地到达设定点。 使用轨迹控制模式可以使您更灵活地调整反馈增益&#xff0c;以消除干扰&#xff0c;同时保持平稳的运…

【安卓逆向】逆向APP界面UI修改再安装

1.背景 有一客户找到我&#xff0c;说能不能把APP首页的底部多余界面去掉。 逆向实战 想要去除安卓应用软件中的内容&#xff0c;需要对APP逆向进行修改再打包。 通过工具 MIT管理器工具 提取APK包&#xff0c;点击apk文件&#xff0c;点击查看反编译apk。 搜索关键字。这里关键…

SAP Webide系列(7)- 优化FreeStyle新建项目预设模板

目录 一、背景 二、优化目标 三、定位调整点 四、调整步骤 五、效果展示 六、附言 一、背景 在每次通过Webide进行FreeStyle方式自开发SAP UI5应用的时候&#xff0c;新建项目&#xff0c;得到的模板文件都是只有很少的内容&#xff08;没有路由配置、没有设置默认全屏等…

python读取sqlite温度数据,并画出折线图

需求&#xff1a; 在Windows下请用python画出折线图&#xff0c;x轴是时间&#xff0c;y轴是温度temperature 和体感温度feels_like_temperature 。可以选择县市近1小时&#xff0c;近1天&#xff0c;近1个月的。sqlite文件weather_data.db当前目录下&#xff0c;建表结构如下…

【Linux网络编程】高效I/O--select/poll服务器

目录 多路转接之select select服务器实现 获取连接 handlerEvent select服务器代码链接 select的优缺点 多路转接之poll poll服务器实现(select服务器改写) poll的优缺点 多路转接之select select的作用 I/O的本质 等 拷贝 多路转接就是通过同时等待多个文件描述…

C语言实战项目(1)---------->猜数字游戏

在学习完循环和选择结构之后&#xff0c;我们可以做一个猜数字游戏。在此项目之前&#xff0c;如果还不会C语言的if语句、switch语句等组成选择结构的语句&#xff0c;while循环、for循环、do-while循环等组成循环结构的语句。可以参考我之前的博客&#xff1a; C语言&#xf…

Failed to start The PHP FastCGI Process Manager.

报错如下&#xff1a; Job for php-fpm.service failed because the control process exited with error code. See "systemctl status php-fpm.service" and "journalctl -xe" for details. 2月 25 21:49:00 nginx systemd[1]: Starting The PHP FastC…

【REST2SQL】15银河麒麟系统下达梦数据库部署REST2SQL

【REST2SQL】01RDB关系型数据库REST初设计 【REST2SQL】02 GO连接Oracle数据库 【REST2SQL】03 GO读取JSON文件 【REST2SQL】04 REST2SQL第一版Oracle版实现 【REST2SQL】05 GO 操作 达梦 数据库 【REST2SQL】06 GO 跨包接口重构代码 【REST2SQL】07 GO 操作 Mysql 数据库 【RE…

晶体管输出光耦和逻辑输出光耦

晶体管输出光耦&#xff08;非线性&#xff09;和逻辑输出光耦&#xff08;线性&#xff09;的区别&#xff1a; 逻辑输出光耦的电流传输特性曲线是非线性的&#xff0c;适合于开关信号的传输&#xff0c;不适合于传输模拟量&#xff1b; 光电晶体管输出光耦的电流传输特性是线…

绕过过滤order by

一、常见绕过技术 1、注释符截断 利用注释符&#xff08;如 --、#&#xff09;截断后续查询&#xff0c;消除过滤逻辑的影响。 ORDER BY 1-- 若原查询为 SELECT * FROM table ORDER BY 用户输入&#xff0c;注入后可能忽略后续过滤逻辑。 2、大小写混淆/编码绕过 若过滤是大…

面试八股文--数据库基础知识总结(1)

1、数据库的定义 数据库&#xff08;DataBase&#xff0c;DB&#xff09;简单来说就是数据的集合数据库管理系统&#xff08;Database Management System&#xff0c;DBMS&#xff09;是一种操纵和管理数据库的大型软件&#xff0c;通常用于建立、使用和维护数据库。数据库系统…

机试刷题_1614. 括号的最大嵌套深度【python】

1614. 括号的最大嵌套深度 class Solution:def maxDepth(self, s: str) -> int:maxD 0if not s:return maxDstack []for char in s:if char(:stack.append(char)maxD max(maxD,len(stack))elif char) :stack.pop()return maxD

VM虚拟机安装与配置Ubuntu Linux操作系统详细教程~

一、下载VM虚拟机 VMware16.0.zip百度网盘下载链接:https://pan.baidu.com/s/1-l-CcAVNINqhRLSiQ26R7w?pwd=tznn 提取码: tznn 二、软件介绍 VMware(虚拟机)是指通过软件模拟的具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统,通过它可在一台电脑上同…

免费PDF工具

Smallpdf.com - A Free Solution to all your PDF Problems Smallpdf - the platform that makes it super easy to convert and edit all your PDF files. Solving all your PDF problems in one place - and yes, free. https://smallpdf.com/#rappSmallpdf.com-解决您所有PD…

组件注册方式、传递数据

组件注册 一个vue组件要先被注册&#xff0c;这样vue才能在渲染模版时找到其对应的实现。有两种注册方式&#xff1a;全局注册和局部注册。&#xff08;组件的引入方式&#xff09; 以下这种属于局部引用。 组件传递数据 注意&#xff1a;props传递数据&#xff0c;只能从父…

异步fifo学习

FIFO 本质是由 RAM 加上读写逻辑构成的先入先出的数据缓冲器。与 RAM 的区别是 FIFO 没有外部读写地址线&#xff0c;顺序写入顺序读出数据&#xff0c;其数据地址是由内部读写指针自增完成&#xff0c;因此 FIFO 在读写时不需要考虑读写冲突的问题。 根据 FIFO 工作的时钟域&a…

【有啥问啥】All-to-All 通信:原理、实现与应用

All-to-All 通信&#xff1a;原理、实现与应用 一、引言 在分布式计算和并行处理领域&#xff0c;进程之间的通信是至关重要的。All-to-All 通信作为一种高效的通信模式&#xff0c;广泛应用于各种高性能计算和分布式系统中。本文将详细介绍 All-to-All 通信的定义、工作原理…