百摩网
当前位置: 首页 生活百科

一串字符的hash值(面试官问为什么)

时间:2023-08-15 作者: 小编 阅读量: 2 栏目名: 生活百科

从网上的资料来看,一般有如下两个原因:第一31是一个不大不小的质数,是作为hashCode乘子的优选质数之一。一般在设计哈希算法时,会选择一个特殊的质数。也就是说,哈希值会分布在一个较小的数值区间内,分布性不佳,最终可能会导致冲突率上升。如果用int类型表示哈希值,结果会溢出,最终导致数值信息丢失。选择质数的优势并不是特别的明显,但这是一个传统。

一串字符的hash值?优质文章,及时送达作者 | 田小波,下面我们就来说一说关于一串字符的hash值?我们一起去了解并探讨一下这个问题吧!

一串字符的hash值

优质文章,及时送达

作者 | 田小波

链接 | http://www.tianxiaobo.com

某天,我在写代码的时候,无意中点开了 String hashcode 方法。然后大致看了一下 hashCode 的实现,发现并不是很复杂。但是我从源码中发现了一个奇怪的数字,也就是本文的主角31。这个数字居然不是用常量声明的,所以没法从字面意思上推断这个数字的用途。后来带着疑问和好奇心,到网上去找资料查询一下。在看完资料后,默默的感叹了一句,原来是这样啊。那么到底是哪样呢?在接下来章节里,请大家带着好奇心和我揭开数字31的用途之谜。

选择31的原因

在详细说明 String hashCode 方法选择数字31的作为乘子的原因之前,我们先来看看 String hashCode 方法是怎样实现的,如下:

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 * hval[i];}hash = h;}return h;}

上面的代码就是 String hashCode 方法的实现,是不是很简单。实际上 hashCode 方法核心的计算逻辑只有三行,也就是代码中的 for 循环。我们可以由上面的 for 循环推导出一个计算公式,hashCode 方法注释中已经给出。如下:

s[0]*31^(n-1)s[1]*31^(n-2)...s[n-1]

这里说明一下,上面的 s 数组即源码中的 val 数组,是 String 内部维护的一个 char 类型数组。这里我来简单推导一下这个公式:

假设 n=3i=0 -> h = 31 * 0val[0]i=1 -> h = 31 * (31 * 0val[0])val[1]i=2 -> h = 31 * (31 * (31 * 0val[0])val[1])val[2]h = 31*31*31*031*31*val[0]31*val[1]val[2]h = 31^(n-1)*val[0]31^(n-2)*val[1]val[2]

上面的公式包括公式的推导并不是本文的重点,大家了解了解即可。接下来来说说本文的重点,即选择31的理由。从网上的资料来看,一般有如下两个原因:

第一 31是一个不大不小的质数,是作为 hashCode 乘子的优选质数之一。另外一些相近的质数,比如37、41、43等等,也都是不错的选择。那么为啥偏偏选中了31呢?请看第二个原因。

第二 31可以被 JVM 优化,31 * i = (i << 5) - i

上面两个原因中,第一个需要解释一下,第二个比较简单,就不说了。下面我来解释第一个理由。一般在设计哈希算法时,会选择一个特殊的质数。至于为啥选择质数,我想应该是可以降低哈希算法的冲突率。至于原因,这个就要问数学家了,我几乎可以忽略的数学水平解释不了这个原因。上面说到,31是一个不大不小的质数,是优选乘子。那为啥同是质数的2和101(或者更大的质数)就不是优选乘子呢,分析如下。

这里先分析质数2。首先,假设n = 6,然后把质数2和 n 带入上面的计算公式。并仅计算公式中次数最高的那一项,结果是2^5 = 32,是不是很小。所以这里可以断定,当字符串长度不是很长时,用质数2做为乘子算出的哈希值,数值不会很大。也就是说,哈希值会分布在一个较小的数值区间内,分布性不佳,最终可能会导致冲突率上升。

上面说了,质数2做为乘子会导致哈希值分布在一个较小区间内,那么如果用一个较大的大质数101会产生什么样的结果呢?根据上面的分析,我想大家应该可以猜出结果了。就是不用再担心哈希值会分布在一个小的区间内了,因为101^5 = 10,510,100,501。但是要注意的是,这个计算结果太大了。如果用 int 类型表示哈希值,结果会溢出,最终导致数值信息丢失。尽管数值信息丢失并不一定会导致冲突率上升,但是我们暂且先认为质数101(或者更大的质数)也不是很好的选择。最后,我们再来看看质数31的计算结果:31^5 = 28629151,结果值相对于3210,510,100,501来说。是不是很nice,不大不小。

上面用了比较简陋的数学手段证明了数字31是一个不大不小的质数,是作为 hashCode 乘子的优选质数之一。

接下来我会用详细的实验来验证上面的结论,不过在验证前,我们先看看 Stack Overflow 上关于这个问题的讨论,Why does Java's hashCode in String use 31 as a multiplier? (地址:http://stackoverflow.com/questions/299304/why-does-Javas-hashcode-in-string-use-31-as-a-multiplier)。其中排名第一的答案引用了《Effective Java》中的一段话,这里也引用一下:

The value 31 was chosen because it is an odd prime. If it were even and the multiplication overflowed, information would be lost, as multiplication by 2 is equivalent to shifting. The advantage of using a prime is less clear, but it is traditional. A nice property of 31 is that the multiplication can be replaced by a shift and a subtraction for better performance: 31 * i == (i << 5) - i. Modern VMs do this sort of optimization automatically.

简单翻译一下:

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

排名第二的答案设这样说的:

As Goodrich and Tamassia point out, If you take over 50,000 English words (formed as the union of the word lists provided in two variants of Unix), using the constants 31, 33, 37, 39, and 41 will produce less than 7 collisions in each case. Knowing this, it should come as no surprise that many Java implementations choose one of these constants.

这段话也翻译一下:

正如 Goodrich 和 Tamassia 指出的那样,如果你对超过 50,000 个英文单词(由两个不同版本的 Unix 字典合并而成)进行 hash code 运算,并使用常数 31, 33, 37, 39 和 41 作为乘子,每个常数算出的哈希值冲突数都小于7个,所以在上面几个常数中,常数 31 被 Java 实现所选用也就不足为奇了。

上面的两个答案完美的解释了 Java 源码中选用数字 31 的原因。接下来,我将针对第二个答案就行验证,请大家继续往下看。

实验及数据可视化

本节,我将使用不同的数字作为乘子,对超过23万个英文单词进行哈希运算,并计算哈希算法的冲突率。同时,我也将针对不同乘子算出的哈希值分布情况进行可视化处理,让大家可以直观的看到数据分布情况。本次实验所使用的数据是 Unix/Linux 平台中的英文字典文件,文件路径为/usr/share/dict/words

哈希值冲突率计算

计算哈希算法冲突率并不难,比如可以一次性将所有单词的 hash code 算出,并放入 Set 中去除重复值。之后拿单词数减去 set.size 即可得出冲突数,有了冲突数,冲突率就可以算出来了。当然,如果使用 JDK8 提供的流式计算 API,则可更方便算出,代码片段如下:

public static Integer hashCode(String str, Integer multiplier) {int hash = 0;for (int i = 0; i < str.length; i) {hash = multiplier * hashstr.charAt(i);}return hash;}/*** 计算 hash code 冲突率,顺便分析一下 hash code 最大值和最小值,并输出* @param multiplier* @param hashs*/public static void calculateConflictRate(Integer multiplier, List<Integer> hashs) {Comparator<Integer> cp = (x, y) -> x > y ? 1 : (x < y ? -1 : 0);int maxHash = hashs.stream.max(cp).get;int minHash = hashs.stream.min(cp).get;// 计算冲突数及冲突率int uniqueHashNum = (int) hashs.stream.distinct.count;int conflictNum = hashs.size - uniqueHashNum;double conflictRate = (conflictNum * 1.0) / hashs.size;System.out.println(String.format("multiplier=M, minHash=d, maxHash=d, conflictNum=m, conflictRate=%.4f%%",multiplier, minHash, maxHash, conflictNum, conflictRate * 100));}

结果如下:

从上图可以看出,使用较小的质数做为乘子时,冲突率会很高。尤其是质数2,冲突率达到了 55.14%。同时我们注意观察质数2作为乘子时,哈希值的分布情况。可以看得出来,哈希值分布并不是很广,仅仅分布在了整个哈希空间的正半轴部分,即 0 ~ 231-1。而负半轴 -231 ~ -1,则无分布。

这也证明了我们上面断言,即质数2作为乘子时,对于短字符串,生成的哈希值分布性不佳。然后再来看看我们之前所说的 31、37、41 这三个不大不小的质数,表现都不错,冲突数都低于7个。而质数 101 和 199 表现的也很不错,冲突率很低,这也说明哈希值溢出并不一定会导致冲突率上升。但是这两个家伙一言不合就溢出,我们认为他们不是哈希算法的优选乘子。最后我们再来看看 32 和 36 这两个偶数的表现,结果并不好,尤其是 32,冲突率超过了了50%。尽管 36 表现的要好一点,不过和 31,37相比,冲突率还是比较高的。当然并非所有的偶数作为乘子时,冲突率都会比较高,大家有兴趣可以自己验证。

哈希值分布可视化

上一节分析了不同数字作为乘子时的冲突率情况,这一节来分析一下不同数字作为乘子时,哈希值的分布情况。在详细分析之前,我先说说哈希值可视化的过程。我原本是打算将所有的哈希值用一维散点图进行可视化,但是后来找了一圈,也没找到合适的画图工具。加之后来想了想,一维散点图可能不合适做哈希值可视化,因为这里有超过23万个哈希值。也就意味着会在图上显示超过23万个散点,如果不出意外的话,这23万个散点会聚集的很密,有可能会变成一个大黑块,就失去了可视化的意义了。

所以这里选择了另一种可视化效果更好的图表,也就是 excel 中的平滑曲线的二维散点图(下面简称散点曲线图)。当然这里同样没有把23万散点都显示在图表上,太多了。所以在实际绘图过程中,我将哈希空间等分成了64个子区间,并统计每个区间内的哈希值数量。最后将分区编号做为X轴,哈希值数量为Y轴,就绘制出了我想要的二维散点曲线图了。

这里举个例子说明一下吧,以第0分区为例。第0分区数值区间是[-2147483648, -2080374784),我们统计落在该数值区间内哈希值的数量,得到 <分区编号, 哈希值数量> 数值对,这样就可以绘图了。分区代码如下:

/*** 将整个哈希空间等分成64份,统计每个空间内的哈希值数量* @param hashs*/public static Map<Integer, Integer> partition(List<Integer> hashs) {// step = 2^32 / 64 = 2^26final int step = 67108864;List<Integer> nums = new ArrayList<>;Map<Integer, Integer> statistics = new LinkedHashMap<>;int start = 0;for (long i = Integer.MIN_VALUE; i <= Integer.MAX_VALUE; i= step) {final long min = i;final long max = minstep;int num = (int) hashs.parallelStream.filter(x -> x >= min && x < max).count;statistics.put(start, num);nums.add(num);}// 为了防止计算出错,这里验证一下int hashNum = nums.stream.reduce((x, y) -> xy).get;assert hashNum == hashs.size;return statistics;}

本文中的哈希值是用整形表示的,整形的数值区间是 [-2147483648, 2147483647],区间大小为 2^32。所以这里可以将区间等分成64个子区间,每个自子区间大小为 2^26。详细的分区对照表如下:

接下来,让我们按照分区,对数字2、3、17、31、101的散点曲线图进行简单的分析。先从数字2开始,数字2对于的散点曲线图如下:

上面的图还是很一目了然的,乘子2算出的哈希值几乎全部落在第32分区,也就是 [0, 67108864)数值区间内,落在其他区间内的哈希值数量几乎可以忽略不计。这也就不难解释为什么数字2作为乘子时,算出哈希值的冲突率如此之高的原因了。所以这样的哈希算法要它有何用啊,拖出去斩了吧。接下来看看数字3作为乘子时的表现:

3作为乘子时,算出的哈希值分布情况和2很像,只不过稍微好了那么一点点。从图中可以看出绝大部分的哈希值最终都落在了第32分区里,哈希值的分布性很差。这个也没啥用,拖出去枪毙5分钟吧。在看看数字17的情况怎么样:

数字17作为乘子时的表现,明显比上面两个数字好点了。虽然哈希值在第32分区和第34分区有一定的聚集,但是相比较上面2和3,情况明显好好了很多。除此之外,17作为乘子算出的哈希值在其他区也均有分布,且较为均匀,还算是一个不错的乘子吧。

接下来来看看我们本文的主角31了,31作为乘子算出的哈希值在第33分区有一定的小聚集。不过相比于数字17,主角31的表现又好了一些。首先是哈希值的聚集程度没有17那么严重,其次哈希值在其他区分布的情况也要好于17。总之,选31,准没错啊。

最后再来看看大质数101的表现,不难看出,质数101作为乘子时,算出的哈希值分布情况要好于主角31,有点喧宾夺主的意思。不过不可否认的是,质数101的作为乘子时,哈希值的分布性确实更加均匀。所以如果不在意质数101容易导致数据信息丢失问题,或许其是一个更好的选择。

写在最后

经过上面的分析与实践,我想大家应该明白了 String hashCode 方法中选择使用数字31作为乘子的原因了。本文本质是一篇简单的科普文而已,并没有银弹。如果大家读完后觉得又涨知识了,那这篇文章的目的就达到了。最后,本篇文章的配图画的还是很辛苦的,所以如果大家觉得文章不错,不妨就给个赞吧,就当是对我的鼓励了。

另外,如果文章中有不妥或者错误的地方,也欢迎指出来。

如果看到这里,说明你喜欢这篇文章,请转发

    推荐阅读
  • 做黄金豆腐用什么豆腐(今天教大家做一道饭店卖得火爆的)

    食材:干黄豆600克,鸡蛋30只配料:油,盐,蒜蓉,白糖,甜辣酱600克干黄豆用清水浸泡8个小时以上,清洗干净,再磨成豆浆,也可以用破壁机打,配3500克清水,打成豆浆后用布袋过滤好,想要做出嫩滑豆腐这步很关键。锅里倒入大量的油,烧至七成热,把豆腐倒入油炸,炸至金黄捞出来摆盘。锅里留底油,把蒜蓉倒入炒香,倒入甜辣酱,再加少许清水和一勺白糖搅拌均匀,烧开后淋在豆腐上就做好了。

  • 迷你世界怎么做模拟飞机(迷你世界做模拟飞机的方法)

    迷你世界怎么做模拟飞机?以下内容希望对你有帮助!接下来在右边的红色方块上放置一层红色硬沙块,看好尺寸。在其左右各放置三个硅石台阶,放置在红色硬沙块的下半格。放置尾部螺旋桨。将对称的两处硅石台阶换成白色硬沙块,在上边放门。

  • 梅花最著名的十首诗(古来梅花诗千篇)

    在宋代之前,虽也有歌咏梅花的诗词,但数量较少,也不广为人知。第一首极品的梅花诗,诞生于杭州西湖的孤山上。欧阳修、陈与义等当世名家,也对其不吝笔墨进行赞赏,承认这首诗是咏梅诗中的绝唱。第二首写梅花的极品诗词,诞生于南宋的陆游之手。这首卜算子,在当时名声并不响亮,却在后世大受好评。

  • 怎么补票高铁站(高铁怎么补票)

    但要注意有下列行为时,除按规定补票,核收手续费以外,铁路运输企业有权对其身份进行登记,并须加收已乘区间应补票价50%的票款。持用伪造或涂改的车票乘车时,除按无票处理外并送交公安部门处理。持站台票上车并在开车20分钟后仍不声明时,按无票处理。旅客持儿童票、学生票、残疾军人票没有规定的减价凭证或不符合减价条件时,按照全价票价补收票价差额。

  • 更新了n卡驱动游戏闪退怎么解决(更新了n卡驱动游戏闪退怎么办)

    更新了n卡驱动游戏闪退怎么解决?我们一起去了解并探讨一下这个问题吧!更新了n卡驱动游戏闪退怎么解决如果显卡的驱动正常,不影响电脑的正常使用,可以不选择更高版本的显卡驱动进行更新。因为高版本的驱动不一定完全适合当前使用的显卡,即使是官方提供的显卡驱动,有可能出现更新驱动之后导致电脑蓝屏、死机等现象。

  • 丰田卡罗拉上防滑灯是怎样的图标(丰田卡罗拉上防滑灯是怎样的图标呢)

    旋动最左侧的部分,白线对准的部位即为现在所使用的灯光,中间的部分也能旋动,是开启雾灯的。整个拨杆向前、后可以推动,是转向灯。不过,往里拨动拨杆闪动一次远光灯则没有限制条件,即使在关闭车灯的状态下也可以操作。

  • 汽车前挡风玻璃用什么擦干净(这个一定要用)

    下面更多详细答案一起来看看吧!汽车前挡风玻璃用什么擦干净清洗玻璃的水要使用玻璃水,不要直接用自来水代替。因为玻璃上会沾染很多油污,这些是单纯的自来水无法冲洗干净的,玻璃水的去污能力要高于自来水。并且玻璃水还具有一定的润滑作用,减少雨刮与玻璃的摩擦,保护玻璃不被划伤。还有一点,在冬天选择具有防冻指数的玻璃水,可以防止结冰,而自来水不具有这项功能。

  • 扬州如何申请个税退税 扬州如何申请个税退税额度

    个税改革后,个税的计算方法发生了改变,即将工资薪金、劳务报酬、稿酬、特许权使用费4项所得合并为“综合所得”,按年计算个税。pkgname=cn.gov.tax.its&channel=0002160650432d595942&fromcase=60001个人所得税退税流程:1.如图所示,登录个税APP后,点击进入[综合所得年度汇算]2.自动进入简易申报流程,弹框显示[简易申报须知],等待几秒,点击[我已阅读并知晓]。

  • 农业中的粮食作物与经济作物有什么区别(农业中的粮食作物与经济作物的不同)

    我国对谷类作物、薯类作物及食用豆类作物的总称。一般用作人类主食。经济作物的实质:经济作物亦称“工业原料作物”、“技术作物”。一般指为工业,特别是指为轻工业提供原料的作物。两者的性质不同:粮食作物的性质:经加工而成为人类基本食粮的作物。经济作物的性质:有某种特定经济用途的农作物。

  • 小鳄龟有几个品种 「鳄龟的名字大全」

    小鳄龟有几个品种「鳄龟的名字大全」?小鳄龟有几个品种「鳄龟的名字大全」四肢根部的疣粒圆」而小,因为大鳄的背甲是明显的三纵突起,小鳄龟上颌似钩状,钩大,此龟咬合力是距鳄鱼鲨鱼之后咬合力上前十的,别去攀比他人2品种我记得有俩佛鳄和北美,前面两张非常好辫。头颈部,鳄鱼龟,佛鳄贵一些,比如说背甲发白肉为原色,但钩小,背甲隆起极大,其次是南美,南美,背甲就像半球形的屋顶。