哈希表
哈希表的底层实现
哈希表的底层实际上是基于数组来存储的。
当插入键值对时,并不是直接插入该数组中,而是通过对键进行Hash运算得到Hash值,
然后使用哈希值和数组长度来确定键值对存储的位置(索引),得到在数组中的位置后分两种情况:
- 如果索引位置为空:直接插入键值对。
- 如果索引位置不为空:
- 遍历链表(或者红黑树)以检查是否有相同的键。如果有,更新值。
- 如果没有相同的键,将新节点添加到链表末尾或树中。
取值时,先对指定的键求Hash值,再和容量取模得到底层数组中对应的位置,如果指定的键值与存贮的键相匹配,则返回该键值对,如果不匹配,则表示哈希表中没有对应的键值对。这样做的好处是在查找、插入、删除等操作可以做到O(1),最坏的情况是O(n),当然这种是最极端的情况,极少遇到。
不管哪门语言,实现一个HashMap的过程均可分为三大步骤:
- 实现一个Hash函数
- 合理解决Hash冲突
- 实现HashMap的操作方法
Hash函数
Hash函数非常重要,一个好的Hash函数不仅性能优越,而且还会让存储于底层数组中的值分配的更加均匀,减少冲突发生。之所以是减少冲突,是因为取Hash的过程,实际上是将输入键(定义域)映射到一个非常小的空间中,所以冲突是无法避免的,能做的只是减少Hash碰撞发生的概率。具体实现时,哈希函数算法可能不同,在Rust及很多语言的实现中,默认选择SipHash哈希算法。
影响Hash碰撞(冲突)发生的除了Hash函数本身意外,底层数组容量也是一个重要原因。很明显,极端情况下如果数组容量为1,哪必然发生碰撞,如果数组容量无限大,哪碰撞的概率非常之低。所以,哈希碰撞还取决于负载因子。负载因子是存储的键值对数目与数组容量的比值,比如数组容量100,当前存贮了90个键值对,负载因子为0.9。负载因子决定了哈希表什么时候扩容,如果负载因子的值太大,说明存储的键值对接近容量,增加碰撞的风险,如果值太小,则浪费空间。
所以,既然冲突无法避免,就必须要有解决Hash冲突的机制方法。
处理哈希冲突的几种方法
主要有四类处理冲突的方法:
- 外部拉链法(常用)
- 开放定址法(常用)
- 公共溢出区(不常用)
- 再Hash法(不常用)
外部链表法
当发生哈希冲突时,会将新的元素存入计算好的索引值位置,将旧元素挂在新元素的下面(Node链表)。
开放地址法
发生哈希冲突时,新的元素会继续寻找下一块未被占用的索引位置,并在该位置存放。
哈希表的优缺点
优点:
- 无论数据有多少,处理起来都特别的快
- 能够快速的进行增删改查
缺点:
- 哈希表中的元素没有顺序
- 哈希表中的元素不可以重复
哈希表什么时候转化为红黑树?
Hash冲突解决办法:HashMap在1.8之前采用数组 + 链表组合作为底层数据结构。在JDK1.8版本中,为了对HashMap做进一步优化,我们引入了红黑树。当链表长度太长(默认超过8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高HashMap的性能。当红黑树结点个数少于8个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。
红黑树
红黑树是一种含有红黑结点并能自平衡的二叉查找树。它必须满足下面性质:
- 每个节点不是红色就是黑色
- 根节点为黑色
- 每个红色结点的两个子结点一定都是黑色
- 任意一结点到每个叶子结点的路径都包含数量相同的黑结点
从性质4又可以推出:
- 性质4.1:如果一个结点存在黑子结点,那么该结点肯定有两个子结点
根据上述规则,新增节点必须为红;新增节点父结点必须是黑。当不能满足时,就需要进行调整颜色并旋转。
红黑树的变换:(插入节点默认为红色)
**改变颜色、左旋、右旋:**红黑树总是通过旋转和变色达到自平衡
- 左旋:以某个结点作为支点(旋转结点),其右子结点变为旋转结点的父结点,右子结点的左子结点变为旋转结点的右子结点,左子结点保持不变。
- 右旋:以某个结点作为支点(旋转结点),其左子结点变为旋转结点的父结点,左子结点的右子结点变为旋转结点的左子结点,右子结点保持不变。
- 变色:结点的颜色由红变黑或由黑变红。
例如: