0
点赞
收藏
分享

微信扫一扫

String.hashCode()为什么使用31作为乘数【深度长文】

佛贝鲁先生 2022-04-13 阅读 36
javahash

String.hashCode()为什么使用31作为乘数【深度长文】

文章目录

String.hashCode()源码

公式 H ( k e y ) = k e y   m o d   p H(key) = key\ mod \ p H(key)=key mod p

源码

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,在for循环每次执行时进行乘积计算,循环后的公式如下;s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]

这里的乘数也就是公式中的模/除数,那么为什么这里是31呢?我们先来看看散列函数和散列表的性质。

散列函数

所有散列函数都有如下一个基本特性:如果两个散列值是不相同的(根据同一函数),那么这两个散列值的原始输入也是不相同的

https://zh.wikipedia.org/wiki/%E6%95%A3%E5%88%97%E5%87%BD%E6%95%B8

散列表

散列表是散列函数的一个主要应用,使用散列表能够快速的按照关键字查找数据记录。(注意:关键字不是像在加密中所使用的那样是秘密的,但它们都是用来“解锁”或者访问数据的)。举个英文字典的例子,开头相同字母对应多个单词,相同字母越多(规则越严格),匹配结果越少(冲突越少)。

一个好的散列函数具有均匀的真正随机输出,因而平均只需要一两次探测(依赖于装填因子)就能找到目标。同样重要的是,随机散列函数不太会出现非常高的冲突率。但是,少量的可以估计的冲突在实际状况下是不可避免的(参考生日悖论鸽巢原理)。

《Effective Java》上的回答

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rPMrpf4i-1649521270552)(image-20220409132255628.png)]

https://stackoverflow.com/questions/299304/why-does-javas-hashcode-in-string-use-31-as-a-multiplier

这是个比较有争议的答案,分为三部分来讨论:

为什么要选择奇数

如果用2作乘数,则所有的数会落在01两个位置(余0或余1)。

2不能作为乘数,则剩下的素数肯定是奇数

为什么要选择素数

实践角度

1. 取模

67为候选乘数,6的因子集合为{1,2,3,6}7的因子集合为{1,7}

2. 选取数列

数列的选取很重要,有些文章将验证的数列间隔选为1,发现素数与合数并没有什么区别。

这是因为素数与合数最大的区别不是间隔为1,而是因子的个数,我们可以做这样的一个假设,取模运算产生的碰撞冲突乘数的因子相关。

3. 验证

{1,3,5,7,9,11,13,15,17},取模6

余数012345
哈希表135
冲突17911
冲突2131517

{1,3,5,7,9,11,13,15,17},取模7

余数01234
哈希表719311
冲突11517

{2,4,6,8,10,12,14,16,18},取模6

余数01234
哈希表624
冲突112810
冲突2181416

{2,4,6,8,10,12,14,16,18},取模7

余数01234
哈希表1482104
冲突11618

{1,4,7,10,13,16,19,22,25,28},取模6

余数01234
哈希表14
冲突1710
冲突21316
冲突31922
冲突42528

{1,4,7,10,13,16,19,22,25,28},取模7

余数0123456
哈希表71161041913
冲突1282225

{1,7,13,19,25,31,37,43,49},取模6

余数01234
哈希表1
冲突17
冲突213
冲突319
冲突425
冲突531
冲突637
冲突743
冲突849

{1,7,13,19,25,31,37,43,49},取模7

余数01234
哈希表71373125
冲突14943

{1,8,15,22,29,36,43,50,57},取模6

余数01234
哈希表36181522
冲突1435057

{1,8,15,22,29,36,43,50,57},取模7

余数0123
哈希表1
冲突18
冲突215
冲突322
冲突429
冲突536
冲突643
冲突750
冲突857

4. 结论

  • 如果有一个数列s,间隔为1,那么不管模数为几,都是均匀分布的,因为间隔为1是最小单位

  • 如果一个数列s,间隔为模本身,那么在哈希表中的分布仅占有其中的一列,也就是处处冲突

  • 数列的冲突分布间隔为因子大小,同样的随机数列,因子越多,冲突的可能性就越大


理论角度

举例

给定一个整数的集合 A = { a 0 , a 1 , a 2 , … , a i } A=\{a_0,a_1,a_2,…,a_i\} A={a0,a1,a2,,ai},我们要对该集合里面的所有元素对数字 j j j 取余,那么 A A A 中所有元素取余后的值的集合 B = { 0 , 1 , 2 , … , j − 1 } B=\{0,1,2,…,j-1\} B={0,1,2,,j1},取余实际上就是将集合 A A A 映射到集合 B B B


**例1:**取 i = 12 i=12 i=12 j = 8 j=8 j=8,则有

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M9Y58sqP-1649521270552)(image-20220409145347476.png)]


**例2:**取 i = 12 i=12 i=12 j = 8 j=8 j=8,则有

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sJZukL9T-1649521270553)(image-20220409145707968.png)]


**例3:**取 i = 12 i=12 i=12 j = 7 j=7 j=7,则有

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GGj4wKNw-1649521270553)(image-20220409145539378.png)]

数学证明

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3t63uiqY-1649521270553)(image-20220409150116841.png)]

现在考虑 q q q 的情况:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rv5hrYQb-1649521270554)(image-20220409150501940.png)]


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DnRygRz2-1649521270554)(image-20220409150529358.png)]

总结:虽然说不能只考虑特定序列的一组数字,而是要考虑到更普遍的情况。但合数能应用的场景素数都能应用,素数能以不变应万变,稳赚不赔。。。

为什么要选择31

我们先看Jdk开发者之一兼《Effective Java》作者joshua.bloch在 https://bugs.java.com/bugdatabase/view_bug.do?bug_id=4045622 的回复(jdk1.1.1使用37处理短字符串,使用39处理长字符串):

源码

public int hashCode() {
    int h = 0;
    int off = offset;
    char val[] = value;
    int len = count;

    if (len < 16) {
        for (int i = len ; i > 0; i--) {
            h = (h * 37) + val[off++];
        }
    } else {
        // only sample some characters
        int skip = len / 8;
        for (int i = len ; i > 0; i -= skip, off += skip) {
            h = (h * 39) + val[off];
        }
    }
    return h;
}

存在的问题

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mS8BKXrJ-1649521270565)(image-20220409153946508.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WoDQxVUn-1649521270566)(image-20220409152700037.png)]

大意是Java语言规范 JLS使用乘数39处理超过15个字符的字符串时会抛出异常,从而影响性能。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-T1miUie5-1649521270566)(image-20220409155511277.png)]

处理小字符串为什么用37已不可考。

解决方案

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HClY36iL-1649521270567)(image-20220409154703072.png)]

将乘数修正为31,是他经过大量研究的结果。而且对大字符串进行散列将更有效。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5p785Ksg-1649521270567)(image-20220409155915748.png)]

几种候选乘数的比较

数据集

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HqD2YV4U-1649521270567)(image-20220409161718976.png)]

哈希表平均查找次数越小越好):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-s1lc1zrE-1649521270567)(image-20220409162707434.png)]

结果分析

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wJq9za8x-1649521270568)(image-20220409162116318.png)]

小结

通过以上分析,我们知道了31是开发者权衡了计算成本、兼容性(Java是个跨平台语言,考虑兼容小系统)、规范复杂性的综合选择,同时它又恰好是个素数(如序列{11,44,77}取31作为乘数比33更好)

那么用31作为乘数的哈希函数是的吗?什么才是理想的哈希函数?它们有多大差距呢?

理想的哈希函数

int类型占 4 字节 32 位,有 2 32 2^{32} 232 种取值,但一个String常量可以包含任意数量的字符,考虑极端情况,由于鸽巢原理,碰撞/冲突必然存在。

著名的、反直觉的生日问题指出,对于365种可能的哈希值,在达到50%碰撞概率时,至少存在23个唯一的哈希值,即使对于最理想的哈希函数也是如此。

如果有 2 32 2^{32} 232 种可能的哈希值,在达到50%碰撞概率时,至少存在约77164个唯一的哈希值。

这个数字怎么来的?下面进行推导。

公式推导

假设散列函数非常好——它在可用范围内均匀分布散列值。

在这种情况下,为输入集合生成哈希值与生成随机数集合非常相似。那么,我们的问题可以转化为下面的问题:

我们从反向思路着手,先计算它们都是唯一的概率。

给定一个由 N 个可能的散列值组成的序列。

假设已经选定一个值,还有 N-1 个值,它们都不等于选定的值,则随机两数相互不等的概率 p 1 = N − 1 N p_1=\frac{N-1}{N} p1=NN1

假设已经选定两个值,还有 N-2 个值,它们都不等于选定的值,则随机两数相互不等的概率 p 2 = N − 1 N × N − 2 N p_2=\frac{N-1}{N} \times \frac{N-2}{N} p2=NN1×NN2。(事件相互独立)

往后推到,得到所有数互相不等的概率 P 反 = N − 1 N × N − 2 N × … × N − ( k − 2 ) N × N − ( k − 1 ) N P_反=\frac{N-1}{N} \times \frac{N-2}{N} \times … \times \frac{N-(k-2)}{N} \times \frac{N-(k-1)}{N} P=NN1×NN2××NN(k2)×NN(k1)

该公式近似为: e − k ( k − 1 ) 2 N e^{\frac{-k(k-1)}{2N}} e2Nk(k1),原理是 e x e^x ex 泰勒展开式+等差公式,推导过程如下:

在这里插入图片描述

公式应用

N = 2 32 N=2^{32} N=232,则下图反映了使用 32 位哈希值时的冲突概率。

当哈希数为77163时,发生冲突的几率为50%,这个数字可以作为一个基准

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HqsLdG88-1649521270568)(probability-distribution.png)]

python代码:

from math import exp
def prob(x,N):
    return 1.0 -exp(-0.5 * x * (x-1) / N)

prob(77163,2**32) # 0.4999978150170551
prob(77164,2**32) # 0.500006797931095

换句话说,针对不同的数据集,碰撞最少的理想状态乘数分布在(0,77163)这个区间中,可以写个循环找到最少碰撞的乘数,代码如下:

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.IOException;

import java.util.*;

import java.nio.charset.StandardCharsets;

public class HashTest {

    //计算给定乘数x和数据长度N时的碰撞率
    public static double prob(int k, long N) {
        return 1 - Math.exp(-0.5 * k * (k - 1) / N);
    }

    //按碰撞率获取乘数的边界值
    public static int getBorder(long N, double rate) {
        int i = 0;
        int k = 0;
        while (i < N) {
            double result = prob(i, N);
            if (result > rate) {
                k = i - 1;
                break;
            }
            i++;
        }
        return k;
    }

    //获取理想状态的哈希值
    public static int getIdeaHashCode(String value, int k) {
        int hash = 0;
        for (int i = 0; i < value.length(); i++) {
            hash = k * hash + value.charAt(i);
        }
        return hash;
    }

    //统计碰撞集合,去重
    private static Map<Integer, Set> collisions(Collection values, int k, Boolean idea_on) {
        Map<Integer, Set> result = new HashMap<>();
        for (Object value : values) {
            Integer hc = (value.toString()).hashCode();//调用String.hashCode()
            if (idea_on) {	//如果要统计理想哈希值,调用该方法
                hc = HashTest.getIdeaHashCode((String) value, k);
            }
            Set bucket = result.get(hc);
            if (bucket == null)
                result.put(hc, bucket = new TreeSet<>());

            bucket.add(value);
        }
        return result;
    }

    //读入文本文件,寻找最少碰撞情况的乘数k,打印碰撞检测结果
    public static void test(String path, Boolean idea_on) throws IOException {
        System.err.println("Loading lines from stdin...");
        Set lines = new HashSet<>();
        try (BufferedReader r = new BufferedReader(new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
            for (String line = r.readLine(); line != null; line = r.readLine())
                lines.add(line);
        }

        System.err.println("Computing collisions...");
        Map<Integer, Set> collisions;
        int max = 0;
        double rate = 0.5;
        for (double i = 0.5; i > 0; i -= 0.01) {	//调用循环找理想乘数
            collisions = collisions(lines, getBorder(lines.size(), i), idea_on);
            if (collisions.size() > max) {
                max = collisions.size();
                rate = i;
            }
        }
        int border = getBorder(lines.size(), rate);
        if (idea_on) {
            System.out.println("k = " + border);
        }
        collisions = collisions(lines, border, idea_on);

        int maxsize = 0;
        for (Map.Entry<Integer, Set> e : collisions.entrySet()) {
            Set bucket = e.getValue();
            if (bucket.size() > maxsize) {
                maxsize = bucket.size();
            }
        }

        System.out.println("Total unique lines: " + lines.size());
        System.out.println("Total unique hashcodes: " + collisions.size());
        System.out.println("Total collisions: " + (lines.size() - collisions.size()));
        System.out.println("Collision rate: " + String.format("%.8f", 1.0 * (lines.size() - collisions.size()) / lines.size()));
        if (maxsize != 0)
            System.out.println("Max collisions: " + maxsize);
    }

    public static void main(String[] args) throws IOException {
        test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\words.txt", false);	//乘数31
        test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\words.txt", true);	//理想状态下的乘数
        test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\shakespeare.txt", false);	//乘数31
        test("D:\\code\\idea_projects\\Test\\src\\main\\resources\\shakespeare.txt", true);	//理想状态下的乘数
    }
}

数据集:

https://sigpwned.com/wp-content/uploads/2018/08/words.txt

https://sigpwned.com/wp-content/uploads/2018/08/shakespeare.txt

结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BGHYrZxe-1649521270568)(image-20220409234952761.png)]

String.hashCode()的目的

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xrKDi203-1649521270568)(1649430646675-b9464307-072a-43f8-a623-39b325373bca.png)]

https://www.reddit.com/r/programming/comments/967h8m/stringhashcode_is_not_even_a_little_unique/

哈希表/散列表一般有两种用途:加密 or 索引,这里将hashCode用作索引方便查找,没有必要花费额外的性能成本(比如调用安全散列函数)。

总结

  • Java是一门跨平台的语言,31提高小系统运算效率(乘法转换为:移位+加法)
  • 与合数相比,选择素数普适性更好
  • 31不一定是最好的,但至少不差(与理想乘数相比)
  • Java中String类型的对象是常量,它的hashCode()只用作索引没有必要花费更多计算成本提高安全性或保证无冲突,它是速度、碰撞次数、平台兼容性等多方面综合考虑的结果。
举报

相关推荐

0 条评论