当前位置: 首页 > news >正文

算法笔记 05

1 哈希表和 Map 键值对

哈希表(Hash Table)和 Map 键值对在概念和应用上既有联系又有区别,主要体现在以下几个方面:

1. 定义与本质

  • 哈希表:是一种数据结构,底层通过哈希函数将键(Key)映射到数组的索引位置,实现高效的插入、查询和删除操作(平均时间复杂度为 O (1))。它是一种具体的存储和组织数据的方式,核心是利用哈希函数解决键与值的映射关系。
  • Map:是一种抽象数据类型(ADT) 或接口,定义了 “键值对” 的集合,支持通过键查找、添加、删除值等操作。它描述的是 “键唯一对应值” 的逻辑关系,不规定具体的实现方式。

2. 实现与被实现的关系

  • Map 作为接口,需要具体的实现类来完成功能。哈希表是实现 Map 的常见方式之一(例如 Java 中的 HashMap、Python 中的 dict 本质上都是基于哈希表实现的 Map)。
  • 除了哈希表,Map 还可以通过其他数据结构实现,例如:
    • 平衡二叉搜索树(如 Java 中的 TreeMap,基于红黑树实现,保证键的有序性);
    • 链表(效率较低,较少直接使用)。

3. 功能与特性

  • 哈希表:核心特性是 “哈希映射”,强调通过哈希函数快速定位元素,通常不保证键的顺序(插入顺序或自然顺序)。
  • Map:作为键值对集合的抽象,更关注 “键唯一” 和 “键值关联” 的逻辑,不同实现类会附加不同特性:
    • 基于哈希表的 Map(如 HashMap):高效但无序;
    • 基于树的 Map(如 TreeMap):有序但效率略低(O (log n));
    • 部分 Map(如 Java 的 LinkedHashMap):通过链表维护插入顺序,底层仍依赖哈希表。

4. 术语使用场景

  • 在讨论数据结构实现时,常用 “哈希表” 描述具体的存储方式(如解决哈希冲突的链地址法、开放寻址法等)。
  • 在讨论键值对操作接口时,常用 “Map” 表示这种键值映射的抽象(如调用 put(key, value)get(key) 等方法)。

总结

  • 哈希表是具体的存储结构,通过哈希函数实现高效的键值映射;
  • Map 是键值对集合的抽象接口,哈希表是其最常用的实现方式之一。

简单说:Map 是 “键值对功能” 的定义,哈希表是实现该功能的一种高效手段。

2 哈希冲突

哈希表核心原理 | labuladong 的算法笔记https://labuladong.online/algo/data-structure-basic/hashmap-basic/#%E5%93%88%E5%B8%8C%E5%86%B2%E7%AA%81如果两个不同的 key 通过哈希函数得到了相同的索引,怎么办呢?这种情况就叫做「哈希冲突」。

哈希冲突是否可以避免?

哈希冲突不可能避免,只能在算法层面妥善处理出现哈希冲突的情况。

哈希冲突是一定会出现的,因为这个 hash 函数相当于是把一个无穷大的空间映射到了一个有限的索引空间,所以必然会有不同的 key 映射到同一个索引上。

就好比三维物体映射到二维影子一样,这种有损压缩必然会出现信息丢失,有损信息本就无法和原信息一一对应。

3 我的理解与细节纠正

我的理解是:

hash表底层是一个table数组,例如java里面会通过hashcode来查找有关值存放的内存地址,所以如果要查找元素用hash表会很高效率,本质上是一个直接访问的数组。

map键值对是一个接口,接口也就是一个蓝图,没有具体的实例方法,而具体实例里面的键值对是人为规定的,和底层table数组无关,类似于一种可以直接检索的字典。

你的理解方向是对的,尤其是对哈希表底层实现和 Map 接口本质的把握,不过可以再补充一些细节让认知更完整:

  1. 关于哈希表的底层实现你说哈希表底层是 table 数组(比如 Java 中 HashMap 的 table 字段),且通过 hashCode 定位内存地址,这一点很准确。但需要注意:

    • 哈希表并非直接用 hashCode 作为数组索引(因为 hashCode 可能是很大的整数,超出数组长度),而是会对 hashCode 进行哈希运算(比如取模、高位扰动等),得到一个落在数组长度范围内的索引,再通过这个索引访问数组元素
    • 当多个键的哈希结果冲突时(即索引相同),哈希表会通过 “链地址法”(链表或红黑树)或 “开放寻址法” 解决冲突,这也是哈希表实现中很重要的部分。
    • 你说它 “本质上是一个直接访问的数组”,核心是对的 —— 通过哈希函数将 “键” 转化为类似 “数组下标” 的索引,从而实现 O (1) 级别的快速访问,这是哈希表高效的根本原因。
  2. 关于 Map 接口与键值对的关系你说 Map 是 “蓝图”(接口),没有具体实现,而具体实例的键值对是 “人为规定的”,这一点没错。但需要明确:

    • Map 接口定义的 “键值对” 逻辑(键唯一、通过键访问值),其具体实现必然依赖底层数据结构,而不是 “和底层数组无关”。比如:
      • HashMap 依赖哈希表(数组 + 链表 / 红黑树)存储键值对
      • TreeMap 依赖红黑树(一种平衡二叉树)存储键值对,此时底层就不是数组,而是树结构。
    • 你将 Map 类比为 “可直接检索的字典”,这个比喻很贴切 —— 无论底层用哈希表还是树,Map 给用户的都是 “通过键查值” 的统一体验,底层细节对用户透明。

总结来说,你的理解抓住了核心:哈希表是具体的高效存储结构(基于数组和哈希运算),Map 是键值对操作的抽象接口(定义 “字典” 式的功能),而哈希表是实现 Map 的最常用方式之一。补充细节后,对两者的关系和本质会更清晰。

4 注意hash表的key是无序的

由于 hash 函数要把你的 key 进行映射,所以 key 在底层 table 数组中的分布是随机的,不像数组/链表结构那样有个明确的元素顺序。

哈希表的 key 之所以 “无序”(通常指不保证插入顺序或自然顺序),本质上是由其底层存储和查找的核心机制决定的,具体原因如下:

1. key 的存储位置由哈希函数计算,与插入顺序无关

哈希表的核心是通过 哈希函数 将 key 映射到数组的索引位置。这个映射过程完全由 key 自身的特征(如 hashCode)和哈希函数的逻辑决定,与 key 的插入顺序毫无关系。例如:

  • 先插入的 key A 可能被哈希函数映射到索引 10;
  • 后插入的 key B 可能被映射到索引 2;
  • 再插入的 key C 可能被映射到索引 15。

最终 key 在底层数组中的存储位置是 “随机” 分布的(由哈希函数决定),自然无法保证与插入顺序一致。

2. 哈希冲突处理进一步打破顺序

即使两个 key 的哈希值不同,也可能因数组长度限制(通过取模等操作压缩索引范围)导致 哈希冲突(映射到同一个数组索引)。此时哈希表会通过 “链地址法”(链表或红黑树)或 “开放寻址法” 处理冲突:

  • 链地址法中,冲突的 key 会被放入同一个索引下的链表 / 树中,它们的顺序取决于插入和扩容时的调整,与原始插入顺序无关;
  • 开放寻址法中,冲突的 key 会被放到下一个空闲位置,进一步打乱顺序。

冲突处理机制使得 key 的实际存储位置更加 “无序”。

3. 扩容机制会打乱已有 key 的位置

哈希表的数组长度(容量)是动态扩容的(如 Java HashMap 负载因子达到阈值时扩容)。扩容时,所有 key 会被重新计算哈希值并映射到新的数组中,原有位置被彻底打乱。例如,原本在索引 2 的 key,扩容后可能被映射到新数组的索引 5 或 10,导致顺序完全改变。

4. “无序” 是为了换取高效性能

哈希表的设计目标是 O (1) 级别的插入、查询、删除效率

为了实现这一点,它必须通过哈希函数直接定位 key 的位置,而不是维护一个有序结构(如链表的前后指针、树的父子关系)。

维护顺序需要额外的开销(如红黑树的旋转、链表的插入排序),会降低操作效率。

因此,哈希表选择牺牲 “顺序性” 来换取高效的随机访问能力。(有得必有失啊~)

例外:有序的哈希表实现

需要注意的是,“哈希表的 key 无序” 是一般情况,并非绝对。有些哈希表变种会通过额外结构维护顺序,例如:

  • Java 的 LinkedHashMap:底层仍是哈希表,但通过额外的双向链表记录插入顺序或访问顺序;
  • Python 3.7+ 的 dict:优化了底层实现,保证插入顺序(但本质仍是哈希表,通过额外数组记录顺序)。

这些实现的 “有序” 是通过增加额外存储和维护成本实现的,并非哈希表的原生特性。

总结

哈希表的 key 无序,是因为其底层通过哈希函数随机映射存储位置,且受冲突处理、扩容等机制影响,无法保证与插入顺序或自然顺序一致。这种 “无序” 是哈希表为了追求高效性能而做出的设计取舍。

5 用链表加强哈希表(LinkedHashMap)

一、先明确:为什么要用链表 “加强” 哈希表?

普通哈希表(如 HashMap)的核心问题是 “无序”,而实际开发中经常需要 “按插入顺序遍历” 或 “按访问频率排序”(比如 LRU 缓存)。(key无序)

但哈希表的底层是数组 + 链表 / 红黑树,其存储位置由哈希函数决定,天然无法保证顺序。

这时候,链表的 “有序性” 就成了完美补充 —— 用链表记录元素的顺序,用哈希表保证查询效率,两者结合就是 LinkedHashMap 的核心思路。

二、底层结构:哈希表 + 双向链表,怎么 “缝合” 的?

LinkedHashMap 本质是 “哈希表的骨架 + 双向链表的灵魂”,底层结构可以拆解为两部分:

1. 继承 HashMap,复用哈希表核心功能

LinkedHashMap 继承自 HashMap,所以它底层同样有:

  • 一个 table 数组(哈希桶数组),每个元素是链表或红黑树的头节点(解决哈希冲突);
  • 通过 hashCode() 和哈希函数计算索引,实现 O (1) 级别的 get/put 操作;
  • 动态扩容机制(负载因子超过阈值时扩容)。

这意味着 LinkedHashMap 完全拥有哈希表的高效查询能力,和 HashMap 的核心操作逻辑一致。

2. 新增双向链表,记录 “顺序”

为了实现 “有序”,LinkedHashMap 在 HashMap 的基础上,给每个键值对(Entry)增加了两个指针:before 和 after用来串联所有元素形成 双向链表

可以理解为:每个键值对不仅 “挂在” 哈希表的某个桶里(数组 + 链表 / 红黑树),还 “串在” 一条双向链表上。

想象一个场景:

哈希表的每个桶里有一堆元素,同时所有元素又像穿珠子一样用一条线串起来,这条线就是双向链表)

  • 双向链表的作用:严格记录元素的插入顺序或访问顺序(二选一,由参数控制)。
  • 每个 Entry 的结构(简化版):
    static class Entry<K,V> extends HashMap.Node<K,V> {Entry<K,V> before, after; // 双向链表的前后指针Entry(int hash, K key, V value, Node<K,V> next) {super(hash, key, value, next); // 继承 HashMap.Node 的哈希表相关字段(hash、key、value、next)}
    }
    
    • next 指针:属于哈希表的一部分,指向当前桶中冲突的下一个元素(解决哈希冲突);
    • before/after 指针:属于双向链表的一部分,指向前后元素(维护顺序)。

三、核心特性:两种 “顺序” 怎么实现的?

LinkedHashMap 的有序性体现在遍历顺序上,有两种模式,由构造函数的 accessOrder 参数控制:

1. 插入顺序(默认,accessOrder = false
  • 规则:元素在双向链表中的位置,严格按照插入的先后顺序排列。先插入的在链表前面,后插入的在后面;修改已有元素的值(put 已存在的 key)不改变顺序。
  • 举例
    LinkedHashMap<String, Integer> map = new LinkedHashMap<>();
    map.put("A", 1); // 链表:A
    map.put("B", 2); // 链表:A -> B
    map.put("C", 3); // 链表:A -> B -> C
    map.put("A", 10); // 链表不变(仍为 A -> B -> C),仅更新值
    
    遍历结果:A -> B -> C(和插入顺序完全一致)。
2. 访问顺序(accessOrder = true
  • 规则:每次 “访问” 元素(get 或 put 已存在的 key)后,该元素会被移到双向链表的末尾(成为 “最近访问” 的元素);新插入的元素仍放在链表末尾。

  • 举例

    // 构造时指定 accessOrder = true
    LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
    map.put("A", 1); // 链表:A
    map.put("B", 2); // 链表:A -> B
    map.get("A");    // 访问 A,移到末尾 → 链表:B -> A
    map.put("C", 3); // 新插入,放末尾 → 链表:B -> A -> C
    map.put("B", 20); // 访问 B(修改已有key),移到末尾 → 链表:A -> C -> B
    

    遍历结果:A -> C -> B(最近访问的 B 在最后)。

  • 关键:这种模式是实现 “LRU 缓存” 的核心(LRU 即 “最近最少使用”,淘汰链表头部的元素即可)。

四、关键操作:如何维护双向链表的顺序?

LinkedHashMap 对 HashMap 的核心改造,是在 put get 等操作时,额外维护双向链表的顺序。我们拆解开来看:

1. 插入元素(put 方法)

HashMap 的 put 方法会调用 newNode 创建新节点,LinkedHashMap 重写了这个方法,在创建节点时把它加到双向链表的末尾

// LinkedHashMap 重写 newNode,新增双向链表操作
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {LinkedHashMap.Entry<K,V> p = new LinkedHashMap.Entry<>(hash, key, value, e);linkNodeLast(p); // 把新节点加到双向链表的最后return p;
}// 私有方法:将节点 p 链接到链表末尾
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {LinkedHashMap.Entry<K,V> last = tail; // 记录当前尾节点tail = p; // 新节点成为新的尾节点if (last == null) // 如果链表为空,新节点也是头节点head = p;else { // 否则,把新节点接在原尾节点后面p.before = last;last.after = p;}
}

这就是 “插入顺序” 的核心:新节点一定放在链表末尾。

2. 访问元素(get 方法)

当 accessOrder = true 时,get 方法会触发 “将元素移到链表末尾” 的操作:

public V get(Object key) {Node<K,V> e;if ((e = getNode(hash(key), key)) == null) // 先调用 HashMap 的 getNode 查元素return null;if (accessOrder) // 如果是访问顺序模式afterNodeAccess(e); // 把访问的节点移到链表末尾return e.value;
}// 关键:将节点 e 移到链表末尾
void afterNodeAccess(Node<K,V> e) {LinkedHashMap.Entry<K,V> last;if (accessOrder && (last = tail) != e) { // 当 e 不是尾节点时LinkedHashMap.Entry<K,V> p = (LinkedHashMap.Entry<K,V>)e;LinkedHashMap.Entry<K,V> b = p.before; // e 的前节点LinkedHashMap.Entry<K,V> a = p.after;  // e 的后节点p.after = null;// 从原位置移除 eif (b == null) // e 是头节点head = a;elseb.after = a;if (a != null)a.before = b;else // e 是尾节点(但上面已判断 last != e,所以这里不会触发)last = b;// 把 e 接到尾节点后面if (last == null)head = p;else {p.before = last;last.after = p;}tail = p; // e 成为新的尾节点}
}

简单说:先把节点从原来的位置 “拆下来”,再 “接到” 链表的最后,这就是 “访问顺序” 的核心。

3. 扩容时的链表维护

HashMap 扩容时会把旧数组的元素重新哈希到新数组,LinkedHashMap 重写了扩容的 transfer 方法,确保在转移元素时,双向链表的顺序不变(因为链表顺序和哈希表的桶位置无关,只和插入 / 访问顺序有关)。

五、常见疑惑:和 HashMap 比,有什么代价?

“加强” 是有成本的,LinkedHashMap 比 HashMap 多了这些开销:

  1. 内存开销:每个节点多了 before 和 after 两个指针,占用更多内存;
  2. 时间开销put get 操作时需要额外维护双向链表(调整指针),效率略低于 HashMap(但日常使用中差距不大);
  3. 遍历效率:虽然遍历 LinkedHashMap 是按顺序的,但本质是遍历双向链表,时间复杂度仍是 O (n),和 HashMap 遍历哈希桶的 O (n) 相当(只是顺序不同)。

六、实际应用:什么时候用 LinkedHashMap?

  1. 需要按插入顺序遍历:比如日志记录(按时间顺序输出)、配置项存储(按定义顺序读取);
  2. 实现 LRU 缓存:利用 accessOrder = true,链表头部就是 “最久未使用” 的元素,淘汰时直接删头部即可(Java 中可重写 removeEldestEntry 方法自动淘汰);
    // 简易 LRU 缓存:最多存 3 个元素,满了就删最久未用的
    LinkedHashMap<String, Integer> lruCache = new LinkedHashMap<>(16, 0.75f, true) {@Overrideprotected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {return size() > 3; // 当 size 超过 3 时,自动删除最久未用的元素(链表头部)}
    };
    
  3. 需要稳定的遍历顺序HashMap 遍历顺序可能因扩容改变,LinkedHashMap 的顺序则绝对稳定(插入顺序模式下)。

七、总结:一句话说清 LinkedHashMap

用哈希表保证 get/put 的高效性(O (1)),用双向链表记录元素的插入 / 访问顺序,代价是稍多的内存和时间开销,换来有序性和缓存等实用功能。

⭐初学者可以这样记:

LinkedHashMap = HashMap 的效率 + 链表的顺序,是哈希表 “有序化” 的最佳实践。

 “HashMap”“、哈希表数据结构”、“抽象接口” 

【哈希表的接口就是hashmap吗,就是java前面那个键值对,又不是hash表这种数据结构,所以说hashmap的api应该就是泛指各个编程语言的hash原理的实现,一种抽象接口?】

你的理解已经很接近核心了,但需要把 “HashMap”“哈希表数据结构”“抽象接口” 这几个概念的关系再捋顺,避免混淆。

简单说:HashMap 是 “哈希表数据结构” 的一种具体实现,而 “哈希表的接口” 是所有哈希表实现都要遵守的 “操作规范”,不是 “泛指实现”,而是 “定义实现要满足的功能”。

一、先破误区:HashMap ≠ 抽象接口,而是 “哈希表数据结构” 的 Java 实现

你说 “HashMap 是 Java 前面那个键值对”,这个没错 —— 但要明确:

  • HashMap 的本质:是 Java 语言中,对 “哈希表数据结构” 的具体实现类。它底层用 “数组 + 链表 / 红黑树” 实现了哈希表的核心逻辑(哈希映射、冲突解决),同时遵循了 Java 中的 Map 接口(这才是 “抽象接口”)。
  • 它不是接口:接口是 “只定义方法,没有具体代码” 的(比如 Java 的 Map 接口,里面只有 put() get() 的方法声明,没有实现);而 HashMap 是 “写了具体代码” 的实现类,能直接 new HashMap<>() 创建对象来用。
  • 类比理解:如果把 “哈希表” 看作 “手机”(一种设备类型),那么:
    • “哈希表的接口” 就是 “手机的功能规范”(能打电话、能上网、能发短信);
    • HashMap 就是 “某品牌的具体手机”(比如苹果 15),它符合 “手机功能规范”,同时有自己的硬件实现(比如用了 A17 芯片);
    • Java 中的 Map 接口,就是 “手机功能规范” 的书面定义(写清楚 “必须有打电话方法、必须有上网方法”)。

二、再明确:“哈希表的接口” 不是 “泛指实现”,而是 “统一功能约定”

你说 “HashMap 的 api 应该就是泛指各个编程语言的 hash 原理的实现,一种抽象接口”,这里有个小偏差:

  • “哈希表的接口” 不是 “泛指实现”:而是 “所有哈希表实现都要遵守的功能约定”。比如 “必须支持存键值对、必须支持按键取值、必须支持删键值对”—— 不管是 Java 的 HashMap、Python 的 dict、C++ 的 unordered_map,都要满足这个约定,这才是 “哈希表的接口” 的核心。
  • HashMap 的 API 是 “具体实现的操作方法”:比如 HashMap 的 put(K key, V value) 方法,是 Java 中对 “存键值对” 这个接口约定的具体实现代码;而 Python dict 的 dict[key] = value,是 Python 中对同一个 “存键值对” 约定的另一种实现语法。它们都遵循 “哈希表接口” 的功能约定,但实现方式(语法、底层细节)不同。
  • 抽象接口是 “跨语言的功能共识”:比如 “按 key 取 value” 这个功能,Java 叫 get(key),Python 叫 dict.get(key),JavaScript 叫 map.get(key)—— 虽然方法名 / 语法略有差异,但功能完全一致,这就是 “哈希表抽象接口” 在不同语言中的体现。

三、关键关系图:一句话理清所有概念

为了让你一眼看明白,用 “功能约定→具体实现” 的逻辑串起来:

plaintext

哈希表的核心功能约定(抽象接口)  
├─ 必须支持:存键值对、按键取值、按删除键值对、判断键是否存在  
│  
├─ Java 中的体现:  
│  ├─ 抽象接口:java.util.Map(写死上述功能的方法声明,如 put()、get())  
│  └─ 具体实现:HashMap(遵守 Map 接口,用“数组+链表/红黑树”实现哈希表逻辑)  
│  
├─ Python 中的体现:  
│  ├─ 抽象接口:无明确的“Map 接口”类,但 dict 遵守上述功能约定  
│  └─ 具体实现:dict(用哈希表逻辑实现,支持 dict[key]=value、dict.get(key) 等操作)  
│  
└─ C++ 中的体现:  ├─ 抽象接口:无单独接口类,unordered_map 遵守哈希表功能约定  └─ 具体实现:unordered_map(哈希表实现,支持 insert()、find() 等操作)

总结:纠正两个小偏差,理解就通了

  1. 不是 “HashMap 的 API 是抽象接口”而是 HashMap 遵守 “哈希表的抽象接口”(Java 中对应 Map 接口),并实现了接口约定的所有功能;
  2. 不是 “泛指各个语言的 hash 实现”而是 “各个语言的 hash 实现(如 HashMap、dict),都遵循同一个‘哈希表抽象接口’的功能约定”—— 约定是统一的,实现是多样的。

比如你用 Java 的 HashMap 会 map.put(key, value),换成 Python 的 dict 用 dict[key] = value,本质都是在做 “存键值对” 这个接口约定的操作 —— 这就是抽象接口的价值。

7 用数组加强哈希表(ArrayHashMap)

在学过普通哈希表(数组 + 链表解决冲突)和链表加强哈希表(LinkedHashMap,用双向链表维护顺序)之后,我们来理解 “用数组加强哈希表”(暂称 ArrayHashMap)。

它的核心思路是:用两个平行数组分别存储键和值,通过简化哈希映射实现高效操作,同时利用数组特性轻松支持随机访问键(randomKey()

一、为什么需要 “数组加强”?—— 解决普通哈希表的两个痛点

普通哈希表(如 HashMap)和链表加强哈希表(LinkedHashMap)虽然高效,但有两个场景处理起来不够直观:

  1. 随机获取一个键(randomKey():普通哈希表的键分布在哈希桶中,随机访问需要遍历所有桶找非空元素,效率低;
  2. 避免复杂的冲突处理:普通哈希表需要用链表 / 红黑树解决哈希冲突,代码复杂,而 ArrayHashMap 通过 “键的哈希值直接作为索引”(或简单映射),配合数组的连续存储空间,简化实现。

数组加强的核心优势利用数组的 “索引直接访问” 特性,让 put/get/remove 操作更简单,同时天然支持 O (1) 级别的随机键访问。

二、底层结构:两个平行数组 + 哈希映射

ArrayHashMap 的底层不依赖 “哈希桶数组 + 链表”,而是用 两个平行数组 和 简单哈希函数 实现:

1. 核心存储:键数组和值数组
  • keys 数组:专门存储所有键(key),索引由哈希函数计算得出;
  • values 数组:专门存储对应的值(value),values[i] 对应 keys[i] 的值;
  • 数组容量固定:初始化时指定容量(如 1000),假设所有键的哈希值都能映射到 [0, 容量-1] 范围内,且不冲突(如何保证?后面讲)。

例:keys = [null, "a", null, "b", ...]values = [null, 10, null, 20, ...]表示键 "a" 对应值 10(索引 1),键 "b" 对应值 20(索引 3)。

2. 哈希函数:简化到 “直接映射”

普通哈希表的哈希函数需要 “扰动 + 取模”(如 index = (hashCode ^ (hashCode >>> 16)) & (n-1)),目的是压缩索引并减少冲突。但 ArrayHashMap 为了简化,哈希函数设计为:index = key.hashCode() % capacitycapacity 是数组容量)且要求 所有键的哈希值经过取模后不重复(即无哈希冲突)。

疑惑点 1:如何保证无冲突?实际使用中,ArrayHashMap 适合键的哈希值分布均匀且已知范围的场景(如键是整数 0~999,直接用 index = key 即可)。如果键可能冲突,需提前处理(如选择更大的容量,或手动确保键的唯一性)。

3. 与普通哈希表的结构对比
结构普通哈希表(HashMap)ArrayHashMap
底层存储哈希桶数组 + 链表 / 红黑树两个平行数组(keys + values)
冲突处理链表 / 红黑树要求无冲突(设计时保证)
索引计算复杂哈希函数(扰动 + 取模)简单取模(或直接映射)
随机访问键需遍历所有桶(低效)直接随机访问数组非空元素(高效)

三、核心 API 实现:从基础操作到 randomKey()

1. 初始化(构造函数)

需要指定数组容量,并初始化 keys 和 values 数组,同时用一个 size 变量记录实际存储的键值对数量(非空元素数)。

public class ArrayHashMap<K, V> {private K[] keys;       // 存储键的数组private V[] values;     // 存储值的数组private int capacity;   // 数组容量private int size;       // 实际存储的键值对数量// 构造函数:指定容量@SuppressWarnings("unchecked")public ArrayHashMap(int capacity) {this.capacity = capacity;this.keys = (K[]) new Object[capacity];  // 泛型数组初始化this.values = (V[]) new Object[capacity];this.size = 0;}
}

疑惑点 2:为什么用泛型数组?因为要支持任意类型的键和值(如 StringInteger),泛型数组可以保证类型安全,避免强制转换错误。

2. 插入键值对(put 方法)

步骤:

① 计算键的哈希索引;

② 检查该索引是否已存在键(若存在且不是当前键,说明冲突,抛出异常);

③ 存入键和值,更新 size

public void put(K key, V value) {if (key == null) {throw new IllegalArgumentException("键不能为null");}int index = hash(key);  // 计算索引// 检查冲突:索引已有其他键if (keys[index] != null && !keys[index].equals(key)) {throw new RuntimeException("哈希冲突:键 " + key + " 与现有键 " + keys[index] + " 冲突");}// 若索引为空,说明是新键,size+1if (keys[index] == null) {size++;}// 存入键值对keys[index] = key;values[index] = value;
}// 哈希函数:计算索引(简化版)
private int hash(K key) {// 确保哈希值为非负数,再取模return (key.hashCode() & 0x7FFFFFFF) % capacity;
}

疑惑点 3:hashCode() & 0x7FFFFFFF 有什么用?因为 hashCode() 可能返回负数(如字符串 "a" 的哈希值是 97,而 "A" 是 65,但有些对象的哈希值可能为负),与 0x7FFFFFFF(二进制最高位为 0,其余为 1)按位与后,可将负数转为正数,避免取模后得到负索引。

3. 获取值(get 方法)

步骤:① 计算键的哈希索引;② 若索引处的键与目标键一致,返回对应值;否则返回 null

public V get(K key) {if (key == null) {return null;}int index = hash(key);// 检查索引处的键是否匹配if (keys[index] != null && keys[index].equals(key)) {return values[index];}return null;  // 键不存在
}
4. 删除键值对(remove 方法)

步骤:① 计算键的哈希索引;② 若索引处的键与目标键一致,清空该位置的键和值,更新 size

public V remove(K key) {if (key == null) {return null;}int index = hash(key);if (keys[index] != null && keys[index].equals(key)) {V oldValue = values[index];keys[index] = null;    // 清空键values[index] = null;  // 清空值size--;return oldValue;}return null;  // 键不存在,返回null
}
5. 随机获取一个键(randomKey 方法)—— 数组加强的核心优势

普通哈希表要随机获取键,需要遍历所有哈希桶找非空元素,时间复杂度 O (n)。而 ArrayHashMap 利用数组的随机访问特性,可实现 O (1) 级别的随机键访问:

步骤:

① 生成一个随机索引(0 ~ capacity-1);

② 若该索引处的键不为 null,直接返回;否则重复

③(直到找到非空键)。

import java.util.Random;public K randomKey() {if (size == 0) {return null;  // 空表返回null}Random random = new Random();while (true) {int index = random.nextInt(capacity);  // 随机生成索引if (keys[index] != null) {return keys[index];  // 返回非空键}}
}

疑惑点 4:如果数组中空位置很多,randomKey() 会不会效率低?会。因此 ArrayHashMap 适合 “负载因子较高” 的场景(即数组利用率高,空位置少)。如果空位置多,可维护一个 “非空索引列表”,随机访问时直接从列表中取索引(优化版见下文)。

6. 其他辅助方法
public int size() {return size;  // 返回实际键值对数量
}public boolean isEmpty() {return size == 0;
}public boolean containsKey(K key) {return get(key) != null;  // 利用get方法判断键是否存在
}

四、优化:解决 randomKey() 在低负载因子下的效率问题

当数组容量大但实际存储的键值对少(负载因子低)时,randomKey() 可能多次生成空索引,效率下降。优化方案:维护一个 nonNullIndices 列表,记录所有非空索引

import java.util.ArrayList;
import java.util.List;
import java.util.Random;public class ArrayHashMapOptimized<K, V> {private K[] keys;private V[] values;private int capacity;private int size;private List<Integer> nonNullIndices;  // 存储所有非空索引@SuppressWarnings("unchecked")public ArrayHashMapOptimized(int capacity) {this.capacity = capacity;this.keys = (K[]) new Object[capacity];this.values = (V[]) new Object[capacity];this.size = 0;this.nonNullIndices = new ArrayList<>();}// 重写put方法:新增键时记录索引到nonNullIndicespublic void put(K key, V value) {if (key == null) {throw new IllegalArgumentException("键不能为null");}int index = hash(key);if (keys[index] != null && !keys[index].equals(key)) {throw new RuntimeException("哈希冲突");}if (keys[index] == null) {size++;nonNullIndices.add(index);  // 新增非空索引}keys[index] = key;values[index] = value;}// 重写remove方法:删除键时从nonNullIndices移除索引public V remove(K key) {if (key == null) {return null;}int index = hash(key);if (keys[index] != null && keys[index].equals(key)) {V oldValue = values[index];keys[index] = null;values[index] = null;size--;nonNullIndices.remove((Integer) index);  // 移除非空索引return oldValue;}return null;}// 优化后的randomKey:直接从nonNullIndices中随机取索引public K randomKey() {if (size == 0) {return null;}Random random = new Random();int randomIndexInList = random.nextInt(nonNullIndices.size());  // 随机取列表中的索引int actualIndex = nonNullIndices.get(randomIndexInList);  // 得到实际数组索引return keys[actualIndex];}private int hash(K key) {return (key.hashCode() & 0x7FFFFFFF) % capacity;}
}

疑惑点 5:nonNullIndices 为什么用 ArrayList?因为 ArrayList 支持 O (1) 级别的随机访问(get 方法)和 O (n) 级别的删除(remove 方法)。对于 put 操作,新增索引是 O (1)(尾部添加);对于 randomKey,直接随机访问列表元素,效率极高。

五、ArrayHashMap 的适用场景与局限性

适用场景:
  1. 需要高效随机获取键:如抽奖程序(随机从参与用户中选一个)、随机推荐系统(随机返回一个商品);
  2. 键的哈希值分布已知且无冲突:如键是连续整数(0~999),可直接用键作为索引,无需处理冲突;
  3. 追求简单实现:比普通哈希表少了链表 / 红黑树的复杂逻辑,适合初学者理解哈希表核心原理。
局限性:
  1. 空间利用率低:为了避免冲突,数组容量通常大于实际存储的键值对数量,浪费空间;
  2. 无法处理哈希冲突:设计时必须保证键的哈希值无冲突,否则会抛出异常;
  3. 不适合动态扩容:数组容量固定,若键数量超过容量,需要手动重建更大的数组,操作繁琐。

六、总结:ArrayHashMap 的核心价值

ArrayHashMap 是 “用数组特性简化哈希表实现” 的典型案例:

  • 用 两个平行数组 存储键值对,省去了普通哈希表的冲突处理逻辑;
  • 利用数组的 随机访问特性,轻松实现 randomKey() 等普通哈希表难以高效支持的操作;
  • 适合 键分布已知、需要随机访问 的场景,是对普通哈希表和链表加强哈希表的补充。

理解它的设计后,你会更清晰地认识到:数据结构的 “加强” 并非一定要复杂化,有时利用基础结构(如数组)的特性,就能针对性解决特定问题

8 一点疑惑:我感觉hash在java的语言里面比较多,像C语言,js,就不怎么讨论这个,为什么?

你的感觉很准!

不是 C 语言、JavaScript 不重视哈希,而是它们对 “哈希” 的处理方式、场景需求和 Java 有本质差异,导致 “讨论频率” 和 “呈现形式” 不同。

核心原因可以拆成「语言设计目标」「哈希功能的载体」「使用场景侧重」三个维度,我们一个个嚼碎了说:

一、先破误区:C/JS 不是不讨论哈希,而是 “不提‘哈希’这两个字,但一直在用”

首先要明确:所有需要 “高效键值映射” 的场景,都离不开哈希原理

C 和 JS 不是没有哈希,而是它们没有像 Java 那样,把 “哈希” 封装成一个明确的、叫 HashMap 的类,也没有强制要求开发者理解 “哈希表数据结构” 才能用 —— 而是把哈希功能 “藏” 在了更贴合自身语言特性的载体里。

比如:

  • JS 里你天天用的 Object/Map,底层就是哈希表实现;
  • C 里你可能用过的 uthash 库、glib 库的 GHashTable,本质也是哈希表;
  • 甚至 C 里手动写 “数组 + 链表” 实现键值映射,只要用到 “键→哈希值→索引” 的逻辑,就是在玩哈希。

只是它们不刻意强调 “这是哈希”,而是让你 “直接用结果”—— 就像你用手机不用天天说 “我在用电信号传输”,但你一直在用一样。

二、核心原因 1:语言设计目标不同,决定了 “哈希功能的封装程度”

Java 和 C/JS 的设计目标完全不同,这直接导致了 “哈希” 的呈现形式差异:

(1)Java:“强类型 + 面向对象”,必须把哈希封装成 “标准类”

Java 是 强类型、纯面向对象 的语言,追求 “万物皆对象” 和 “接口统一”。

它的核心目标是让开发者在 “复杂项目” 中,能快速复用标准化的容器(比如存储键值对),不用重复造轮子。

所以 Java 会:

  • 专门设计 Map 接口(定义哈希表的操作规范),再提供 HashMap(哈希表实现)、LinkedHashMap(带链表的哈希表)等具体类;
  • 强制开发者理解 “哈希表的特性”(比如键唯一、无序 / 有序、冲突处理),因为你要明确选择用 HashMap 还是 TreeMap
  • 甚至在面试中频繁考察 HashMap 底层(数组 + 链表 / 红黑树、哈希函数、扩容机制),因为这是 “面向对象 + 容器设计” 的典型案例。

简单说:Java 把 “哈希” 做成了 “标准化的工具包”,你必须认识这个工具包,才能用好它。

(2)C:“底层 + 高性能”,哈希需要 “手动搭骨架”

C 是 面向过程、贴近硬件 的语言,核心目标是 “高性能” 和 “灵活控制内存”。它没有 “类” 的概念,也不提供现成的 “容器库”(标准库只有数组、链表等基础结构),所有复杂结构都需要开发者手动实现。

所以 C 里的 “哈希” 是这样的:

  • 没有现成的 “哈希表类”,你要自己写 “哈希函数(比如 hash(key) = key % capacity)+ 数组 + 链表(解决冲突)” 的逻辑;
  • 或者用第三方库(比如 uthash)—— 但这些库本质是 “用宏定义封装的结构体 + 函数”,不是 “类”,你还是要理解底层的结构体设计(比如存储键、值、下一个节点指针的结构体);
  • 开发者讨论的不是 “怎么用哈希表”,而是 “怎么写一个高效的哈希函数”“怎么用链表解决冲突更省内存”—— 因为你在 “造轮子”,而不是 “用轮子”。

简单说:C 里的 “哈希” 是 “一堆零散的零件”,你要自己拼出哈希表,所以不常提 “哈希表” 这个整体,而是提 “零件怎么拼”。

(3)JS:“弱类型 + 脚本化”,哈希功能 “藏在日常用法里”

JS 是 弱类型、脚本语言,核心目标是 “快速开发前端 / 简单后端逻辑”,追求 “易用性”—— 让开发者不用关心底层,一行代码就能实现功能。

所以 JS 里的 “哈希” 是这样的:

  • 用 Object 存键值对(const obj = { name: 'a' }),底层是哈希表,但你不用知道 —— 你只需要知道 “用 obj.name 能取到值”;
  • 后来新增的 Map 虽然更像 “标准哈希表”(支持任意类型键、可遍历),但开发者用它时,关注的是 “能存非字符串键”“能按插入顺序遍历”,而不是 “它的底层是哈希表”;
  • 甚至 JS 引擎(比如 V8)会偷偷优化 Object/Map 的底层(比如小数据量用数组,大数据量用哈希表),但开发者完全感知不到 —— 你只用关心 “能不能存、能不能取”。

简单说:JS 把 “哈希” 做成了 “隐形的底层能力”,你一直在用,但不用知道它叫 “哈希”。

三、核心原因 2:使用场景侧重不同,决定了 “哈希的讨论频率”

不同语言的主流使用场景,也决定了 “哈希” 是否会被频繁讨论:

(1)Java:高频用于 “后端业务开发”,哈希表是 “核心容器”

Java 主要用来写后端(比如电商系统、管理系统),这些场景里大量需要 “存储键值对”

  • 存用户信息(键:用户 ID,值:用户对象);
  • 存缓存数据(键:缓存 Key,值:缓存内容);
  • 存配置项(键:配置名,值:配置值)。

这些场景都需要用 HashMap,而且为了优化性能(比如避免扩容、减少冲突),开发者必须讨论 HashMap 的底层细节 —— 比如 “初始容量设多少”“负载因子为什么是 0.75”“红黑树什么时候转链表”。

所以 Java 里 “哈希” 的讨论频率高,本质是 “后端开发离不开 HashMap,且需要优化它”

(2)C:高频用于 “底层开发”,哈希不是 “首选工具”

C 主要用来写操作系统、驱动、嵌入式程序 —— 这些场景的核心需求是 “内存可控”“速度极致”,而哈希表的 “随机访问”“冲突处理” 反而可能带来额外开销(比如哈希函数计算时间、链表节点的内存开销)。

所以 C 里更常用的是:

  • 数组(直接用索引访问,比哈希表快);
  • 链表(内存连续,适合动态增删);
  • 二叉搜索树(适合有序遍历,比哈希表省内存)。

只有在 “必须用键值映射” 的场景(比如数据库索引、缓存),才会手动实现哈希表 —— 但这种场景少,所以 “哈希” 的讨论频率自然低。

(3)JS:高频用于 “前端交互”,哈希功能 “够用就行”

JS 主要用来写前端(比如页面交互、表单处理),这些场景里 “键值映射” 的需求很简单:

  • 存页面状态(比如 const state = { isShow: true });
  • 存临时数据(比如 const user = { id: 1, name: 'a' });

这些需求用 Object 就能满足,而且数据量通常很小(最多几百条),完全不用关心 “哈希冲突”“扩容”—— 哪怕底层有性能问题,用户也感知不到。

就算用 Map,也只是因为 “需要存非字符串键”(比如用对象当键),而不是 “需要优化哈希性能”。所以 JS 里几乎没人讨论 “Map 的底层哈希实现”,因为 “够用就行”。

四、总结:不是 “不讨论”,而是 “以不同形式融入语言”

用一句话概括三者的差异:

  • Java:把 “哈希” 做成了 “需要深入理解的标准工具”,所以讨论多;
  • C:把 “哈希” 做成了 “需要手动拼接的零件”,所以讨论少(场景也少);
  • JS:把 “哈希” 做成了 “不用关心的隐形能力”,所以没人讨论(感知不到)。

本质上,所有语言只要需要 “高效键值映射”,都离不开哈希原理 —— 只是呈现形式和讨论频率,由语言的设计目标和使用场景决定。

http://www.dtcms.com/a/503579.html

相关文章:

  • 游戏科技网站杭州建设工程交易中心网站
  • 网站做推广要备案吗四川seo快速排名
  • 有关房地产开发建设的网站哪些网站可以接点私活做的
  • 有关应广单片机数据类型与ROM空间优化的一点看法
  • 【2026计算机毕业设计】基于Jsp的医疗设备及业务综合巡检系统
  • 个人业务技能提升如何助力:从“做事”到“成事”的跃迁之路
  • 我们需要的中文编程是什么样子的?
  • 那些网站可以接私活做做兼职有哪些网站
  • 绚星智慧科技斩获智享会三项大奖,领跑AI时代学习与发展赛道
  • 零基础入门Java·基础篇 学习大纲
  • XSS 攻击详解:原理、类型与防范策略
  • 青岛网站建设 百度天津基础设施建设
  • 内蒙古住房和建设厅网站wordpress会员修改
  • 网站排名优化教程wordpress themes.php
  • 机器学习18:扩散模型(Diffusion Model)与Stable Diffusion
  • 17-机器学习与大模型开发数学教程-第1章 1-9 凸函数与凸优化基础
  • 空间建设网站企业建站程序推荐
  • 网站建设 点指成名网站开发调查问卷
  • 【经典书籍】C++ Primer 第10到12章精华讲解
  • 前端数据存储localStorage、sessionStorage 与 Cookie
  • 电影网站制作有哪些做微信小游戏的网站
  • Git从入门到精通教程
  • 课程视频网站建设的必要性色母图片
  • GEO内容更新与迭代策略:趋势话题的快速响应机制
  • 【Spring】Spring事务和事务传播机制
  • php网站开发源码网站开发部门结构
  • 03-流程控制语句-导读
  • MATLAB基于混合算法改进灰色模型的装备故障预测
  • Next.JS环境搭建,对接Rust的RESTful API
  • 目前流行的网站分辨率做多大自己做网站需要备份么