浅析哈希表——hash函数、hash冲突以及HashMap、HashSet

什么是哈希表?

哈希表

哈希冲突

哈希函数设计

负载因子调节

解决哈希冲突

实现

性能分析

哈希表的实现

HashMap

HashMap和HashSet的区别

HashSet检查重复

HashMap和HashTable的区别


什么是哈希表?

哈希表

 哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。

也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过关键码的多次比较。顺序查找时间复杂度为O(N),平衡树中为树的高度,即O( log N),搜索的效率取决于搜索过程中元素的比较次数。

理想的搜索方法:可以不经过任何比较,一次直接从表中得到要搜索的元素。

如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系

那么在查找时通过该函数可以很快找到该元素。
当向该结构中:

  • 插入元素

根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。

  • 搜索元素

对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,

若关键码相等,则搜索成功。

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

哈希冲突

两个不同的元素,通过哈希函数计算出来的哈希地址是相同的,这种现象称为哈希冲突或哈希碰撞

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

哈希函数设计

哈希函数设计的原则:

  • 哈希函数的定义域必须包含需要存储的全部关键码,如果散列表允许有m个地址时,其值域必须是0 到m-1之间
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数要比较简单

常见的哈希函数

  1. 直接定址法:取关键字的某个线性函数为散列地址:Hash(Key)= A*Key + B。
  2. 除留余数法:设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(key) = key% p(p<=m),将关键码转换成哈希地址。
  3. 平方取中法
  4. 折叠法
  5. 随机数法:选择一个随机函数,取关键字的随机函数值为它的哈希地址,H(key)=random(key),其中random为随机数函数。
  6. 数学分析法

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

负载因子调节

冲突率 ~ 元素个数/数组长度

  1. 对冲突率有一个上限的阈值,所以对于负载因子有一个上限的阈值
  2. 要降低冲突,需要降低负载因子:元素个数不能懂,所以只能增加数组的长度(扩容)
  3. Java中一般负载因子是0.75

解决哈希冲突

闭散列(开放定址法)

当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。

线性探测法

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

插入

  • 通过哈希函数获得待插入元素在哈希表中的位置
  • 如果该位置没有元素,直接插入
  • 如果该位置有元素,发生哈希冲突,使用线程探测找到下一个空位置,进行插入

开散列(拉链法)——用另一种数据结构来解决冲突的元素

Java中HashMap使用的就是拉链法来解决哈希冲突的。

首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,

各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

每一个桶中放的都是发生哈希冲突的元素。

实现

// 使用拉链法解决冲突
public class MyHashTable {
    // 1. 数组
    private Node[] array = new Node[11];
    // 2. 维护哈希表中的有的元素个数
    private int size;

    // true: key 之前不在哈希表中
    // false: key 之前已经在哈希表中
    public boolean insert(Integer key) {
        // 1. 把对象转成 int 类型
        // hashCode() 方法的调用是核心
        int hashValue = key.hashCode();
        // 2. 把 hashValue 转成合法的下标
        int index = hashValue % array.length;
        // 3. 遍历 index 位置处的链表,确定 key 在不在元素中
        Node current = array[index];
        while (current != null) {
            // equals() 方法的调用是核心
            if (key.equals(current.key)) {
                return false;
            }
            current = current.next;
        }
        // 4. 把 key 装入节点中,并插入到对应的链表中
        // 头插尾插都可以,头插相对简单
        Node node = new Node(key);
        node.next = array[index];
        array[index] = node;

        // 5. 维护 元素个数
        size++;
        // 6. 通过维护负载因子,进而维护较低的冲突率
        if (size / array.length * 100 >= 75) {
            扩容();
        }
        return true;
    }

    public boolean remove(Integer key) {
        // hashCode()
        int hashValue = key.hashCode();
        // 得到合法下标
        int index = hashValue % array.length;
        Node preivous = null;
        Node current = array[index];
        while (current != null) {
            if (key.equals(current.key)) {
                // 删除
                if (preivous != null) {
                    preivous.next = current.next;
                } else {
                    // current 是这条链表的头节点
                    array[index] = current.next;
                }

                size--;
                return true;
            }

            preivous = current;
            current = current.next;
        }

        return false;
    }

    public boolean contains(Integer key) {
        int hashValue = key.hashCode();
        int index = hashValue % array.length;
        Node current = array[index];
        while (current != null) {
            if (key.equals(current.key)) {
                return true;
            }

            current = current.next;
        }

        return false;
    }

    // O(n)
    private void 扩容() {
        Node[] newArray = new Node[array.length * 2];

        // 搬原来的元素过来
        // 不能直接按链表搬运,因为元素保存的下标和数组长度有关
        // 数组长度变了,下标也会变
        // 所以,需要把每个元素重新计算一次

        // 遍历整个数组
        for (int i = 0; i < array.length; i++) {
            // 遍历每条链表
            Node current = array[i];
            while (current != null) {
                // 高效的办法是搬节点,写起来麻烦
                // 我们采用复制节点,简单一点
                Integer key = current.key;
                int hashValue = key.hashCode();
                int index = hashValue % newArray.length;
                // 头插尾插都可以,头插简单
                Node node = new Node(key);
                node.next = newArray[index];
                newArray[index] = node;

                current = current.next;
            }
        }

        array = newArray;
    }
}

性能分析

在实际的使用过程中,哈希表的冲突率是不高的,冲突的个数也是可控的,每个桶中的链表的长度是一个常数,所以,通常认为哈希表的插入/删除/查找的时间复杂度是O(1).

哈希表的实现

纯Key模型:HashSet——实现了Set接口

Key-Value模型:HashMap——实现了Map接口

HashMap

HashMap源码剖析

JDK1.8 之前 HashMap 底层是 数组和链表 结合在⼀起使⽤也就是 链表散列。

HashMap 通过 key 的hashCode 经过哈希函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这⾥的 n 指的是数组的⻓度),如果当前位置存在元素的话,就判断该元素与要存⼊的元素的 hash值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
使⽤ hash ⽅法是为了防⽌⼀些实现⽐较差的 hashCode() ⽅法【使⽤哈希函数之后可以减少碰撞】

JDK1.8 之后
相⽐于之前的版本, JDK1.8 之后在解决哈希冲突时有了较⼤的变化

当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间.

——将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。

HashMap和HashSet的区别:

HashSet 底层就是基于 HashMap 实现的。(HashSet 的源码⾮常⾮常少,因为除了 clone() 、 writeObject() 、 readObject() 是 HashSet ⾃⼰不得不实现之外,其他⽅法都是直接调⽤ HashMap 中的⽅法。

HashSet检查重复:

当把对象加入HashSet时,HashSet 会先计算对象的 hashcode 值来判断对象加⼊的位置,

同时也会与其他加⼊的对象的 hashcode 值作⽐较,如果没有相符的 hashcode, HashSet 会假设对象没有重复出现。

但是如果发现有相同 hashcode 值的对象,这时会调⽤ equals() ⽅法来检查 hashcode 相等的对象是否真的相同。

如果两者相同, HashSet 就不会让加⼊操作成功。

hashcode() 和equals() 方法:

  1. 如果两个对象相等,则hashcode一定也要相同
  2. 两个对象相等,equals方法返回true
  3. 两个对象有相同的hashcode值,不一定是相等的
  4. equals ⽅法被覆盖过,则 hashCode ⽅法也必须被覆盖
  5. hashCode()的默认⾏为是对堆上的对象产⽣独特值。如果没有重写 hashCode(),则该 class 的两个对象⽆论如何都不会相等(即使这两个对象指向相同的数据)。

HashMap和HashTable的区别:

  1. 线程是否安全:HashMap 是⾮线程安全的, HashTable 是线程安全的,因为 HashTable 内部的⽅法基本都经过 synchronized 修饰。
  2. 效率:因为线程安全的问题, HashMap 要⽐ HashTable 效率⾼⼀点。HashTable基本被淘汰了
  3. 对Null Key 和Nul Value 的支持:
    1. HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有⼀个, null 作为值可以有多个;
    2. HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
  4. 初始容量大小和每次扩容大小的不同
    1. 创建时如果不指定容量初始值, Hashtable 默认的初始⼤⼩为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为16。之后每次扩充,容量变为原来的 2 倍。
    2. 创建时如果给定了容量初始值,那么 Hashtable会直接使⽤你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小。
  5. 底层数据结构:JDK1.8 以后的 HashMap 在解决哈希冲突时有了较⼤的变化,当链表⻓度⼤于阈值(默认为 8)(将链表转换成红⿊树前会判断,如果当前数组的⻓度⼩于 64,那么会选择
    先进⾏数组扩容,⽽不是转换为红⿊树)时,将链表转化为红⿊树,以减少搜索时间。Hashtable 没有这样的机制。
  • 5
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值