zl程序教程

您现在的位置是:首页 >  大数据

当前栏目

编程参考 - 如何计算字符串的哈希值

计算编程哈希 如何 字符串 参考
2023-09-11 14:22:08 时间

字符串的哈希值是什么?

在C++,Java等编程语言中,有一种hashmap的数据结构,存储一对key / value,分别是两种对象。

为了加快存取的速度,键值key对象会被转换成一个hash值,一个整数。一般来讲,可以将任何对象转化成一个固定整数值。

比如,key和value都是字符串,那就会将key的字符串转换成整数值,作为索引,这样查找hashmap数据结构就会更快,而不用使用其他遍历方法。

那这里说的将字符串转化或简化成一个数字的方法,就是计算一个字符串的哈希值。

期望的要求如下:

1,要保证同样的字符串,每次生成的都是同一个数值。

2,如果字符串里的字符顺序不同,生成的数值是不同的。

3,不同的字符串产生的哈希值要尽量不要重复,保证其几率非常小。

Java里的string类( java.util.String),有一个hashCode方法,就是用来生成字符串的哈希值(hash code),源码如下:

/**

* Returns a hash code for this string. The hash code for a

* <code>String</code> object is computed as

* <blockquote><pre>

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

* </pre></blockquote>

* using <code>int</code> arithmetic, where <code>s[i]</code> is the

* <i>i</i>th character of the string, <code>n</code> is the length of

* the string, and <code>^</code> indicates exponentiation.

* (The hash value of the empty string is zero.)

*

* @return  a hash code value for this object.

*/

public int hashCode() {

    int h = hash;

    int len = count;

    if (h == 0 && len > 0) {

        int off = offset;

        char val[] = value;

        for (int i = 0; i < len; i++) {

            h = 31*h + val[off++];

        }

        hash = h;

    }

    return h;

}

根据上面代码,得出Java里计算字符串的哈希值或散列值(hash code)的方法是:

S0 * 31 ^ (n-1) + S1 * 31 ^ (n-2) + .... + S(n-2) * 31 + S(n-1)  

这里,n表示的是字符串长度,S0表示字符串第一个字符的ASCII值,S(n-1)就是最后一个字符的ASCII值。“^”是指数运算符(exponential operand)。

举例来说,如果字符串是“Hi”的话:

HashCode = 'H'*31^(2-1) + 'i'*31^(2-2) = 'H'*31 + 'i' =  72 * 31 + 105 * 1 = 2232 + 105 = 2337

如果是空字符串,则哈希值为0。

另外,一些其他的计算散列值(哈希值)的散列函数会以某个大数(如一个大素数,也有以2^n的)为模数的。这就避免了溢出,并使函数返回的数值范围保持在指定的范围内。但这也意味着无限范围的输入值会从有限的可能值(即[0,modulus])中得到一个哈希值,因此会出现哈希碰撞的问题。

举例来说,代码类似下面,这里是用的权重乘数是29:

使用迭代法计算哈希值,从第一个字符开始计算,多一个字符,前面的哈希值乘以权重数再加上新字符的值:

   public int hash(String x){

        int hashcode=0;

        int MOD=10007;

        int shift=29;

        for(int i=0;i<x.length();i++){

            hashcode=(shift*hashcode)+x.charAt(i);

        }

        return hashcode;

    }

在哈希值计算时取模:

   public int hash(String x){

        int hashcode=0;

        int MOD=10007;

        int shift=29;

        for(int i=0;i<x.length();i++){

            hashcode=((shift*hashcode)%MOD+x.charAt(i))%MOD;

        }

        return hashcode;

    }

为什么计算使用31作为基数,用字符偏移作为指数,一起做成字符位置的权重,和字符的ASCII值相乘?

Java里string的hashCode值计算,使用31作为乘数的原因是什么呢。(在计算时,要乘以或左移一个magic number。进行的是multiply或shift操作)

首先,是因为31不是偶数,如果你使用一个偶数,那里面就有2。计算机里一个数值乘以2,就相当于左移一位,那计算乘法时结果溢出的话,就浪费了一位有效信息。

第二,31是一个质数,按照传统一般都会选个质数。假如选择9进制,那100的话就是121,如果是3进制的话,就是10201,就是将121展开(1)(02)(01),9进制和3进制的表示法是有关联的,就像16进制和8进制。选择不同的乘数,就和这个进制表示法类似的,如果不是质数,就会引发一些表达上的不纯粹。

第三,使用31还可以带来计算的方便,可以将乘法转换为位移和减法操作:31 * i =(i << 5) - i;   在很多环境下, 会自动使用这种优化。

第四,这个值不是很大,一般长度的单词,计算的话,不会溢出,而导致信息丢失。也不是太小,太小的话,大多数字符串的哈希值的范围会较集中,而不是分布在更大的整数区间内。

第五,经过碰撞测试,能够较好符合要求。即根据大量的单词测试,发生不同字符串出现相同哈希值的情况,概率很低。

解释一下,为什么选择一个质数(prime number)作为乘数。

因为一般我们使用哈希值,都是作为一个索引(slot)使用,用在hashtable中,而这个索引通常是有范围限定的,比如索引范围是 0 ~ N-1,也就是N个可用索引位置。

那使用的计算公式可以理解成hash(X) % N,也就是对X求出一个哈希值,然后对N取余,求得的余数就是索引位置。

N就是我们限定了哈希值的有效范围,通过取余来实现。

那如果我们使用的乘数不是质数,那就有可能和N有公约数(share divisor),这样取余的话,公约数消掉以后,就改变了取余结果的范围。

那哈希值的计算结果就不能随机的散落在 0 ~ (N-1)的范围内,这不符合我们的使用需求和期望,我们希望求得的哈希值,在整个有效范围内,是固定的、不易重复的,所以更希望在整个值域内是随机分布的,有公约数会影响所求结果的分布范围,显然不好。

虽然字符串的散列值计算时,最后一个字符乘以1,而不是用这个乘数,但一个字符对结果的影响是有限的,尤其是N比较大时,对最终取值的范围影响有限。

另外一般来说 ,我们N的取值是2的n次方,取余操作可以直接转换为移位操作。这样一个奇数就不会和N有公约数,但通用一些的话,还是用质数比较好。

在很多的处理器上,执行乘以31的操作会的代价较小,比如在ARM上,只需一条指令:

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

大多数其他处理器都需要单独的移位和减法指令。然而,如果你的乘法器很慢,这就有很大帮助。现代的处理器往往有快速的乘法器,所以有时没有太大的区别。

测试结果对比

在JDK的bug base里的JDK-4045622条目中的说明:

Bug ID: JDK-4045622 java.lang.String.hashCode spec incorrectly describes the hash algorithm

首先,最开始的hashcode版本是:

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;

    }

这个哈希计算函数,在字符串较长时,做了一个过滤,不是每个字符都参与计算,而是间隔几个,这样的目的应该是加快计算速度。

这里说明了一个对比测试结果,那java的使用37作为乘数的函数和其他的相比较:

使用的数据类型,

1,Merriam-Webster's 2nd Int'l Unabridged Dictionary里的所有单词和短语(words and phrases),大约311, 141隔字符串,平均长度为10.

2,这些文件系统路径下用到的字符串,/bin/, /usr/bin/, /usr/lib/, /usr/ucb/ 和 /usr/openwin/bin/*,66304个字符串,平均长度21个字符。

3,使用web-crawler网络爬虫收集到的URL地址列表,抓取几个小时,得到28373个字符串,平均长度49个字符。

不同内容使用不同的哈希计算函数对比的性能结果, 以从hashtable访问元素的性能来比较:(performance metric)

表中显示的性能指标是哈希表中所有元素的 "average chain size"(即查找一个元素所需的键数比较的预期值)。

从这个对比结果来看,用31生成的hashtable,结果还是可以的。

这不是一个伟大的哈希算法,但已经足够好了。

使用其他的质数作为乘数的话

使用其他的质数,也可以将乘法转化为更快的移位和加减操作。

比如:

x*37 = ((x+8*x)*4)+x  ( y = x+8*x,   4*y + x = x*37)

x*73 = ((x+8*x)*8)+x  ( y = x+8*x,   8*y + x = x*73)

先计算括号以内的公式,左移三位,然后加上x,再左移两位或三位,然后再加x。

参考:

https://www.youtube.com/watch?v=6JoXp1e8jAQ

java - How to calculate the hash code of a string by hand? - Stack Overflow

How String Hashcode value is calculated? - GeeksforGeeks

How the Java String hash function works (2)

Why does Java's hashCode() in String use 31 as a multiplier? - Stack Overflow

编程参考 - 如何计算字符串的哈希值