不就几种数据类型么?04-散列表的神奇之处

hi~这是我重新开始学习的数据结构篇第四篇。这里大致说下为什么把算法和数据结构分开,苗某觉得先把数据结构搞熟悉,然后学算法时候就不会很吃力。前三篇讲了数组,链表,栈和队列。对了上篇留了一个小关子,优先队列如何实现。这个不是在本篇讲解哦,这个设计到树的知识。所以要到下篇。很快的,以苗某的这单身二十年的手速~是吧哈哈。

1、散列表

学习散列表可以带着几个问题。

1)、实现原理-数据结构是什么样子?

2)、什么时候发生hash碰撞?

3)、散列函数是怎样做的?

4)、如何进行扩容?

学习之前我们来看一个小例子

假设你在一家杂货店上班。有顾客来买东西时,你得在一个本子中查找价格。如果本子的内容不是按字母顺序排列的,你可能为查找苹果(apple)的价格而浏览每一行,这需要很长的时间。此时你使用的是简单查找,需要浏览每一行。还记得这需要多长时间吗?O(n)。如果本子的内容是按字母顺序排列的,可使用二分查找(后面会有介绍)来找出苹果的价格,这需要的时间更短,为O(log n)。

但是你要知道, 二分查找的速度非常快。但作为收银员,在本子中查找价格是件很痛苦的事情,哪怕本子的内容是有序的。在查找价格时,你都能感觉到顾客的怒气。看来真的需要一名能够记住所有商品价格的雇员,这样你就不用查找了:问她就能马上知道答案。如:

不管商品有多少,这位雇员(假设她的名字为Maggie)报出任何商品的价格的时间都为O(1),速度比二分查找都快。
因为这些需求,一个重要的数据结构诞生了,这个数据结构叫做散列表。

1.1概念:

散列表也叫作哈希表(hash table),这种数据结构提供了键(Key) 和值(Value) 的映射关系。 只要给出一个Key,就可以高效查找到它所匹配的Value,时间复杂度接近于O(1)。
 

那么,散列表是如何根据key来快速找到它所匹配的Value的呢?这就是我们下面要讲的散列表的基本原理。

1.2 hash函数

为了让给我看官能够循序渐进明白,苗某再举个例子:其实对于计算机来说物理结构也就两种,数据和链表。拿数据和链表那个查询比较快呢?当然是数组了,因为数组可以根据下标,进行元素的随机访问。其实本质上散列表也是一个数组。但是数组是通过index去访问,而散列表是key获取到index再去访问。所以我们需要一个中转站。通过某种方式,把key和数组下标进行转换。这个中转站就叫做hash函数。

 

这个所谓的hash函数是怎么实现的呢?在不同的语言中,hash函数的实现方式是不一样的。这里以java的常用集合hashmap(后面讲到juc是会进行对比,因为hashmap在并发编程时不安全)。

在java及大多数面对对象的语言中,每一个对象都有属于自己的hashcode,这个hashcode去区分不同的重要标识。无论对象的自身类型是什么,他们的hashcode都是一个整型变量(目的就是可以将vlaue方式数组的某一个index下)。

既然都是整型变量,想要转化数组的下边就不难实现了。最简单的方式是什么呢,是按照数组的长度进行取模运算。

index = HashCode (Key) % Array.length

但是有一个问题,假设有100个元素要放到hashmap里面,有99个取模都是相同值,是不是发生hash碰撞了,而且还要变换数据类型(链表+红黑树)后面细讲。而且,这个100份数据分配的很不均匀。所以这里是演示,真正在jdk中是有提供hash函数,没有直接采用取模运算,而是利用了native方法hashcode+位运算(后面也会学习到,小伙伴们不需要着急)的方式来优化性能。

final int hash(Object k) {
        int h = hashSeed;
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

 在这里苗某姑且理解为取模操作。

通过哈希函数,我们可以把字符串或其他类型的Key,转化成数组的下标index。下面苗某写一段简单的代码帮助理解

    @Test
    public void hashvlue(){
        //先自定义一个数组长度为9
        int[] array = new int[8];
        //假设有一笔数据 名称->数量
        String name="小苹果";
        int num=10;
        //将这笔数据放入数组中
        int nameHashValue = name.hashCode();//获取hash值
        //取模获取下标
        int index = nameHashValue % array.length;
        //放入数组
        array[index]=num;
    }

看卡执行结果

对于任何一个key进行hash操作后都可以得到一个int,在根据数组的长度取模就是的一个0-7之间的index值。

好了介绍就到这里,下面看看散列表到底是怎么进行读写的

2、散列表的读写操作

有了hash函数,就可以再散列表里进行读写操作了。

2.1 写操作

其实写操作就是在散列表中插入新的键值对(在HashMap里叫做Entry)

如:调用hsahMap.put("小苹果",10)。意思就是插入一组key为小苹果,value为10的键值对。

具体怎么操作呢?

第1步,通过哈希函数,把Key转化成数组下标2。
第2步,如果数组下标2对应的位置没有元素,就把这个Entry填充到数组下标2的位置。

但是,由于数组的长度是有限的,当插入的Entry越来越多时,不同的Key通过哈希函数获得的下标有可能是相同的。 例如002936这个Key对应的数组下标是2;002947这个Key对应的数组下标也是2。这种情况,就叫作哈希冲突。怎么办呢?

哈希冲突是无法避免的, 既然不能避免, 我们就要想办法来解决。 解决哈希冲突的方法主要有两种, 一种是开放寻址法, 一种是链表法。

开放寻址法的原理很简单,当一个Key通过哈希函数获得对应的数组下标已被占用时,我们可以“另谋高就” ,寻找下一个空档位置。

 

重点讲一下解决哈希冲突的另一种方法——链表法。 这种方法被应用在了Java的集合类HashMap当中。

HashMap数组的每一个元素不仅是一个Entry对象,还是一个链表的头节点。 每一个Entry对象通过next指针指向它的下一个Entry节点。 当新来的Entry映射到与之冲突的数组位置时,只需要插入到对应的链表中即可。
 

 static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
...
...
...
}

但是如果碰撞越来越多,链表的长度会越来越长,我们都是链表的数据结构对于遍历是很慢的。java8中做了优化,当链表的长度到达8的时候会把Entry转化为红黑树这种数据结构。

这里其实还有一个问题:当hashMap在并发的情况下有可能形成环状结构。这个后面讲到juc时候会具体讨论。

2.2 读操作(get)

讲完了写操作,我们再来讲一讲读操作。读操作就是通过给定的Key,在散列表中查找对应的Value。

列如:我们调用hashMap.get(“小苹果”),意思是查找key为小苹果的Entry在散列表中对应的值。

第1步,通过哈希函数,把Key转化成数组下标2。
第2步,找到数组下标2所对应的元素,如果这个元素的Key是小苹果,那么就找到了;如果这个Key不是小苹果也没关系,由于数组的每个元素都与一个链表对应,我们可以顺着链表慢慢往下找,看看能否找到与Key相匹配的节点。

2.3 扩容

在讲解数组时,曾经介绍过数组的扩容。 既然散列表是基于数组实现的,那么散列表也要涉及扩容的问题。

首先,什么时候需要进行扩容呢?
当经过多次元素插入,散列表达到一定饱和度时,Key映射位置发生冲突的概率会逐渐提高。 这样一来,大量元素拥挤在相同的数组下标位置,形成很长的链表,对后续插入操作和查询操作的性能都有很大影响。

这时,散列表就需要扩展它的长度,也就是进行扩容。对于JDK中的散列表实现类HashMap来说,影响其扩容的因素有两个。
1.当前hashMap的当前容量size

2.hashMap的负载因子。默认是0.75f

衡量HashMap需要进行扩容的条件如下:

当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值---即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。具体想了解可以查看hashMap的resize方法如何扩容。

需要注意的是,关于HashMap的实现,JDK 8和以前的版本有着很大的不同。 当多个Entry被Hash到同一个数组下标位置时,为了提升插入和查找的效率,HashMap会把Entry的链表转化为红黑树这种数据结构。 建议读者把两个版本的实现都认真地看一看,这会让你受益匪浅。

好了散列表的知识就介绍到这里,其实只是浅显介绍少下,后面苗某会进行深度介绍。

对前几篇的总结:

  • 什么是数组

数组是由有限个相同类型的变量所组成的有序集合,它的物理存储方式是顺序
存储,访问方式是随机访问。 利用下标查找数组元素的时间复杂度是O(1),中间插
入、 删除数组元素的时间复杂度是O(n)。

  • 什么是链表

链表是一种链式数据结构,由若干节点组成,每个节点包含指向下一节点的指针。 链表的物理存储方式是随机存储,访问方式是顺序访问。 查找链表节点的时间复杂度是O(n),中间插入、 删除节点的时间复杂度是O(1)。

  • 什么是栈

栈是一种线性逻辑结构,可以用数组实现,也可以用链表实现。 栈包含入栈和出栈操作,遵循先入后出的原则(FILO)。

  • 什么是队列

队列也是一种线性逻辑结构,可以用数组实现,也可以用链表实现。 队列包含入队和出队操作,遵循先入先出的原则(FIFO)。

  • 什么是散列表

散列表也叫哈希表,是存储Key-Value映射的集合。 对于某一个Key,散列表可以在接近O(1)的时间内进行读写操作。 散列表通过哈希函数实现Key和数组下标的转换,通过开放寻址法和链表法来解决哈希冲突。

今天就到这里,下期见。下期我们学习树,还有介绍优先队列。哈哈~ 这个小关子在下期就会解开了。

©️2020 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页