《java数据结构》--哈希表

概念

在之前我们学习的数据结构中,查找的时间复杂度大多都是O(N),或者O(logN),二叉搜索树在稳定的情况下可以达到O(1),但是还是会有极端情况为O(logN),那么有没有一种较为稳定的查找效率为O(1)的数据结构呢?

我们可以先思考一下,理想的搜索方法是怎么样的,搜索的效率主要取决与比较的次数,那么这种方法就应该不经过任何比较,一次直接从表中得到要搜索的元素。

如果构造一种存储结构,通过某种函 数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快 找到该元素,想要插入元素就根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。想要搜索元素就对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若 关键码相等,则搜索成功。

这种方法确实存在,就是哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)

举个例子:

设置哈希函数为:hash(key) = key % capacity;capacity为总空间大小

《java数据结构》--哈希表_第1张图片

如果用这种方法我们要查找7只需要通过哈希函数就可以直接得到7所在位置,不需要进行多次比较。不过你可能会有疑问,按照上述哈希方式,向集合中插入元素13,会出现什么问题?13%12 =1;可是1位置已经有元素了。

哈希冲突

不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞。

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

哈希函数的设计

一个好的哈希函数一个有以下特点:

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

常用的方法有两种:

直接定制法

用关键字的某个线性函数,比如Hash(Key) = A*Key + B

优点:简单,均匀

缺点:需要事先知道关键字的线性函数(分布情况),条件较为局限

这种方法适合查找比较小且连续的情况

除留余数法

就是我们刚刚例子里用的方法,设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数,按照哈希函数: Hash(key) = key% p(p将关键码转换成哈希地址)

除了这两种方法还有很多设计哈希函数的方法,这里就不一一阐述了

哈希函数设计的越精妙,发生哈希碰撞的概率越小,但是依然不可避免

冲突避免

负载因子调节

哈希表中利用一种变量来衡量冲突率,就是负载因子,哈希表的负载因子的定义是:

负载因子(a) = 插入表中的元素个数  / 表的长度(总空间大小)

如果不扩容的话,表的长度就是一定的,负载因子就和“插入表中的元素个数”成正比,所以,负载因子越大,表明插入表中的元素越多,冲突产生的概率越大;反之,表明插入表中的元素越少,冲突产生的概率越小。

冲突率和负载因子的关系大致如下:

《java数据结构》--哈希表_第2张图片

//负载因子一般不应超过0.75

所以当冲突率达到一个无法忍受的程度时,我们需要通过降低负载因子来变相的降低冲突率。 但是哈希表中已有的关键字个数是不可变的,那我们能调整的就只有哈希表中的数组的大小。

冲突解决

解决哈希冲突两种常见的方法是:闭散列和开散列

闭散列(开放定址法)

当发生哈希冲突时,如果表中还有位置,那么就可以将冲突的元素key放到冲突位置的下一个空位置中去。那么如何找到下一个空位置呢?

方法有两种:

线性探测:

如果发生冲突,就从冲突的位置开始依次向后探测,直到找到下一个可以存放的空位置为止。通过哈希函数获取待插入元素在哈希表中的位置 如果该位置中没有元素则直接插入新元素,如果该位置中有元素发生哈希冲突,使用线性探测找到 下一个空位置,插入新元素

//注意采用这种方法时,不可以随便删除表中的元素,如果直接删除元素可能会影响到其他元素的查找,比如有两个数1和11发生哈希冲突那么11会插到1后面一个空位置里,如果直接删除1那么11的位置就找不到了。所以线性探测采用标记的伪删除法来删除一个元素。

伪删除大概方法就是,将表中的每一个位置加上一个状态,状态有三种,空,有值,删除,删除状态就是这个位置之前有值但是现在被删除了,但是这个位置的值并没有改变,而是将状态设定为删除,这样我们进行其他操作就能知道那些时不用选中的了,这样我们即完成了删除又可以找到另一个冲突的值。

线性探测的优点就是简单,缺点是容易发生连锁冲突,比如1和11发生哈希冲突那么11可能就会放在2的位置但是,如果之后又有一个数通过哈希函数需要放到2的位置,那么就又发生了冲突,当数据比较多时会使搜索的效率大大降低

二次探测:

二次探测为了避免线性探测的问题,找下一个空位置的方法为:

下一个空位置(H) = (  冲突位置(N)  +  i的平方  )  % m 或者

下一个空位置(H) = (  冲突位置(N)  +  i的平方  )  % m

其中i=1 2 3 4 ....,m是表的长度,二次探测的增长量是以二次方增长的 第一次向后找1个位置然后是2的平方个位置,然后是3的平方个位置,这样就可以跳过很多位置,从而使关键字在哈希表中更加分散

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

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

开散列(开链法)

我们可以思考这样一个问题,为什么会发生冲突,因为有两个元素通过哈希被放到了同一个位置,而一个位置只能放一个元素,既然这样,那么我们让一个位置可以放多个数据不就好了,需要某个数据,在其所在位置查找就行。

这种方法就是开散列,将具有相同地址的关键码放到同一个子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来(哈希桶),各链表的头结点存储在哈希表中。

这里画一个图来举个例子:

hash(key) = key % capacity;capacity为总空间大小

《java数据结构》--哈希表_第3张图片

从图中可以看出来,每一个桶(链表)里都是冲突的元素,可以看到我们将大集合搜索的问题,转化为小集合搜索的问题,这样就大大提高了效率,但是如果小集合的搜索效率本身就不高呢?比如一个桶中的冲突元素很多,这个时候我们就要对小集合再次转化,比如每一个桶不一定非要是链表,也可以是另一个哈希表,或者是一棵搜索树,再或者可以灵活一点,当链表的元素大于8时会变成一棵搜索树等等。

实现哈希桶

创建哈希桶

每一个结点的数值域包含两个,key是用来查找val的,val是我们具体要存的值

 static class Node{
        int key;
        int val;
        Node next;
        public Node(int key,int val){
            this.key = key;
            this.val = val;
        }
    }
    private Node[] array;
    private int Usedside;
    public HashBuck(){
        array = new Node[20];
        this.Usedside = 0;
    }

扩容

扩容我们直接定义一个新的数组,大小为原来的两倍然后将原来的数据从新拷贝过来就行,那么怎么拷贝?直接 Array = NewArray,就可以吗?,当然不是,如果只是简单的将原来的数据拷贝过来,这时数组大小扩容总空间变大,根据我们的哈希函数 hash(key) = key % capacitycapacity变大,所以每个元素在新数组所对应的哈希值也会变。所以正确的做法应该是遍历原来的数组中的每个元素,将所有元素《重新哈希》(这里我用的是头插法作为链表的插入方法)

private void resize(){
    //扩容
    Node[] newArr = new Node[array.length * 2];
    //遍历原来的数组,将所有元素《重新哈希》(因为每个链表的头节点所在下标可能在扩容之后改变位置)
    for (int i = 0; i < array.length; i++) {
        Node cur = array[i];
        //遍历当前链表,复制到新数组所对应下标位置
        while (cur != null){
            //记录当前节点的下一个节点
            Node curNext = cur.next;
            //该链表所在新数组的下标
            int newIndex = cur.key / newArr.length;
            //头插
            cur.next = newArr[newIndex];
            newArr[newIndex] = cur;
            cur = curNext;
        }
    }
    //array这个引用指向了newArr这个引用所指向的对象
    array = newArr;
}

插入

首先要通过哈希函数将key值变成我们要的哈希值,因为哈希表内不能有重复的值所以要先遍历一遍链表,看看有没有当前的key值,然后再利用头插的方法插入元素

注意:在插入元素之后我们要判断一下当前的负载因子,如果过高(我这里设定是0.75)则需要扩容

 private Double loadFactor(){
        return (Usedside * 1.0) / array.length;
    }
    public void put(int key,int val){
        int index = key % array.length;
        Node cur = array[index];
        Node node = new Node(key,val);
        //头插
        //先遍历一遍链表,看看有没有存在当前key
        while (cur != null){
            //修改
            if(cur.key == key){
               cur.val = val;
              return;
           }
        }
        node.next = array[index];
        array[index] = node;
        Usedside++;
        if(loadFactor() > 0.75){
            resize();
        }

获得元素

通过key得到对应的哈希值然后去对应位置查找就行

  private int get(int key){
        int index = key / array.length;
        Node cur = array[index];
        while (cur != null){
            if(cur.key == key){
                return cur.val;
            }
            cur = cur.next;
        }
        return -1;
    }

性能分析

虽然我们用大量篇章再将冲突,但在实际使用中,我们认为哈希表的冲突率是不高的,冲突个数是可控的, 也就是每个桶中的链表的长度是一个常数,所以我们一般认为哈希表的插入/删除/查找时间复杂度是 O(1) 。

到这里我们的哈希表就聊完了,如果有什么疑问或者其他见解欢迎在下方评论或者私信博主!!

你可能感兴趣的:(数据结构,散列表,java,开发语言,学习,链表,哈希算法)