散列表

一、散列表基本概念

1、基本定义

散列表(Hash Table,又叫哈希表),是根据关键码值(Key Value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

2、散列表思想

符号表是一种用于存储键值对(Key — Value)的数据结构,数组也可以看做是一种特殊的符号表,其中数组的索引即为键,数组元素为相应的值。也就是说,当符号表所有的键都是较小的整数时,我们可以使用数组来实现符号表,将数组的索引作为键,而索引处的数组元素即为对应的值,但是这一表示仅限于所有的键都是比较小的整数时,否则可能会使用一个非常大的数组。而散列表是对以上策略的一种升级,它可以支持任意的键而并没有对他们作过多的限定,对于基于散列表的符号表,若我们要在其中查找一个键,需要以下步骤,当然这也是散列表的思想:

散列表思想:

  1. 使用散列函数将给定键转化为一个“数组的索引”,理想情况下,不同的key会被转化为不同的索引,但是在实际情况中,我们会遇到不同的键转化为相同索引的情况,这种情况叫做散列冲突/碰撞,后文中会详细讲解;
  2. 得到了索引后,我们就可以像访问数组一样,通过这个索引访问到相应的键值对。
  1. 散列表是“时间--空间”权衡的例子。当我们的空间无限大时,我们可以直接使用一个很大的数组来保存键值对,并用key作为数组索引,因为空间不受限,所以我们的键的取值可以无穷大,因此查找任何键都只需要一次普通的数组访问。反过来,若查找操作没有任何时间上的限制,我们就可以直接使用链表来保存所有的键值对,这样把空间的使用降到最低,但查找时只能顺序查找。
  2. 然而,在时间的应用中,我们的时间和空间都是有限的。所以,我们必须要在两者之间做出权衡,散列表在时间和空间的使用上找到了一个很大的平衡点。散列表的一个优势在于我们只需要调整散列算法的相应参数而无需对其他部分的代码做任何修改就能在时间和空间上做出策略调整。
  3. 从上面可以看出:散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来,可以说,如果没有数组,就没有散列表。

二、散列函数

1、定义

散列函数(又叫哈希函数),顾名思义,它是一个函数。我们可以把它定义为hash(key),其中key表示元素的键值,hasn(key)的键表示经过散列函数计算得到的散列值。

2、散列函数设计的基本要求

(1)散列函数计算得到的散列值是一个非负整数;

(2)如果key1 = key2,那么hash(key1) == hash(key2);

(3)如果key1 != key2,那么hash(key1) != hash(key2)。

解释下上面三点:

第一点:数组下标是从0开始的,所以散列函数生成的散列值也要上非负整数;

第二点:相同的key,经过散列函数得到的散列值也应该是相同的;

第三点:这个要求看起来没什么问题,但是在真实情况下,要想找到一个不同的key对应的散列值都不一样的散列函数,几乎是不可能的。即便是业界非常著名的MD5、SHA、CRC等哈希算法,也无法避免这种散列冲突。而且,因为数组的存储空间有限,所以也会加大散列冲突的概率。

3、如何设计散列函数

散列函数的设计的好坏,决定了散列冲突的概率大小,也直接决定了散列表的性能,那么我们应该如何设计散列函数呢?

(1)散列函数的设计不能太复杂,过于复杂的散列函数,势必会消耗很多计算时间,也会间接的影响到散列表的性能;

(2)散列函数生成的值要尽可能随机且均匀分布,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会再出现某个槽里数据特别多的情况。

实际工作中,我们还需要综合考虑各种因素,比如:关键字的长度、特点、分布以及散列表的大小等。常用的散列函数的设计方法有:直接寻址法、平方取中法、随机数法等等,这些方法了解即可,不是重点。

<1> 直接寻址法:

取k或者k的某个线性函数为Hash值;

特点:由于直接寻址法相当于有多少个关键字就必须有多少个相应的地址去对应,所以不会产生冲突,也正因为这样,所以实际中很少用到这种方法。

<2> 数字分析法:

首先分析待存的一组关键字,比如是一个班级学生的出生年月日,我们发现他们的出生年份大体相同,那么我们肯定不能用他们的年来作为存储地址,这样出现冲突的概率非常大。但是,我们发现月日的具体数字差别很大,如果我们用月日来作为Hash值,则会明显降低冲突几率。因此,数字分析法就是找出关键字的规律,尽可能的用差异数据来构造Hash地址;

特点:需要提前知道所有的关键字,才能分析运用此种方法,所以不太常用。

<3> 平方取中法:

先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。这种方法比较常用。

例:我们把英文字母在字母表中的位置序号作为该英文字母的内部编码。例如K的内部编码为11,E的内部编码为05,Y的内部编码为25,A的内部编码为01, B的内部编码为02。由此组成关键字“KEYA”的内部代码为11052501,同理我们可以得到关键字“KYAB”、“AKEY”、“BKEY”的内部编码。之后对关键字进行平方运算后,取出第7到第9位作为该关键字哈希地址,如下图所示:

关键字 内部编码 内部编码的平方值 H(k)关键字的哈希地址
KEYA 11050201 122157778355001 778
KYAB 11250102 126564795010404 795
AKEY 01110525 001233265775625 265
BKEY 02110525 004454315775625 315

<4> 折叠法:

将关键字分割成位数相同的几部分(最后一部分位数可以不同),然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。

<5>随机数法:

选择一个随机函数,取关键字的随机函数值作为Hash地址 ,通常用于关键字长度不同的场合。

特点:通常,关键字长度不相等时,采用此法构建Hash函数 较为合适。

<6>除留取余法:

取关键字被某个不大于Hash表 长m 的数p 除后所得的余数为Hash地址 。

特点:这是最简单也是最常用的Hash函数构造方法。可以直接取模,也可以在平方法、折叠法之后再取模。

值得注意的是,在使用除留取余法 时,对p 的选择很重要,如果p 选的不好会容易产生同义词 。

由经验得知:p 最好选择不大于表长m的一个质数 、或者不包含小于20的质因数的合数。

【说明:上诉6种方法出自于:https://blog.csdn.net/xiaoxik/article/details/74926090】

三、散列冲突

【ps:此段落内容摘抄自极客时间的《数据结构与算法之美》专栏】

首先,我们需要知道的是:再好的散列函数也无法避免散列冲突。

如何处理冲突是哈希表不可缺少的一个方面,现在完整的描述下处理冲突:【不适用于链表法】

散列冲突是指由关键字得到的哈希地址的位置上已存有记录,则“处理冲突”就是为该关键字的记录找到另一个“空”的哈希地址。在处理冲突的过程中,可能得到一个地址序列,即在处理哈希地址的冲突时,若得到的另一个哈希表地址仍然发生冲突,则再求下一个地址,若仍然冲突,再求,以此类推,直至不发生冲突为止,则为记录在表中的地址。

解决散列冲突的方法有:开放寻址法(Open Addressing)和链表法(Chaining),以及再哈希法和公共溢出法。

1、开放寻址法

开放寻址的核心思想是:如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。这里我们可以使用线性探测方法(Linear Probing)。

当我们往散列表中插入数据时,如果某个数据经过散列函数之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。

查找: 散列表查找的过程类似于插入,通过散列函数计算散列值,比较数组下标为散列值的位置的值是否等于要查找的值,一直遍历到空闲位置,说明不存在。

删除: 删除操作稍微复杂点,若查找到值,直接删除,会存在问题,比如说删除下标为7的值,位置就变成空闲了,此时要是进行查找(找到空闲位置查找就停止,说明不存在),就会影响下标为7以后的位置是否存在的判断。如何解决呢?我们可以不删除,将要删除的位置标记为deleted,在查找的时候遇到deleted的继续查找即可。

存在问题: 插入的数据越来越多时,空闲位置越来越少,线性探测时间越来越长,发生冲突的概率越来越大。

下面举例说明:下图中黄色的块表示空闲位置,橙色的块表示已经存储了数据的。从图中可以看出,散列表的大小为10,在元素x插入散列表之前,已经有6个元素插入到散列表中了。x经过Hash算法之后,被散列到下标为7的位置,但是这个位置已经有数据了,所以就产生了冲突。那么就需要顺序的一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是我们再从表头开始找,直到找到空闲位置2,于是将其插入到这个位置。

15. 散列表 - 图1
在散列表中查找元素的过程有点儿类似插入的过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素,如果相等,则说明就是我们要找的元素;否则就是顺序往后依次查找,如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。(ps:之所以要往后找,是因为插入数据时可能发生散列冲突,要查找元素插入到了数组中下标为散列值的后面,但是如果遇到空位置,则要查找的元素肯定不存在)

15. 散列表 - 图2
散列表和数组一样,不仅支持插入和查找操作,也支持删除操作。对于线性探测法解决冲突的散列表,删除操作稍微有些特别,我们不能单纯地把要删除的元素位置设置为空,想想这是为什么呢?

上面讲查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,就会被认定为不存在。

解决方法:我们可以将删除的元素,特殊标记为deleted。当线性探测查找的时候,遇到标记为deleted的位置,并不是停下来,而是继续往下探测。

15. 散列表 - 图3
通过上面的讲诉,不难发现其实线性探测法存在很大的问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。在极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到或者删除数据。

对于开放寻址冲突解决方法,除了线性探测法之外,还有另外两种比较经典的探测方法,二次探测(Quadratic Probing)和双重散列(Double Hashing)。

二次探测:和线性探测很像,线性探测每次探测的步长是1,那它探测的下标序列就是hash(key) + 0,hash(key) + 1,hash(key) + 2 …而二次探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是hash(key) + 0,hash(key) + ,hash(key) + …….

双重散列:不仅要使用一个散列函数,我们使用一组散列函数hash1(key)、hash2(key)、hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,以此类推,直到找到空闲的存储位置。

不管采用哪种探测方法,当散列表中的空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲位置。这里我们用装载因子(Load Factor)来表示空位的多少。装载因子的计算公式如下:

15. 散列表 - 图4

装载因子越大,说明空闲位置越少,冲突越多,散列表的性能就会下降。

开放寻址法解决散列冲突的代码实现:

public class LinearProbingHashMap<K, V> {

    private int num;       // 散列表中键值对数目
    private int capacity;  // 散列表的大小
    private K[] keys;      // 散列表中的键
    private V[] values;    // 散列表中的值

    public LinearProbingHashMap(int capacity){
        keys = (K[]) new Object[capacity];
        values = (V[]) new Object[capacity];
        this.capacity = capacity;
    }

    // 散列函数
    private int hash(K key){
        return (key.hashCode() & 0x7fffffff) % capacity;
    }

    // get()方法
    public V get(K key){
        int index = hash(key);
        while(keys[index] != null && !keys.equals(keys[index])){
            index = (index + 1) % capacity;
        }
        // 若给定key在散列表中存在会返回相应value,否则这里返回的是null
        return values[index];
    }

    // put()方法
    public void put(K key, V value){
        if(num >= capacity / 2){
            resize(2 * capacity);
        }

        int index = hash(key);
        while(keys[index] != null && !keys.equals(keys[index])){
            index = (index + 1) % capacity;
        }

        if(keys[index] == null){
            keys[index] = key;
            values[index] = value;
            return;
        }

        values[index] = value;
        num++;
    }

    // 删除操作
    public void delete(K key){

        if((keys.toString()).contains((CharSequence) key)){
            return;
        }

        int index = hash(key); 

        while(!key.equals(keys[index])){
            index = (index + 1) % capacity;
        }

        keys[index] = null;
        values[index] = null;
        index = (index + 1) % capacity;
        while(keys[index] != null){
            K keyToRedo = keys[index];
            V valueToRedo = values[index];
            keys[index] = null;
            values[index] = null;
            num--;
            put(keyToRedo, valueToRedo);
            index = (index + 1) % capacity;
        }
        num--;
        if(num > 0 && num == capacity / 8){
            resize(capacity / 8);
        }
    }

    // 动态扩容
    private void resize(int newCapacity) {
        LinearProbingHashMap<K, V> hashmap = new LinearProbingHashMap<>(newCapacity);
        for (int i = 0; i < capacity; i++) {
            if (keys[i] != null) {
                hashmap.put(keys[i], values[i]);
            }
        }
        keys = hashmap.keys;
        values = hashmap.values;
        capacity = hashmap.capacity;
    }

}

2、链表法

链表法是一种更加常用的散列冲突解决办法,相比较开放寻址法,它要简单很多。如图4所示,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应成一条链表,所有散列值相同的元素,我们都放到相同槽位对应的链表中。

15. 散列表 - 图5

当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度为O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。实际上,删除和查找的时间复杂度和链表的长度k成正比,也就是O(k)。对于散列比较均匀的散列函数来说,理论上讲,k = n / m,其中n表示散列中数据的个数,m表示散列中“槽”的个数。

链表法解决散列冲突的Java代码实现:

class ChainingHashSet<K, V> {

    private int num;                   // 当前散列表中的键值对总数
    private int capacity;              // 散列表的大小
    private SeqSearchST<K, V>[] st;   // 链表对象数组

    // 构造函数
    public ChainingHashSet(int initialCapacity){
        capacity = initialCapacity;
        st = (SeqSearchST<K, V>[]) new Object[capacity];
        for(int i = 0; i < capacity; i++){
            st[i] = new SeqSearchST<>();
        }
    }

    // hash()方法
    private int hash(K key){
        return (key.hashCode() & 0x7fffffff) % capacity;
    }

    public V get(K key){
        return st[hash(key)].get(key);
    }

    public void put(K key, V value){
        st[hash(key)].put(key, value);
    }
}

// SeqSearchST基于链表的符号表实现
class SeqSearchST<K, V>{

    private Node first;

    // 结点类
    private class Node{
        K key;
        V value;
        Node next;

        // 构造函数
        public Node(K key, V val, Node next){
            this.key = key;
            this.value = val;
            this.next = next;
        }
    }

    // get()方法
    public V get(K key) {
        for(Node node = first; node != null; node = node.next){
            if(key.equals(node.key)){
                return node.value;
            }
        }
        return null;
    }

    // put()方法
    public void put(K key, V value) {
        // 先查找表中是否已经存在相应的key
        Node node;
        for(node = first; node != null; node = node.next){
            if(key.equals(key)){
                node.value = value;  // 如果key存在,就把当前value插入node.next中
                return;
            }
        }

        // 表中不存在相应的key,直接插入表头
        first = new Node(key, value, first);
    }
}

3、如何选择散列冲突解决的方法

上面提到了四种解决散列冲突的方法,其中开放寻址法和链表法比较常用。比如:Java中LinkedHashMap采用了链表解决冲突,ThreadLocalMap是通过线性探测的开放寻址法来解决冲突的。现在对这两种解决散列冲突的方法进行优缺点对比以及适用场景进行说明:

(1)开放寻址法

优点:开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效的利用CPU缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。而链表包含指针,序列化起来就没那么容易。

缺点:用开放寻址法解决散列冲突,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在在开放寻址法中,所有的数据都存储在一个数组中,比起链表来说,冲突的代价更高,所以使用开放寻址法解决散列冲突时,装载因子的上限不能太大,这也导致这种方法比链表更浪费内存空间。

适用场景:当数据量比较小,装载因子比较小的时候,适合采用开放寻址法。这也是Java中ThreadLocalMap使用开放寻址法解决散列冲突的原因。

(2)链表法

优点:首先,链表法对内存的利用率比开放寻执法要高,因为链表结点可以在用的时候再创建,并不需要像开放寻址法那样需要事先申请好;其次,链表法比起开放寻址法,对装载因子的容忍度更高。开放寻址法只能适用于装载因子小于1的情况。接近1的时候,就有可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表来说,只要散列函数的值随机均匀,即便装载因子变成10,也就是链表的长度变长了而已。

缺点:因为链表要存储指针,所以对于比较小的对象存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对CPU缓存是不友好的,这方面对执行效率会有一定的影响。当然如果我们存储的是大对象,也就是说要存储对象的大小远远比一个指针的大小(4个字节或者8个字节),那链表中指针的内存消耗就可以忽略不计了。

实际上,我们可以对链表法稍加改造,就可以实现一个更加高效的散列表。那就是,我们将链表法的链表改造为其他高效的动态数据结构,比如:跳表、红黑树等。这样即便出现散列冲突,极端情况下,所有的数据都散列到一个桶内,那最终退化成的散列表查找时间复杂度也只不过是O(logn)。

适用场景:基于链表的散列冲突处理方法比较适合大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如红黑树替代链表。