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. MySQL中使用Show Profile

    Show profile 默认是禁用的,用处是记录在服务器中运行的查询耗费的时间和其他一些查询执行状态变更相关的数据. 当前系统是win10,Mysql版本是8.0.15 1.查看当前profilin ...

  2. Dart 2.13 版现已发布

    作者 / Kevin Moore & Michael Thomsen Dart 2.13 版现已发布,其中新增了类型别名功能,这是目前用户呼声第二高的语言功能.Dart 2.13 还改进了 D ...

  3. Java GUI学习,贪吃蛇小游戏

    JAVA GUI练习 贪吃蛇小游戏 前几天虽然生病了,但还是跟着狂神学习了GUI的方面,跟着练习了贪吃蛇的小项目,这里有狂神写的源码点我下载,还有我跟着敲的点我下载,嘿嘿,也就注释了下重要的地方,这方 ...

  4. springboot+Thymeleaf+layui 实现分页

    layui分页插件 引入相关的js和css layui:css <link rel="stylesheet" th:href="@{layui/css/layui. ...

  5. python类变量的分类和调用方式

    #!/usr/bin/python # -*- coding: UTF-8 -*- # 父类 class JustCounter: ''' 类变量:类变量在整个实例化的对象中是公用的.类变量定义在类中 ...

  6. Sql Server 课堂笔记

    创建表 --创建学生表 create table student (sno char(8) primary key, sname char(8) not null unique, ssex char( ...

  7. linux系统开机自动挂载光驱 和 fstab文件详解

    Linux 通过 UUID 在 fstab 中自动挂载分区 summerm6关注 2019.10.17 16:29:00字数 1,542阅读 607 https://xiexianbin.cn/lin ...

  8. gitbook安装使用教程

    以下是gitbook的简略安装使用过程,可以参考一下.后续有时间我再回头修改完善实验目的:安装gitbook后,将相关的文件发布到gitlab上安装node.js在cmd下执行安装npm instal ...

  9. Spring5学习 (核心)

    Spring5 官方文档:https://docs.spring.io/spring/docs/5.3.0-SNAPSHOT/spring-framework-reference/index.html ...

  10. 9.11 strace:跟踪进程的系统调用 、ltrace:跟踪进程调用库函数

    strace 是Linux环境下的一款程序调试工具,用于检查一个应用程序所使用的系统调用以及它所接收的系统信息.strace会追踪程序运行时的整个生命周期,输出每一个系统调用的名字.参数.返回值和执行 ...