ChatGPT解决这个技术问题 Extra ChatGPT

为什么 Java 的 String 中的 hashCode() 使用 31 作为乘数?

根据 Java 文档,String 对象的 hash code 计算为:

s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1] 使用 int 算术,其中 s[i] 是第 i 个字符字符串,n是字符串的长度,^表示取幂。

为什么用 31 作为乘数?

我知道乘数应该是一个相对较大的素数。那么为什么不是 29、37 甚至 97 呢?

也比较 stackoverflow.com/questions/1835976/… - 如果您编写自己的 hashCode 函数,我认为 31 是一个糟糕的选择。
如果是 29、37 甚至 97,你会问为什么不是 31?
@EJP 重要的是要知道选择否的原因。除非这个数字是黑魔法的结果。
@peter-lawrey 有一篇关于它的博客文章:vanilla-java.github.io/2018/08/12/… 和这里:vanilla-java.github.io/2018/08/15/…
@DushyantSabharwal 我的意思是,它可能是 29 或 37 或 97,或 41,或许多其他值,而没有太大的实际差异。我们在 1976 年使用的是 37。

m
matt b

根据 Joshua Bloch 的 Effective Java (这本书推荐得不够多,由于在 stackoverflow 上的不断提及,我买了这本书):

选择值 31 是因为它是一个奇数素数。如果它是偶数并且乘法溢出,则信息将丢失,因为乘以 2 相当于移位。使用素数的优势不太明显,但它是传统的。 31 的一个很好的特性是乘法可以用移位和减法代替以获得更好的性能:31 * i == (i << 5) - i。现代虚拟机自动进行这种优化。

(来自第 3 章第 9 项:覆盖等于时始终覆盖哈希码,第 48 页)


好吧,所有素数都是奇数,除了 2。只是说。
我不认为布洛赫说选择它是因为它是一个奇怪的素数,而是因为它是奇数并且因为它是素数(并且因为它可以很容易地优化为移位/减法)。
选择 31 是因为它是一个奇怪的素数???这没有任何意义 - 我说选择 31 是因为它提供了最佳分布 - 检查 computinglife.wordpress.com/2008/11/20/…
我认为选择31是相当不幸的。当然,它可能会在旧机器上节省一些 CPU 周期,但是您已经在短的 ascii 字符串(如 "@ 和 #! ,或 Ca 和 DB 上存在哈希冲突。如果您选择例如 1327144003 或 at至少 524287 也允许位移:524287 * i == i << 19 - i。
@Jason 看我的回答 stackoverflow.com/questions/1835976/… 。我的观点是:如果你使用更大的素数,你得到的碰撞会少得多,而且这些天不会有任何损失。如果您使用具有常见非 ascii 字符的非英语语言,问题会更严重。在编写自己的 hashCode 函数时,31 对许多程序员来说是一个坏例子。
D
Dave Jarvis

Goodrich 和 Tamassia 从超过 50,000 个英语单词(由两个 Unix 变体中提供的单词列表的并集)计算得出,使用常量 31、33、37、39 和 41 在每种情况下将产生少于 7 个冲突。这可能是许多 Java 实现选择此类常量的原因。

请参阅 Data Structures and Algorithms in Java 的第 9.2 节哈希表(第 522 页)。


但是请注意,如果您使用任何类型的国际字符集以及 ASCII 范围之外的常见字符,您可能会遇到更多的冲突。至少,我检查了这个 31 和德语。所以我认为31的选择是错误的。
m
mikej

在(大部分)旧处理器上,乘以 31 可能相对便宜。例如,在 ARM 上,它只有一条指令:

RSB       r1, r0, r0, ASL #5    ; r1 := - r0 + (r0<<5)

大多数其他处理器将需要单独的移位和减法指令。但是,如果您的乘数很慢,这仍然是一个胜利。现代处理器往往具有快速乘法器,因此只要 32 在正确的一侧,它就不会产生太大影响。

这不是一个很好的哈希算法,但它已经足够好并且比 1.0 代码更好(并且比 1.0 规范好得多!)。


有趣的是,在我的台式机上与 31 的乘法实际上比与 92821 的乘法要慢一些。我猜编译器会尝试将其“优化”为移位和加法。 :-)
我认为我从来没有使用过 ARM,它的所有值都在 +/-255 范围内。使用 2 减去 1 的幂具有不幸的效果,即对两个值的匹配更改会将散列码更改为 2 的幂。 -31 的值会更好,我认为像 -83 (64+16+2+1) 这样的值可能会更好(混合位更好一些)。
@supercat 不相信减号。看来你会回到零。 / String.hashCode 早于 StrongARM,IIRC 引入了一个 8 位乘法器,并且可能增加到两个周期,用于组合算术/逻辑与移位操作。
@TomHawtin-tackline:使用 31,四个值的哈希值将是 29791*a + 961*b + 31*c + d;使用 -31,它将是 -29791*a + 961*b - 31*c + d。如果四个项目是独立的,我认为差异不会很大,但如果相邻项目对匹配,则生成的哈希码将是所有未配对项目的贡献,加上 32 的一些倍数(来自配对项目)。对于字符串,它可能无关紧要,但如果正在编写一种用于散列聚合的通用方法,则相邻项匹配的情况将不成比例地常见。
@supercat 有趣的事实,Map.Entry 的哈希码已被规范固定为 key.hashCode() ^ value.hashCode(),尽管它甚至不是无序对,因为 keyvalue 具有完全不同的含义。是的,这意味着 Map.of(42, 42).hashCode()Map.of("foo", "foo", "bar", "bar").hashCode() 等可以预见为零。所以不要使用地图作为其他地图的键......
e
erickson

通过乘法,位向左移动。这使用了更多可用的哈希码空间,减少了冲突。

通过不使用 2 的幂,低位、最右边的位也被填充,与进入散列的下一条数据混合。

表达式 n * 31 等价于 (n << 5) - n


M
Mark Amery

您可以在 http://bugs.java.com/bugdatabase/view_bug.do?bug_id=4045622 的“评论”下阅读 Bloch 的原始推理。他研究了不同哈希函数在哈希表中产生的“平均链大小”的性能。 P(31) 是他在 K&R 的书中找到的那个时期的常见函数之一(但即使是 Kernighan 和 Ritchie 也不记得它来自哪里)。最后他基本上只能选择一个,所以他选择了P(31),因为它似乎表现得足够好。尽管 P(33) 并没有更糟,并且乘以 33 的计算速度同样快(只是移位 5 和加法),但他选择了 31,因为 33 不是素数:

在剩下的四个中,我可能会选择 P(31),因为它是在 RISC 机器上计算的最便宜的(因为 31 是 2 的两个幂的差)。 P(33) 计算起来同样便宜,但它的性能稍差,而且 33 是复合的,这让我有点紧张。

因此,推理并不像这里的许多答案似乎暗示的那样合理。但我们都善于在直觉决定后提出合理的理由(甚至布洛赫也可能倾向于这样做)。


h
hrr

实际上,37 会很好用! z := 37 * x 可以计算为 y := x + 8 * x; z := x + 4 * y。这两个步骤都对应一个 LEA x86 指令,因此速度非常快。

事实上,通过设置 y := x + 8 * x; z := x + 8 * y,可以以相同的速度与更大的素数 73 相乘。

使用 73 或 37(而不是 31)可能会更好,因为它会导致代码更密集:两条 LEA 指令只占用 6 个字节,而 move+shift+subtract 的 7 个字节用于乘以 31。一个可能的警告是此处使用的 3 参数 LEA 指令在英特尔的 Sandy 桥架构上变得更慢,延迟增加了 3 个周期。

此外,73 是 Sheldon Cooper 最喜欢的数字。


@Mainguy 它实际上是 ALGOL 语法,并且在伪代码中经常使用。
但在 ARM 汇编中乘以 31 可以在一条指令中完成
TPOP (1999) 中,人们可以阅读到早期 Java(第 57 页):“...通过将散列替换为与我们展示的散列等效的散列(乘数为 37< /b>) ..."
T
TheJuice

Neil Coffey explains 为什么在消除偏见下使用 31。

基本上使用 31 可以为散列函数提供更均匀的设置位概率分布。


F
Flow

来自 JDK-4045622,其中 Joshua Bloch 描述了选择特定(新)String.hashCode() 实现的原因

下表总结了上述各种散列函数的性能,用于三个数据集:1) Merriam-Webster's 2nd Int'l Unabridged Dictionary 中包含条目的所有单词和短语(311,141 个字符串,平均长度 10 个字符)。 2) /bin/、/usr/bin/、/usr/lib/、/usr/ucb/ 和 /usr/openwin/bin/* 中的所有字符串(66,304 个字符串,平均长度 21 个字符)。 3) 昨晚运行了几个小时的网络爬虫收集的 URL 列表(28,372 个字符串,平均长度 49 个字符)。表中显示的性能指标是散列表中所有元素的“平均链大小”(即,比较查找元素的键数的预期值)。 Webster 的代码字符串 URL --------- ------------ ---- 当前 Java Fn。 1.2509 1.2738 13.2560 P(37) [Java] 1.2508 1.2481 1.2454 P(65599) [Aho et al] 1.2490 1.2510 1.2450 P(31) [K+R] 1.2500 1.2488 1.2425 P(33) [Torek] 1.2500 1.2500 1.2453 Vo's Fn 1.2487 1.2471 1.2462 WAIS Fn 1.2497 1.2519 1.2452 Weinberger's Fn(MatPak) 6.5169 7.2142 30.6864 Weinberger's Fn(24) 1.3222 1.2791 1.9732 Weinberger's Fn(28) 1.2530 1.2506 1.2439 Looking at this table, it's clear that all of the functions except for the current Java function and Weinberger 函数的两个损坏版本提供了出色的、几乎无法区分的性能。我强烈推测这种性能本质上是“理论理想”,如果您使用真正的随机数生成器代替散列函数,您会得到这种性能。我会排除 WAIS 函数,因为它的规范包含随机数页,并且它的性能并不比任何更简单的函数更好。其余六个功能中的任何一个似乎都是不错的选择,但我们必须选择一个。我想我会排除 Vo 的变体和 Weinberger 的函数,因为它们增加了复杂性,尽管很小。在剩下的四个中,我可能会选择 P(31),因为它是在 RISC 机器上计算的最便宜的(因为 31 是 2 的两个幂的差)。 P(33) 计算起来同样便宜,但它的性能稍差,而且 33 是复合的,这让我有点紧张。乔什


J
Jason

布洛赫并没有完全深入,但我一直听到/相信的基本原理是这是基本代数。哈希归结为乘法和取模运算,这意味着如果可以提供帮助,您永远不想使用具有公因数的数字。换句话说,相对质数提供了答案的均匀分布。

使用哈希组成的数字通常是:

您放入的数据类型的模数(2^32 或 2^64)

哈希表中存储桶计数的模数(变化。在 java 中曾经是素数,现在是 2^n)

在混合函数中乘以或移位一个幻数

输入值

您实际上只能控制其中几个值,因此需要格外小心。


J
James Graham

在最新版本的 JDK 中,仍然使用 31。 https://docs.oracle.com/en/java/javase/12/docs/api/java.base/java/lang/String.html#hashCode()

哈希字符串的目的是

唯一(让查看哈希码计算文档中的运算符^,它有助于唯一)

计算成本低廉

31是最大值可以放入8位(= 1字节)寄存器,最大素数可以放入1字节寄存器,是奇数。

乘以 31 <<5 然后减去自身,因此需要廉价资源。


y
yoAlex5

Java String hashCode() 和 31

这是因为 31 有一个很好的属性——它的乘法可以用比标准乘法更快的按位移位来代替:

31 * i == (i << 5) - i

D
Dave L.

我不确定,但我猜他们测试了一些素数样本,发现 31 在一些可能的字符串样本上给出了最佳分布。


A
Altan

散列函数的一个很大期望是,它们的结果的均匀随机性可以在诸如 hash(x) % N 的操作中幸存下来,其中 N 是任意数(在许多情况下,是 2 的幂),一个原因是此类操作通常在散列表中使用用于确定插槽。在计算哈希时使用素数乘数会降低乘数和 N 共享除数的概率,这会使运算结果的随机性降低。

其他人指出了乘以 31 可以通过乘法和减法来完成的好特性。我只想指出,此类素数有一个数学术语:Mersenne Prime

所有梅森素数都小于 2 的幂,因此我们可以将它们写为:

p = 2^n - 1

将 x 乘以 p:

x * p = x * (2^n - 1) = x * 2^n - x = (x << n) - x

在许多机器上,移位 (SAL/SHL) 和减法 (SUB) 通常比乘法 (MUL) 快。请参阅instruction tables from Agner Fog

这就是为什么 GCC 似乎通过用移位和子替换它们来优化梅森素数的乘法,see here

然而,在我看来,这么小的素数对于散列函数来说是一个糟糕的选择。使用相对较好的散列函数,您会期望散列的较高位具有随机性。然而,使用 Java 散列函数,具有较短字符串的较高位几乎没有随机性(并且较低位的随机性仍然非常值得怀疑)。这使得构建高效的哈希表变得更加困难。请参阅this nice trick you couldn't do with the Java hash function

一些答案提到他们认为 31 适合一个字节是好的。这实际上是无用的,因为:

(1) 我们执行移位而不是乘法,因此乘数的大小无关紧要。

(2) 据我所知,没有特定的 x86 指令可以将 8 字节值与 1 字节值相乘,因此即使您正在相乘,您也需要将“31”转换为 8 字节值。请参阅 here,您将整个 64 位寄存器相乘。

(而 127 实际上是一个字节中最大的梅森素数。)

较小的值会增加中低位的随机性吗?也许吧,但它似乎也大大增加了可能的冲突:)。

人们可以列出许多不同的问题,但它们通常归结为两个没有很好地实现的核心原则:Confusion and Diffusion

但它快吗?可能,因为它没有多大作用。但是,如果性能真的是这里的重点,那么每个循环一个字符是非常低效的。对于较长的字符串 like this,为什么不每次循环迭代一次 4 个字符(8 个字节)?好吧,这与当前的哈希定义很难做到,您需要将每个字符单独相乘(请告诉我是否有一些技巧可以解决这个问题:D)。