今天学习了哈希表这一数据结构,双向链表我明天过几天再实现一下,趁着昨天完成了单向链表的实现,今天先实现一下哈希表的增删改查。
我们首先要先知道哈希表这种数据结构是什么,如果大家学过python的吧,对字典应该并不模式,按照我的理解,哈希表和python的字典其实是很像的,它们都是以键值对的方式存储数据的。
但对于Java中的哈希表还是与python的字典有一点不同,下面我来说一下在Java中哈希表的特点:(个人理解,主要说一些重要的,如果没解释全,大家去搜一下大佬讲哈希表这种数据结构)
对于哈希比,我们可以这样理解,它是数组和链表的结合体,首先要清楚,数组和链表的优势是什么
数组的内存地址是连续的,可以通过计算得到数组的内存地址,所以数组的查询效率高,但是由于要保存数组的内存地址是连续的,所以在增删的时候涉及数据的移动,所以增删的效率低
而对于链表来说,链表通过节点指向下一个节点这种连接方式,可以直接获取到连接的对象,链表的内存地址是非连续的,在增删的时候不涉及到数据的移动,所以增删的效率高,但是查询效率低
如果我们将这两个极端的数据结构各个的优点结合一下,是不是增删和查询的效率都很高
所以哈希表的概念就出现了
哈希表的底层是数组,而数组里面存的数据是链表,我来画一幅图,先看一下哈希表的样子:
有点丑哈哈哈哈但是希望大家能理解我的意思,因为这是哈希表的关键,本质上就是数组上的每个下标,都连接着一个连续的链表,链表上的节点均匀分布在数组上。
而对于链表上的节点,其实是和单向列表几乎一样的,保存着数据和下一个对象的内存地址
但是对于节点上的数据,在哈希表中是发生了改变的,它不再是一个单个的数据,而是以键值对的形式保存
下面我来解释一下什么是键值对:
举个例子,我们每个人都有属于自己的身份证号,我们可以通过身份证号,去知道身份证代表着谁,而身份证号,是我们每个人独一无二的
在这个例子中,身份证号就是键,而人是值,起着主导的是键,通过键来获取值,且键是独一无二的,也就是不重复且无序。
让我来画一个键值对的图:
我们可以通过这个图知道:
110:张三
120:李四
130:王五
140:赵六
我们可以通过搜索110,就知道110代表着张三,这就是键值对的体现,如果还有一个人钱七的键也是110呢,这时候为了保证键的唯一性,会将张三修改成钱七,也就是覆盖。
下面我们回到节点保存的数据上,这下我们就知道,挂在数组的单向链表上的节点,有三个数据了,分别是节点的键,节点的键所对应的值,还有指向下个节点的地址。
但是还有一个关键的值,在这里叫哈希值(hast)
在说哈希值之前,我们应该思考一个问题,就是数组上面的链表节点是怎么知道它该去哪个位置。
回到这幅图,如果我们添加张三节点,该节点有四个属性,
键:110
值:张三
next:节点(李四)
hast(哈希值):我们先不去不研究
张三应该到下面数组的哪个索引位置下的链表呢?
所以引入一个值,就是哈希值:
哈希值是用来寻找该节点在数组的位置的
当我们清楚了哈希值的概念后我们再来研究一下哈希值
哈希值并不是索引,而是经过一个函数的计算得来的 ,这个函数是Object下的函数叫
hashCode()
我这里简单说一下hashCode是怎么计算的,首先我们建立一个节点,也就是键值对,我们需要将该节点的键传入到hashCode函数中,hashCode会返回给我们一个数字,这里称这个数字为散列码,也叫哈希值
通常hashCode函数会返回给我们当前对象的内存地址(默认,hashCode函数可以重写)
下面我们得到哈希值后,会对它进行取模的运算,hash % 数组的长度 得到的数字,就是该对象存入数组的位置
举个例子:我们创建了一个,110:张三,这一个键值对对象,初始化哈希表,数组的长度为16
下一步我们将key(110)传入hashCode函数,会返回给我们一个内存地址(假设返回的数字为121547)
下面对121547进行取模运算:12115547 % 16 = 757221,余数是11
所以返回的是11这个数字,那么这个对象会存放到数组下标为11的地方:数组[11]
当我们知道了怎么将对象存放到数组的对应位置,我们还要思考一个问题,就是,如果该数组位置,已经有数据了怎么办?没有数据很简单,直接插入即可
还记得哈希表的结构吗,数组加单向列表,且key不能重复,所以我们遍历挂在该数组下标位置的链表节点,去看看有没有重复的,没有,直接进行尾部插入即可。
在这里我还要再多说一个事情,那么就是数组的长度也就是容量,通常是2的次幂,我这里简单说一下为什么要这么规定,对于取模这个计算过程,我们完全可以通过与运算(&)来提高效率
所以我们要满足一个公式:(n - 1) & hash = hash % n
我们首先需要知道二进制的概念,这里我就不去详细讲述二进制了,就说一下重点
在二进制中,偶数的最后一位,一定是0,而奇数的最后一位,一定是1,我们要清楚这一个概念,下面我来举一个例子,证明为什么是2的次幂
与运算,大家可以对一下,主要看最后一位
当数组的长度为2的次幂也就是偶数时:
数组长度的二进制(偶数):10100010
哈希值(偶数) :01001010
====================================
结 果 : 00000010 (偶数)
数组长度的二进制(偶数):10100010
哈希值(奇数) :01001011
====================================
结 果 : 00000010(偶数)
当数组的长度为奇数时:
数组长度的二进制(奇数): 10100011
哈希值(偶数) : 01001010
====================================
结 果 : 00000010 (偶数)
数组长度的二进制(偶数):10100011
哈希值(奇数) :01001011
====================================
结 果 : 00000011(奇数)
经过对比我们可以知道,当数组长度的偶数时候会出现一种情况,那就是无论你的哈希值是多少,最终得到的都是偶数,数组的下标为0,1,2......分配时,只会分配到偶数区,奇数永远不会分到
所以我们在进行 (数组长度 - 1) & hash 如果数组长度为奇数 -1 后一定是偶数,大家可以理一下
还有就是
当哈希表需要扩容时,将容量翻倍是一种常见的做法。如果当前容量是2的次幂,那么扩容后的容量仍然会保持2的次幂,这使得计算新的索引位置非常高效,因为只需简单地通过增加一个更高位的0来完成。
总之,选择2的次幂作为哈希表的默认初始容量可以提高性能和均衡性,并且在扩容时也更加高效。不过,这并不是绝对必须的规则,有些哈希表实现可能选择不同的策略来处理初始容量,但通常选择2的次幂是一种很好的默认选择。
我来提一下:当节点对象找到数组的对应位置,但是该位置已经有对象时,挂上去的这个行为,被称为哈希冲突,这里我就不去讲了,我的理解也没有那么深刻,大家有兴趣可以去查看
说了这么多让我们理一下哈希表的内容:
1:哈希表是数组与链表的结合
2:哈希表的数据是以键值对的形式存储的,且key唯一,如果key相同会进行覆盖
3:节点对象是根据哈希值来获取它在数组的对应位置,首先通过key获取对象的内存地址,也就是哈希值,再通过对哈希值的取模运算,得到在数组的对应位置
4:哈希表的数组长度通常是2的次幂
大概就是这么多,下面我利用Java去实现一下一个简单的哈希表,代码如下
import java.util.Arrays;
import java.util.Objects;
public class MyHashMap<K,V> {
//哈希表存储节点的数组
private Node<K,V>[] table;
//哈希表节点的个数
private int size;
//向哈希表中添加节点。键值对
public V put(K key,V value){
//假如添加的键值对的key为null,则将该节点存储到table数组索引为0的位置
if (key == null){
return putForNullKey(value);
}
//这里证明key不是空,进行尾部插值或者覆盖
//首先要找到要在数组的哪个位置上插值
//获取当前key的哈希值
int hash = key.hashCode();
//将哈希值转换成数组的下标
int index = Math.abs(hash & (table.length-1));
//当前下标处的链表节点
Node<K,V> node = table[index];
//本次添加的节点对象
Node<K,V> new_node = new Node<>(hash,key,value,null);
//判断当前下标处是否存在链表节点
if (node == null){
table[index] = new_node;
size++;
return value;
}
while (node != null){
//判断当前遍历的节点key值是否相同,相同则进行覆盖
if (node.key.equals(key)){
V oldvalue = node.value;
node.value = value;
return oldvalue;
}
node = node.next;
}
//到这里证明没有相同的key值节点,进行尾插
node.next = new_node;
size++;
return value;
}
private V putForNullKey(V value) {
//将数组0位置的数据提取
Node<K,V> node = table[0];
Node<K,V> new_node = new Node<>(0,null,value,null);
//此处没有链表节点
if (node == null){
table[0] = new_node;
size++;
return value;
}
//证明0索引处有单向链表,需要找到尾节点
V oldvalue;
while (node != null){
if (node.key == null){
oldvalue = node.value;
node.value = value;
return oldvalue;
}
node = node.next;
}
//这里node一定返回的是单向链表中的尾节点,对尾节点判断key是否为空
if (node.key == null){
oldvalue = node.value;
node.value = value;
return oldvalue;
}else {
node.next = new_node;
size++;
return value;
}
}
//通过key获取value
public V get(K key){
//输入的key为null时
if (key == null){
return getNullKey();
}
//输入的key为其他值时
//首先获取当前key的哈希值然后转换为下标
int hashNum = key.hashCode();
int index = Math.abs(hashNum & (table.length - 1));
Node<K,V> node = table[index];
//判断当前索引位置是否有链表,没有返回null
if (node == null){
return null;
}
//当前索引位置有链表,遍历查询key的节点
while (node != null){
if (node.key.equals(key)){
return node.value;
}
node = node.next;
}
//没有找到对应key的value值
return null;
}
private V getNullKey() {
Node<K,V> node = table[0];
//判断当前索引位置是否有链表,如果没有返回null
if (node == null){
return null;
}
//当前索引位置有链表,遍历查询key为null的节点
while (node != null){
if (node.key == null){
return node.value;
}
node = node.next;
}
//证明没找到key为null的
return null;
}
//通过key删除键值对
public V delete(K key){
if (key == null){
return deleteNullKey();
}
//输入的key为其他值时
//首先获取当前key的哈希值然后转换为下标
int hashNum = key.hashCode();
int index = Math.abs(hashNum & (table.length - 1));
Node<K,V> node = table[index];
//判断当前索引位置是否有链表,没有返回null
if (node == null){
return null;
}
//判断当前索引位置的首节点是否是key
if (node.key == key){
V oldValue = node.value;
table[index] = node.next;
node.value = null;
node.key =null;
node.next = null;
size--;
return oldValue;
}
//当前索引位置有链表
Node<K,V> prevNone;
while (node.next != null){
prevNone = node;
Node<K, V> nextNode = node.next;
if (nextNode.key.equals(key)){
V nextNodeoldValue = nextNode.value;
nextNode.key = null;
nextNode.value = null;
size--;
//判断此节点的下一个节点是否为尾节点
if (nextNode.next == null){
//如果是尾节点,让此节点变为尾节点
prevNone.next = null;
return nextNodeoldValue;
}
//此节点不为尾节点时.让该节点指向下一个节点的next
prevNone.next = nextNode.next;
return nextNodeoldValue;
}
node = node.next;
}
//证明没找到key为null的
return null;
}
private V deleteNullKey() {
Node<K,V> node = table[0];
//判断当前索引位置是否有链表,如果没有返回null
if (node == null){
System.out.println("没有找到对应键值对");
return null;
}
//判断当前索引位置的第一个节点的key是否为null
if (node.key == null){
V oldValue = node.value;
table[0] = node.next;
node.value = null;
node.next = null;
size--;
return oldValue;
}
//当前索引位置有链表,遍历查询key为null的节点
Node<K,V> prevNone;
while (node.next != null){
prevNone = node;
Node<K, V> nextNode = node.next;
if (nextNode.key == null){
V nextNodeoldValue = nextNode.value;
nextNode.value = null;
size--;
//判断此节点的下一个节点是否为尾节点
if (nextNode.next == null){
//如果是尾节点,让此节点变为尾节点
prevNone.next = null;
return nextNodeoldValue;
}
//此节点不为尾节点时.让该节点指向下一个节点的next
prevNone.next = nextNode.next;
return nextNodeoldValue;
}
node = node.next;
}
//证明没找到key为null的
return null;
}
//获取哈希表的节点个数
public int size(){
return size;
}
public MyHashMap() {
this.table = new Node[32];
}
@Override
public String toString() {
StringBuilder string = new StringBuilder();
for (Node<K, V> kvNode : table) {
//获取数组每个索引处的链表
Node<K, V> node = kvNode;
//如果当前索引处有链表,输出该链表的所有节点
if (node != null) {
while (node != null) {
string.append("[").append(node).append("]");
node = node.next;
}
}
}
return string.toString();
}
static class Node<K,V>{
//哈希值
int hash;
//哈希表中节点的key
K key;
//哈希表中节点的value
V value;
//哈希表中的节点
Node<K,V> next;
public Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
@Override
public String toString() {
return "key=" + key + ", value=" + value ;
}
}
}
下面我来说一下我的实现思路:
哈希表的初始化要有两个属性,一个是存放链条节点的数组,一个是记录哈希表数据的个数
private Node<K,V>[] table;
//哈希表节点的个数
private int size;
而对于节点对象的初始化要有四个属性,键,值,哈希值,指向下个节点的内存地址
static class Node<K,V>{
//哈希值
int hash;
//哈希表中节点的key
K key;
//哈希表中节点的value
V value;
//哈希表中的节点
Node<K,V> next;
首先是哈希表的添加功能,put (代码上也是有注释的)
//向哈希表中添加节点。键值对
public V put(K key,V value){
//假如添加的键值对的key为null,则将该节点存储到table数组索引为0的位置
if (key == null){
return putForNullKey(value);
}
//这里证明key不是空,进行尾部插值或者覆盖
//首先要找到要在数组的哪个位置上插值
//获取当前key的哈希值
int hash = key.hashCode();
//将哈希值转换成数组的下标
int index = Math.abs(hash & (table.length-1));
//当前下标处的链表节点
Node<K,V> node = table[index];
//本次添加的节点对象
Node<K,V> new_node = new Node<>(hash,key,value,null);
//判断当前下标处是否存在链表节点
if (node == null){
table[index] = new_node;
size++;
return value;
}
while (node != null){
//判断当前遍历的节点key值是否相同,相同则进行覆盖
if (node.key.equals(key)){
V oldvalue = node.value;
node.value = value;
return oldvalue;
}
node = node.next;
}
//到这里证明没有相同的key值节点,进行尾插
node.next = new_node;
size++;
return value;
}
首先我们需要检查,添加的键值对的键是否为null,如果为null我们执行putFornullkey这个方法,下面再去说。还记得我们的步骤吧
获取哈希值:int hash = key.hashCode();
通过哈希值和数组长度的与运算找到索引 : int index = Math.abs(hash & (table.length-1));(防止是负数,取了一个绝对值)
下面我们获取到对应索引处的链表节点,判断该位置处有没有对象,如果没有,直接将新节点放在该位置,并成为头节点。如果有的话,我们对该链表进行遍历,操作是和单向链表一样的,通过判断节点的next是否为null,来找到链表的尾节点,在这个遍历的过程中要进行一个判断,如果该节点的key是我们新节点的key,那么就要进行覆盖,并返回曾经的值,如果没有,则进行尾插法,将size++,最后返回新节点的value值。
那么下面的putForNullKey这个方法也一定可以理解了
private V putForNullKey(V value) {
//将数组0位置的数据提取
Node<K,V> node = table[0];
Node<K,V> new_node = new Node<>(0,null,value,null);
//此处没有链表节点
if (node == null){
table[0] = new_node;
size++;
return value;
}
//证明0索引处有单向链表,需要找到尾节点
V oldvalue;
while (node != null){
if (node.key == null){
oldvalue = node.value;
node.value = value;
return oldvalue;
}
node = node.next;
}
//这里node一定返回的是单向链表中的尾节点,对尾节点判断key是否为空
if (node.key == null){
oldvalue = node.value;
node.value = value;
return oldvalue;
}else {
node.next = new_node;
size++;
return value;
}
}
这段代码其实就是将index变成了固定值0,如果key为null的话,直接强制让该节点挂在数组索引为0的地方,剩余的操作和上面是一样的
下面让我们来看删除的方法:
//通过key删除键值对
public V delete(K key){
if (key == null){
return deleteNullKey();
}
//输入的key为其他值时
//首先获取当前key的哈希值然后转换为下标
int hashNum = key.hashCode();
int index = Math.abs(hashNum & (table.length - 1));
Node<K,V> node = table[index];
//判断当前索引位置是否有链表,没有返回null
if (node == null){
return null;
}
//判断当前索引位置的首节点是否是key
if (node.key == key){
V oldValue = node.value;
table[index] = node.next;
node.value = null;
node.key =null;
node.next = null;
size--;
return oldValue;
}
//当前索引位置有链表
Node<K,V> prevNone;
while (node.next != null){
prevNone = node;
Node<K, V> nextNode = node.next;
if (nextNode.key.equals(key)){
V nextNodeoldValue = nextNode.value;
nextNode.key = null;
nextNode.value = null;
size--;
//判断此节点的下一个节点是否为尾节点
if (nextNode.next == null){
//如果是尾节点,让此节点变为尾节点
prevNone.next = null;
return nextNodeoldValue;
}
//此节点不为尾节点时.让该节点指向下一个节点的next
prevNone.next = nextNode.next;
return nextNodeoldValue;
}
node = node.next;
}
//证明没找到key为null的
return null;
}
与添加一样,如果key为null的话我们执行null的特有方法。然后也是一样的步骤,获取哈希值,通过哈希值来获取该key在数组的对应位置。
在下面有一些变化,我们首先要考虑一种情况,那么就是该索引处的第一个节点对象,就是key所对应的节点,我们就不需要再去遍历寻找了,直接将该节点的所有值置空,并将该节点的下一个节点变为该索引处的第一个节点,size--,返回删除的数据
下面一种情况就是第一个节点对象不是key所对应的节点,我们就需要进行遍历查找了。因为删除,我们需要将删除节点的上一个节点与下一个节点进行连接,所以我们要保存上一个节点对象,方便我们对它进行修改。
这里我为了方便区分,分别用三个变量来分辨节点的关系
prevNone:当前节点
nextNode:下一个节点
node:判断条件
我这里举例吧
第一次循环:node:张三节点(头节点),prevNone:张三节点,nextNode:李四节点
此时我们已经知道了,张三头节点一定与key不相符,所以我们直接去判断nextNode也就是李四节点的key是不是与删除的key相符,假设我们不是,那么进入第二次循环
第二次循环:node:李四节点,prevNone:李四节点,nextNode:王五节点
我们再去判断王五节点的key是不是与删除的key相符,这时我们假设,王五节点就是我们需要删除的节点,我们需要做一件事,那么就是将李四和王五的下一个节点进行连接,这时还有一种情况,那么就是,王五就是尾节点怎么办,它的next为null,我们这时直接将李四节点变成尾节点不就好了嘛,也就是prevNone.next = null。
可能会有疑问,为什么node也代表当前节点,为什么不直接用node修改,因为node的next是判断条件,我们如果直接对node这个变量修改,那么会影响循环的。
下面是如果删除的key为null时的方法,如果这个看懂了,相信下面的代码也能理解了,我就不去详细说了,思路是一样的。
private V deleteNullKey() {
Node<K,V> node = table[0];
//判断当前索引位置是否有链表,如果没有返回null
if (node == null){
System.out.println("没有找到对应键值对");
return null;
}
//判断当前索引位置的第一个节点的key是否为null
if (node.key == null){
V oldValue = node.value;
table[0] = node.next;
node.value = null;
node.next = null;
size--;
return oldValue;
}
//当前索引位置有链表,遍历查询key为null的节点
Node<K,V> prevNone;
while (node.next != null){
prevNone = node;
Node<K, V> nextNode = node.next;
if (nextNode.key == null){
V nextNodeoldValue = nextNode.value;
nextNode.value = null;
size--;
//判断此节点的下一个节点是否为尾节点
if (nextNode.next == null){
//如果是尾节点,让此节点变为尾节点
prevNone.next = null;
return nextNodeoldValue;
}
//此节点不为尾节点时.让该节点指向下一个节点的next
prevNone.next = nextNode.next;
return nextNodeoldValue;
}
node = node.next;
}
//证明没找到key为null的
return null;
}
下面是get,通过key获取对应值的方法
//通过key获取value
public V get(K key){
//输入的key为null时
if (key == null){
return getNullKey();
}
//输入的key为其他值时
//首先获取当前key的哈希值然后转换为下标
int hashNum = key.hashCode();
int index = Math.abs(hashNum & (table.length - 1));
Node<K,V> node = table[index];
//判断当前索引位置是否有链表,没有返回null
if (node == null){
return null;
}
//当前索引位置有链表,遍历查询key的节点
while (node != null){
if (node.key.equals(key)){
return node.value;
}
node = node.next;
}
//没有找到对应key的value值
return null;
}
如果上面理解了,get方法是很简单的也是相同的步骤,获取哈希值,通过哈希值找到对应索引位置,遍历该位置的节点,如果找到相同key的节点,将该节点的值返回,很简单。我就不去详细说明了
下面让我们测试一下这个哈希表能不能正常实现:
代码如下:
public class TextHashMap {
public static void main(String[] args) {
MyHashMap<String,String> map = new MyHashMap<>();
map.put("110","张三");
map.put("120","赵四");
map.put("130","王五");
map.put("140","赵六");
map.put(null,"你好");
System.out.println(map);
System.out.println(map.size());
System.out.println("===================");
System.out.println(map);
map.delete("140");
System.out.println(map.size());
System.out.println("===================");
map.delete(null);
System.out.println(map);
System.out.println(map.size());
System.out.println("===================");
System.out.println("key为110值为" + map.get("110"));
}
}
可以看到添加,删除和查找都是正常的,我没有写修改,大家可以自己去实现一下,只在查找方法上添加几行代码,好了,到这里就结束了,感谢大家的观看,如果有问题希望指出,谢谢大家!