漫谈散列函数

说到散列,一般对应于散列表(哈希表)和散列函数。
我们今天不谈哈希表,仅谈下散列函数。

定义

引一段百度百科关于散列函数的定义。

Hash,一般翻译做“散列”,也有直接音译为“哈希”的,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。
这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。

简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。

关于散列函数的定义有很多表述,大同小异,理解其概念和内涵即可。

性质

查看维基百科和百度百科,两者关于散列的性质都提到了几点:

1、确定性

如果两个散列值是不相同的,那么这两个散列值的原始输入也是不相同的;

2、冲突(碰撞)

散列函数的输入和输出不是唯一对应关系的,如果两个散列值相同,两个输入值很可能是相同的,但也可能不同;

3、不可逆性

最后一个是关于是否可逆的,两者表述有所出入:

维基百科-散列函数百度百科-散列函数

维基百科中,很明确地提出“散列函数必须具有不可逆性”,而百度百科的表述则模棱两可,相比之下,后者显得太不严谨了。
笔者比较倾向于维基百科提到的不可逆性。

4、混淆性

在“散列函数的性质”一节,维基百科还提到一点:

输入一些数据计算出散列值,然后部分改变输入值,一个具有强混淆特性的散列函数会产生一个完全不同的散列值。

该表述中有两个词:“强混淆”, “完全不同”。就是什么含义呢?

先来了解一个概念:雪崩效应
其精髓在于“严格雪崩准则”:当任何一个输入位被反转时,输出中的每一位均有50%的概率发生变化。

再了解一个概念:Hamming distance。
有的译作“海明距离”,有的则是“汉明距离”。名字不重要,重要的内涵。

两个码字的对应比特取值不同的比特数称为这两个码字的海明距离。举例如下:10101和00110从第一位开始依次有第一位、第四、第五位不同,则海明距离为3。

对应于散列,如果“部分改变输入值”,  前后两个两个散列的海明距离为散列长度的一半(也就是有一半的bit不相同),则为“50%的概率发生变化”。
这样的散列函数,就是“具有强混淆特性的散列函数”。

散列函数举例

常见的散列函数有MD5和SHA家族等加密散列函数,CRC也该也算是散列。
两者都有用于数据校验,而前者还用于数字签名,访问认证等安全领域。
不过我们今天不对加密散列做太多展开,主要讲讲下面两个散列:

BKDRHash

这个散列函数大家应该见过,可能有的读者不知道它的名字而已。
JDK中String的hashCode()就是这个散列函数实现的:

    public int hashCode() {
        int h = hash;
        final int len = length();
        if (h == 0 && len > 0) {
            for (int i = 0; i < len; i++) {
                h = 31 * h + charAt(i);
            }
            hash = h;
        }
        return h;
    }

定义一个类,如果让IDE自动生成hashCode()函数的话,其实现也是类似的:

    public static class Foo{
        int a;
        double b;
        String c;
        
        @Override
        public int hashCode() {
            int result;
            long temp;
            result = a;
            temp = Double.doubleToLongBits(b);
            result = 31 * result + (int) (temp ^ (temp >>> 32));
            result = 31 * result + (c != null ? c.hashCode() : 0);
            return result;
        }
    }

为什么总是跟“31”过不去呢?为什么要这样迭代地求积和求和呢?
这篇文章讲到了其中一些原理:哈希表之bkdrhash算法解析及扩展
而知乎上也有很多大神做了分析:hash算法的数学原理是什么,如何保证尽可能少的碰撞
从第二个链接给出的评分对比可以看出,BKDRHash虽然实现简单,但是很有效(冲突率低)。

低冲突,使得BKDRHash不仅仅用于哈希表,还用于索引对象。
这样的用法,最常见的还是MD5,有的网站可能会用文件的MD5作为检索文件的key,
像DiskLruCache也是用MD5作为key, 不过通常不是对文件本身计算MD5,而是对url做MD5(例如OkHttp, Glide)。
MD5生成的消息摘要有128bit, 如果要标识的对象不多,冲突率会很低;
当冲突率远远低于硬件损坏的概率,那么可以认为用MD5作为key是可靠的。
对于网站,如果要存储海量文件,不建议用MD5作为key。
顺便提一下,UUID其实也是128bit的精度,只是其为了方便阅读多加了几个分割线而已。

扯远了, 回归正题~
之所以看到BKDRHash用来索引对象,主要是看到这篇文章(笔者没有研究过Volley源码):
Android Volley 源码解析(二),探究缓存机制
里面提到Volley缓存的key的设计:

private String getFilenameForKey(String key) {
       int firstHalfLength = key.length() / 2;
       String localFilename = String.valueOf(key.substring(0, firstHalfLength).hashCode());
       localFilename += String.valueOf(key.substring(firstHalfLength).hashCode());
       return localFilename;
}

由于JDK的hashCode()返回值是int型,这个函数可以说是64bit精度的。
不能说它是散列函数,因为其返回值长度并不固定,按照定义,不能称之为散列函数,虽然思想很接近。
其等价写法如下:

    public static String getFilenameForKey(String key) {
        byte[] bytes = key.getBytes();
        int h1 = 0, h2 = 0;
        int len = bytes.length;
        int firstHalfLength = len / 2;
        for (int i = 0; i < firstHalfLength; i++) {
            byte ch = bytes[i];
            h1 = 31 * h1 + ch;
        }
        for (int i = firstHalfLength; i < len; i++) {
            byte ch = bytes[i];
            h2 = 31 * h2 + ch;
        }
        long hash = (((long) h1 << 32) & 0xFFFFFFFF00000000L) | ((long) h2 & 0xFFFFFFFFL);
        return Long.toHexString(hash);
    }

效果大约等价于64bit精度的BKDRHash。
64bit的BKDRHash如下:

    public static long BKDRHash(byte[] bytes) {
        long seed = 1313; // 31 131 1313 13131 131313 etc..
        long hash = 0;
        int len = bytes.length;
        for (int i = 0; i < len; i++) {
            hash = (hash * seed) + bytes[i];
        }
        return hash;
    }

笔者编了程序比较其二者的冲突率,前者比后者要高一些(篇幅限制,不贴测试代码了,有兴趣的读者可自行测试)。

32bit的散列,无论哪一种,只要数据集(随机数据)上10^6, 基本上每次跑都会有冲突。
64bit的散列,只要性能不是太差,如果数据的长度是比较长的(比方说20个字节的随机数组),即使千万级别的数据集也很难出现冲突(笔者没有试过上亿的,机器撑不住)。

笔者曾经也是BKDRHash的拥趸,并在项目中使用了一段时间(作为缓存的key)。
直到看到上面那篇知乎的讨论,的一个回答:


看了知乎心里一惊,回头修改了下测试用例,构造随机数据时用不定长的数据,比方说1-30个随机字节,
测试于上面写的64bitBKDRHash, 其结果是:
数据集上5万就可以看到冲突了。

之前是知道BKDRHash的混淆性不足的(比方说最后一个字节的值加1,hash值也只是加1而已,如果未溢出的话);
但是由于其实现简单,以及前面那个不合理的测试结果,就用来做缓存的key了,毕竟Volley也这么干了。
实际上也没有太大的问题,因为用的地方输入通常比较长,而且要缓存的文件也不是很多(几百上千的级别),所以应该不会有冲突。
但是心里面还是不踏实,最终还是很快换了另一个散列函数。

MurmurHash

初次看到这个散列函数,也是被它的名字雷到了。
不过也有觉得这个名字很“萌”的:


不过有道是“人不可貌相”,算法也不可以名字来评判,还是要看其效果。
如截图所示,很多大名鼎鼎的开源组件都用到这个散列,那其究竟是何方神圣呢?让我们来一探究竟。
先看源码:sites.google.com/site/m
源码是用C++写的:

uint64_t MurmurHash64A ( const void * key, int len, unsigned int seed )
{
    const uint64_t m = 0xc6a4a7935bd1e995;
    const int r = 47;

    uint64_t h = seed ^ (len * m);

    const uint64_t * data = (const uint64_t *)key;
    const uint64_t * end = data + (len/8);

    while(data != end)
    {
        uint64_t k = *data++;

        k *= m; 
        k ^= k >> r; 
        k *= m; 
        
        h ^= k;
        h *= m; 
    }

    const unsigned char * data2 = (const unsigned char*)data;

    switch(len & 7)
    {
    case 7: h ^= uint64_t(data2[6]) << 48;
    case 6: h ^= uint64_t(data2[5]) << 40;
    case 5: h ^= uint64_t(data2[4]) << 32;
    case 4: h ^= uint64_t(data2[3]) << 24;
    case 3: h ^= uint64_t(data2[2]) << 16;
    case 2: h ^= uint64_t(data2[1]) << 8;
    case 1: h ^= uint64_t(data2[0]);
            h *= m;
    };
 
    h ^= h >> r;
    h *= m;
    h ^= h >> r;

    return h;
}

总的来说也不是很复杂,BKDHHash相比,都是一遍循环的事。
而且C++可以将int64的指针指向char数组,可以一次算8个字节,对于长度较长的数组,运算更快。
而对于java来说,就相对麻烦一些了:

展开阅读全文

本文系作者在时代Java发表,未经许可,不得转载。

如有侵权,请联系nowjava@qq.com删除。

编辑于

关注时代Java

关注时代Java