Java哈希表的理解和手写哈希表

今天学习了哈希表这一数据结构,双向链表我明天过几天再实现一下,趁着昨天完成了单向链表的实现,今天先实现一下哈希表的增删改查。

我们首先要先知道哈希表这种数据结构是什么,如果大家学过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"));

    }
}

可以看到添加,删除和查找都是正常的,我没有写修改,大家可以自己去实现一下,只在查找方法上添加几行代码,好了,到这里就结束了,感谢大家的观看,如果有问题希望指出,谢谢大家!

  • 25
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值