1. 为什么需要散列表?

对于线性表和链表而言,访问表中的元素,时间复杂度均为O(n)。即便是通过树结构存储数据,时间复杂度也为O(logn)。那么有没有一种方式可以将这个时间复杂度降为O(1)呢?当然有,这就是接下来要介绍的散列表散列表是普通数组概念的推广。由于对于普通数组只要知道其下标位置就可以使用O(1)的时间内访问任意元素,如果存储空间允许,我们可以提供一个足够大的数组,为每个可能的关键字保留一个位置,这个位置也被称之“”,从而可以充分的利用直接寻址的技术优势,其实就是典型的空间换时间。

2. 散列函数

既然散列表是对关键字进行计算,从而确定该关键字对应的数据在存储中的位置,在下文中统一称之为“槽”,那么又该通过什么方式进行计算呢?其实这个方式就是散列函数。散列函数的设计对于散列表的性能将起到决定性的作用。因为如果散列函数设计不当导致多个关键字计算出的结果都是同一个位置,即存在大量的散列冲突(也可以称为散列碰撞)。现如今存在的散列函数算法非常多,通常的散列算法都是将关键字转换为自然数,然后通过除法或是乘法进行散列。一些简单的散列算法,比如关键字是整数直接使用求余法;关键字是字符串的话,一种可行的算法是每个字符的ASCII码相加之后对表的长度进行取模。对于同一类型的关键字的散列算法是多种多样的,但无论如何应该尽可能的避免散列冲突并且保证其散列的结果是均匀分布的。之所以要尽可能的保证散列结果是均匀分布其实也是为了尽可能的避免散列冲突。

3.散列冲突以及冲突解决

但是无论散列算法设计的多么完美,散列冲突它都是一定存在的。因为对于散列表的大小而言它是固定的,一旦你初始化之后就不会改变。但是对于元素而言是可以无限制的添加的,换句话说就是散列表中的“槽”位,对于关键字来说总归是不够的,所以就会出现多个关键字通过散列函数计算出的“槽”位是相同的。

当散列冲突出现的时候,主要通过开放寻址法完全散列法分离链接法等其他算法解决冲突

1.开放寻址法

在开放寻址法中,散列表中的每个槽位最多只会存储一个元素。当出现散列冲突的时候,就会从该槽位出发选择一个方向(向前或是向后)开始探测,(每次探测的距离为1则称之为线性探查,距离为某个数字的平方则称之为平方探查)只要散列表足够大,总归是可以找到一个可以存储的槽位,但是如此花费的时间是相当多的。更糟糕的是,即使散列表相对较空这样占据的槽位一旦开始形成,当后面出现本应该放到该槽位的关键字由于已被占据,而不得不进行探测寻找可以存储的槽位,这种现象也被称之为聚集。除此之外可以采用双重散列法,使用一组散列函数,知道找到空闲的位置为止,一种比较流行的做法是使用两个相对独立的散列函数hash1(),hash2()。当发生碰撞时,通过步长i进行探测。

(hash1(key) + i * hash2(key)) % TABLE_SIZE

这种双散列如果hash2()设计的不好将会是灾难性的。一个好的hash2()表现好的特征是:1.不会产生0索引、2.可以探测整个散列表

2.分离链接法

在分离链接法中,散列表中出现冲突时,可以通过链表的方式将元素连接起来,在对元素进行访问时,若发现该槽位中是一个链表则对该链表进行遍历。此种分离方式并不只是仅限于链表,比如一颗树或是另一个散列表都是可以的。比如即将在下文中提到的HashMap就是使用链表+红黑树来实现的。

3.再散列

如果散列表很多槽位已经被占据,name操作的运行时间将开始消耗过长,且插入操作可能失败。此时一种解决方法是建立另外一个大约两倍大的表,扫描整个原始散列表计算每个元素的新的槽位并将其插入到新的表中,整个操作就被称为再散列。其实本质上就是通过扩容减少冲突。

4.完全散列法

虽然全域散列和完全散列具有良好的理论性能,但实现起来不太方便,前提条件也多。在实际应用上,往往会更偏向其他方式解决冲突。

4.动态扩容

因为散列表在创建的时候其大小是固定的,而关键字是不断被添加到但列表中,所以随着关键字的不断添加,产生散列冲突的概率就会越来越大。因此为了避免哈希冲突就需要扩大散列表的容量。当已被占据的“槽”的个数和散列表的大小的比例达到一定的阈值时,就开始执行散列表的扩容,而这个阈值也被称之为加载因子(或扩容因子)。在扩容的时候,往往需要对原来的关键字重新进行散列,但是通过某些技巧其实是可以避免再散列的情况,比如HashMap的源码中在扩容的时候就没有进行再散列,这一部分在下文将详细讲解。

5.散列在HashMap的应用

1、散列函数

 1 public int hashCode() {
2 int h = hash;
3 if (h == 0 && value.length > 0) {
4 char val[] = value;
5 for (int i = 0; i < value.length; i++) {
6 h = 31 * h + val[i];
7 }
8 hash = h;
9 }
10 return h;
11 }

在这里为什么选择31作为乘数,为什么不是偶数或其他奇质数3,5,…,33,37,97…等其他数字? 原因如下:
1. 31 是一个奇质数,如果选择偶数会导致乘积运算时数据溢出,造成数据丢失;
2.哈希碰撞:实验数据表明乘数为大于等于31的奇质数碰撞概率很小,基本稳定;
3.哈希分布:实验数据表明乘数为大于等于31的奇质数哈希分布相对来说较为均匀。
4.另外在二进制中,2的5次方是32,那么也就是 31 * i == (i << 5) -i。这主要是说乘积运算可以使用位移提升性能,同时目前的 JVM 虚拟机也会自动支持此类的优化

2、扰动函数

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

在这里HashMap并没有直接将key的散列值返回,而是进行了一次干扰计算

(h = key.hashCode()) ^ (h >>> 16)

把哈希值右移16位,也就是自己长度的一般,之后再与原哈希值进行异或运算。这样做的目的就是混合哈希值中的高位和低位增大随机性,使得哈希分布更加均匀,减少碰撞。

3、初始化容量

 1 static final int MAXIMUM_CAPACITY = 1 << 30;
2
3 static final int tableSizeFor(int cap) {
4 int n = cap - 1;
5 n |= n >>> 1;
6 n |= n >>> 2;
7 n |= n >>> 4;
8 n |= n >>> 8;
9 n |= n >>> 16;
10 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
11
12 }

在这里进行初始化容量的时候,会不断进行或运算将二进制数都填上1,目的就是去寻找2的次幂的最小值。如传入的cap值为9则返回距离9最小的2的次幂值即16。那在这里为什么需要寻找2的次幂的最小值呢?

4、插入、链表树化 、红黑树

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

通过源码分析,HashMap增加元素的过程如下:
1. 如果散列表不存在或是其长度为0则进行一次扩容操作
2. 通过key的哈希值对散列表的长度进行与计算获得槽位
   2.1 若该槽位对应的元素为空
         直接添加一个节点,添加节点后需要判断是否超过负载阈值,超过则进行扩容。
   2.2 该槽位存在值
      2.2.1 判断key是否与当前的key一致
             一致时,修改该元素,然后返回旧值。
      2.2.2 判断该槽位对应的元素是否为树节点,这个树其实是一颗红黑树为树节点时,则进入putTreeVal()方法,这个方法要做的事简单的说就是“根据哈希值遍历树的结构,是否可以找到该key,若是可以找到就返回该节点,若是找不到就会新增的一个节点,并且平衡该树,最终返回一个空值”。putTreeVal()方法在新增节点的是后续返回null最终需要判断是否超过负载阈值,超过则进行扩容;修改节点时返回该节点数据,则将该树节点对应的值修改为当前的value并直接返回。
     2.2.3 说明这个槽位对应的元素是一个链表
为链表时,则先对链表进行遍历,是否可以找到该key,若可以找到则将该元素,则将该节点的值修改为value并退出;找不到该key时,说明这是一个新增元素,所以会在链表的尾部在添加一个节点。添加完节点后还需要判断该链表的长度是否超过了阈值(默认是8),超过阈值后并且表的大小还要超过64,则会将该链表进行转成二叉树,然后在转成红黑树,在转换成树的时候也会记录各节点的在链表中的位置;否则也只会对该散列表进行扩容。最终判断是否超过负载阈值,超过则进行扩容。

4、负载因子

 1 static final float DEFAULT_LOAD_FACTOR = 0.75f;
2
3 public HashMap(int initialCapacity) {
4 this(initialCapacity, DEFAULT_LOAD_FACTOR);
5 }
6
7 final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
8 int s = m.size();
9 if (s > 0) {
10 if (table == null) { // pre-size
11 float ft = ((float)s / loadFactor) + 1.0F;
12 int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
13 if (t > threshold)
14 threshold = tableSizeFor(t);
15 }
16 else if (s > threshold)
17 resize();
18 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
19 K key = e.getKey();
20 V value = e.getValue();
21 putVal(hash(key), key, value, false, evict);
22 }
23 }
24 }

负载因子是关键字与散列表大小的比值,它决定了数据量达到多少之后进行扩容,默认的负载因子为0.75。如果希望以更多的空间换时间,尽量避免散列碰撞,则可以手动指定更小的负载因子。

5、扩容元素拆分

当数组长度不足时,或是当前关键字与散列表大小的比值超过了负载因子则进行散列表的扩容。在jdk1.7中,散列表扩容时,需要进行再散列的操作,重新计算各个key在新表中的槽位。而在jdk1.8中,扩容机制进行了优化,已经不需要进行再散列了,而是通过该key新的哈希值与原来的散列表进行与运算【key.hash()&oldCap==0】,如果为0,则不需要修改槽位,否则将该槽位移动到原来的位置+oldCap的位置,即【j+oldCap】。当红黑树扩容后的节点数小于 UNTREEIFY_THRESHOLD(默认是6)即小于7个节点数时,红黑树则会进行链化,因为链表在转成红黑树的时候,是有记录各节点在链表中的位置的,所以红黑树在转成链表的时候会相对简单很多。

6、查找

HashMap查找元素的过程如下:
  1. 通过散列函数计算并且扰动后的哈希值
  2. 若散列表为空或其大小为0,则直接返回null;
  3. 根据计算出的哈希值与散列表的大小-1做与运算获得槽位【在表中的下标索引】
  4. 该槽位的元素是否为树节点
    4.1 不为树节点,按链表形式进行遍历
    4.2 为树节点,则按照红黑树形式进行遍历

10、删除

HashMap删除元素的过程如下:
  1. 通过散列函数计算并且扰动后的哈希值
  2. 若散列表为空或其大小为0,则直接返回null;
  3. 根据计算出的哈希值与散列表的大小-1做与运算获得槽位【在表中的下标索引】
  4. 该槽位的元素是否为树节点
    4.1 不为树节点,按链表形式进行删除
    4.2 为树节点,则按照红黑树形式进行删除,删除之后会进行红黑树的平衡

散列数据结构以及在HashMap中的应用的更多相关文章

  1. 基于散列的集合 HashSet\HashMap\HashTable

    HashSet\HashMap\HashTable 1 基于散列的集合 2 元素会根据hashcode散列,因此,集合中元素的顺序不一定与插入的顺序一致. 3 根据equals方法与hashCode方 ...

  2. java 散列与散列码探讨 ,简单HashMap实现散列映射表运行各种操作示列

    java 散列与散列码探讨 ,简单HashMap实现散列映射表运行各种操作示列 package org.rui.collection2.maps; /** * 散列与散列码 * 将土拔鼠对象与预报对象 ...

  3. 散列--数据结构与算法JavaScript描述(8)

    散列 散列是一种常用的数据存储技术,散列后的数据可以快速地插入或取用. 散列使用的数据结构叫做散列表. 在散列表上插入.删除和取用数据都非常快,但是对于查找操作来说却效率低下,比如查找一组数据中的最大 ...

  4. 【数据结构】之散列链表(Java语言描述)

    散列链表,在JDK中的API实现是 HashMap 类. 为什么HashMap被称为“散列链表”?这与HashMap的内部存储结构有关.下面将根据源码进行分析. 首先要说的是,HashMap中维护着的 ...

  5. HashMap中的散列函数、冲突解决机制和rehash

    一.概述 散列算法有两个主要的实现方式:开散列和闭散列,HashMap采用开散列实现. HashMap中,键值对(key-value)在内部是以Entry(HashMap中的静态内部类)实例的方式存储 ...

  6. Redis从基础命令到实战之散列类型(Hash)

    从上一篇的实例中可以看出,用字符串类型存储对象有一些不足,在存储/读取时需要进行序列化/反序列化,即时只想修改一项内容,如价格,也必须修改整个键值.不仅增大开发的复杂度,也增加了不必要的性能开销. 一 ...

  7. 《java编程思想》:散列的原理

    以实现一个简单的HashMap为例,详细讲解在code之中. 简单解释散列原理: 1.map中内建固定大小数组,但是数组并不保存key值本身,而是保存标识key的信息 2.通过key生成数组角标,对应 ...

  8. 关于HashMap中hash()函数的思考

    关于HashMap中hash()函数的思考 JDK7中hash函数的实现   static int hash(int h) { h ^= (h >>> 20) ^ (h >&g ...

  9. StackExchange.Redis帮助类解决方案RedisRepository封装(散列Hash类型数据操作)

    本文版权归博客园和作者本人共同所有,转载和爬虫请注明本系列分享地址:http://www.cnblogs.com/tdws/p/5815735.html 上一篇文章的不合理之处,已经有所修改. 今天分 ...

随机推荐

  1. ZOHO的下一个25年:用心为企业服务

    来源:中国软件网 作者:海策 在25周年会上,ZOHO大中华区总裁侯康宁先生豪情壮志,"25岁的ZOHO,已经成长为非典型一线大厂." 1996年,ZOHO成立.截止2021年,Z ...

  2. selenium多表单切换以及多窗口切换、警告窗处理

    selenium表单切换 在做UI自动化,有时候要定位的元素属性在页面上明明是唯一的.却怎么也不执行对元素的操作动作,这时候多半是iframe表单在作怪. 切入表单:iddriver.switch_t ...

  3. Java - Java 8 新特性

    一.Java8新特性 Java8概述:Java8,也就是jdk1.8版本,是意义深远的一个新版本.是Java5之后一个大的版本升级,让Java语言和库仿佛获得了新生. 二.Lambda表达式 Lamb ...

  4. Python中PyQuery库的使用

    pyquery库是jQuery的Python实现,可以用于解析HTML网页内容,我个人写过的一些抓取网页数据的脚本就是用它来解析html获取数据的. 它的官方文档地址是:http://packages ...

  5. Nginx的配置参数中文说明

    Nginx的配置参数中文说明   前言 Nginx是一款轻量级的Web 服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,在BSD-like 协议下发行.其特点是占有内存少,并发能力强 ...

  6. 2-2.5-3D的室内场景理解

    2-2.5-3D的室内场景理解 主要内容 摘要随着低成本.紧凑型2-2.5-3D视觉传感设备的出现,计算机视觉界对室内环境的视景理解越来越感兴趣.本文为本课题的研究提供了一个全面的背景,从历史的角度开 ...

  7. NVIDIA DeepStream 5.0构建智能视频分析应用程序

    NVIDIA DeepStream 5.0构建智能视频分析应用程序 无论是要平衡产品分配和优化流量的仓库,工厂流水线检查还是医院管理,要确保员工和护理人员在照顾病人的同时使用个人保护设备(PPE),就 ...

  8. MindSpore应用目标

    MindSpore应用目标 以下将展示MindSpore近一年的高阶计划,会根据用户的反馈诉求,持续调整计划的优先级. 总体而言,会努力在以下几个方面不断改进. 1. 提供更多的预置模型支持. 2. ...

  9. AMD–7nm “Rome”芯片SOC体系结构,支持64核

    AMD–7nm "Rome"芯片SOC体系结构,支持64核 AMD Fully Discloses Zeppelin SOC Architecture Details at ISS ...

  10. 短波红外(SWIR)相机camera

    短波红外(SWIR)相机camera AVs Can't Drive Everywhere. Can TriEye's SWIR Camera Help? TriEye的短波红外(SWIR)摄像机能否 ...