散列表漫谈

散列表漫谈

散列表的英文叫做Hash Table,平时我们也称之为哈希表,其基本思想是通过某个函数将数据映射到数组中的某个位置,再通过数组可以通过下标随机访问特性快速查找元素的。

通过这样的定义我们可以分析出,散列表的关键在于两个因素

  • HASH函数
  • 数组

作为最著名的散列表实现,我们来看一下JavaHashMap是怎么实现的。

首先膜拜这几个亮闪闪的人物

  • Doug Lea
  • Josh Bloch

正所谓"编程不识Doug Lea,写尽Java也枉然",我们在java.utils.concurrent包里还会再次遇到。

对于一个工业级的散列表实现需要考虑的问题

  1. 如何设计散列函数?

  2. 如何解决散列冲突?

  3. 装载因子过大/过小怎么办?

  4. 如何避免低效的扩容?

    针对这几个问题,以下依次分析

1. 如何设计散列函数?

散列函数的设计应该有两个要求:高效、随机且均匀分布

散列表的查询时间复杂度包括三个方面,hash的时间开销,从数组中索引值得开销,当然还有解决hash冲突的开销。

理论上我们会说O(10000+N)的复杂度也是O(N)呀,但是工程上是要有取舍的,假如一个hash函数需要上万次操作,而元素的个数只有几十个,这时候反而是顺序遍历更快,工程上也有很多这种场景,如果数组元素很少的情况下直接遍历反而会比HashMap更快。

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 
        && (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do {
                if (e.hash == hash && 
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

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

上面的代码是Java 11中的HashMapget方法及其实现,getNode的过程很好理解:

  1. 先通过hash找到在数组中的下标,如果数组是空或者对应位置为空说明元素不在表中;
  2. 否则找对应位置元素的key是否与查找的key相等,如果相等则返回
  3. 如果下标相同的位置key不同,说明遇到hash冲突了,按开链法依次从链表找key相同的,或者是从红黑树里查找

这里有几个问题:

1. first = tab[(n - 1) & hash]是什么意思?

实际上这个做法等价于hash % n,也就是对数组长度取余,当然这是有前提的,当n是2的幂次时该等式成立。这里需要回答两个问题:

  1. 为什么要取余?

    因为hash函数返回的数字是一个int可能超出数组长度,这样做的目的是将hash值均匀分布到数组的范围内,实际上,取余也是一种常见的hash算法

  2. 为什么不用%呢?

    当然是为了性能,HashMap是一个非常常用的类,能扣一点性能是一点,一般认为JAVA中的&会比%快10倍左右(来源待考).

    为什么这样做能成立呢,实际上\(2^n-1\)用二进制表示就是\(0b\underbrace{111111}, n个1\),对其做与运算相当于舍弃高位,等价于取余

2. (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)是什么意思?

  1. key == null 时hash值是0,所以HashMap可以存key为null的值;

  2. (h = key.hashCode()) ^ (h >>> 16)这句话的意思是将key.hashCode())h >>> 16异或

    假设length=8,keyhashcode = 78897121,转换成二进制就是:100101100111101111111100001

    (length-1) & 运算如下

      0000 0100 1011 0011 1101 1111 1110 0001
    & 0000 0000 0000 0000 0000 0000 0000 0111
    = 0000 0000 0000 0000 0000 0000 0000 0001
    

    我们可以得出结论:

    • length = 2^N时,下标运算的结果取决于哈希值的低N位
    • 对于大多数情况,length小于2^16,为了让高16位参与运算将h右移16位与自身做异或运算,得到的下标更为随机

Times33算法与最快的Hash表

事实上,我们在StringhashCode里面看到了这样一段代码

public static int hashCode(byte[] value) {
    int h = 0;
    for (byte v : value) {
        h = 31 * h + (v & 0xff);
    }
    return h;
}

这个算法思想来源于Times33算法hash(i) = hash(i-1) * 33 + str[i],为毛是31,可以参考StackOverflow上的这个回答

Why does Java's hashCode() in String use 31 as a multiplier?

简单来说:

  1. 它是一个质数
  2. n * 31 == (n << 5) - n
  3. 基于测试和统计数据,如果你使用 31,33, 37,39 和 41 这几个数值,将其应用于 hashCode 的算法中,每一个数字对超过 50000 个英语单词(由两个 Unix 版本的字典的并集构成)产生的 hash 只会产生少于 7 个的冲突。

说完HASH函数,我们再扯扯数组。

散列函数的设计前文已经分析了,HashMap使用的是对象的hashCode(),为了使哈希函数高效,生成的值分布均匀,使用了高16位异或,和length - 1取或运算。

2. 如何解决散列冲突?

既然用到hash,就免不了有hash 冲突,所谓hash冲突就是有多个key经过hash计算后映射到到了相同的下标,常用的解决方法有

  • 开放寻址法(open addressing)
  • 链表法(chaining)

开放寻址法

开放寻址法的核心思想是,如果出现了散列冲突,就重新探测一个新的空闲位置,主要方法有线性探测(Linear Probing)二次探测(Quadratic probing)双重散列(Double hashing)

1. 线性探测

线性探测的思路就是,如果某个数据经过hash后发现位置已经被占用了,就从这个位置开始,依次向后查找,直到有空闲位置就插入。

查找的时候也是一样的,如果通过hash后发现位置存在值且不相等,就从这个位置开始,依次向后查找,直到找到相等的元素,如果找到空闲位置说明该元素不在表中。

删除的时候呢,从上面的描述知道,如果单纯的线性遍历然后删除对应的元素会造成空洞,查找时候会提前返回,算法失效,如何解决呢,可以在删除的时候将后续的元素依次往前搬移,当然也可以优化一点,将最后一个元素交换到删除的位置;还有更优化的方法是在删除的位置使用deleted标记,相应的也需要修改插入和查找的逻辑。

2. 二次探测

所谓二次探测思路和线性探测类似,只不过每次探测的步长改成了原来的二次方`

\[H_0 = hash(x) \\ H_i = mod((H_0 + i^2), m), m = 1, 2, 3, ..., (m-1)/2 \]

主要优势是可以解决线性探测分布更为随机,避免数据过于聚集。

3. 双重散列

实际上就是用多组散列函数,第一组发现冲突后再用第二组,依次直到找到空闲位置

链表法

链表法很简单就是在发现hash冲突后在将冲突的元素依次向该下标的槽位对应链表的后面加一条数据即可,查询也是顺着链表查询,删除直接删除链表的该节点即可;这里就有一个问题了,如果链表过长的话,散列表就退化成链表了,解决的方法有两个,一是减小装载因子,降低冲突的概率;二是将链表转成快速查找的动态数据结构,常见的有跳表、红黑树,这样最坏的情况下也只是退化成O(logn)的复杂度。

同样的我们来分析HashMap的实现

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

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;
}

以上是插入的代码,可以看到是标准的链表法

  • 如果位置没被占用,调用newNode在该位置插入一条数据
  • 如果位置占用,且不存在冲突,修改Node的值
  • 有冲突判断是红黑树还是链表,如果是链表就在链表尾部插入一条数据,如果链表长度大于TREEIFY_THRESHOLD=8,将会调用treeifyBin将链表转成红黑树。
  • 如果元素个数超过阈值,执行resize(),这个我们后面分析扩容操作

3. 装载因子过大/过小怎么办?

不管使用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高(抽屉原理),为了描述散列表中空闲的程度,我们引入装载因子的概念

\[loadFactor = size / capacity \]

也就是填入表中元素的个数/散列表的长度,这样我们就能理解工程上为什么比较常用的是链表法了,链表法可以容纳的装载因子更高,甚至可以大于1,而开放寻址法的可容纳的装载因子较低。

总结起来就是,开放寻址法简单,数据存储为数组结构,能够有效的利用CPU缓存,但是装载因子不能过高,常用于数据量较小,装载因子小的情况,例如 Java 中的 ThreadLocalMap;链表法适合于通用的场景,支持更多的优化策略,例如HashMap中的红黑树。

再回答上面的问题,装载因子过大/过小怎么办

  • 过大的散列冲突的概率也越大,效率降低,需要扩容
  • 过小浪费空间,需要缩容

数组扩容/缩容

基本原理很简单,就是新开辟一个数组,长度是原来的一倍、1/2,再将原来的元素依次hash并插入新的数组,这里涉及几个问题

  • 移动大量数据耗时,造成系统抖动
  • 冲突链表的移动
/**
 * Initializes or doubles table size.  If null, allocates in
 * accord with initial capacity target held in field threshold.
 * Otherwise, because we are using power-of-two expansion, the
 * elements from each bin must either stay at same index, or move
 * with a power of two offset in the new table.
 *
 * @return the table
 */
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    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;
                    }
                }
            }
        }
    }
    return newTab;
}

如何避免低效的扩容?

这里可以考虑redis的rehash机制,采用渐进式的方式。

posted @ 2020-09-14 22:08  HiroSyu  阅读(215)  评论(0编辑  收藏  举报