float表设计长度_Hash函数设计及面试题分析

哈希函数基本工作中每天都在使用,独特的数据结构以及复杂的设计原则成为了面试中的重点考点,所以这个系列致力于梳理java中关于hash表的大部分知识点,如有疏漏,欢迎交流,会进行补充。

为了不耽误读者时间,每一篇文章都会列出文章核心知识点。

面试题,收录面试题有时候会将公司列出来,都是收集整理的大厂原题 :

1.Hash是什么?java中你了解哪些Hash表,谈谈(朋友最近去游族面试真题)

2.java中常见类型以及自定义对象的hash函数如何实现?

3.除了链地址法,你还了解哪些处理hash冲突的方法?(网易)

4.为什么HashTable的初始容量设计为11,扩容为何采用原容量*2+1,保持奇数呢?

5.为什么HashMap的容量转变为 2 次幂?

6.为什么要HashMap的hash函数要增加扰动函数,并且位数为16?

7.负载因子为什么是0.75?

8.hashcode和equals方法的作用以及如何重写?

一、哈希函数相关概念

1.hash定义

把任意长度的输入通过散列算法变换成固定长度的输出,该输出就是散列值。

2.hash函数设计原则

良好的hash函数应该是哈希值更加均匀,能够减少哈希冲突次数,提升哈希表的性能。

3.回顾位运算

(1)位运算为什么快?

位运算是汇编级的代码,位运算汇编级执行速度是很快的。

如果只是数值交换,正常情况不用位运算,这点速度提高没意义,而且代码不直观。

口说无凭,写了一段测算代码:

一、测试代码public class BitAndModulusTest {    @Test    public void bit() {        //分别取值10万、100万、1000万        int number = 10000 * 10;        int a = 1;        long start = System.currentTimeMillis();        for(int i = number; i > 0 ; i++) {            a &= i;        }        long end = System.currentTimeMillis();        System.out.println("位运算耗时:" + (end - start));    }    @Test    public void modulus() {        //分别取值10万、100万、1000万        int number = 10000 * 1000;        int a = 1;        long start = System.currentTimeMillis();        for(int i = number; i > 0; i++) {            a %= i;        }        long end = System.currentTimeMillis();        System.out.println("取模运算耗时:" + (end - start));    }}二、测试结果:(时间单位:毫秒)  计算次数     位运算      取模运算    倍数(位运算:取模运算)  10万:       1378      14609    10  100万:       1605      14716    9  1000万:      1637      17669    10三、结论  位运算确实比取模运算快得多,大约快了10倍,当然不同配置机器会有出入。

(2)常见位运算

符号描述运算规则
&同1为1,反之为0
|有1为1,反之为0
^异或相同为0,反之为1
~取反0变1,1变0
<<左移各二进位全部左移若干位,高位丢弃,低位补0
>>右移各二进位全部右移若干位,对无符号数,高位补0,有符号数,各编译器处理方法不一样,有的补符号位(算术右移),有的补0(逻辑右移)

二、java中常见类型以及对象的hash值

java中的hash值都是是32位的int类型

1.Integer和Short类型hash值就是当前值

//Integer 直接返回当前值public static int hashCode(int value) {    return value;}//Shortpublic static int hashCode(short value) {    return (int)value;}

2.Float类型hash值:将存储的二级制格式转为整数值

浮点数在计算机存储的格式为二级制,hash值是将其该二级制转换为32位整数

(1)Java中如何获得浮点数的hash值以及对应的二进制呢?

//Float.floatToIntBits:将浮点数在计算机中存储的2进制转换为10进制;int hash  = Float.floatToIntBits(1.4f);//Integer.toBinaryString :将10进制转换为2进制String binaryStr = Integer.toBinaryString(hash);System.out.println("hash值:"+hash);System.out.println("二级制字符串:"+binaryStr);输出:hash值:1068708659二级制字符串:111111101100110011001100110011//采用JDK中float的hashcode方法:Float f = new Float(1.4f);System.out.println(f.hashCode());输出:1068708659

3.Long及Double类型hash值:高32位与低32为进行异或运算

由于Long本身就是整数,但它是64位,固只能取其中32位。如果只取前32 或后32 位,并不能保证其高低位的特征,并且容易导致hash值高位或低位相同的不同key发生hash冲突。

问题在于如何最大化的保证其特征,降低冲突。JDK中的做法是将高低位进行异或运算  (HashMap的扰动函数做法相同)

Float类型public static int hashCode(long value) {    //无符号右移再与原值进行异或运算    return (int)(value ^ (value >>> 32));}
Double类型public static int hashCode(double value) {    long bits = doubleToLongBits(value);    return (int)(bits ^ (bits >>> 32));}

计算:

3b9a35316c848db58f961e738ab06463.png

为什么采用异或而不是其他的位运算?

如果用与运算:如果高32位全是1,那么结果就是低32位,无法保证高低位的特征。

同理如果用或运算:若高32位全是1,结果就是高32位,同样无法保证高低位的特征。

但采用异或运算便可以最大化的保证让高低32位都参与运算,保证特征,降低hash冲突。

4.String类型hash值,每一位char都与31相乘并循环累加。

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;}

素数的魔力

为什么采用31,31是一个奇素数?

参考这篇文章:

https://www.cnblogs.com/nullllun/p/8350178.html#autoid-3-1-0

大致意思:选择数字31是因为它是一个奇质数,如果选择一个偶数会在乘法运算中产生溢出,导致数值信息丢失,因为乘二相当于移位运算。选择质数的优势并不是特别的明显,但这是一个传统。同时,数字31有一个很好的特性,即乘法运算可以被移位和减法运算取代,来获取更好的性能:31 * i == (i << 5) - i,现代的 Java 虚拟机可以自动的完成这个优化。

5.Boolean:  true为1231,false为1237,并没有什么好说的。

public static int hashCode(boolean value) {    return value ? 1231 : 1237;}

6.Object:与内存地址有关

public native int hashCode();

7.自定义对象hash值

如果没有覆盖hashcode方法,则使用内存地址进行运算获得hash值;

public class Student {    private int age;    private String name;    private float height;    public Student(String name, int age, float height) {        this.age = age;        this.name = name;        this.height = height;    }    @Override    public int hashCode() {        int ageHash = Integer.hashCode(age);        int heightHash = Float.hashCode(height);        int nameHash = (name == null || name == "") ? 0 : name.hashCode();        //利用String的hash值计算的特性;        int result = ageHash * 31 + heightHash;        result = result*31+nameHash;        return result;    }}

三、Hash冲突常见的解决方案

Hash冲突的常见处理方式:

1.链地址法  HashMap,ConcurrentHashMap

比如通过链表将同一个桶位置的元素连接起来

2.开放地址法

按照一定规则向其他地址探测,直到遇到空桶。

3.再Hash法  ThreadLocalMap中采用

设计多个hash函数

四、hashcode和equals

hash值一样,一定位于同一个bucket中,用链表;

hash值不一样,有可能与运算得到的索引一样,位于同一个bucket;

hashcode使用:定位索引

equals:索引相同,比较对象是否相同

不实现equals方法,对象默认比较内存地址

不实现hashcode,对象默认比较内存地址

Person p1 = new Person(13,170,"老大");Person p2 = new Person(13,170,"老大");

(1)只实现equals,不实现hashcode

p1和p2的hashcode基于内存计算一定不一样,但他们的hash值对数值与运算后的所有可能一样,可能不一样;

如果索引一样,equals比较后发现是同一个对象,则覆盖;size 为1;

如果索引不一样,则放在不同的bucket;size为2;

(2)只实现hashcode,不实现equals

p1和p2的hash值一样;对数值长度进行与运算后获得索引一样;放在同一个bucket;size=1;

默认equals方法比较内存地址,发现地址不同,p2放在p1后面。size =1;

(3)同时实现hashcode和equals的场景

如果认定对象中成员变量相同就为同一个key,则必须同时实现hashcode和equals方法;

五、Hash表容量的设计原则

Hash表中影响效率的最主要两个参数就是容量以及负载因子,重点探讨这两个参数的设计原则。

Hash表用来保存数据,首先想到的肯定是数据应该被放在哪个桶位,如果定位这个桶。

常规思路:

1.先生成key的hash值(注意java里面hash值都是int类型)

2.让这个hash值与数组长度进行取模,生成索引值

public int getIndex(Object key){    return  hashcode(key)%table.length;}

1.HashTable采用取模运算的数组长度设计

HashTable的特点:

初始容量为11,扩容算法为 原容量*2+1, 保证数组容量始终都是一个素数或奇数。

为什么要这样设计呢?

先说结论:素数或奇数作为hash表的长度,做取模运算能够使得hash分布更加均匀。

2.HashMap容量设计为何转变为2次幂,以及扰动函数的作用?

(1)容量为2的幂次方原因

权威解答参考连接,HashMap的作者Josh Bloch的回答,总结来看即位运算性能较取模运算更高。

如果采用位运算的话数组长度应该如何设计?为什么要设计为2的幂,如何不设置为2的幂会怎样?

下面结合资料以及自身的思考谈谈我的理解:

假设数组长度是15,对应的二级制为:0000 0000 0000 0000 0000 0001 1110key1的hash值假设为1111 1111 1111 1111 1111 1111 1110与15做与运算结果:0000 0000 0000 0000 0000 0000 1110key2的hash值假设为:1111 1111 1111 1111 1111 1111 1111与15做与运算结果:0000 0000 0000 0000 0000 0000 1110结果相同,造成冲突;究其原因无论key的hash是多少,最后一位始终是0,数组的一半空间被浪费。最佳应是每一位都是1,如下形式1111 1111 1111 1111 1111 1111 1111        思考下,这种形式就是  2的幂次方 -10        2^0-1(这种不用考虑,数组容量设置为0干啥)1        2^1-111       2^2-1111      2^3-11111     2^4-111111    2^5-111...11  2^n-1所以将数组的长度设置为2的幂次方后数组的利用率达到最大。

采用位运算的来计算索引,与(2^n-1)做与运算,结果就是自身,且一定小于 2^n-1:

public int getIndex(Object key){    return  hashcode(key)&(table.length-1);}

另外HashMap的容量一般都不会特别大,比如一般不会超过2的16次方,及65536,

假设数组长度为2的10次方,对应的二级制为:0000 0000 0000 0000 0011 1111 1111 1111假设key1的hash值为:1010 0011 0001 1010 1110 1110 0101 0011计算结果:0000 0000 0000 0000 0000 1110 0101 0011可以看到只有最后10位是有效位,参与了运算。这种情况下很多key的hash值后10为可能相同,但是前面22位却可能不同,但他们的索引是相同的,这就造成了冲突。所以需要将key的hash中所有位都能参与运算,最大的保证key的特性。

结论:

1.位运算比求余算法更快。 

2.hashcode(key)%table.length 等价于 hashcode(key)&(table.length-1)(数组长度为2次幂)

(2)扰动函数的作用

HashMap使用2的幂作为按位,并且比使用模数更快。但仅仅采用简单hash函数很容易造成hash冲突,原因如下:

key1 = "德玛" 假设其hash值为:0101 1111 0000 1011 0111 0011 1011 0011key2 = "提莫" 假设其hash值为:1100 1011 1010 0000 0100 11111 1011 0011假定数组长度为2的10次方,即1024二级制为:0000 0000 0000 0000 0000 0011 1111 1111计算key1的index:0101 1111 0000 1011 0111 0011 1011 0011 &    0000 0000 0000 0000 0000 0011 1111 1111结果:0000 0000 0000 0000 0000 0011 1011 0011可以看到结果仅仅是保留低10位; 计算key2的index:1100 1011 1010 0000 0100 11111 1011 0011 &    0000 0000 0000 0000 0000 0011 1111 1111结果:0000 0000 0000 0000 0000 0011 1011 0011同样看到结果仅仅是保留低10位;key1和key2的结果是一样的,就造成了hash冲突,原因就在于两个key的高位并没有参与运算,仅仅是保留了低位,所以说模数为2的幂对低位非常敏感。

HashMap增加了扰动函数,让高低16位进行异或运算,最大化的保留了高低位的特征。

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

为什么要采用异或运算^,而不是与运算&或者或运算| 呢?上面第二节已说明,不再赘述。

六、负载因子的设计原则

该元素表示数组实际存储元素占据数组容量比例,若该比例达到负载因子,则进行扩容。

负载因子多少是合适的呢?

看HashMap和HashTable源码的doc解释,面试的时候回答这些应该够用了,

通常,默认负载因子(.75)在时间和空间成本之间提供了一个很好的权衡。较高的值会减少空间开销,但会增加查找成本(在HashMap类的大多数操作中都得到体现,包括get和put)。设置其初始容量时,应考虑映射中的预期条目数及其负载因子,以最大程度地减少重新哈希操作的次数。如果初始容量大于最大条目数除以负载因子,则将不会进行任何哈希操作。

其实这个数字的选择在其他语言中并不是同一的,比如 Java 是 0.75,Go 中是 0.65,Dart 中是0.8,python 中是0.762,不同场景也会不同,但java设计的思路肯定是保证通用性。

实际上,根据我的计算,“完美”的负载系数更接近对数2(〜0.7)。尽管任何小于此的负载因子都会产生更好的性能。我认为.75可能已被取消。

证明:

通过预测存储桶是否为空,可以避免链接并利用分支预测。如果存储桶为空的可能性超过0.5,则该存储桶可能为空。

让s代表大小,n代表增加的键数。使用二项式定理,存储桶为空的概率为:

P(0) = C(n, 0) * (1/s)^0 * (1 - 1/s)^(n - 0)

因此,如果少于

log(2)/log(s/(s - 1)) keys

当s达到无穷大并且如果添加的键数使得P(0)= .5时,则n / s迅速接近log(2):

lim (log(2)/log(s/(s - 1)))/s as s -> infinity = log(2) ~ 0.693...

七、谈一谈你了解的java中Map实现及异同

JDK中有哪些Map?

HashMap

LinkedHashMap(经常会问到手写LRU)

TreeMap (一致性hash的实现)

WeakHashMap

IdentityHashMap

ThreadLocalMap(ThreadLocal的实现)

ConcurrentHashMap

后面会对这些Map逐一进行比较分析。

引用资料:

1、HashMap为什么需要更好的hashcode算法?

https://www.javaspecialists.eu/archive/Issue054-HashMap-Requires-a-Better-hashCode---JDK-1.4-Part-II.html

2、为什么hash函数采用素数作为容量更好?

https://stackoverflow.com/questions/1145217/why-should-hash-functions-use-a-prime-number-modulus

3、负载因子的取值原因?

https://stackoverflow.com/questions/10901752/what-is-the-significance-of-load-factor-in-hashmap

https://www.javacodegeeks.com/2015/09/an-introduction-to-optimising-a-hashing-strategy.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值