漫谈散列函数

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

定义

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

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

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

性质

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

1、确定性

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

2、冲突(碰撞)

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

3、不可逆性

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


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

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

4、混淆性

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

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

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

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

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

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

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

散列函数举例

常见的散列函数有MD5SHA家族等加密散列函数,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的拥趸,直到看到上面那篇知乎的讨论,的一个回答:



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

MurmurHash

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


不过有道是“人不可貌相”,算法也不可以名字来评判,还是要看其效果。
如截图所示,很多大名鼎鼎的开源组件都用到这个散列,那其究竟是何方神圣呢?让我们来一探究竟。
先看源码:https://sites.google.com/site/murmurhash/
源码是用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来说,就相对麻烦一些了:

public static long hash64(final byte[] data) {
        if (data == null || data.length == 0) {
            return 0L;
        }
        final int len = data.length;
        final long m = 0xc6a4a7935bd1e995L;
        final long seed = 0xe17a1465;
        final int r = 47;

        long h = seed ^ (len * m);
        int remain = len & 7;
        int size = len - remain;

        for (int i = 0; i < size; i += 8) {
            long k = ((long) data[i+7] << 56) +
                    ((long) (data[i + 6] & 0xFF) << 48) +
                    ((long) (data[i + 5] & 0xFF) << 40) +
                    ((long) (data[i + 4] & 0xFF) << 32) +
                    ((long) (data[i + 3] & 0xFF) << 24) +
                    ((data[i + 2] & 0xFF) << 16) +
                    ((data[i + 1] & 0xFF) << 8) +
                    ((data[i] & 0xFF));
            k *= m;
            k ^= k >>> r;
            k *= m;
            h ^= k;
            h *= m;
        }

        switch (remain) {
            case 7: h ^= (long)(data[size + 6] & 0xFF) << 48;
            case 6: h ^= (long)(data[size + 5] & 0xFF) << 40;
            case 5: h ^= (long)(data[size + 4] & 0xFF) << 32;
            case 4: h ^= (long)(data[size + 3] & 0xFF) << 24;
            case 3: h ^= (data[size + 2] & 0xFF) << 16;
            case 2: h ^= (data[size + 1] & 0xFF) << 8;
            case 1: h ^= (data[size] & 0xFF);
                h *= m;
        }

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

        return h;
    }

以上实现参考:
https://github.com/tnm/murmurhash-java
https://github.com/tamtam180/MurmurHash-For-Java/

做了一下测试,随机数组,个数10^7,长度1-30, 结果如下:

散列函数 冲突个数 运算时间(毫秒)
BKDRHash 1673 1121
CRC64 1673 1331
MurmurHash 0 1119

该测试把另一个64bit的hash, CRC64掺和进来了。
很神奇的是,CRC64和BKDRHash的冲突率是一样的(测了很多次,都是一样),可能是都存在溢出问题的原因。
至于运算时间,差别不大。
如果把随机数组的长度调整为1-50,则前者(BKDRHash & CRC64)的冲突率会相对降低,而后者的运算效率会稍胜于前者。

我想MurmurHash之所以应用广泛,应该不仅仅是因为其冲突率低,还有一个很重要的特性:
前面提到的“强混淆性”。
编写一个计算码距的函数如下:

   private static int getHammingDistance(long a, long b) {
       int count = 0;
       long mask = 1L;
       while (mask != 0) {
           if ((a & mask) != (b & mask)) {
               count += 1;
           }
           mask <<= 1;
       }
       return count;
   }

构造随机数组,每次改变其中一个bit,计算MurmurHash,码距在31-32之间。
对于64bit的散列,正好在50%左右,说明该散列函数具备雪崩效应。
或者从另一个角度看,MurmurHash算出的散列值比较“均匀”,这个特点很重要。
比如如果用于哈希表,布隆过滤器等, 元素就会均匀分布。

生日悖论

最后了解一下冲突概率的估算: 生日悖论
生日悖论讲的是给定一群人,其中有至少有两个人生日相同的概率。
以一年365天算,23人(及以上),有两人生日相同的概率大约50%;而如果到了60人以上,则概率已经大于99%了。
同样的,给定一组随机数,也可以算出有相同数值(冲突)的概率。
根据维基百科生日攻击中的描述,其冲突分布如下表:


如果随机数是32位,那么可能性有约43亿种,但是只要数量达到50000个,其冲突概率就有25%。
我们常看到MD5作为文件索引,SHA,MD5用于文件校验,就是因为其冲突概率很低,想要篡改文件还保持Hash不变,极其困难。
不过困难不意味着不可能,例如,Google建议不要使用SHA-1加密了,
参见:为什么Google急着杀死加密算法SHA-1

扯远了,回到前面说的缓存key,64bit的MurmurHash, 当缓存数量在10^4这个级别时,
有两个缓存的key冲突的概率是10^-12(万亿份之一)的量级,应该是比较可靠的。
如果觉得还不够,可以用128bit的MurmurHash,仅做索引的话,应该比MD5要更合适(运算快,低碰撞,高混淆)

结语

散列广泛应用于计算机的各个领域,了解散列函数的一些性质,对于解决工程问题或有帮助,
故而有了这篇“漫谈”,希望对读者有所启发。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,875评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,569评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,475评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,459评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,537评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,563评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,580评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,326评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,773评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,086评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,252评论 1 343
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,921评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,566评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,190评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,435评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,129评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,125评论 2 352

推荐阅读更多精彩内容

  • 1.ios高性能编程 (1).内层 最小的内层平均值和峰值(2).耗电量 高效的算法和数据结构(3).初始化时...
    欧辰_OSR阅读 29,358评论 8 265
  • 单向散列函数的说明单向散列函数也称为消息摘要函数, 哈希函数 或者 杂凑函数单向散列函数输出的散列值又称为消息摘要...
    Mario_ZJ阅读 3,780评论 0 1
  • 三生缘之七七箫长 萧凰无所谓的态度明显惹怒了持剑之人,因为她感觉到剑刃离自己的脖子又近了一分。 头上悬刀的...
    Vivi酱9开心come阅读 277评论 0 1
  • 继昨天成功完成了一锅不错的浓汤之后,计划今天继续调整汤底的味道,一早还是一样跑到了农贸市场,采购新的香料以及调味料...
    也牛牛肉阅读 271评论 0 1
  • 我们有时候需要判断前端的用户是否登录,才能执行后端脚本。其实实现很简单,后端脚本中使用req.user来获取即可(...
    NextStack阅读 462评论 2 0