算法与数据结构系列之[哈希表]

前言

前面我们介绍线性表的时候,如果要查找某个元素,比如key,就需要从表头开始,逐个比较记录a[i]和key的值是相等还是不相等,知道有相等才算查找成功,返回i。那么我们有没有一种方法不用逐个比较就能查找到要查询的元素呢?答案是肯定的,我们可以通过关键字key得到要查找记录的内存位置,这就是我们这篇要介绍的哈希表,其实哈希表算是线性存储结构的范畴,因为哈希表用到数组作为存储结构。

1.什么是哈希表

哈希表(Hash Table),也叫散列表。哈希表是用数组作为存储结构,具有数组按下表进行随机访问的特性,所以哈希表是数组的一种扩展。哈希表通过哈希函数对我们要存储的元素计算哈希值,将这个哈希值作为数组的下标,然后将数据存储在数组中对应的下标位置。当我们按照键值查询元素时,我们用同样的哈希函数,将键值转换为数组下标,从对应的 数组下标位置取数据。那么哈希表到底有什么用途呢?

当然,哈希表也是用来存储数据的,比如,我们维护一个班级的所有学生的信息,为了方便维护数据,我们可以把一个班级的学生的信息放在一个数组里,假如这个班有40人,学号为1的学生放在数组下标为1的位置,学号为2的学生放在数组下标为2的位置,依次类推,学号为40的学生放在数组下标为40的位置。因为学生学号和下标一一对应,所以当我们需要查询学号为x的学生信息时,只需要将数组下标为x的数组元素取出来 即可,时间复杂度为O(1)。

2.哈希函数

说到哈希表,我们自然绕不开哈希函数,哈希函数是哈希表中非常重要的一个函数,哈希表用这个哈希函数对键值计算哈希值,每次存取都通过计算哈希值找到元素在数组中对应的下标位置。哈希函数设计的好坏直接决定了哈希表冲突的概率大小,也直接决定了哈希表的性能的好坏。那么什么样的哈希函数才算是好的哈希函数呢?首先哈希函数的设计不能太复杂,过于复杂的哈希函数本身就增加了计算时间,影响哈希表的性能。其次,哈希函数生成的值要尽可能随机并且均匀分布,这样才能避免或者最小化哈希冲突,散列到每个槽或桶里的数据才会比较平均,不至于出现某个槽内的数据特别多。

3.哈希冲突

再好的哈希函数也无法避免哈希冲突的,随着数据量的增大,哈希冲突就有可能发生,那么发生了哈希冲突,我们该如何解决呢?

其实,关于哈希冲突的方法早就有人研究好了,解决的方法也有很多种,不过我们常用的有链地址法和开放地址法。

  • 链地址法:顾名思义,链地址法就是利用链表来作为冲突后的存储结构,相比于开放地址法,它更加简单,接下来,我们通过图示方法,介绍下链地址法。

如图,在哈希表中,每个槽或桶会对应一条链表,所有哈希值相同的元素我们都放在相同的槽位对应的链表中。

在这里插入图片描述
当插入的时候,我们只需要通过哈希函数计算出对应的哈希槽位,将其插入对应的链表中即可,所以插入的时间复杂度为O(1)。当查找或删除一个元素时,我们同样通过哈希函数计算出对应的槽,然后遍历链表 查找或删除,这连个操作的时间复杂度和链表的长度成k正比,也就是O(k),当链表的长度越长时,性能随之越差,所以,java中的HashMap底层的哈希表解决哈希冲突的方法是链表加红黑树。当链表的长度大于8时,就转化成红黑树,当小于8时又转化为链表。

  • 开放地址法:开放地址法的核心思想是,如果出现哈希冲突,我们就重新探测一个空闲位置,将其插入,探测新位置的常用方法有线性探测,二次探测和多重哈希法。

所谓的线性探测就是当我们往哈希表中插入元素时,如果某个元素经过哈希函数计算哈希值后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,知道找到空闲位置为止。

二次探测和线性探测很像,只是线性探测每次增长的步长为1,二次探测增长的步长为原来的平方,比如下标为1的位置占用了,下一个探测的位置就是2,下标为2的位子被占用了,下次探测的位置就是4,依次类推,直到找到空闲位置为止。

所谓的多重哈希就是哈希函数不止一个,而是有多个哈希函数可以计算哈希值,当然我们先用第一个哈希函数计算哈希值,如果计算得到的存储位置已经被占用,就用第二个哈希函数重新计算哈希值,如果得到的存储位置仍然被占用就用第三个,依次类推,直到找到空闲位置为止。

上面介绍了链地址法和开放地址法两种解决哈希冲突的方法,两种方法各有其优缺点,那么我们在实际开发中到底应该选择哪种冲突解决方法呢?下面对两种方法的优缺点和使用场景做一个介绍。

1.开放地址法

首先我们先看一下开放地址法的优点。

开放地址法不像链地址法需要很多的链表,哈希表中的数据都存储在数组中,在内存中是连续的存储空间,可以有效的利用CPU的缓存加快查询速度。

接下来我们看一下开放地址法的缺点。

在开放地址法中,所有的数据都存储在一个数组中,比起链表来,冲突代价更高,更加浪费存储空间,装载因子(元素个数/存储位置的个数)的上限不能太高。

所以当数据量比较小,装载因子比较小的时候,适合采用开放地址法,Java中的ThreadLocalMap就是采用开放地址法来解决哈希冲突的。

2.链地址法

首先,链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。其次, 链表法比起开放寻址法,对大装载因子的容忍度更高。

但是链表要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对CPU缓存是不友好的,这方面对于执行效率也有一定的影响。

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

4.代码实现

public class HashTable<K,V> {
    private final int[] capacity=
            {53,97,193,389,769,1543,3079,6151,12289,49157,98317,196613,393241,786433,1572869,3145739,6291469,
            12582917,25165843,50331653,100663319,201326611,402653189,805306457,1610612741};//初始化容量数组
    private static final int upperTol = 10; //最大上界,超过最大上界数组扩容
    private static final int lowerTol = 2; //最小下界,低于最小下界数组缩容
    private  int capacityIndex = capacity[0]; //初始化容量数组下标

    private TreeMap<K,V>[] hashtable;
    private int M; //hash表中有M个位置
    private int size; //hash表中元素的个数

    public HashTable(){
        this.M = capacity[capacityIndex];
        size = 0;
        hashtable = new TreeMap[M];
        for (int i = 0; i < M; i++) {
            hashtable[i] = new TreeMap<>();
        }
    }

    //哈希函数
    private int hash(K key){
        return (key.hashCode() & 0x7fffffff) % M; //key.hashCode() & 0x7fffffff将key的hashcode值的符号去掉
    }

    //获取哈希表的元素个数
    public int getSize(){
        return size;
    }

    //向哈希表中添加元素
    public void add(K key,V value){
        TreeMap<K,V> map = hashtable[hash(key)];
        if(map.containsKey(key))
            map.put(key,value);
        else{
            map.put(key,value);
            size++;
            //判断是否需要扩容
            if(size >= upperTol*M && capacityIndex+1 < capacity.length){
                capacityIndex++;
                resize(capacity[capacityIndex]);
            }
        }
    }

    //从哈希表中删除元素
    public V remove(K key){
        TreeMap<K,V> map = hashtable[hash(key)];
        V ret = null;
        if(map.containsKey(key)){
            ret = map.remove(key);
            size--;
            //判断是否需要缩容
            if(size < lowerTol * M && capacityIndex-1 >= 0){
                capacityIndex--;
                resize(capacity[capacityIndex]);
            }
        }
        return ret;
    }

    //扩容
    private void resize(int newM) {
        TreeMap<K,V>[] newHashTable = new  TreeMap[newM];
        for (int i = 0; i < newM; i++) {
            newHashTable[i] = new TreeMap<>();
        }
        int oldM = M;
        this.M = newM;
        for (int i = 0; i < oldM; i++) {
            for (K key : hashtable[i].keySet()) {
                newHashTable[hash(key)].put(key,hashtable[i].get(key));
            }
        }
        this.hashtable = newHashTable;
    }

    //将哈希表中键为key的元素值修改为value
    public void set(K key,V value){
        TreeMap<K,V> map = hashtable[hash(key)];
        if(!map.containsKey(key))
            throw new IllegalArgumentException("要修改的键不存在");
        map.put(key,value);
    }


    //判断哈希表中是否存在key
    public boolean contains(K key){
        return hashtable[hash(key)].containsKey(key);
    }

    //获取哈希表中键为key的value值
    public V get(K key){
        return hashtable[hash(key)].get(key);
    }

    public static void main(String[] args) {
        HashTable<String,Integer> hashTable = new HashTable();
        hashTable.add("黎明",100);
        hashTable.add("赵薇",98);
        hashTable.add("周凯",79);
        System.out.println(hashTable.get("周凯"));
    }
}

5.总结

这篇我们介绍了哈希表的相关概念,哈希函数,哈希冲突以及解决哈希冲突的方法,最后还贴出了哈希表的代码实现以供参考,关于哈希表的实现,可以参看Java中的HashMap的底层实现。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值