为什么哈希函数应使用质数模数?


335

很久以前,我花了1.25美元从讲价表上买了一本数据结构书。在其中,对散列函数的解释说,由于“数学的性质”,它最终应按质数进行模运算。

您对1.25美元的书有什么期待?

无论如何,我已经花了很多年考虑数学的本质,但仍然无法弄清楚。

即使存储桶数是素数,数字的分布确实真的更多吗?还是这是每个人都因为别人都接受而接受的古老程序员的故事?


1
完全合理的问题:为什么要有一个素数的水桶?
Draemon

1
这个问题似乎是题外话,因为它很可能属于计算机科学
2014年



这里的另一个很好的解释了几分相关的问题有一些惊人的证据号码- quora.com/...
AnBisw

Answers:


242

通常,一个简单的哈希函数通过获取输入的“组成部分”(在字符串的情况下为字符),然后将它们乘以某个常数的幂,然后将它们加在一起以某种整数类型来工作。因此,例如,字符串的典型(尽管不是特别好)哈希可能是:

(first char) + k * (second char) + k^2 * (third char) + ...

然后,如果输入一串都具有相同第一个字符的字符串,那么结果将全部取相同的模k,至少直到整数类型溢出为止。

[例如,Java的字符串hashCode与此极为相似-它以相反的顺序执行字符,k = 31。因此,在以相同方式结尾的字符串之间会得到31的模数关系,而在末端附近的相同字符串之间将有2 ^ 32的模数关系。这不会严重破坏哈希表的行为。]

哈希表的工作原理是将哈希的模数乘以存储桶数。

在哈希表中,重要的是不要在可能的情况下产生冲突,因为冲突会降低哈希表的效率。

现在,假设有人将一堆值放入一个散列表中,这些散列表中的项目之间具有某种关系,例如所有的项目都具有相同的第一个字符。我会说这是一个相当可预测的使用模式,因此我们不希望它产生太多冲突。

事实证明,“由于数学的性质”,如果哈希中使用的常量和存储区的数量是互质的,那么在某些常见情况下,冲突会最小化。如果它们不是互质的,那么在输入之间存在一些相当简单的关系,对于这些关系,不会最小化冲突。所有散列都以公因数取模相等,这意味着它们将全部落入具有该值以公因数取模的存储桶的1 / n。您得到的碰撞次数是n倍,其中n是公因子。由于n至少为2,我想说一个相当简单的用例产生的碰撞至少是正常情况的两倍是不可接受的。如果某些用户打算将我们的发行分为几类,我们希望这是一次怪胎事故,而不是一些简单的可预测用法。

现在,哈希表实现显然无法控制放入其中的项目。他们无法阻止他们之间的联系。因此,要做的是确保常数和存储桶数互质。这样一来,您就不会仅依靠“最后一个”组件来确定铲斗相对于一些小的公因子的模数。据我所知,他们并不一定要做到这一点,而只需同质。

但是,如果哈希函数和哈希表是独立编写的,则哈希表将不知道哈希函数的工作方式。它可能使用的常数很小。如果幸运的话,它的工作原理可能会完全不同,而且可能是非线性的。如果哈希足够好,那么任何存储桶计数都很好。但是偏执的哈希表不能假设一个好的哈希函数,因此应使用素数的存储桶。类似地,偏执散列函数应使用较大的质数常数,以减少某人使用多个存储桶的机会,这些存储桶恰好与该常数具有公因子。

实际上,我认为使用2的幂作为存储桶数是很正常的。这很方便,省去了搜索或预先选择合适大小的质数的麻烦。因此,您依赖哈希函数不使用偶数乘法器,这通常是一个安全的假设。但是您仍然会偶尔基于上述散列函数获得不良的散列行为,并且主存储桶计数可以进一步提供帮助。

据我所知,提出“一切都必须是素数”的原则,是在哈希表上进行良好分布的充分但非必要条件。它允许每个人进行互操作,而无需假设其他人遵循相同的规则。

[编辑:还有另一个更专业的原因使用质数的存储桶,即如果您使用线性探测来处理碰撞。然后,您可以根据哈希码计算跨度,如果跨度成为存储桶计数的一个因素,那么您只能在返回起点之前进行(bucket_count /跨步)探测。当然,您最想避免的情况是stride = 0,这必须是特殊情况,但要避免还使用特殊情况的bucket_count / stride等于一个小整数,您可以使bucket_count为素数,而不必关心跨度不为0。]



9
这是一个了不起的答案。您能进一步解释一下吗?“因此,在以相同方式结尾的字符串之间会得到31的模数关系,而在结尾附近的字符串之间将具有2 ^ 32的模数关系。这不会严重破坏哈希表的行为。 ” 我特别不理解2 ^ 32部分
普通的

2
补充说明,以使事情更清楚:“所有散列均以公因数取模”->这是因为,如果您考虑示例哈希函数hash = 1st char + 2nd char * k + ...,并且接受具有相同第一个字符的字符串,则这些字符串的hash%k将相同。如果M是哈希表的大小,并且g是M和k的gcd,则(hash%k)%g等于hash%g(因为g除以k),因此hash%g对于这些字符串也将是相同的。现在考虑(hash%M)%g,它等于hash%g(因为g除以M)。因此(hash%M)%g对于所有这些字符串都是相等的。
Quark

1
@DanielMcLaury约书亚·布洛赫(Joshua Bloch)解释了使用Java的原因 -在两本热门书籍(K&R,龙书)中都推荐使用Java,并且在英语词典上的碰撞率很小。它很快(使用霍纳法)。显然,甚至K&R都不记得它的来源。类似的功能是Rabin -Karp算法(1981)的Rabin指纹,但K&R(1978)早于此。
bain

1
@SteveJessop,请您解释一下“除了末尾以外,相同的字符串之间的模数关系为2 ^ 32”?谢谢。
Khanna111

29

从哈希表插入/检索哈希表时,您要做的第一件事是计算给定键的hashCode,然后通过执行hashCode%table_length将hashCode修剪为hashTable的大小来找到正确的存储桶。这是您最有可能在某处阅读的2份“声明”

  1. 如果对table_length使用2的幂,则查找(hashCode(key)%2 ^ n)与(hashCode(key)&(2 ^ n -1))一样简单快捷。但是,如果您为给定键计算hashCode的功能不好,那么您肯定会在多个哈希存储桶中聚集许多键。
  2. 但是,如果您使用质数作为table_length的值,则即使您的哈希码功能有些愚蠢,所计算的哈希码也可能映射到不同的哈希桶中。

这是证明。

如果假设您的hashCode函数在以下{x,2x,3x,4x,5x,6x ...}中导致以下hashCode,那么所有这些将被聚类到m个存储桶中,其中m = table_length / GreatestCommonFactor (table_length,x)。(验证/得出这一点很简单)。现在,您可以执行以下操作之一以避免聚类

确保您不会生成太多的哈希码,这些哈希码是另一个哈希码的倍数,例如{x,2x,3x,4x,5x,6x ...}。但是,如果您的hashTable应该具有数百万个条目。或者通过使GreatestCommonFactor(table_length,x)等于1,即通过使table_length与x互质来使m等于table_length。如果x可以是任意数,请确保table_length是质数。

来自-http: //srinvis.blogspot.com/2006/07/hash-table-lengths-and-prime-numbers.html


11

http://computinglife.wordpress.com/2008/11/20/why-do-hash-functions-use-prime-numbers/

解释很清楚,也有图片。

编辑:作为总结,使用质数,因为当将值乘以所选的质数并将它们加起来时,您就有最大的机会获得唯一值。例如,给定一个字符串,将每个字母值与质数相乘,然后将所有字母值相加,即可得到其哈希值。

更好的问题是,为什么数字精确为31?


5
虽然,我认为摘要是有帮助的,以防万一该网站死了,但其内容的一些残余将保存在此处。
Thomas Owens

2
这篇文章没有解释为什么,但是说:“研究人员发现使用31的质数可以更好地分配键,并且减少了碰撞次数。没有人知道为什么……”有趣的是,我实际上问了同样的问题。
theschmitzer

>一个更好的问题是,为什么数字精确为31?如果您是说为什么使用31号,那么您所指的文章会告诉您原因,即因为它乘以快速,并且cos测试表明它是最适合使用的数字。我看到的另一个流行的乘数是33,它证明了速度问题(至少在最初是如此)是一个重要因素的理论。如果您的意思是说,使31在测试中变得更好的原因是什么,那么我恐怕不知道。
sgmoore

确实,因此可以将其用作乘法器的唯一原因是因为它易于乘以。(当我说我已经将33用作乘数时,我并不是说最近,这可能是几十年前的事了,而且可能在对散列进行大量分析之前)。
sgmoore

3
@SteveJessop数字31通过(x * 32)-1操作很容易被CPU优化,这*32是一个简单的位移,甚至更好的是立即地址比例因子(例如lea eax,eax*8; leax, eax,eax*4在x86 / x64上)。所以*31是一个很好的候选人素数相乘。几年前,这几乎是真的-现在最新的CPU架构几乎具有即时乘法功能-除法总是很慢...
Arnaud Bouchez 2013年

10

tl; dr

index[hash(input)%2]将导致所有可能的哈希值和值范围的一半发生冲突。 index[hash(input)%prime]导致所有小于2的哈希冲突。将除数固定为表格大小还可以确保该数字不能大于表格。


1
2是一个素数的花花公子
加尼甚Chowdhary Sadanala

8

之所以使用质数,是因为您有很好的机会为典型的使用多项式取模P的哈希函数获得唯一值。假设,对于长度<= N的字符串使用此类哈希函数,则会发生冲突。这意味着2个不同的多项式产生相同的模P值。这些多项式的差再次是相同次数N(或更小)的多项式。它的根数不超过N(这是数学的本质,这表明它是正确的,因为这种主张仅适用于域=>质数上的多项式)。因此,如果N远小于P,则可能不会发生碰撞。之后,实验可能会显示37足够大,可以避免长度为5-10的字符串的哈希表发生冲突,并且足够小,可以用于计算。


1
尽管现在的解释似乎很明显,但是在阅读了A.Shen着的《编程:定理和问题》(俄语)之后,我就明白了,请参阅Rabin算法的讨论。不确定是否存在英文翻译。
TT_

5

只是为了提供一个替代的观点,这里有这个网站:

http://www.codexon.com/posts/hash-functions-the-modulo-prime-myth

这表明您应该使用尽可能多的存储桶,而不是四舍五入为最基本的存储桶。这似乎是合理的可能性。凭直觉,我当然可以看到更多的存储桶会更好,但是我无法对此进行数学上的论证。


数量更多的铲斗意味着更少的碰撞:请参阅鸽孔原理。
未知

11
@未知:我不相信那是真的。如果我错了,请指正我,但是我相信将鸽子洞原理应用于哈希表只能使您断言,如果您的元素比容器多,就会发生冲突,而不是对冲突的数量或密度得出任何结论。我仍然相信,更多的垃圾箱是正确的路线。
Falaina

如果您假设碰撞是出于所有意图和目的随机发生的,那么通过生日悖论,较大的空间(存储桶)将减少发生碰撞的可能性。
未知

1
@未知,您错过了冲突也取决于哈希函数本身。因此,如果has函数真的很糟糕,那么无论您增加大小多少,都可能会发生大量碰撞
Suraj Chandran

原始文章似乎不见了,但是这里有一些有见地的评论,包括与原始作者的讨论。 news.ycombinator.com/item?id=650487
Adrian McCarthy

3

质数是唯一数字。它们的独特之处在于,由于使用了素数来构成素数,因此素数与任何其他数字的乘积具有最大的唯一性机会(不像素数本身那样唯一)。此属性在哈希函数中使用。

给定字符串“ Samuel”,您可以通过将每个组成数字或字母乘以质数并将它们相加来生成唯一的哈希。这就是为什么要使用素数的原因。

但是,使用素数是一种古老的技术。此处的密钥是要理解的,只要您可以生成足够唯一的密钥,就可以使用其他哈希技术。转到此处,以获取有关http://www.azillionmonkeys.com/qed/hash.html的更多信息

http://computinglife.wordpress.com/2008/11/20/why-do-hash-functions-use-prime-numbers/


1
哈哈哈....实际上,两个素数的乘积不是比素数和任何其他数的乘积具有更好的“唯一性”机会吗?
2009年

@Beska在这里,“唯一性”是递归定义的,因此我认为应该以同样的方式定义“唯一性” :)
TT_

3

这取决于哈希函数的选择。

许多散列函数通过将数据中的各种元素与一些乘​​数相乘来组合数据中的各种元素,这些乘数与机器字长相对应(对模数的溢出是通过释放模数来实现的)。

您不希望数据元素的乘数与哈希表的大小之间有任何共同的因素,因为那样的话,改变数据元素不会将数据散布到整个表中。如果您为表的大小选择素数,那么这种共同因素极不可能发生。

另一方面,那些因素通常是由奇数质数组成的,因此您也应该对哈希表使用2的幂进行安全处理(例如,Eclipse在生成Java hashCode()方法时使用31)。


2

假设您的表大小(或模数)为T =(B * C)。现在,如果输入的哈希值类似于(N * A * B),其中N可以是任何整数,那么您的输出将无法很好地分布。因为每当n变为C,2C,3C等时,您的输出将开始重复。即您的输出将仅分布在C位置。请注意,这里的C是(T / HCF(表大小,哈希))。

通过制作HCF 1可以消除此问题。素数对此非常有用。

另一个有趣的事情是,当T为2 ^ N时。这些将提供与输入哈希的所有低N位完全相同的输出。因为每个数字都可以表示为2的幂,所以当我们用T取任何数字的模时,我们将减去2形式的所有幂,即> = N,因此始终根据输入而给出特定模式的数量。这也是一个不好的选择。

类似地,由于类似的原因(以数字的十进制表示形式而不是二进制的形式),T为10 ^ N也是不好的。

因此,质数趋向于提供更好的分布式结果,因此是表大小的不错选择。


2

从我的其他答案复制https://stackoverflow.com/a/43126969/917428。有关更多详细信息和示例,请参见它。

我认为这仅与计算机在base 2中可以使用的事实有关。请想一想同样的事情在base 10中如何工作:

  • 8%10 = 8
  • 18%10 = 8
  • 87865378%10 = 8

多少是无关紧要的:只要以8结尾,其模10将为8。

选择足够大的非二乘幂数将确保哈希函数确实是所有输入位的函数,而不是它们的子集。


1

我想为史蒂夫·杰索普(Steve Jessop)的答案添加一些内容(因为我没有足够的声誉,所以我无法对此发表评论)。但是我发现了一些有用的材料。他的回答很有帮助,但他犯了一个错误:存储桶大小不应该是2的幂。我仅引用第263页的Thomas Cormen,Charles Leisersen等人的“算法介绍”一书:

使用除法时,我们通常避免使用某些m值。例如,m不应该是2的幂,因为如果m = 2 ^ p,则h(k)只是k的p个最低位。除非我们知道所有低阶p位模式均等可能,否则我们最好将散列函数设计为依赖于密钥的所有位。正如练习11.3-3所要求的那样,当k是用基数2 ^ p解释的字符串时,选择m = 2 ^ p-1可能不是一个好选择,因为对k的字符进行置换不会改变其哈希值。

希望能帮助到你。


0

对于散列函数,不仅重要的是最大程度地减少细菌总数,而且在改变几个字节的同时使其无法保持相同的散列。

假设你有一个公式: (x + y*z) % key = x0<x<key0<z<key。如果key是素数,则n * y = key对于N中的每n个为true,而对于其他每个数字为false。

key不是主要示例的示例:x = 1,z = 2和key = 8因为key / z = 4仍然是自然数,所以4成为方程的解,在这种情况下为(n / 2) * y = N中的每n个键都为真。该方程的解数实际上增加了一倍,因为8不是质数。

如果我们的攻击者已经知道该方程式可能是8,那么他可以将文件从产生8更改为4并仍然获得相同的哈希值。


0

我已经阅读了流行的wordpress网站,这些网站在顶部列出了一些上述流行的答案。据我了解,我想分享一个简单的观察。

您可以在此处找到本文中的所有详细信息,但假定以下情况成立:

  • 使用质数为我们提供唯一值的“最佳机会”

一般的hashmap实现需要2件事是唯一的。

  • 密钥的唯一哈希码
  • 唯一索引存储实际

我们如何获得唯一索引?通过使内部容器的初始尺寸也成为质数。因此,基本上涉及质数,因为质数具有产生唯一数字的独特特征,最终我们将其用于ID对象并在内部容器中查找索引。

例:

键=“键”

值=“值” uniqueId = "k" * 31 ^ 2 + "e" * 31 ^ 1` + "y"

映射到唯一ID

现在我们想要一个独特的位置来实现我们的价值-因此我们

uniqueId % internalContainerSize == uniqueLocationForValue,假设internalContainerSize也是素数。

我知道这是简化的,但是我希望能使总体思路得以通过。


0

关于素数幂模的“数学性质”是它们是有限域的一个基本组成部分。其他两个构造块是加法和乘法运算。质数模的特殊性质是,它们与“常规”加法和乘法运算形成了一个有限域,刚好考虑了模数。这意味着每个乘法都以质数为模映射到一个不同的整数,每个加法也是如此。

质数模是有优势的,因为:

  • 在二级哈希中选择二级乘法器时,它们提供最大的自由度,除0以外的所有乘法器最终都会访问所有元素一次
  • 如果所有哈希值均小于模数,则根本不会发生冲突
  • 随机质数的混合比两个模的幂更好,并且压缩所有比特的信息,而不仅仅是子集

但是,它们有很大的缺点,它们需要整数除法,即使在现代CPU上,也需要许多(〜15-40)个周期。通过大约一半的计算,可以确保哈希混合得很好。两次乘法和异或移位运算将比主要模数混合得更好。然后,我们可以使用任何最快的哈希表大小和哈希减少方法,对于2种表大小的幂,总共可以进行7次操作,对于任意大小,总共可以进行9次操作。

我最近查看了许多最快的哈希表实现,其中大多数不使用素数模。


0

这个问题与一个更合适的问题合并,为什么哈希表应该使用素数数组,而不是2的幂。对于哈希函数本身,这里有很多好的答案,但是对于相关的问题,为什么有些安全性至关重要的哈希表,像glibc一样,使用素数数组,但还没有。

通常,2张桌子的功效要快得多。那里很贵h % n => h & bitmask,可以通过clz大小为n的(“前导零计数”)来计算位掩码。模函数需要做整数除法,比逻辑函数慢50倍and。有一些技巧可以避免取模,例如使用Lemire的https://lemire.me/blog/2016/06/27/a-fast-alternative-to-the-modulo-reduction/,但是通常快速哈希表会使用幂2,安全哈希表使用素数。

为什么这样?

在这种情况下,安全性是通过对冲突解决策略的攻击来定义的,对于大多数哈希表而言,冲突表中的线性表只是线性搜索。或者使用更快的开放式地址表直接在表中进行线性搜索。因此,借助2张表的功能和对该表的一些内部知识(例如,某些JSON接口提供的键的大小或顺序),您可以获得使用的正确位数。位掩码上的1的数量。通常低于10位。对于5到10位,即使使用最强大和最慢的哈希函数,对于暴力冲突也是微不足道的。您再也无法获得32位或64位哈希函数的完全安全性。关键是要使用快速的小型哈希函数,而不要使用诸如杂音甚至是siphash之类的怪物。

因此,如果您为哈希表提供外部接口(例如DNS解析器,编程语言),那么您希望关心那些喜欢使用DOS这样的服务的人。对于这类人来说,通常更容易用更简单的方法来关闭您的公共服务,但这确实发生了。所以人们确实在乎。

因此,防止此类碰撞攻击的最佳选择是

1)使用素数表,因为

  • 所有32位或64位都与找到存储区相关,而不仅仅是少数。
  • 哈希表调整大小功能比仅使用double更为自然。最佳的增长函数是斐波那契数列,素数比加倍数更接近。

2)结合实际使用的2种大小的快速力量,使用更好的措施抵御实际攻击。

  • 计算碰撞次数,并在检测到的攻击时中止或睡眠,这是发生碰撞的可能性<1%的次数。就像100个带有32位哈希表的表一样。例如djb的dns解析器就是这样做的。
  • 当检测到碰撞攻击时,使用O(log n)搜索而不是O(n)将碰撞的链接列表转换为树。例如,java就是这样做的。

有一个广泛的神话,即更安全的哈希函数有助于防止此类攻击,这是错误的,正如我所解释的那样。仅低位就没有安全性。这仅适用于素数大小的表,但这将结合使用两种最慢的方法(慢散列加慢素数模)。

哈希表的哈希函数主要需要小(易于使用)和快速。安全只能来自防止冲突中的线性搜索。并且不要使用琐碎的哈希函数,例如对某些值不敏感的哈希函数(如使用乘法时为\ 0)。

使用随机种子也是一个不错的选择,人们首先会使用它,但是有了足够的表信息,即使是随机种子也无济于事,动态语言通常不容易通过其他方法来获取种子,因为它存储在已知的内存位置。


-1
function eratosthenes(n) {

    function getPrime(x) {
        var middle = (x-(x%2))/2;
        var arr_rest = [];
        for(var j=2 ; j<=middle;j++){
            arr_rest.push(x%j);
        }

        if(arr_rest.indexOf(0) == -1) {
            return true
        }else {
            return false
        }

    }
    if(n<2)  {
        return []
    }else if(n==2){
        return [2]
    }else {
        var arr = [2]
        for(var i=3;i<n;i++) {
            if(getPrime(i)){
                arr.push(i)
            }
        }
    }

    return arr;
}

2
您可以添加评论以解释您的解决方案吗?
pom421 '19
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.