为什么Java String哈希乘数为31?

发表文章之后,发现很多图片显示不了,请阅读我的公众号文章,以获得本文最佳体验:

为什么Java String哈希乘数为31?

 

前面简单介绍了[ 经典的Times 33 哈希算法 ],这篇我们通过分析Java 1.8 String类的哈希算法,继续聊聊对乘数的选择。

String类的hashCode()源码

/** Cache the hash code for the string */
private int hash;

/** 
Returns a hash code for this string. The hash code for a String object is computed as 
 s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
using int arithmetic, where s[i] is the ith character of the string, 
n is the length of the string, and ^ indicates exponentiation. 
(The hash value of the empty string is zero.) 
*/

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

可以看到,String的哈希算法也是采用了Times 33的思路,只不过乘数选择了31。

其中

  • hash默认值为0.
  • 判断h == 0是为了缓存哈希值.
  • 判断value.length > 0是因为空字符串的哈希值为0.

用数据说话

前一篇我们提到:

这个神奇的数字33,为什么用来计算哈希的效果会比其他许多常数(无论是否为质数)更有效,并没有人给过足够充分的解释。因此,Ralf S. Engelschall尝试通过自己的方法解释其原因。通过对1到256中的每个数字进行测试,发现偶数的哈希效果非常差,根据用不了。而剩下的128个奇数,除了1之外,效果都差不多。这些奇数在分布上都表现不错,对哈希表的填充覆盖大概在86%。

从哈希效果来看(Chi^2应该是指卡方分布),虽然33并不一定是最好的数值。但17、31、33、63、127和129等相对其他的奇数的一个很明显的优势是,由于这些奇数与16、32、64、128只相差1,可以通过移位(如1 << 4 = 16)和加减1来代替乘法,速度更快

那么接下来,我们通过实验数据,来看看偶数、奇数,以及17、31、33、63、127和129等这些神奇数字的哈希效果,来验证Ralf S. Engelschall的说法。

环境准备

个人笔记本,Windows 7操作系统,酷睿i5双核64位CPU。

测试数据:CentOS Linux release 7.5.1804的/usr/share/dict/words字典文件对应的所有单词。

由于CentOS上找不到该字典文件,通过yum -y install words进行了安装。

/usr/share/dict/words共有479828个单词,该文件链接的原始文件为linux.words。

计算冲突率与哈希耗时

测试代码

/**
 * 以1-256为乘数,分别计算/usr/share/dict/words所有单词的哈希冲突率、总耗时.
 * 
 * @throws IOException
 */

@Test
public void testHash() throws IOException {
    List<String> words = getWords();

    System.out.println();
    System.out.println("multiplier, conflictSize, conflictRate, timeCost, listSize, minHash, maxHash");
    for (int i = 1; i <=256; i++) {
        computeConflictRate(words, i);
    }
}

/**
 * 读取/usr/share/dict/words所有单词
 * 
 * @return
 * @throws IOException
 */

private List<String> getWords() throws IOException {
    // read file
    InputStream is = HashConflictTester.class.getClassLoader().getResourceAsStream("linux.words");
    List<String> lines = IOUtils.readLines(is, "UTF-8");
    return lines;
}

/**
 * 计算冲突率
 * 
 * @param lines
 */

private void computeConflictRate(List<String> lines, int multiplier) {
    // compute hash
    long startTime = System.currentTimeMillis();
    List<Integer> hashList = computeHashes(lines, multiplier);
    long timeCost = System.currentTimeMillis() - startTime;

    // find max and min hash
    Comparator<Integer> comparator = (x,y) -> x > y ? 1 : (x < y ? -1 : 0);
    int maxHash = hashList.parallelStream().max(comparator).get();
    int minHash = hashList.parallelStream().min(comparator).get();

    // hash set
    Set<Integer> hashSet = hashList.parallelStream().collect(Collectors.toSet());

    int conflictSize = lines.size() - hashSet.size();
    float conflictRate = conflictSize * 1.0f / lines.size();
    System.out.println(String.format("%s, %s, %s, %s, %s, %s, %s", multiplier, conflictSize, conflictRate, timeCost, lines.size(), minHash, maxHash));
}

/**
 * 根据乘数计算hash值
 * 
 * @param lines
 * @param multiplier
 * @return
 */

private List<Integer> computeHashes(List<String> lines, int multiplier) {
    Function<String, Integer> hashFunction = x -> {
        int hash = 0;
        for (int i = 0; i < x.length(); i++) {
            hash = (multiplier * hash) + x.charAt(i);
        }
        return hash;
    };
    return lines.parallelStream().map(hashFunction).collect(Collectors.toList());
}

执行测试方法testHash(),稍等片刻后,我们将得到一份测试报告。

哈希冲突率降序排序

通过对哈希冲突率进行降序排序,得到下面的结果。

结果分析

  • 偶数的冲突率基本都很高,只有少数例外。
  • 较小的乘数,冲突率也比较高,如1至20。
  • 乘数1、2、256的分布不均匀。Java哈希值为32位int类型,取值范围为[-2147483648,2147483647]。

哈希冲突率降序排序哈希冲突率降序排序

哈希耗时降序排序

我们再对冲突数量为1000以内的乘数进行分析,通过对执行耗时进行降序排序,得到下面的结果。

分析17、31、33、63、127和129

  • 17在上一轮已经出局。
  • 63执行计算耗时比较长。
  • 31、33的冲突率分别为0.13%、0.14%,执行耗时分别为10、11,实时基本相当
  • 127、129的冲突率分别为0.01%、0.004%,执行耗时分别为9、10

总体上看,129执行耗时低,冲突率也是最小的,似乎先择它更为合适?

哈希耗时降序排序哈希耗时降序排序

哈希分布情况

将整个哈希空间[-2147483648,2147483647]分为128个分区,分别统计每个分区的哈希值数量,以此来观察各个乘数的分布情况。每个分区的哈希桶位为2^32 / 128 = 33554432。

之所以通过分区来统计,主要是因为单词数太多,尝试过画成图表后密密麻麻的,无法直观的观察对比。

计算哈希分布代码

@Test
public void testHashDistribution() throws IOException {
    int[] multipliers = {21731336312773133237161};
    List<String> words = getWords();
    for (int multiplier : multipliers) {
        List<Integer> hashList = computeHashes(words, multiplier);
        Map<Integer, Integer> hashMap = partition(hashList);
        System.out.println("\n" + multiplier + "\n,count");
        hashMap.forEach((x, y) -> System.out.println(x + "," + y));
    }
}

/**
 * 将整个哈希空间等分成128份,统计每个空间内的哈希值数量
 * 
 * @param hashs
 */

public static Map<Integer, Integer> partition(List<Integer> hashs) {
    // step = 2^32 / 128 = 33554432
    final int step = 33554432;
    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 = min + step;
        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) -> x + y).get();
    assert hashNum == hashs.size();

    return statistics;
}

生成数据之后,保存文本为csv后缀,通过Excel打开。再通过Excel的图表功能,选择柱状图,生成以下图表。

乘数2乘数2乘数17乘数17乘数31乘数31乘数33乘数33乘数73乘数73乘数127乘数127乘数133乘数133乘数161乘数161乘数237乘数237

除了2和17,其他数字的分布基本都比较均匀。

总结

现在我们基本了解了Java String类的哈希乘数选择31的原因了,主要有以下几点。

  • 31是奇素数。
  • 哈希分布较为均匀。偶数的冲突率基本都很高,只有少数例外。较小的乘数,冲突率也比较高,如1至20
  • 哈希计算速度快。可用移位和减法来代替乘法。现代的VM可以自动完成这种优化,如31 * i = (i << 5) - i
  • 31和33的计算速度和哈希分布基本一致,整体表现好,选择它们就很自然了。

当参与哈希计算的项有很多个时,越大的乘数就越有可能出现结果溢出,从而丢失信息。我想这也是原因之一吧。

尽管从测试结果来看,比31、33大的奇数整体表现有更好的选择。然而31、33不仅整体表现好,而且32的移位操作是最少的,理论上来讲计算速度应该是最快的。

最后说明一下,我通过另外两台Linux服务器进行测试对比,发现结果基本一致。但以上测试方法不是很严谨,与实际生产运行可能存在偏差,结果仅供参考。

几个常用实现选项

values chosen to initialize h and a for some of the popular implementationsvalues chosen to initialize h and a for some of the popular implementations

其中

  • INITIAL_VALUE:哈希初始值。Java String的初始值hash=0。
  • a:哈希乘数。Java String的哈希乘数为31。

 

参考

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

https://segmentfault.com/a/1190000010799123

https://en.wikipedia.org/wiki/Universal_hashing

《Effective Java中文版本》第2版

 

转载请注明来源:http://zhanjia.iteye.com/blog/2426892

 

个人公众号

二进制之路

 

<think>我们正在讨论如何设计高效的哈希函数以减少键值冲突。哈希函数的设计是unordered_map性能的关键,因为一个好的哈希函数应该将键均匀地分布到哈希表的各个桶中,从而最小化冲突。设计原则:1.均匀性:哈希函数应该使输出值在哈希表的大小范围内均匀分布。2.确定性:相同的键必须总是产生相同的哈希值。3.高效性:计算哈希值的时间复杂度应该是O(1)或尽可能低。4.抗碰撞性:不同的键应尽可能产生不同的哈希值(尽管完全避免不可能)。常见设计方法:1.整数键:可以直接使用整数本身,但通常需要再处理(比如取模)以适应哈希表大小。但注意,如果整数有规律,可能造成聚集,因此有时会使用一个质数进行乘法或取模运算。2.字符串键:常用多项式滚动哈希,例如:hash= (s[0]*p^(n-1) +s[1]*p^(n-2)+ ...+ s[n-1])mod M,其中p是一个质数(如31、131等),M是一个大质数(或者使用无符号整型的自然溢出)。3.组合键:当键由多个部分组成时,可以将每个部分分别哈希然后组合(例如使用异或、加法、乘法等)。示例代码(字符串哈希函数):```cpp#include <string> #include<functional> //一个简单的字符串哈希函数size_t string_hash(conststd::string&key){size_thash =0;const size_t prime=31;//常用质数for (char c: key) {hash= hash* prime+ c; //多项式滚动哈希}return hash; }//在unordered_map中使用自定义哈希函数structStringHash {size_t operator()(const std::string& s) const{return string_hash(s);} };std::unordered_map<std::string,int,StringHash> my_map;```高级技巧:1.使用质数作为乘数:可以减少周期性规律带来的冲突。2.双重哈希:使用两个不同的哈希函数来进一步减少冲突。3.考虑哈希表大小:哈希函数通常需要与哈希表大小结合(如取模),而哈希表大小最好是一个质数,这样可以减少冲突。注意事项: -避免可预测的冲突:例如,如果使用简单的取模运算,而输入键具有某种模式(如都是偶数),则取模一个偶数会导致冲突。因此,哈希表大小取质数,并且哈希函数内部使用质数乘法,可以缓解此问题。-测试:在实际数据上测试哈希函数的分布情况(例如,计算每个桶的负载,标准差等)。示例:整数键的哈希函数设计```cpp//整数键的哈希函数示例structIntHash{size_toperator()(intkey) const{//使用一个质数进行乘法,然后右移或异或以增加随机性key= ((key >>16) ^key)*0x45d9f3b; //一种混合操作return key;} };```总结:设计高效哈希函数需要根据键的类型和分布特点来选择合适的方法,并通过实验调整参数以达到均匀分布。§§相关问题§§1.为什么在字符串哈希中常用质数作为乘数? 2.双重哈希的具体实现方式是什么,它是如何减少冲突的? 3.如何测试一个哈希函数的均匀性和效率?4.当哈希表的桶数量是2的幂时,为什么取模运算可以用位运算代替?这有什么优缺点?5.在自定义类型作为键时,除了自定义哈希函数,还需要注意什么?</think>### 如何设计高效的哈希函数 设计高效哈希函数的核心目标是**最小化冲突**,同时保证**计算速度快**和**分布均匀**。以下是关键设计原则和实现方法: #### 核心设计原则 | 原则 | 说明 | 重要性 | |------|------|--------| | **均匀性** | 键值应均匀分布在所有桶中 | ★★★★★ | | **确定性** | 相同键必须产生相同哈希值 | ★★★★★ | | **高效性** | 计算速度要快(O(1)复杂度) | ★★★★☆ | | **雪崩效应** | 微小键值变化应导致哈希值剧变 | ★★★☆☆ | #### 常用设计方法 1. **整数键处理** ```cpp // 乘法+位移混合法(适用于32位整数) uint32_t hash_int(uint32_t key) { key = ((key >> 16) ^ key) * 0x45d9f3b; key = ((key >> 16) ^ key) * 0x45d9f3b; return (key >> 16) ^ key; } ``` 2. **字符串键处理(多项式滚动哈希)** ```cpp // 使用质数乘数31Java的选择,131是CPP常用) size_t hash_string(const string& str) { size_t hash = 5381; // 魔法种子值 for(char c : str) { hash = (hash * 131) + c; // 131是质数 } return hash; } ``` 3. **组合键处理** ```cpp // 混合多个字段的哈希(使用异或和乘法) struct Point { int x, y; size_t hash() const { return (std::hash<int>()(x) * 131) ^ (std::hash<int>()(y) * 163); } }; ``` #### 冲突优化技术 1. **双重哈希法** ```cpp size_t hash1 = std::hash<string>()(key); size_t hash2 = secondary_hash(key); // 使用不同算法 size_t final_hash = hash1 ^ (hash2 << 1); ``` 2. **动态种子值(防碰撞攻击)** ```cpp // C++17引入的哈希种子 std::unordered_map<std::string, int> map; auto hasher = map.hash_function(); size_t seed = hasher(""); // 获取运行时随机种子 ``` #### 性能对比表 | 方法 | 冲突率 | 计算速度 | 适用场景 | |------|--------|----------|----------| | 取模法 | 高 | 极快 | 整数键且分布均匀 | | 乘法移位 | 中 | 快 | 通用整数键 | | 多项式哈希 | 低 | 中 | 字符串/复合键 | | 加密哈希(SHA) | 极低 | 慢 | 安全敏感场景 | #### 实际设计步骤 1. **分析键特征** - 键的数据类型分布 - 预期键数量范围 - 是否包含敏感数据 2. **选择基础算法** ```mermaid graph LR A[键类型] --> B{整数} A --> C{字符串} A --> D{复合对象} B --> E[乘法移位哈希] C --> F[多项式滚动哈希] D --> G[字段混合哈希] ``` 3. **测试与优化** ```cpp // 冲突率测试函数 void test_hash(HashFunc func, const vector<Key>& keys) { vector<int> buckets(BUCKET_SIZE, 0); for(const auto& key : keys) { size_t h = func(key) % BUCKET_SIZE; buckets[h]++; } // 计算标准差和最大冲突值 } ``` > **最佳实践**:对未知数据,使用`std::hash`作为基础(C++标准库已优化),对性能敏感场景使用[abseil::Hash](https://abseil.io/blog/20180927-swisstables)或[xxHash](https://cyan4973.github.io/xxHash/)
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值