哈希表、哈希桶数据结构以及刨析HashMap源码中哈希桶的使用

x33g5p2x  于2022-02-07 转载在 Java  
字(9.6k)|赞(0)|评价(0)|浏览(514)

一、哈希表

1.哈希表概念

可以不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。它存储的空间叫哈希表。增删查改的时间复杂度都是O(1)。

当向该结构中:

1、插入元素
根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
2、搜索元素
对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)。

例如:数据集合{1,7,6,4,5,9};

哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。

用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快,时间复杂度为O(1) 。 问题:按照上述哈希方式,若下标4中已经有元素,向集合中插入元素44,会出现什么问题?

2.冲突的概念

冲突是指:不同的关键字key,通过相同的哈希函数,得到了相同的位置,该种现象称为哈希冲突或哈希碰撞。

把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

3.避免冲突与解决冲突

首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一个问题,冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率。

避免冲突的方式有两种,一种是设置合理的哈希函数,另一种是负载因子调节。负载因子越大,则冲突率越大。

3.1 避免冲突的方式1——哈希函数的设计

哈希函数设计原则:
1、哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间。
2、哈希函数计算出来的地址能均匀分布在整个空间中。
3、哈希函数应该比较简单。

常见哈希函数的设置:

  1. 直接定制法–(常用)
    取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B 优点:简单、均匀 缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况。
  2. 除留余数法–(常用)
    设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址
  3. 平方取中法–(了解)
    假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址; 再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址 平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况。
  4. 折叠法–(了解)
    折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。
    折叠法适合事先不需要知道关键字的分布,适合关键字位数比较多的情况。
  5. 随机数法–(了解)
    选择一个随机函数,取关键字的随机函数值为它的哈希地址,即H(key) = random(key),其中random为随机数函数。通常应用于关键字长度不等时采用此法。
  6. 数学分析法–(了解)
    设有n个d位数,每一位可能有r种不同的符号,这r种不同的符号在各位上出现的频率不一定相同,可能在某
    些位上分布比较均匀,每种符号出现的机会均等,在某些位上分布不均匀只有某几种符号经常出现。可根据散列表的大小,选择其中各种符号分布均匀的若干位作为散列地址。例如:

假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。

数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况。

注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。

3.2 避免冲突的方式2——负载因子的调节

负载因子和冲突率的关系粗略演示:

所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。

已知哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。

总结:负载因子的计算公式:当前存入表中的数据个数/表的大小。因此如果要降低负载因子,只能通过增大表的长度来实现。

3.3 解决冲突的方式1——闭散列

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。找到下一个空位置的闭散列的方法有两种:

  1. 线性探测
    比如上面的场景,现在需要插入元素44,先通过哈希函数计算哈希地址,下标为4,因此44理论上应该插在该位置,但是该位置已经放了值为4的元素,即发生哈希冲突。

线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

插入操作:
1、通过哈希函数获取待插入元素在哈希表中的位置。
2、如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到下一个空位置,插入新元素。

采用闭散列处理哈希冲突时,不能随便物理删除哈希表中已有的元素,若直接删除元素会影响其他元素的搜索。比如删除元素4,如果直接删除掉,44查找起来可能会受影响。因此线性探测采用标记的伪删除法来删除一个元素。

缺点:会把冲突的元素都堆积在一起。

  1. 二次探测
    二次探测为了避免线性探测的缺点,它找下一个空位置的方法为:

或者减i的平方。i为冲突的次数,i从1开始。

例如:
当哈希表如下图后,要插入44,但4下标中已经有元素了,此时是第一次冲突,因此计算Hi=(4+1)%10=5 。

此时再插入24,但是4下标已经有元素了,是第一次冲突。再向后一个下标找,此时5下标已经有元素了,是第二次冲突。再向后找,6下标没有元素,则Hi=(4+4)%10=8,则24放入8下标中。

研究表明:当表的长度为质数且表装载因子a不超过0.5时,新的表项一定能够插入,而且任何一个位置都不会被探查两次。因此只要表中有一半的空位置,就不会存在表满的问题。在搜索时可以不考虑表装满的情况,但在插入时必须确保表的装载因子a不超过0.5,如果超出必须考虑增容。

因此:闭散列最大的缺陷就是空间利用率比较低,这也是哈希的缺陷。

3.4 解决冲突的方式2——开散列、哈希桶(重要)

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,那如果冲突严重,就意味着小集合的搜索性能其实也时不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:

  1. 每个桶的背后是另一个哈希表
  2. 每个桶的背后是一棵搜索树(红黑树)

链地址法的图示:当有哈希桶在某个下标下,则哈希表对应下标的值为链表头结点的地址,如果没有则为null。每个结点都由key,value与next组成。

细节操作:
因为是用哈希桶来解决冲突,因此要创建一个每个下标底下都是结点组成的链表的数组Node[] array;,并且用usedSize来记录数组中结点的个数,目的是求出负载因子。而每个哈希桶都是由结点组成的,因此结点类可以定义到哈希桶类里面。

put、resize方法:先根据设计的哈希函数求出对应的下标,找到对应下标后去遍历该下标的链表,**如果已经有相同的key值的结点,则更新完key值中的value后退出。**如果没有,则创建一个哈希桶,设置cur结点从头结点开始,用头插法插入新的结点。当然,插入后要计算出负载因子是否超过了0.75,如果超过就扩容。**扩容后数组的长度会变长,因此每个哈希桶根据新的哈希函数的计算结果跟原来的不同,则要重新哈希。(resize方法)**等全部哈希桶重新哈希完之后,新的数组的引用要赋值给旧的数组的引用。

get方法:首先要根据哈希函数求出对应的下标,再从下标当中去找是否有相同的值的哈希桶,若有,则返回该哈希桶的value值,否则返回-1。

总体代码:

public class HashBucket {

    static class Node {
        public int key;
        public int val;
        public Node next;

        public Node(int key, int val) {
            this.key = key;
            this.val = val;
        }
    }

    private Node[] array;
    public int usedSize;

    public HashBucket() {
        this.array = new Node[10];
        this.usedSize = 0;
    }

    public void put(int key,int val) {
        //1、确定下标
        int index = key % this.array.length;
        //2、遍历这个下标的链表
        Node cur = array[index];
        while (cur != null) {
            //更新val
            if(cur.key == key) {
                cur.val = val;
                return;
            }
            cur = cur.next;
        }
        //3、cur == null   当前数组下标的 链表  没要key
        Node node = new Node(key,val);
        node.next = array[index];
        array[index] = node;
        this.usedSize++;
        //4、判断  当前 有没有超过负载因子
        if(loadFactor() >= 0.75) {
            //扩容
            resize();
        }
    }

    public int get(int key) {
        //以什么方式存储的  那就以什么方式取
        int index = key % this.array.length;
        Node cur = array[index];
        while (cur != null) {
            if(cur.key == key) {
                return cur.val;
            }
            cur = cur.next;
        }
        return -1;//
    }

    public double loadFactor() {
        return this.usedSize*1.0 / this.array.length;
    }

    public void resize() {
        //自己创建新的2倍数组
        Node[] newArray = new Node[2*this.array.length];
        //遍历原来的哈希桶
        //最外层循环 控制数组下标
        for (int i = 0; i < this.array.length; i++) {
            Node cur = array[i];
            Node curNext = null;
            while (cur != null) {
                //记录cur.next
                curNext = cur.next;
                //在新的数组里面的下标
                int index = cur.key % newArray.length;
                //进行头插法
                cur.next = newArray[index];
                newArray[index] = cur;
                cur = curNext;
            }
        }
        this.array = newArray;
    }

    public static void main(String[] args) {
        HashBucket hashBucket = new HashBucket();
        hashBucket.put(1,1);
        hashBucket.put(4,4);
        hashBucket.put(14,14);
        hashBucket.put(24,24);
        hashBucket.put(34,34);
        hashBucket.put(44,44);
        hashBucket.put(54,54);
        hashBucket.put(64,64);

        System.out.println(hashBucket.get(54));
        System.out.println("ffafas");
    }
}

4.哈希表查找成功与查找失败的求法

查找成功和查找失败是将数据都填入哈希表后再求的。

4.1 查找成功的求法

一个哈希表如下图:

假设现在要查找4,则根据哈希函数Hash(key)=key%array.length,找到了4下标,因为4下标下的数据就是4,则查找一次就成功,1和9数据的查找成功次数求法也是一致。
假设此时要查找24,则先根据哈希函数求出下标为4,但是4下标的数据是4,而不是24,算查找一次。此时往后移一个单位,发现是44不是我们查找的24,则算查找第二次。此时再往后移一个单位,发现是24,算是查找的次数是第三次。

因此上图中查找成功的平均查找长度:(1+1+2+3+1)/5=8/5。

如果找9下标的值查找的不是要查找的数据,则返回到0下标开始找,并且找9下标失败后算查找1次。

4.2 查找失败的求法

一个哈希表如下图:

第一次假如找数据1,则根据哈希函数Hash(key)=key%array.length求得下标为1,假设下标1中的数据不是我们找的数据1,算查找失败了一次,则向后移一个单位去找。此时下标2的值为空,则说明查找失败,算查找失败了两次。

假如此时找数据44,根据哈希函数求得下标为4,但此时下标4下的数据不是44,则没有保证在查找成功的前提下去查找。因此向后移一个单位去找,此时发现下标5的数据是44,则查找失败的次数从此时才开始计算。我们还是要假设当前5下标是查找失败的,算查找失败一次,则再向后移一个单位去找。还是一样,假设下标6的值不是44,查找失败,则向后移一个单位去找。到下标7后,发现该下标的数据为空,则肯定是查找失败,这次也算查找一次,因此查找失败的长度加起来一共有三次。

注:查找失败的操作是从真正找到要找的值开始向后找下一个空的地方。

二、用泛型实现开散列与开散列在源码上的底层实现

1.用泛型实现开散列

用泛型实现哈希表则它的key和val都是引用类型,不能跟上面的一样直接用大于小于号比较。

步骤:首先定义一个类Person创建带有一个参数的构造方法。再将HashBuck类变为泛型类,直接再类名后加<K,V>,并且哈希表中每个下标的值都是一个链表,链表由结点组成,因此要设置public Node<K,V>[] array = (Node<K,V>[])new Node[10];

因为我们的key是引用类型,不能直接用哈希函数去求下标,那么如何将一个引用转化为一个数值去求下标呢?

此时我们自己实现的类(key)要同时实现equals和hashcode方法,hashcode方法是为了将一个引用类型转换为一个数值从而达到去求它要存储的对应的下标。equals方法是判断两个引用是否是相同的。

如:
当之间用== 比较,则比较的是地址,为false。

class Student {
    public int id;

    public Student(int id) {
        this.id = id;
    }
}
public class Test {
    public static void main(String[] args) {
        Student student1 = new Student(12);
        Student student2 = new Student(12);
        System.out.println(student1==student2);
    }
}
//打印结果:false

但是重写equals方法后,如下例子相当于判断是否是相同的一个人,因为它们的id都相同。

class Student {
    public int id;

    public Student(int id) {
        this.id = id;
    }
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Student student = (Student) o;
        return id == student.id;
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
public class Test {
    public static void main(String[] args) {
        Student student1 = new Student(12);
        Student student2 = new Student(12);
        System.out.println(student1.equals(student2));
    }
}
//打印结果:true

面试问题:

  1. hashcode和equals的区别?
    答:如果在HashMap中,hashcode的作用是定位当前key值应该存储的下标位置。equals的作用是在经过hashcode定位某个下标后,遍历链表,比较哪个key是相同的。
  2. 为什么hashcode和equals要同时出现?
    答:在HashMap中,首先要经过hashcode定位存储的下标,但是如果要获取某个key时,要用到equals方法,因此在HashMap中hashcode和equals是配套使用的。因此以后在hashmap中要存放自己自定义的数据类型,一定要在自定义的数据类型的类里面重新hashcode和equals方法。
  3. 当两个数据的equals相同,则hashcode相同吗?那如果两个数据的hashcode相同,equals一定相同吗?如果两个数据的hashcode不同,则equals一定相同吗?
    答:当两个数据的equals相同,hashcode一定相同。如果两个数据的hashcode相同,equals不一定相同。如果两个数据的hashcode不同,则equals一定不相同。

总体代码:

class Person {
    public String id;

    public Person(String id) {
        this.id = id;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Person person = (Person) o;
        return Objects.equals(id, person.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}
class HashBuck<K,V> {
    static class Node<K,V> {
        public K key;
        public V val;
        public Node<K,V> next;

        public Node(K key, V val) {
            this.key = key;
            this.val = val;
        }
    }

    public Node<K,V>[] array = (Node<K,V>[])new Node[10];
    public int usedSize ;

    public void put(K key,V val) {
        int hash = key.hashCode();
        int index = hash%array.length;
        Node<K,V> cur = array[index];
        while(cur!=null) {
            if(cur.val.equals(val)) {
                return;
            }
            cur=cur.next;
        }
        Node<K,V> node = new Node<>(key,val);
        node.next=array[index];
        array[index]=node;
        usedSize++;
        if(loadFactor()>=0.75) {
            resize();
        }
    }
    public void resize() {
        Node<K,V>[] newArray = (Node<K,V>[])new Node[2*array.length];
        for (int i = 0; i < array.length; i++) {
            Node<K,V> cur = array[i];
            while(cur!=null) {
                int hash = cur.key.hashCode();
                int index = hash% newArray.length;
                Node<K,V> curNext = cur.next;
                cur.next=newArray[index];
                newArray[index]=cur;
            }
        }
        array=newArray;
    }

    public double loadFactor() {
        return usedSize*1.0/array.length;
    }

    public V get(K key) {
        int hash = key.hashCode();
        int index = hash%array.length;
        Node<K,V> cur = array[index];
        while(cur!=null) {
            if(cur.key.equals(key)) {
                return cur.val;
            }
            cur=cur.next;
        }
        return null;
    }
}
public class Test {
    public static void main(String[] args) {
        Person person1 = new Person("123");
        Person person2 = new Person("123");
        HashBuck<Person,String> hashBuck = new HashBuck<>();
        hashBuck.put(person1,"zjr");
        System.out.println(hashBuck.get(person2));
    }
}

2.开散列在源码上的底层实现

哈希桶的基本原理在3.4小节中已经提到,那么在Java当中它是如何去实现开散列的呢?
注:Java中HashMap和HashSet的底层都是开散列的处理方式。

在存放的时候:
1、找当前位置链表中是否有相同的key。
2、当数组超过64,并且有一个链表超过8时,链表就变成一棵红黑树来处理,它查找的效率更快。
3、JDK1.8后,数组中当有元素插入时链表实现的是尾插法。

点入Java中的HashMap学习它的源码:
1、

在HashMap类当中定义了一个结点类,并且实现了Map.Entry<K,V>,说明HashMap当中有结点的组成。

2、默认的初始容量为16。

3、数组的最大长度为2^30

4、默认的负载因子0.75f

5、变为红黑树的前提是数组的大小超过64

6、HashMap中的数组名为table,初始长度为0。

7、调用无参的构造方法是将负载因子赋值。

2.1 HashMap底层面试题

要想会面试题,必须把下面的图以及参考部分的源码看懂。

第一次put:

第二次put:

冲突时如何去调整:

对应第一题:

1、如果new HashMap(19),bucket数组多大? 答:2^5=32 。
2、HashMap什么时候开辟bucket数组占用内存? 答:第一次put时。
3、hashmap何时扩容? 答:负载因子为0.75f时。
4、当两个对象的hashcode相同会发生什么? 答:哈希冲突或碰撞。
5、如果两个键的hashcode相同,你如何获取值对象? 答:在hashcode的数组位置开始遍历链表。
6、你了解重新调整HashMap大小存在什么问题吗? 答:重新哈希。

相关文章