算法学习-散列表

781 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天,点击查看活动详情

散列表

散列表也指Hash表。我们都知道Hash结构是Key-Value结构。但是Hash结构本质还是源自于数组。

Hash为什么可以做到O1级别的查询,其实就是数组的下标随机访问一样的。

这里可能就会问题,数组的下标都是Int类型,Hash结构的Key可以为任何类型。这个要如何处理呢?

此时就用到了Hash函数,Hash函数本质就是通过算法转换为int类型,以便将数据存储在数组空间,下次查询时也是通过Hash函数获取下标,快速获取数据。

Hash函数理解。

一份数据如果需要用散列表存储,那么一定离不开Hash函数。

Hash函数无法做到不重复,所以总会有一些Hash碰撞,Hash冲突的情况。解决Hash冲突的方法包括线性探测,链表。

Java hashCode()方法

Java在Object类中有一个hashCode方法,此方法与equals方法一起用来判断对象是否一致。

  1. HashCode不一致 两个对象一定不相同。
  2. HashCode相同两个对象可能相同。

通常我们会说,重写了equals方法后一定要重写hashCode。为了避免,equals认为对象一样,但是hashCode确不同情况时出现一些问题。

比如,在一些Hash结构使用过程中,如果遇到上述情况,则无法正常使用。以HashMap为例:

先使用hashCode(Key) 判断是否存在,存在则比较Key(equals),不存在就新增。

假如我们修改了equals方法认为这两个Key表示同一个对象,那么在hashCode时候产生了两个不同的值,势必会影响我们正常的使用(当然,此处的hashCode方法是在Java实现的基础上,HashMap自己又做了高低位的转换,本质上还是Java自己实现的。

public V put(K key, V value) {
    // hash(Key)
    return putVal(hash(key), key, value, false, true);
}


static final int hash(Object key) {
    int h;
    // 调用 hashCode() 方法
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}


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;
    // hash后的值作为下标 判断是否存在数据 并赋值给 p 
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // 如果存在数据 需要比对Key是否相同, hash值必须一致, == 或者 equals 需要一致
        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;
}

HashCode 的目标,以最少的耗时,生成重复率最小的HashCode;

HashCode由于存在Hash冲突的情况,因此并非一直都能达到O1的复杂度。

如何设计工业级的散列表

  1. 合适的Hash函数(快速、冲突少、分布均匀)。
  2. 合适的Hash冲突策略。
    1. 开放寻址法
    2. 链表法
  3. 合适的装载因子,扩容方式。

Hash函数

结合不同的场景我们可以选择不同的Hash函数。比如手机号,大部分手机号前三位都一致。151/130/185而后几位则具备随机的特性,因此我们可以仅使用后几位作为Hash值。

Hash函数的设计还是尽可能的快,尽可能的随机(均匀分布)。

装载因子过大

如何高效的扩容、缩容。

对于扩容来说,8个 -> 16 个很快,1G -> 2G 又该如何处理呢?

正常来讲,我们可以采取一些其他的策略,避免单次迁移大量数据。这方面Redis的设计就很巧妙。

HASH冲突

Hash冲突后的寻址方式,链表方式。

开放寻址方式

优点:所有数据都存储在数组中,没有额外的其他数据结构。序列化比较简单。链表方式序列化就比较麻烦。

缺点:由于所有数据都存在数组中,因此冲突的概率也随之提升。此外,删除时还需要特殊的标记。

适用场景:数据较少(装载因子较小)的场景可以使用开放寻址方式。ThreadLocalMap

链表方式

优点: 链表对于装载因子比较友好。装载因子接近于1时开放寻址的效率就会变的很差。而链表则无此问题,即使到10,只要分配的足够均匀,也要比正常的查询要快很多。

缺点: 链表结构也有很明显的缺点就是需要存储指针,在一些数据较小的情况下,可能就是成倍的消耗。另外由于存储的不连续性,对于CPU缓存来说也不是很友好,无法预加载。

备注: 另外我们再装载因子比较大的情况下,也可以使用 红黑树来提高查询的速度(参考二分查找对效率的提升)。这里思考一下:HashMap的扩容条件是什么?

HashMap

扩容

HashMap作为一个工业级的散列表,除了缺少并发场景设计外,其他方面还是很OK的。

Hash函数方面:采用高低位

装载因子方面:设计的状态因子为 0.75

Hash冲突方面: 采用 链表 以及 红黑树 两种形式来提升查询效率。

此外,在扩容方面: HashMap比较粗暴,直接全量迁移,因此单次扩容成本也较高。

扩容的过程:

  1. 在完成数据新增后。会判断当前数据量 size 是否大于 阈值 (数组当前大小 * 装载因子 0.75 )
  2. 如果大于,则开始初始化新的数组空间。
    1. 挨个遍历旧数组。
    2. 将旧数组数据只有一个值,直接 & 新的数组长度 - 1(相当于取模)。结束
    3. 如果还有其他值,则判断类型是链表还是红黑树。
      1. 如果是链表
        1. 然后挨个遍历 & 旧数组长度 (实际仍然是 hash % 新长度),对原数据进行拆分。
        2. == 0 的不动,大于 0 的放在 原下标 + 旧数组长度 位置。
      2. 如果是红黑树
        1. 暂略

思考:为什么要 & 旧数组的长度?

这里本质其实是想将原数据进行拆分,部分移动到扩容后的位置。移动的算法有很多,感觉 & 运算并不能够均匀的拆分。这里应该用新数组长度取模来拆分比较好。

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
    next = e.next;
    if ((e.hash & oldCap) == 0) {
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {
        if (hiTail == null)
            hiHead = e;
        else
            hiTail.next = e;
        hiTail = e;
    }
} while ((e = next) != null);
if (loTail != null) {
    loTail.next = null;
    newTab[j] = loHead;
}
if (hiTail != null) {
    hiTail.next = null;
    newTab[j + oldCap] = hiHead;
}

实际次数 e.hash & oldCap == 0 等价于 e.hash * (newCap - 1) == 0 等价于 e.hash % newCap == 0

如果为0 不动,不为 0 肯定为为 oldCap,直接放在 j + oldCap 位置刚好。

比如:原数组长度为16时,假如 下标为 1 处防止数据 hashCode 如下,

长度16时,下标为1处 HashCode ( hash % 16 = 1)hash & 旧长度 16长度32时,下标为17处 HashCode ( hash % 32 = 17)
10
1716
330
4916

如上所示,判断 hash & 旧长度 是否为 0 就等价于, hash % 新长度 是否为 原下标 + 原长度,是否要移动。

HashTable区别

最大的区别:HashTable是线程安全的,使用了synchronized 关键字。相对应的,如果作为局部变量使用的话(单线程情况)肯定效率要低于HashMap。

  1. 线程安全(synchronized )。
  2. 数据结构。1.7以前都是数组 + 链表。 1.8 以后 HashMap 加入了红黑树。

链表为什么总是和散列表配合使用

链表在新增,删除时的效率其实是比较低的。而Hash表正好弥补了这一缺陷。

Hash表虽然新增,查询效率很高,但是都是无序的,而链表很好的弥补了这一缺陷。

因此在很多时候,我们总能看到两者配合一起去使用。

比如:

  1. LRU算法,既需要有序性来做淘汰,又需要快速访问来保证读/写效率(链表 + hash 表)。
  2. Redis Sort Set,需要访问速度,需要排序(跳表),而Redis本质也是为了高效查询的Hash结构。

Hash算法

Hash算法其实就是将任意长度的二进制值(Object)映射为固定长度的二进制值。

Hash算法基本要求:

  1. 运算速度要快。毫秒级别
  2. 敏感度要高,任何微小的改变哪怕一个字节,也要保证最后得到的Hash值不相同。
  3. 冲突概率要小。
  4. 不能轻易的计算出原数据。

Hash基本使用场景:

  1. 加密
    1. 加密的重点在于不能轻易的计算出原数据。在这方面,越复杂的算法越难还原,相应的耗时也较高。
  2. 唯一表示
    1. hash冲突概率较小
  3. 数据检验
  4. 散列函数
    1. 要求计算要快,并且尽可能的分散。不强制要求冲突概率,冲突可以采用开放寻址法,链表法解决。
  5. 负载均衡
    1. 负载均衡需要保证同一用户的请求落在同一台服务。基于请求用户ip做Hash取模即可保证。
  6. 数据分片
    1. 一致性Hash算法。数据分片存储离不开对数据的扩缩容,传统的扩缩容机制不适用与互联网大数据量的场景。比如HashMap的扩容,每次扩充两倍,重新进行Hash计算迁移数据。这样的过程涉及数据太多。所以出现了一致性Hash算法,将Hash范围作为一个环,每一个分片负责一部分环,当发生扩容时,只需要重新分配服务负责的范围,仅移动范围内的数据即可,通常范围也不是连续的。比如A服务可以负责:110,100200,1000~1100,只需要保证分配均匀即可。

思考:

Hash算法为什么无法做到0冲突。-- 鸽巢原理