WXG 二面:上次面试你没答出来的 map 底层,现在搞懂了吗 😿

2,165 阅读7分钟

题接上回,在聊到 WXG 二面时,面试官给我抛出了这样的问题,好在我在一面后用心复盘了一下,这篇文章我们就基于这个点来聊聊 map 的相关干货。

上集回顾:终于拿到了 wxg 的 offer,却只能无奈放弃 🥺

背景

在 WXG 一面的时候,写了一道 LRU 算法 ,我是通过 map 实现的,保证了 get 操作的时间复杂度在 O(1),因此面试官发起了追问:map 为什么是有序,为什么查找的时间时间复杂度可以到 O(1)
对于数据结构方面,实在是没有深入的学习,只能表示不会了。

在面试结束后,我也花了一段时间复盘了一下,正好二面问到了这个问题,回答的好坏必然会十分影响这次面试的结果。 虽然一面的问题都回答上来了,但二面后续的追问还是非常的头疼:

  • hashmap 实现一个数组加链表的结构,数组大小怎么设置?固定还是用户设置还是动态变化?什么情况触发扩容?
  • map 最坏查找情况是怎样的?红黑树实现 hashmap 的话缺点在哪里?
  • map 过大时,扩容怎么做,新创立空间的话很卡,怎么优化?

接下来就围绕这些问题,展开 map 的分享吧。

对于这篇文章,我预计从这几个角度分享:

  1. js 中 map 的底层实现
  2. hashmap 深入
  3. 基于 hashmap 总结面试问题的答案

js 中 map 的底层实现

map 是一个数据结构,保存了 key-value,即键-值对,每个键映射到一个值,map 可以按照元素插入的顺序进行遍历,这是如何实现的呢?

在 js 中,map 是由哈希表(hashmap) 实现的,下面我们来介绍一下 hashmap 的组成。

哈希表

一般情况下,哈希表由数组 + 链表的形式组成,综合了两者的优势,使得寻址、插入、删除都比较方便。为什么是一般情况?在 JDK 8 开始,某链表达到阈值会将链表结构转换为红黑树结构,我们在后面细说。

我们可以看看最常见的格式:数组 + 链表。

  

hashmap 深入

一个优秀的哈希函数需要注意什么?

哈希函数的设计相当重要,一般情况下我们需要考虑这些内容:

  • 均匀分布
    • 好的哈希函数会使得键在哈希表中的分布尽可能均匀,也就是说,任何小的改动(比如改变键的一个字符)都将会产生一个完全不同的哈希值。这样就可以最大程度地减少哈希冲突,使得哈希表的性能更优。
    • 同时也可以防止恶意攻击者通过故意构造碰撞来破坏哈希表的性能。
  • 计算效率:哈希函数的计算过程应该尽可能高效,以便能在 O(1) 时间内完成键到索引的映射。
  • 不可逆性:哈希函数应该是单向的,即从哈希值无法还原出原始的输入数据。这是为了保护数据的安全性,防止敏感信息被恢复出来。
  • 可扩展性:哈希函数应该能够适应不同规模的数据集,无论是小型还是大型数据集,都能够保持较好的性能。

hashmap 的 set 过程

hashmap 的 set 过程为:
首先判断 key 是否为空,若不为空则计算 key 的 hash 值:

  • 根据 hash 值计算在数组中的索引位置。
  • 如果数组在该位置有值,则通过比较是否存在相同的 hash 值(即相同的 key 值)。
    • 若存在则将新的 value 覆盖原来的 value。
    • 不存在则将该 key-value 保存在链头。
  • 若数组在该处没有元素,则直接保存。

hashmap 的 get 为什么是 O(1)

hashmap 在寻找指定的 key 时,若 key 不为空,则会先计算 key 的 hash 值,随后根据 hash 值搜索在数组中的索引位置。

这一步很多人以为是 O(n),但其实 index = hash mod x,相关的 value 会直接存在 index 中,其中 x 一般被设计为当前数组被开辟的空间长度

这也是为什么 hash 值通常为素数,这能更好地把 hash 值均匀分布在整个 hashmap 中,减少冲突。

当遇到冲突时,会在数组对应位置的链表头部插入(最先保存的元素放在链尾),考虑到冲突,其实 get 最坏的情况甚至可以到达 O(n),这种情况可以用红黑树替换链表结构,将查找时间稳定在 O(logn),但红黑树的副作用也恰恰体现在无论如何都会 O(logn)

面对 O(n) 的极端情况,hashmap 会通过设计尽量避免 hash 值的冲突,所以一般就认为是 O(1)

hashmap 扩容机制

hashmap 一开始创建时,原始数组的大小可以事先设定,后续会基于一个机制,进行动态扩容。

比如基于 JDKhashmap 设计,创建时默认的初始容量为 16(就是数组的长度),每一次扩容时,容量翻倍,扩充通常在填充因数达到一定阈值时进行。

填充因数:已存储元素/容量,JDK 默认阈值位 0.75,这是一个权衡了时间和空间成本的折中方案。

达到阈值并扩充时,原本数组中的元素会重新计算其在新数组中的位置,并重新插入,在数据量较大时,存在一定性能上的挑战。

注意!javascript 中的规范和 JDK 不同,但没有相关的数据。默认的空间、填充因数等都是通过内部的一个计算逻辑得到的

大数据扩容的优化策略

面对大数据扩容时,我们可以考虑以下几种优化策略:

  1. 并发扩容:将扩容过程分解成更小的任务,并允许他们在可用的处理器上并发运行,每一次扩容操作只对其中一个段进行,从而适度减少了单次扩容的计算量。
  2. 分步扩容:并非一次将所有的数据移到新的位置,而是在每次添加新元素时,将一部分已有元素移到新的位置。这种方式可以将扩容操作的负担分散在其他操作中,从而避免一次性完成所有重哈希操作带来的性能压力。
  3. 预先扩容:如果能预估到数据较多,可以提前选取一个大的初始大小,避免在运行过程中频繁的扩容操作。
  4. 选择合适的填充因数。

红黑树的出现时机与副作用

JDK 8 后,当 hashmap 中元素的数量大于一定阈值(默认为 64)并且要插入的元素在链表中的位置大于TREEIFY_THRESHOLD(默认为 8)时,会将链表转为红黑树,这样可以提高搜索速度,避免性能下降。

红黑树是一种自平衡的二叉搜索树,它在插入和删除节点时通过一系列的旋转和重新着色操作来保持树的平衡。它得名于节点上的颜色标记,每个节点可以是红色或黑色。
红黑树的特性使得树的高度保持在较小的范围内,从而保证了插入、删除和查找操作的时间复杂度都是 O(log n),但这恰恰是一个副作用 —— 查找时间限制到了 O(log n),当冲突较少时这反而是个弊端。

至于红黑树的具体特性?我认为不用这么深入了,如果感兴趣可以看看其他大佬的文章🫡。

最后

这一套下来后,越发感觉还是要多花些时间投入在数据结构的深入上,接下来我也会基于最近面试过程中遇到的有趣的问题进行整理,分享给大家。

最后,可以了解下我们的社区,里面大佬多多,各种问题都能得到很快的解决,还有机会能参与开源项目的贡献,感兴趣可以加我的微信:Tongxx_yj