初识哈希表【数据结构】

x33g5p2x  于2021-11-09 转载在 其他  
字(4.0k)|赞(0)|评价(0)|浏览(322)

概念

前言

举例:
给定多干个整数[0,99],在给定一个数字 n,判断 n 是否出现在刚才的集合中
1.基于顺序表: 可以用一个数组保存若干个整数,拿着 n 在数组中依次遍历,进行比较 — O(N)
2.基于链表: 可以用一个链表保存若干个整数,拿着 n 在链表中依次遍历,进行比较 — O(N)
3.基于二叉搜索树: 可以用一个二叉搜索树来保存这些整数,按照二叉搜索树的方式进行查找比较 — O(N) (比较理想的情况:OlogN)

更高效的办法:
仍然创建一个 boolea 类型的数组,100个元素位置,初始情况都为 false
现有数组:{9,5,2,7,3,6,8},将其插入到下标与自身值相等的位置上
即:将本数组中的内容,转换为另一个数组的下标

判断 n 是否在该数组中,直接使用 n 在指定的下标位上,去数组中判断,是否为 true

时间复杂度 — O(1) (下标访问)

那么问题来了,如果数字更大,变成 [10w,10w零99],此时,难道要创建一个长度为 10w零100 这样的数组吗????如果这样做,就浪费了 [0,10w) 的空间全浪费了

此时,我们仍然可以创建一个长度为 100 的 boolean 类型的数组,将数组中的值,插入时,下标做减法(-10w),再插入到对应的下标位置;最后再判断 n是否在数组中,仍然使用 n-10w 作为下标,去数组中判断值是否为 true

如果数字区间变成了 [0,10w],给定数组就只有 10 个元素
此时,难道要创建一个长度为 10w 的数组吗?这样大部分空间都浪费了,我们仍然创建一个长度为 100 的 boolean 类型的数组,让元素去 %100,作为下标再插入到数组中,判断 n 也一样

把待存储元素进行了一个映射转换,转换成数组下标,我们称为 "哈希函数"

哈希冲突

如果发现两个值不同的元素,计算得到的 hash 值相同,此时就成为 “哈希冲突”

哈希冲突处理方法:

解决哈希冲突的方法,主要有两种方式:

  1. 闭散列
    核心思路:在冲突位置开始,往后找到一个合适位置来存放这个冲突的元素
    当前是采取的 [闭散列] 中的 [线性探测] 的方式来进行存放的

一旦涉及到哈希冲突,此时哈希表的基本操作的时间复杂度,就不是严格的O(1)了,随着冲突越严重,效率就会越低
正因为如此,在选择哈希表的长度的时候,就有说法,一般都要选一个比较大的值(例:若集合中有100个元素,就最好整一个1000个元素的数组),如果数组元素个数选的较大,冲突概率会降低,但浪费的空间也更多了,终究是时间空间之间的权衡~~

  1. 开散列
    核心思路:在冲突位置,存一个 key 的链表,依次往后存节点

对于开散列来说,如果哈希冲突比较严重,可以把每个元素上挂的链表改成一个二叉搜索树(标准库中的HashMap就是采取这种方式)或者另外一个哈希表~
标准库中这种处理方式,若发现链表过长,就会把链表的结构调成 红黑树 的结构

哈希冲突,理论上是避免不了的,但是可以通过开 / 闭散列的方式来处理冲突,虽然出现冲突,只是影响到效率,而不会影响到增删改查结果的正确性
哈希表的 插入、删除、查找的时间复杂度近似为 O(1),最终是多少,取决于 哈希冲突 的严重程度
如何更接近 O(1) :

  • 让数组长度更长一些
  • 选择更合理的哈希函数(例如,%素数…)
  • 对于开散列来说,若哈希冲突比较严重,可以把每个元素上挂的链表改成一个二叉搜索树,或者另外一个哈希表

冲突避免

哈希函数设计

引起哈希冲突的一个原因可能是:哈希函数设计不够合理

  • 直接定制法(常用)

取关键字的某个线性函数为散列地址:Hash(Key) = A*Key + B
优点:简单、均匀
缺点:需要事先知道关键字的分布情况 使用场景:适合查找比较小且连续的情况

  • 除留余数法(常用)

设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数:Hash(Key) = Key % p (p<=m),将关键码转换成哈希地址
例如上述: key % capacity

  • 平方取中法
  • 折叠法
  • 随机数法
  • 数学分析法

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

负载因子调节

负载因子: 通过这个指标可以衡量元素冲突的概率,可以通过负载因子的值来决定是否需要对哈希表进行扩容
即:当前哈希表中的实际元素个数 / 数组的 capacity → 负载因子
闭散列:负载因子 < 1
开散列:负载因子 可以 > 1

冲突解决

  • 闭散列

见文章前面

  • 开散列 / 哈希桶

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

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

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

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

哈希表具体实现:

public class MyHashMap {
    // 节点类
    static class Node{
        public int key;
        public int value;
        public Node next;

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

    // 负载因子
    private static final double LOAD_FACTOR = 0.75;

    // array 就是 哈希表 的本体,数组的每个元素又是链表的头节点
    private Node[] array = new Node[101];
    private int size = 0; // 表示当前 哈希表 中的元素个数

    // 哈希函数
    private int hashFunc(int key){
        return key % array.length;
    }

    // 插入元素
    // 若 key 已经存在,就修改当前的 value 值
    // 若 key 不存在,就插入新的键值对
    public void put(int key,int value){
        // 1.把 key 映射成数组下标
        int index = hashFunc(key);
        // 2.根据下标找到对应的链表
        Node list = array[index];
        // 3.当前的 key 在链表中是否存在
        for (Node cur = list; cur != null;cur = cur.next) {
            // key 已经存在,直接修改 value 即可
            if(cur.key == key){
                cur.value = value;
                return;
            }
        }
        // 4.循环结束,没有找到相同 key 的节点,直接插入到指定链表的头部
        Node newNode = new Node(key,value);
        newNode.next = list;
        array[index] = newNode;
        size++;

        if(size / array.length > LOAD_FACTOR){
            resize();
        }
    }

    // 扩容
    private void resize() {
        Node[] newArray = new Node[array.length * 2];
        // 将 原哈希表 中的所有元素搬运到 新数组 中
        for (int i = 0; i < array.length; i++) {
            for (Node cur = array[i];cur != null;cur = cur.next){
                int index = cur.key % newArray.length;
                Node newNode = new Node(cur.key,cur.value);
                newNode.next = newArray[index];
                newArray[index] = newNode;
            }
        }
        // 新数组代替原来数组
        array = newArray;
    }

    // 查找元素
    // 根据 key 查找指定元素
    // 如果找到返回对应 value,否则 返回 null
    public Integer get(int key){
        // 1.先计算出 key 对应的下标
        int index = hashFunc(key);
        // 2.根据下标找到对应的链表
        Node list = array[index];
        // 3.在链表中查找指定元素
        for (Node cur = list;cur != null;cur = cur.next){
            if(cur.key == key){
                return cur.value;
            }
        }
        return null;
    }

    // 删除元素
    public void remove(int key){
        // 1.先计算出 key 对应的下标
        int index = hashFunc(key);
        // 2.根据下标找到对应的链表
        Node list = array[index];
        // 3.在链表中找到指定元素
        for (Node cur = list;cur != null;cur = cur.next){
            if(cur.next.key == key){
                cur.next = cur.next.next;
            }
        }
    }
}

性能分析:

虽然哈希表一直在和冲突做斗争,但在实际使用过程中,我们认为哈希表的冲突率是不高的,冲突个数是可控的,也就是每个桶中的链表的长度是一个常数,所以,通常意义下,我们认为哈希表的插入 / 删除 / 查找时间复杂度是 O(1)

和 Java 类集的关系

  1. HashMap 和 HashSet 即 java 中利用哈希表实现的 Map 和 Set
  2. java 中使用的是哈希桶方式解决冲突的
  3. java 会在冲突链表长度大于一定阈值后,将链表转变为搜索树(红黑树)
  4. java 中计算哈希值实际上是调用的类的 hashCode 方法,进行 key 的相等性比较是调用 key 的 equals 方法;所以如果要用自定义类作为 HashMap 的 key 或者 HashSet 的值,必须覆写 hashCode 和 equals 方法,而且要做到 equals 相等的对象,hashCode 一定是一致的

相关文章