漫談散列函數

說到散列,一般對應於散列表(哈希表)和散列函數。
我們今天不談哈希表,僅談下散列函數。

定義

引一段百度百科關於散列函數的定義。

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的擁躉,並在項目中使用了一段時間(作爲緩存的key)。
知道看到上面那篇知乎的討論,的一個回答:



看了知乎心裏一驚,回頭修改了下測試用例,構造隨機數據時用不定長的數據,比方說1-30個隨機字節,
測試於上面寫的64bitBKDRHash, 其結果是:
數據集上5萬就可以看到衝突了。

之前是知道BKDRHash的混淆性不足的(比方說最後一個字節的值加1,hash值也只是加1而已,如果未溢出的話);
但是由於其實現簡單,以及前面那個不合理的測試結果,就用來做緩存的key了,畢竟Volley也這麼幹了。
實際上也沒有太大的問題,因爲用的地方輸入通常比較長,而且要緩存的文件也不是很多(幾百上千的級別),所以應該不會有衝突。
但是心裏面還是不踏實,最終還是很快換了另一個散列函數。

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] << 56) +
                    ((long) (data[i + 1] & 0xFF) << 48) +
                    ((long) (data[i + 2] & 0xFF) << 40) +
                    ((long) (data[i + 3] & 0xFF) << 32) +
                    ((long) (data[i + 4] & 0xFF) << 24) +
                    ((data[i + 5] & 0xFF) << 16) +
                    ((data[i + 6] & 0xFF) << 8) +
                    ((data[i + 7] & 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要更合適(運算快,低碰撞,高混淆)

結語

散列廣泛應用於計算機的各個領域,瞭解散列函數的一些性質,對於解決工程問題或有幫助,
故而有了這篇“漫談”,希望對讀者有所啓發。
有理解不正確的地方,請指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章