题图Pid=68670770

在最近的学习过程中,发现身边很多朋友对哈希表的原理和应用场景不甚了解,处于会用但不知道什么时候该用的状态,所以我找出了刚学习Java时写的HashMap实现,并以此为基础拓展关于哈希表的实现原理。

什么是哈希表?

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。
给定表M,存在函数f(key),对任意给定的关键字值key,代入函数后若能得到包含该关键字的记录在表中的地址,则称表M为哈希(Hash)表,函数f(key)为哈希(Hash) 函数。

以上正式的解释摘自百度百科哈希表页面。

从这段解释中,我们理应知道的:

  • 哈希表是一种数据结构
  • 哈希表表示了关键码值和记录的映射关系
  • 哈希表可以加快查找速度
  • 任意哈希表,都满足有哈希函数f(key),代入任意key值都可以获取包含该key值的记录在表中的地址

官方解释听过了,那么如何用大白话来解释呢?

简单的来说,哈希表是一种表结构,我们可以直接根据给定的key值计算出目标位置。在工程中这一表结构实现通常采用数组。

与普通的列表不同的地方在于,普通列表仅能通过下标来获取目标位置的值,而哈希表可以根据给定的key计算得到目标位置的值。

在列表查找中,使用最广泛的二分查找算法,复杂度为O(log2n),但其始终只能用于有序列表。普通无序列表只能采用遍历查找,复杂度为O(n)。

而拥有较为理想的哈希函数实现的哈希表,对其任意元素的查找速度始终为常数级,即O(1)。


图解:

在一个典型的哈希表实现中,我们将数组总长度设为模数,将key值直接对其取模,所得的值为数组下标。

如图所示的三组数据,分别被映射到下标为0和7的位置中,显而易见的,第1组数据和第3组数据发生了哈希碰撞。


如何解决哈希碰撞?

常用的解决方案有散列法和拉链法。散列法又分为开放寻址法和再散列法等,此处不做展开。java中使用的实现为拉链法,即:在每个冲突处构建链表,将所有冲突值链入链表,如同拉链一般一个元素扣一个元素,故名拉链法。

需要注意的是,如果遭到恶意哈希碰撞攻击,拉链法会导致哈希表退化为链表,即所有元素都被存储在同一个节点的链表中,此时哈希表的查找速度=链表遍历查找速度=O(n)。

哈希表有什么优势?

通过前面的概念了解,哈希表的优点呼之欲出:通过关键值计算直接获取目标位置,对于海量数据中的精确查找有非常惊人的速度提升,理论上即使有无限的数据量,一个实现良好的哈希表依旧可以保持O(1)的查找速度,而O(n)的普通列表此时已经无法正常执行查找操作(实际上不可能,受到JVM可用内存限制,机器内存限制等)。

哈希表的主要应用场景

在工程上,经常用于通过名称指定配置信息、通过关键字传递参数、建立对象与对象的映射关系等。目前最流行的NoSql数据库之一Redis,整体的使用了哈希表思想。

一言以蔽之,所有使用了键值对的地方,都运用到了哈希表思想。

Java中的哈希表实现-HashMap

在正式开始对HashMap的介绍和实现之前,你应当知道以下这些知识:

任意数对2的N次方取模时,等同于其和2的N次方-1作位于运算。

公式表述为:

k % 2^n = k & (2^n - 1)

而位于运算相比于取模运算速度大幅度提升(按照Bruce Eckel给出的数据,大约可以提升5~8倍)。

负载因子

负载因子是哈希表的重要参数,其定义为:哈希表中已存有的元素与哈希表长度的比值。

它是一个浮点数,表示哈希表目前的装满程度。由于表长是定值,而表中元素的个数越大,表中空余位置就会更少,发生碰撞的可能性也会进一步增大。

哈希表的扩容策略依赖于负载因子阈值。基于性能与空间的选择,JDK标准库将HashMap的负载因子阈值定为0.75


HashMap继承体系

首先来看HashMap的继承体系:

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>public abstract class AbstractMap<K,V> implements Map<K,V>public interface Map<K, V>

可以看到,抽象类AbstractMap就是对Map接口的抽象实现,HashMap通过继承AbstractMap间接实现了Map接口,同时自身直接声明了对Map接口的实现,即HashMap就是Map接口的直接实现。

Map接口中定义了一个Map实现类必须要实现的方法。所有Map实现类都应当实现这些方法。

Map接口定义的需要实现的方法:

在本篇文章剩余的篇幅中,将会基于Map接口实现一个我们自己的HashMap。

MyHashMap实现:

在动手之前,先分析清楚Map接口提供的方法,实现了哪些功能。其中关键的方法提取出来,结果为:

//实现查找功能。
//containsKey基于此方法实现。
V get(Object key);
//实现新增功能。
//由于哈希表同key覆盖特性,此方法同时实现了更新操作。
V put(K key, V value);
//实现删除功能。
V remove(Object key);
//实现对Map的遍历功能。
Set<Map.Entry<K, V>> entrySet();
Collection<V> values();
Set<K> keySet();

我们的HashMap采用泛型数组作为存储数据的结构。此时应用到两个类Node和Entry。Node类用作拉链法链表节点,其中每个Node存储了一个Entry类,Entry中包含了Key和Value,是真正存储数据的类型。

前文所述的与模运算等价的位与运算,当且仅当模数为2的N次幂时才会生效。所以我们的HashMap初始的数组长度将会定为16,扩容策略为每次扩容为上一次长度的2倍,负载因子0.75(这也是JDK标准库所采用的配置)。

public class MyHashMap<K, V> implements Map<K, V> {private class Node {private MyEntry<K, V> entry = null;public Node next = null;}class MyEntry<K, V> implements Entry<K, V> {private int hash;private K key;private V value;}//常量区private static final double LOAD_FACTOR = 0.75;       //负载因子阈值private static final int INITIAL_SIZE = 16;           //数组初始大小//成员变量区private int element_count = 0;        //当前元素计数private Node[] node_list = (Node[]) Array.newInstance(Node.class, INITIAL_SIZE);       //存储数组。//略去Map列表的实现方法
}

值得注意的是

private Node[] node_list = (Node[]) Array.newInstance(Node.class, INITIAL_SIZE)

Java中并不支持直接申请泛型类的数组。只能通过Array.newInstance静态方法构造数组并强制转换为泛型类的数组。

resize操作时同样需要用到此方法。

Hash表的核心操作就是通过对key值的计算直接查找目标元素下标,因此我们首先参考标准库编写(fuzhi)出getIndex方法:

private int getIndex( int hash, int mod ){return (hash & 0x7fffffff) & (mod - 1);
}

(hash & 0x7fffffff)是为了确保结果为正数。

为什么要对0x7fffffff做位于操作?

0x7fffffff是int可以表达的最大正整数,除了首位为0其他31位都为1。正数& 0x7fffffff结果为其本身,负数& 0x7fffffff结果为正数。

为什么不用Math.abs?

前面说过,位运算很快。而且由于Math.abs只是简单的return -a,因此Math.abs(Integer.MIN_VALUE)时结果仍然为负数,如下图所示:

hash & 0x7fffffff保证结果为正数。

(结果是不是负数的绝对值不重要,只要参数同样时每次计算都可以得出同样的结果,就可以作为哈希函数)

基于getIndex方法,我们可以写出put和remove方法。

@Override
public V put( K key, V value ){put(new MyEntry<>(key, value), node_list, true);return value;
}private void put( MyEntry<K, V> entry, Node[] target, boolean check ){put(new Node(entry), target, check);
}/*** 如果目标位置为空,则创建节点并保存目标位置* 否则在列表中查找并替换重复项。* 如果没有重复项,则插入链表尾部。** @param node   : 被加入数组的节点。* @param target : 目标数组。* @param check : 指示方法是否检查数组的当前元素数量。*/
private void put( Node node, Node[] target, boolean check ){int index = getIndex(node.getEntry().getHash(), node_list.length);if (target[index] == null) {target[index] = new Node(null);}if (target[index].next == null) {target[index].next = node;if (check) {//检查哈希表大小++element_count;checkLoadFactor();}return;}Node temp = target[index].next;while (temp != null) {if (temp.getEntry().getHash() == node.getEntry().getHash()) {temp.setEntry(node.getEntry());return;}if (temp.next == null) {temp.next = node;temp.next.next = null;        //截断节点,防止出现循环引用if (check) {//检查哈希表大小++element_count;checkLoadFactor();}}temp = temp.next;}
}

其中几个值得注意的点:

check参数:指示方法是否检查数组的当前元素数量。由于扩容时同样会使用这个方法作数组元素的迁移行为,一个检查的开关是必须的,否则会出现死循环。

temp.next.next = null :同样,在数据迁移操作时,如果未截断链表的每个节点,会导致新老数组中对应列表发生串联,最终产生死循环。

最终MyHashMap中将集成经典的链表操作。

接着实现remove方法:

@Override
public V remove( Object key ){if (key == null) {return null;}int index = getIndex(key.hashCode(), node_list.length);if (node_list[index] == null || node_list[index].next == null) {return null;}//在目标位置的链表中查找目标键值。Node last = node_list[index];Node current = node_list[index].next;while (current != null) {if (current.getEntry().getHash() == key.hashCode()) {last.next = current.next;--element_count;                            //减少数组元素计数return current.getEntry().getValue();}last = last.next;current = current.next;}return null;
}

在remove方法中,将会计算得到目标节点下标,遍历目标链表节点,当查找到目标元素时,断开并重连链表将目标元素从链表中移除。

非常典型的链表操作。

接下来实现最重要的get操作。然而在HashMap的CRUD三个操作中,get操作最为简单,因为其不需要移动链表节点或改变链表结构,仅需要遍历链表即可。

/*** 从Map中查找目标Key。* @param key* @return*/
@Override
public V get( Object key ){int index = getIndex(key.hashCode(), node_list.length);//目标位置为空则直接返回nullif (node_list[index] == null || node_list[index].next == null) {return null;}//目标位置不为空则遍历链表,查找相同的keyNode temp = node_list[index].next;while (temp != null) {if (temp.getEntry().getHash() == key.hashCode()) {return temp.getEntry().getValue();}temp = temp.next;}return null;
}

接下来是resize方法,它实现了数组元素的迁移操作。

但在resize方法之前,我们先来看一个有趣的方法,也是我的实现中不同于JDK标准库的方法,它提供了对元素数组的遍历操作,采用双指针法实现。它接受一个Consumer接口作为参数,它会对当前数组中的所有Node调用Consumer.accept方法。

values方法,containsValue方法,keySet方法,entrySet方法都基于它来实现:

//遍历list,并对其中的每一个元素执行指定的操作
private void traversing( Node[] nl, Consumer<Node> con ){int head = 0, foot = nl.length - 1;Node node;while (head <= foot) {if (nl[head] != null && nl[head].next != null) {node = nl[head];while ((node = node.next) != null) {con.accept(node);}}if (nl[foot] != null && nl[foot].next != null) {node = nl[foot];while ((node = node.next) != null) {con.accept(node);}}++head;--foot;}
}

有了traversing方法,可以用轻松(甚至是偷懒)的方式写出values,keySet,entrySet,containsValue:

@Override
public Collection<V> values(){Collection<V> collection = new ArrayList<>();traversing(node_list, (node -> {collection.add(node.getEntry().getValue());}));return collection;
}@Override
public Set<K> keySet(){Set<K> set = new HashSet<>();traversing(node_list, (node -> {set.add(node.entry.getKey());}));return set;
}@Override
public Set<Entry<K, V>> entrySet(){Set<Entry<K, V>> set = new HashSet<>();traversing(node_list, ( node ) -> {set.add(node.getEntry());});return set;
}//在最坏情况下,这种实现会将HashMap遍历两次。
//这样写仅仅是为了偷懒。
//如果你要写一个用于生产环境的containsValue,不要这样做。
@Override
public boolean containsValue( Object value ){//遍历哈希表查找值for (Entry<K, V> entry : entrySet()) {V temp_value = entry.getValue();if (temp_value != null && temp_value.equals(value)) {return true;}}return false;
}

用于对HashMap进行扩容的resize方法如下,它的实现原理非常简单易懂:创建一个新数组,随后调用traversing和本类的put方法将原始数组中的所有元素插入到新数组中,最终使用新数组替换原始数组。

随便一提,(hash & 0x7fffffff) & (mod - 1)可以保证将每个链表中的元素平均的放入新数组中的两个对应位置。

/*** 列表扩容。*/
private void resize(){//创建新列表Node[] new_list = (Node[]) Array.newInstance(Node.class, node_list.length << 1);traversing(node_list, (node -> {put(node, new_list, false);}));//移动完成后替换当前列表。node_list = new_list;
}

大功告成!Map接口中的所有核心方法都被实现了。


在OrsPced的Github可以找到本文中的完整实现。

如果有更好的想法,评论或建议,欢迎在评论区提出。

对阅读至此的您表示诚挚的感谢。

hashmap是散列表吗_一篇文章教你读懂哈希表-HashMap相关推荐

  1. java ee 值范围_一篇文章带你读懂: Java EE

    原标题:一篇文章带你读懂: Java EE 点击上图,查看教学大纲 何为 Java EE Java EE是一个标准中间件体系结构 不要被名称"Java PlatformEnterprise ...

  2. 一篇文章教你读懂Spring @Conditional注解

    文章目录 一.Conditional简介 二.Conditional用法 1.Conditonal注解作用在方法上 2.Conditonal注解作用在类上 3.类上注入多个条件类 三.Conditio ...

  3. 一篇文章让你读懂Pivotal的GemFire家族产品

    一篇文章让你读懂Pivotal的GemFire家族产品 学习了:https://www.sohu.com/a/217157517_747818 转载于:https://www.cnblogs.com/ ...

  4. 无线充电技术究竟有何神秘之处?一篇文章带你读懂什么是无线充电技术

    无线充电技术这个概念在很早之前就已经被提出了,发展至今在电子领域已经被深入研究应用,虽然还未曾大范围普及,但在消费电子领域的发展已经取得不错的成绩.手机厂商也纷纷在自家旗舰机上加入这一革新性的先进充电 ...

  5. 一篇文章让你读懂-曼彻斯特编码

    目录 写在前面的话 1 what?什么是曼彻斯特编码 2 how?怎么使用曼彻斯特编码 2.1 曼彻斯特的编码: 2.2 曼彻斯特的译码: 3 why?为什么推荐曼彻斯特编码?这种编码方式的优缺点 写 ...

  6. python爱心代码动态_一篇文章教你用python画动态爱心表白

    hRf免费资源网 初级画心hRf免费资源网 学Python,感觉你们的都好复杂,那我来个简单的,我是直接把心形看作是一个正方形+两个半圆:hRf免费资源网 hRf免费资源网 于是这就很简单了,十行代码 ...

  7. keyshot渲染图文教程_一篇文章教你学会3D建模和渲染 反正我是信了

    平常大家需要学习3D设计,基本上都是通过网上教程,这些教程多以视频为主,因为3D的可操作性非常强,只有在动态的视频上才能完整展示并教学. 但是也有这样或那样的原因,很多小伙伴并不能通过视频学习,那有没 ...

  8. python每隔半个小时执行一次_一篇文章教你用Python抓取微博评论

    [Part1--理论篇] 试想一个问题,如果我们要抓取某个微博大V微博的评论数据,应该怎么实现呢?最简单的做法就是找到微博评论数据接口,然后通过改变参数来获取最新数据并保存.首先从微博api寻找抓取评 ...

  9. 前端捕捉轨迹_一篇文章教你如何捕获前端错误

    常见错误的分类 对于用户在访问页面时发生的错误,主要包括以下几个类型: 1.js运行时错误 JavaScript代码在用户浏览器中执行时,由于一些边界情况.本地环境的不可控等因素,可能会存在js运行时 ...

最新文章

  1. labview的用户身份认证系统设计_elasticsearch 集群身份认证与用户鉴权
  2. abstract interface 和 interface 没有区别
  3. 流行的9个Java框架介绍: 优点、缺点等等
  4. 如何用SendMessage模拟某一按钮的点击事件
  5. ci 框架插入时返回插入的id号
  6. mysql 命令 kill_MySQL之死锁检测
  7. moodle3.7中文语言包
  8. bit java实验2_2018-2019-2 20175120 实验五《Java网络编程》实验报告
  9. 解决centos使用nc命令报错:Ncat: Connection refused.
  10. erlang一次线上问题解决
  11. 计算机视觉中的注意力机制的学习笔记
  12. NET开发资源站点和部分优秀.NET开源项目
  13. 生态系统服务——土壤保持量分布数据
  14. ai人工智能操控什么意思_为什么要建立AI分散式自治组织(AI DAO)
  15. 三种非递归遍历二叉树的方法
  16. 「文档编写」- 常见序号写法 @20210412
  17. 谈谈我了解的那些在线it学习网站
  18. 洛谷刷题C语言:数字反转、再分肥皂水、三角形面积、Apples Prologue/苹果和虫子、数的性质
  19. 用于计算机安全防护的有,《计算机安全防护》课件
  20. 金属铣床行业现状调研及趋势分析报告

热门文章

  1. Linux kernel crypto的介绍
  2. 密码篇——对称加密—DES
  3. 重游java(猜拳项目)
  4. 1.1 对象的概念及面向对象的三个基本特征
  5. 纯JS实现省市县三级下拉联动
  6. 谈C++求a+b(大神勿喷)
  7. 1002 A+B for Polynomials (25 分)【难度: 一般 / 知识点: 模拟】
  8. 4.1.8 文件保护
  9. 2.3.11 管程
  10. MySQL中如何删除数据