散列表的概念理解以及Java HashMap原理分析(学习笔记)

散列表的概念理解以及Java HashMap原理分析(学习笔记)

数组下标索引(基地址+偏移量)是最快速的访问方式,前提是我明确知道下标。但我可能只知道对象的唯一标识(关键字)来查找,散列表就是一种不错的方式。

注解:C语言结构体在编译的时候会把对成员的访问转化为偏移量。

1.散列函数

同一关键字一定计算出同一个散列值,不同关键字有几率计算出同一个散列值,这种情况称为冲突/碰撞。散列值可以理解为数组下标。散列值应尽可能的均匀分布,减少碰撞的发生。

1.1 散列函数的构造方法

(1)模除(除留余数法)
hash=key%n,结果可以保证值在[1,n-1]范围内。n<tableSize and n为质数。
(2)直接定址法
hash=a*key+b,这是一个一次函数,散列值之间最小差值也是a,而且key可能分布不均匀,导致空间不连续,浪费空间。如果表太小还可能越界,保证数组大小=最大散列值+1。优点是绝对不会冲突。
(3)平方取中法
先对关键字求平方,再求中间几位数。至于选择几位要看散列表大小,不能越界。优点是散列值分布比较均匀,不容易冲突。
(4)数字分析法
适用于key有几位是不均匀的,而其他位是比较均匀的,这时候只选择均匀的几位作为散列地址,减少冲突的可能。
(5)折叠法
先把关键字按照位数n分成几部分,前几个结果的位数都是n,最后一个的结果的位数<=n,因为关键字位数可能不是n的整数倍。方法一:移位叠加这几部分。移位叠加是右对齐相加(最低位对齐),去掉进位,保证结果位数为n。方法二:间界叠加,对于第i个部分( i<=i<=s ),如果i为偶数则把这部分反序,再移位叠加。如图:
在这里插入图片描述折叠法适用于每位分布都比较均匀,而且关键字位数很多的情况,折叠能减少位数。

2.处理冲突

不同关键字有几率计算出同一个散列值,冲突是难以避免的,在冲突发生时候依然要正确存取,需要引入解决方法。

发生碰撞的不同关键字叫同义词。

2.1 拉链法

(1)把同义词存储在链表里。插入删除查找时找到相应的链表进行插入删除查找。
(2)在链表中查询的时候需要比较关键字是否相等,因为有碰撞的可能。

举个表大小为5的例子(为了更快速的插入,选择带尾指针的单链表,或者直接插入在头部也可以):
在这里插入图片描述
(3)插入复杂度O(1),删除,查询均需要定位到节点,最好的情况下复杂度为O(1),最坏的情况下为O(n)
(4)高效的插入并且链表删除不需要移动元素,使得拉链法适用于经常插入删除的情况。

2.2 开放地址法

存放新表项的空闲地址即为它的同义词开放,也为它的非同义词开放。

公式为Hi=(H(key)+di)%m,di为增量序列,m为表长。

2.2.1 线性探测法

增量序列:d=0,1,2,…,m-1
冲突时顺序查看下一个元素(环形有方向)

插入时候,如果发现碰撞,则顺序访问下一个元素,要么找到空闲地址,要么遍历全表没有找到,操作失败。
查询时候,如果发现碰撞,则顺序访问下一个元素,要么找到空闲地址查询失败,要么遍历全表没有找到查询失败,要么查找到了元素。

缺点是可能导致大量元素需要查看下一个或几个散列地址,这样连环下去,产生堆积/聚集问题,遍历次数增多,降低效率

举个例子:
在这里插入图片描述
查询性能分析,设查询成功次数为s(α),不成功次数为f(α),装填因子α(表中记录数/表长度)(占用率)
在这里插入图片描述

可见随着装填因子增大,查找/插入次数增加,而且增加越来越快,这应该和聚集有关。

2.2.2 平方探测法/二次探测法

增量序列:pow(0,2),pow(1,2),-pow(1,2),pow(2,2),-pow(2,2),…,pow(k,2),-pow(k,2) k<=m/2 要求m=4k+3且为素数 ( pow(x,y)表示x^y )

冲突时前后有间隔跳跃式查看单元,缺点是不能探测所有单元,但至少能探测一半单元,有可能插入时表还未满但是找不到一个符合条件的空闲地址了。优点是可以避免堆积

2.2.3 再散列法

需要第二个散列函数,记为H2,记冲突次数此处为i。(0<=i<=m-1)

Hi=(H(key)+i*H2(Key))%m

优点是不容易产生堆积

2.2.4 伪随机数法

di是一个伪随机数序列

3. 开放定址法散列表的删除

(1)不能直接删除元素,会中断‘线索’,只能假删除,对散列地址做一个无效标志,查找时候忽略,但是插入时候可以使用,使这个地址再生效。
(2)当删除元素比较多的时候可以定期维护一下,整体处理,加快以后使用的效率。

4. Java HashMap

(1)Java中所有对象都有一个hashCode方法,返回类型为int,默认情况下(Object类)为对象存储地址,对于Integer对象返回值为本身,对于Long对象,先右移32位再强制转换为int,右移是补0而不考虑符号

Integer.java(jdk1.8)中:

public static int hashCode(int value) {
        return value;
    }

Long.java(jdk1.8)中:

public static int hashCode(long value) {
        return (int)(value ^ (value >>> 32));
    }

String.java(jdk1.8)中:

public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            char val[] = value;

            for (int i = 0; i < value.length; i++) {
                h = 31 * h + val[i];
            }
            hash = h;
        }
        return h;
    }

字符串求hashCode是先自乘31再加上char数组对应元素值(默认值为0)。
自已写的类默认继承自Object,可以重写hashCode方法,通常也需要重写equals方法。

(2)hashCode后面还需要一个hash方法

HashMap.java(JDK1.8)中:

static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

hash方法把hash值和hash值右移16位结果按位求与运算。

(3) 定位,求散列值

HashMap.java(JDK1.7)中:

static int indexFor(int h, int length) {
        return h & (length-1);
    }

h为hash方法返回值,和表长-1按位求与,结果肯定小于表长,我觉得位运算的效率是比较快的。这里的index也称为桶索引(bucketIndex)。

(4)jdk中键值对的存储使用了Entry对象,泛型表示为Entry<K,V>,里面记录了key(键),value(值),next(链表下一个节点),hash(key的哈希值,不是数组下标)。根据键
获取键值对的方式如下:

HashMap.java(JDK1.7)中

final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }
        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

根据indexFor方法返回的数组下标遍历链表,找到hash相同且key相同的Entry返回,没有返回null。这也是为什么我上面说需要重写equals方法的原因了。

根据键查询值就是调用getEntry方法后再获得value即可。

(5)插入键值对,代码如下:

如果是新建项:

HashMap.java(JDK1.7)中

void createEntry(int hash, K key, V value, int bucketIndex) {
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<>(hash, key, value, e);
        size++;
    }

可见是插入在表头了,新节点的next域指向原来的头结点。

如果是已有项,则会修改(遍历桶中链表试图找到对应Entry):

HashMap.java(JDK1.7) put方法中:

    int hash = hash(key);  
    int i = indexFor(hash, table.length);  
    for (Entry<K,V> e = table[i]; e != null; e = e.next) {  
        Object k;  
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {  
            V oldValue = e.value;  
            e.value = value;  
            e.recordAccess(this);  
            return oldValue;  
        }  
    }  

实际上在插入过程中可能会扩容的,当表长度=容量*加载因子时,再插入就会扩容重新分配桶,代码略。

可见JDK1.7是用拉链法实现的散列表,散列函数是多个步骤的,hashCode->hash->按位求与。但是1.8中引入了红黑树进一步增加效率,以后会学习和发表这个内容。

转载请注明出处,谢谢合作

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值