【HashMap的底层数据结构——哈希表】

目录

引言

一、概念

二、哈希冲突

1.冲突-概念

2.冲突-避免(哈希函数设计)

3.冲突-避免及解决(重点掌握)

三、哈希表(桶)的实现

总结


引言

http://t.csdnimg.cn/z4XpR 在此篇博客中,介绍了Map集合实现类HashMap和TreeMap的使用,但并没有深究其底层的数据结构,下面介绍的就是HashMap和HashSet底层的数据结构——哈希表


一、概念

顺序结构以及平衡树 中,元素关键码与其存储位置之间没有对应的关系,因此在 查找一个元素时,必须要经过关键 码的多次比较 顺序查找时间复杂度为 O(N) ,平衡树中时间复杂度为树的高度,即 O(logN ) ,搜索的效率取决于搜索过程中元素的比较次数。
理想的搜索方法:可以 不经过任何比较,一次直接从表中得到要搜索的元素 如果构造一种存储结构,通过某种函 (hashFunc) 使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快 找到该元素
当向该结构中:
  • 插入元素
    • 根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放
  • 搜索元素
    • 对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若 关键码相等,则搜索成功
该方式即为哈希 ( 散列 ) 方法, 哈希方法中使用的转换函数称为哈希 ( 散列 ) 函数,构造出来的结构称为哈希表 (Hash Table)( 或者称散列表 )

在Java 中,计算哈希值实际上是调用Object类中的hashCode()方法。


二、哈希冲突

1.冲突-概念

对于两个数据元素的关键字 ki 和 kj  (i != j) ,有  ki != kj ,但有: Hash(ki) == Hash(kj) ,即: 不同关键字通过相同哈 希哈数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞
把具有不同关键码而具有相同哈希地址的数据元素称为 同义词 ”。

2.冲突-避免(哈希函数设计)

首先,我们需要明确一点,由于我们哈希表底层数组的容量往往是小于实际要存储的关键字的数量的,这就导致一 个问题,冲突的发生是必然的 ,但我们能做的应该是尽量的 降低冲突率。
引起哈希冲突的一个原因可能是: 哈希函数设计不够合理 哈希函数设计原则
  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0m-1之间
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数应该比较简单

常见哈希函数有:

  • 直接定制法、除留余数法、平方取中法、折叠法、随机数法、数学分析法
注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突。

3.冲突-避免及解决(重点掌握)

  • 负载因子调节:

        负载因子是一个衡量哈希表使用情况的指标,通常定义为当前哈希表中元素数量与桶的数量的比值。负载因子的调节可以用来控制哈希表的性能。

通常情况下,当负载因子超过某个阈值时(Java中该阈值为0.75),会触发哈希表的扩容操作,以减少哈希冲突的概率,同时会对数组每个元素(桶)进行重新哈希,保持哈希表的高效性能。Java中的HashMap和HashSet等基于哈希表的数据结构都提供了负载因子参数和自动扩容的机制。

  • 解决-开散列/哈希桶:

        在Java中,哈希冲突主要是通过哈希表的两种解决方法来处理的:开放定址法(Open Addressing)和链地址法(Chaining)(重点掌握)

  1. 开放定址法:当发生哈希冲突时,使用一定的规则来查找下一个可用的空槽位。常见的开放定址法包括线性探测(Linear Probing)、二次探测(Quadratic Probing)和双重哈希(Double Hashing)。这些方法都是在哈希表中按照一定的步长进行探测,直到找到一个空槽位为止。

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

从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。 开散列,可以认为是把一个在大集合中的搜索问题转化为在小集合中做搜索了。

在Java中,常用的哈希表实现类如HashMap、HashSet等都采用了链地址法来解决哈希冲突。当发生哈希冲突时,新的元素会被添加到桶对应的链表中。刚才我们提到了,哈希桶其实可以看作将大集合的搜索问题转化为小集合的搜索问题了,如果冲突严重,就意味着小集合的搜索性能其实也是不佳的,这个时候我们就可以将这个所谓的小集合搜索问题继续进行转化,例如:

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

链表过长时(链表长度大于8,哈希表(数组)长度大于等于64),就会将该条链表转化为红黑树来进行存储和查找操作这种方法可以保证在平均情况下具有较高的查找效率,并且具有较好的扩展性。


三、哈希表(桶)的实现

在Java中,哈希表的底层是由数组+链表/红黑树组成的,这个数组的每个元素被称为“桶”(bucket),每个桶可以存放一个或多个键值对。

我这里实现的哈希表中的键值对都为int类型,数组每个元素(桶)是一个链表,哈希函数使用较简单的除留余数法,负载因子为0.75

据此可以列出大概结构:

public class HashBucket {
    private static class Node {
        private int key;
        private int value;
        Node next;

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

    private Node[] array;
    private int size;   // 当前的数据个数
    private static final double LOAD_FACTOR = 0.75;
    private static final int DEFAULT_SIZE = 10;//默认桶的大小

    public HashBucket() {
        array = new Node[DEFAULT_SIZE];
    }

    //添加键值对
    public int put(int key, int value) {
    }

    //重新哈希
    private void resize() {
    }

    //判断负载因子是否大于0.75
    private double loadFactor() {
        return size * 1.0 / array.length;
    }

    //获取键所对应的值
    public int get(int key) {
    }
}
  • put方法整体思路:
  1. 找到插入位置。
  2. 拿到该位置结点,看是否为空。
  3. 不为空:查看是否有相同键的结点,有则将该键对应值覆盖成新值,然后返回;没有相同键结点,则拿到该结点后,走到该链表(桶)的最后一个结点。
  4. 为空直接插入,不为空则进行尾插(Java中为头插,因为哈希表中元素的顺序并不重要,只需要在查找时能够正确地定位到对应的键值对即可。同时,使用头插法可以简化插入操作的实现,并且能够更好地利用空间)
  5. 判断负载因子是否超过0.75,超过则进行重新哈希。
  • 重新哈希整体思路:
  1. 对原哈希表二倍扩容得到新哈希表
  2. 遍历原哈希表数组,对每个元素(链表)重新哈希。
  3. 计算出重新哈希后的每个数组元素(桶)应插入位置。
  4. 由于新数组各元素一定为空,直接将原哈希表元素(链表)放到新哈希表的应插入位置,尾插也可以。

完整代码如下:

//哈希表
public class HashBucket {
    private static class Node {
        private int key;
        private int value;
        Node next;

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

    private Node[] array;
    private int size;   // 当前的数据个数
    private static final double LOAD_FACTOR = 0.75;
    private static final int DEFAULT_SIZE = 10;//默认桶的大小

    public HashBucket() {
        array = new Node[DEFAULT_SIZE];
    }

    public int put(int key, int value) {
        //找到插入位置
        int index = key % array.length;
        Node node = new Node(key, value);
        //拿到该位置结点,遍历
        Node cur = array[index];
        //查看是否有相同键的结点
        while (cur != null) {
            if (cur.key == key) {
                cur.value = value;
                return value;
            }
            cur = cur.next;
        }
        cur = array[index];
        //走到最后一个结点,尾插。
        while (cur != null && cur.next != null) {
            cur = cur.next;
        }
        if (cur != null) {
            cur.next = node;
        } else {
            array[index] = node;
        }
        size++;
        //判断负载因子是否超过0.75
        if (loadFactor() >= LOAD_FACTOR) {
            //重新哈希
            resize();
        }
        return -1;
    }

    //重新哈希
    private void resize() {
        Node[] newArray = new Node[array.length * 2];
        //遍历数组,对每个结点重新哈希
        for (int i = 0; i < array.length; i++) {
            Node cur = array[i];
            while (cur != null) {
                int index = cur.key % newArray.length;
                //进行尾插
                Node node = newArray[index];
                while (node != null && node.next != null) {
                    node = node.next;
                }
                if (node != null) {
                    node.next = new Node(cur.key, cur.value);
                } else {
                    //如果直接赋cur,则cur的next也会带上
                    newArray[index] = new Node(cur.key, cur.value);
                }
                cur = cur.next;
            }
        }
        array = newArray;
    }


    private double loadFactor() {
        return size * 1.0 / array.length;
    }

    public int get(int key) {
        //1.找到位置  2.遍历
        int index = key % array.length;
        Node cur = array[index];
        while (cur != null) {
            if (cur.key == key) {
                return cur.value;
            }
            cur = cur.next;
        }
        return -1;
    }
}

总结

  • 性能分析:
        虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为 哈希表的插入/删除/查找时间复杂度是O(1)
  • 与Java类集的关系:
  1. HashMap HashSet Java 中利用哈希表实现的 Map Set。
  2. Java 中使用的是哈希桶方式解决冲突的。
  3. Java 会在链表长度大于一定阈值后(链表长度大于8,数组长度大于等于64),将链表转变为搜索树(红黑树)。
  4. Java 中计算哈希值实际上是调用的 Object 类的 hashCode 方法,进行 key 的相等性比较是调用 key equals 法。所以如果要用自定义类作为 HashMap key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的。
  • 24
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值