優化哈希策略

2021-03-06 ImportNew

(點擊上方公眾號,可快速關注)

來源:ImportNew - fzr 

概述

散列策略會對HashMap或HashSet之類的散列集合的性能產生直接的影響。

內置的散列(又稱哈希)函數都是通用的,在大多數使用情況下都能表現很好。但是我們能不能做的更好呢,特別是當你對某個用例產生了很好的想法時?

測試一個散列策略

在先前的一篇文章中,我研究了一些測試散列策略的方法,其中特別注意了一種「正交位」優化的散列策略,它僅僅只是改變一個位就能確保每個散列結果儘可能的不同。

http://vanillajava.blogspot.co.uk/2015/08/comparing-hashing-strategies.html?view=magazine

然而,如果要對一個已知的元素或關鍵字集合進行散列,你就可以針對具體案例進行優化,而不是尋求一個通用方案。

減少衝突

一個散列集合中最主要的事情就是避免衝突了。所謂衝突,就是兩個以上關鍵字映射到同一個散列槽中。這些衝突意味著當有多個關鍵字映射到同一個散列槽中時你必須要付出更多的努力來檢查那些關鍵字是否是你想要的那個。理想狀態下一個散列槽中最多有一個關鍵字。

我只需要唯一的散列碼,是嗎?

通常的誤解是只要散列碼唯一就可以避免衝突。雖然都希望散列碼是唯一的,但它還不夠。

假設現在有一些關鍵字,每一個都有唯一的32位散列碼。如果你有40億散列槽,每個關鍵字都有自己的槽,那就沒有衝突了。對於所有的散列集合,這樣大的數組一般是不太現實的。事實上,HashMap和HashSet的大小都收到機器內存的限制,一般為2^30,大概剛剛超過10億。

當你只能有一個規模上比較實際的哈希集合時又該如何呢?散列槽的數目需要更小一些,而散列碼需對散列槽數目取模。如果散列槽數是2的冪值,你可以用最低位當掩碼。

來看個例子,ftse350.csv。如果把第一列作為關鍵字或是元素,就有352個字符串。這些字符串有唯一的String().hashCode()碼,但是假設我們只取這些散列碼的低位。會不會有衝突呢?

https://github.com/OpenHFT/Chronicle-Algorithms/blob/master/src/test/resources/ftse350.csv

一個裝載因子是0.7(默認)的HashMap的大小是512,使用哈希碼的低九位作為掩碼。可以看到,儘管原始的哈希碼是唯一的,仍然有大約30%的關鍵字會衝突。

HashTesterMain的代碼在這裡。

https://github.com/OpenHFT/Chronicle-Algorithms/blob/master/src/test/java/net/openhft/chronicle/algo/hashing/HashTesterMain.java

為了減少糟糕的散列策略造成的影響,HashMap使用了擾動函數。Java 8裡的實現相當簡單。

下面是來自HashMap.hash的源碼。想了解更多細節,可以閱讀Javadoc文檔。

http://grepcode.com/file/repository.grepcode.com/java/root/jdk/openjdk/8-b132/java/util/HashMap.java#318

static final int hash(Object key) {

    int h;

    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

}

這個方法混合了原始哈希碼的高位和低位,以此來提高低位的隨機性。對於像上面的那樣有相當高的衝突率的情況,這就是一個改善方法。請參看第三列。

初探 String類的散列函數

下面是String.hashCode()的代碼:

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的哈希實現已經在Javadoc中規定好了,所以我們基本沒有機會改動,但我們可以定義一個新的哈希策略。

散列策略的組成部分

我會留意散列策略中的兩個部分。

魔法數字固然很重要,但是你並不想讓它過於重要;因為總是有些時候你選的數字並不適合你的使用場景。這就是為什麼你同時還需要一個即使在魔法數字選取很糟糕的情況下其最壞情況依然不是特別差的代碼結構。

用別的乘數代替31

可以看到,魔法數的選取的確很重要,但需要嘗試的數字太多了。我們需要寫一個測試程序,測試出一個不錯的隨機選擇。HashSearchMain的源碼。

https://github.com/OpenHFT/Chronicle-Algorithms/blob/master/src/test/java/net/openhft/chronicle/algo/hashing/HashSearcherMain.java

代碼的關鍵部分:

public static int hash(String s, int multiplier) {

    int h = 0;

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

        h = multiplier * h + s.charAt(i);

    }

    return h;

}

 

private static int xorShift16(int hash) {

    return hash ^ (hash >> 16);

}

 

private static int addShift16(int hash) {

    return hash + (hash >> 16);

}

 

private static int xorShift16n9(int hash) {

    hash ^= (hash >>> 16);

    hash ^= (hash >>> 9);

    return hash;

}

可以發現,如果提供了好的乘數,或是適合於你的關鍵字集合的乘數,那麼重複乘上每個哈希值再加上下一個字符就很合理。對比分別以130795和31作乘數,對於測試的關鍵字集合,前者只有81次碰撞而後者有103次。

如果你再加上擾動函數,碰撞會減少到大約68次。這已經很接近加倍數組規模時的衝突率了:不佔用更多內存,卻可以降低衝突率。

但是當我們向散列集合中添加新的關鍵字時會發生什麼呢?魔法數字還能表現良好嗎?這使我不得不研究最壞衝突率,以決定在面對更大範圍的輸入時,哪種代碼結構可能會表現得更好。hash()的最壞表現是250次碰撞,即70%的關鍵字發生衝突,確實很糟糕。擾動函數能稍稍提高性能,但作用也不大。注意:如果我們選擇與偏移值相加而不是異或會得到更糟糕的結果。

然而,如果選擇位移兩次——不僅僅是混合高低位,還有從產生的哈希值的四個部分選擇的位,結果發現,最壞情況的衝突率大大下降。這使我想到,在選擇的關鍵字改變的情況下,如果結構足夠好,魔法數的影響足夠低,我們得到糟糕結果的可能性就會降低。

如果在哈希函數中選擇了相加而非異或,會發生什麼呢?

在擾動函數中採用異或而不是相加可能會得到更好的結果。如果我們將

h = multiplier * h + s.charAt(i);

變為

h = multiplier * h ^ s.charAt(i);

會發生什麼呢?

最佳情況下的表現變好了一些,然而最壞情況下的衝突率明顯更高了。由此我看出,魔法數選取的重要性提高了,也意味著,關鍵字的影響更大了。考慮到關鍵字可能會隨著時間改變,這種選擇好像有些冒險。

為何選擇奇數作為乘數

當與一個奇數相乘時,結果的低位是0或1的概率相等;因為0*1=0,1*1=1.然而,如果與偶數相乘,最低位一定是0.也就是說,這一位不再是隨機變化的了。假設我們重複先前的測試,但是只採用偶數,結果會怎樣?

如果你很幸運,魔法數選對了,那麼結果將會和奇數情況下一樣好。然而如果不太幸運,結果可能會很糟糕。325次碰撞意味著,512個哈希槽中只有27個被使用了。

更高級的散列策略有何不同?

對於我們使用的基於City,Murmur,XXHash,以及Vanilla Hash(我們自己的)的哈希策略:

我們在實現中使用長哈希值是因為:

總結

通過探索哈希值的產生過程,我們找到了將352個關鍵字的衝突次數從103次降到68次的方法。同時,我們也相信,如果關鍵字集合發生變化,我們也能降低變化可能造成的影響。

這是在不佔用更多內存,或更多處理器資源的情況下完成的。

我們也可以選擇使用更多內存。

作為對比,可以看到,加倍數組規模可以提高最佳情況下的表現。但我們仍然要面對的問題是:當關鍵字集合和魔法數匹配的不好時,哈希衝突率依然很高。

結論

在你的關鍵字集合比較穩定的情況下,你可以通過調整哈希策略來明顯改善衝突率。當關鍵字集合改變時,你還需要測試不優化的情況下結果會糟糕到什麼程度。將這兩種策略結合起來,你可以不佔用更多內存和CPU就能得到提高性能的新哈希策略。

看完本文有收穫?請轉發分享給更多人

關注「ImportNew」,提升Java技能

相關焦點

  • 圖解什麼是一致性哈希算法
    實際中對於規模較小的系統來說,哈希取模策略應用很廣泛,接下來重點介紹hash取模和一致性哈希的區別與聯繫。4.哈希取模的原理和優缺點Hash取模策略是其中常用的一種做法,它可以保證相同請求相同機器處理,這是一種並行轉串行的方法,工程中非常常見。如果數據相對獨立,就避免了線程間的通信和同步,實現了無鎖化處理,所以還是很有用的。
  • 深入理解哈希表
    哈希表概述Objective-C 中的字典 NSDictionary 底層其實是一個哈希表,實際上絕大多數語言中字典都通過哈希表實現,比如我曾經分析過的 Swift 字典的實現原理。在討論哈希表之前,先規範幾個接下來會用到的概念。哈希表的本質是一個數組,數組中每一個元素稱為一個箱子(bin),箱子中存放的是鍵值對。
  • 哈希函數和哈希表
    其核心就是哈希函數和哈希表的應用!哈希函數哈希函數又稱為散列函數,就是把任意長度的輸入(又叫做預映射, pre-image),通過散列算法,變換成固定長度的輸出,該輸出就是散列值。這種轉換是一種壓縮映射,也就是,散列值的空間通常遠小於輸入的空間,不同的輸入可能會散列成相同的輸出,而不可能從散列值來唯一的確定輸入值。
  • 哈希函數
    如果能預先建立表中關鍵字Key與其存儲位置之間的函數,即元素的存儲位置是其關鍵字的函數f(key)的值,那麼查找過程就可以實現。但使用這種哈希函數的情況很少。其它幾個常用的方法有: 數字分析法:分析關鍵字,取關鍵字的若干數位組成哈希地址。平方取中法:取關鍵字平方後的中間幾位為哈希地址。
  • PHP哈希表碰撞攻擊原理
    哈希表碰撞攻擊的基本原理哈希表是一種查找效率極高的數據結構,很多語言都在內部實現了哈希表。PHP中的哈希表是一種極為重要的數據結構,不但用於表示Array數據類型,還在Zend虛擬機內部用於存儲上下文環境信息(執行上下文的變量及函數均使用哈希表結構存儲)。
  • 什麼是哈希算法
    哈希,英文是 hash ,本來意思是」切碎並攪拌「,有一種食物就叫 Hash ,就是把食材切碎並攪拌一下做成的。哈希函數的運算結果就是哈希值,通常簡稱為哈希。哈希函數有時候也翻譯做散列函數。所以,哈希函數的安全性肯定是個相對概念。如果出現了兩個不同輸入有相同輸出的情況,就叫碰撞,collision 。不同的哈希算法,哈希位數越多,也就基本意味著安全級別越高,或者說它的」抗碰撞性「就越好。再來說說哈希函數的主要作用。
  • 哈希函數的特點及應用介紹
    這是我在閱讀人類大歷史這本書中讀到的一篇故事,原文討論的內容是算法程序對於人類的威脅;那些不斷產生出來並且持續的在優化、改進,聲稱用以改善人類生活的算法軟體,既使一開始創作者本身是完全出於良好善意,或是學術研究而建立的系統,最終仍可能會完全失控的造成毀滅性的結果。這個故事讓我想起了Bitcoin等加密貨幣的挖礦程序算法,在過去一段時間以來對我們生活的衝擊影響。
  • 哈希算法現狀——原因、方法及未來
    為了減少哈希衝突可能性,要麼提高哈希內部操作的複雜性,要麼提高哈希輸出的長度,寄希望於攻擊者計算速度不夠快。本文分析了哈希算法的歷史,原理和未來,對於我們理解區塊鏈的安全問題很有幫助。本文作者是Raul Jordan,文章來源於medium.com,由藍狐筆記社群「李熙和」翻譯。
  • 哈希表(散列表)詳解(包含哈希表處理衝突的方法)
    哈希表的建立同函數類似,把函數中的 x 用查找記錄時使用的關鍵字來代替,然後將關鍵字的值帶入一個精心設計的公式中,就可以求出一個值,用這個值來表示記錄存儲的哈希地址。即:數據的哈希地址=f(關鍵字的值)哈希地址只是表示在查找表中的存儲位置,而不是實際的物理存儲位置。
  • 什麼是哈希運算
    哈希值:f7f2cf0bcbfbc11a8ab6b6883b03c721407da5c9745d46a5fc53830d4749504a哈希運算的特性一個優秀的哈希算法要具備正向快速、輸入敏感、逆向困難、強抗碰撞等特徵。
  • 圖解:什麼是哈希?
    為什麼要有哈希?哈希是對直接訪問表的改進。使用哈希函數將給定的電話號碼或任何其他鍵轉換為較小的數字,將該較小的數字稱為哈希表的索引(哈希值)什麼是哈希表?哈希表和直接訪問表很類似,同樣是一個用於存儲指向給定電話號碼對應記錄的指針的數組,只不過,此時的數組下標不再是電話號碼,而是經過哈希函數映射後的輸出值。什麼是哈希函數?
  • 為什麼資料庫使用有序索引,而程式設計師卻在使用哈希表?
    有一次,在談到Google優化C++的哈希表時,有人指出在整個Google的伺服器中,有1%的CPU和4%的內存都被哈希表使用了。然而,資料庫默認總是會使用有序索引,通常是B樹。為什麼程序和資料庫之間的「默認」選擇會產生不同呢?說到底兩者都是為了同一個目標:訪問數據。一年前,我曾就這個問題在Twitter上發表了推文,並得到了許多有趣的答案。下面就來總結一下我得到的答案。
  • 關於哈希算法,必須了解這三點
    哈希算法是區塊鏈技術體系的重要組成部分,也是現代密碼學領域的重要分支,在身份認證、數字籤名等諸多領域有著廣泛的應用。深刻理解哈希算法原理,對於區塊鏈系統的設計與實現有著至關重要的作用。任意輸入值(Message)的二進位編碼經過哈希函數計算後,可以得出n比特的一個0、1字符串的哈希值,在不同算法中n的取值可能不同,例如128、160、192、256、384或512等。哈希算法在區塊鏈技術中得到了廣泛的應用,各個區塊之間通過哈希指針連接形成區塊鏈,每個區塊的完整性檢驗將以哈希運算的方式進行。
  • 詳解哈希表的查找
    根據哈希函數f(key)和處理衝突的方法將一組關鍵字映射到一個有限的連續的地址集(區間)上,並以關鍵字在地址集中的「像」作為記錄在表中的存儲位置,這一映射過程稱為構造哈希表。構造哈希表這個場景就像汽車找停車位,如果車位被人佔了,只能找空的地方停。
  • 哈希表的使用指南
    這個映射函數稱為哈希函數,根據這個原則建立的表稱為哈希表(Hash Table),也叫散列表。以上描述,如果通過數學形式來描述就是:若查找關鍵字為 key,則其值存放在 f(key) 的存儲位置上。由此,不需比較便可直接取得所查記錄。註:哈希查找與線性表查找和樹表查找最大的區別在於,不用數值比較。
  • 生日攻擊與哈希碰撞
    哈希(hash)是在計算機領域常用的一種信息處理方法。(常見的哈希計算方法有MD5、SHA-1、SHA-256等)很顯然,由於哈希計算的結果是固定信息量的數據,而原信息卻可以是各種各樣的數據,所以哈希計算並不像許多函數那樣輸出與輸入一一對應。這就可能導致某兩個完全不同的輸入數據經過哈希計算後得到了同一個「代號」,這種情況就被稱作「哈希碰撞」。同學:感覺腦袋有點暈……好吧,我們還是用簡單的例子來說明。
  • 和你聊聊哈希算法
    哈希查找算法由於其查找速度快,查詢、插入、刪除操作簡單等原因而獲得了廣泛的應用。很多問題本質上都是查找問題。解決查找問題,哈希算法是較好的選擇。1.3 哈希算法的工作原理說了這麼多,大家也肯定想知道哈希算法是怎麼工作的,為什麼這麼快速?其實,哈希就像一本字典,當你需要查詞的時候,通過目錄找到頁碼,再到對應頁碼就能找到所需要的內容了。
  • 數據結構 | 哈希表
    本文為數據結構系列第 11 篇,介紹哈希表的實現。哈希表(也叫散列表)是數據結構課程中都會介紹的概念,一般出現在「散列與查找」相關章節。哈希表的核心思想是構造一個散列函數,鍵值作為散列函數輸入,將元素映射到哈希表某一位置存儲。這樣,在進行查找時就可以根據散列函數快速找到元素所在位置。常見散列函數有:直接定址法、平方取中法、除留餘數法、數字分析法等。
  • 什麼是一致性哈希算法
    2,衡量一致性哈希算法好處的四個標準①平衡性。平衡性是指哈希的結果能夠儘可能分布到所有的緩衝中去,這樣可以使得所有的緩衝空間都得到利用。②單調性。參考這裡:blog.csdn.net/cywosp/article/details/233971793,一致性哈希的原理由於一般的哈希函數返回一個int(32bit)型的hashCode。
  • 一文讀懂哈希函數
    哈希函數哈希函數(Hash):h=H(Data)定義哈希函數H,將可變大小的數據Data作為輸入,產生固定長度的h值。