优化哈希策略的简介
總覽
用于哈希鍵的策略可以直接影響哈希集合(例如HashMap或HashSet)的性能。
內置的哈希函數被設計為通用的,并且可以在各種用例中很好地工作。 我們可以做得更好,特別是如果您對用例有一個很好的了解嗎?
測試哈希策略
在上一篇文章中,我介紹了多種測試哈希策略的方法,特別是針對“正交位”進行了優化的哈希策略,該方法旨在確保每個哈希結果僅基于一個比特就盡可能地不同變化。
但是,如果您有一組已知的要散列的元素/鍵,則可以針對該特定用例進行優化,而不是嘗試查找通用解決方案。
減少碰撞
您希望避免在哈希集合中發生的主要事情之一是沖突。 這是兩個或更多鍵映射到同一存儲桶時。 這些沖突意味著您必須做更多的工作來檢查密鑰是否與您期望的一致,因為同一存儲桶中現在有多個密鑰。 理想情況下,每個存儲桶中最多有一個密鑰。
我只需要唯一的哈希碼,不是嗎?
一個常見的誤解是,為了避免沖突,您需要擁有唯一的哈希碼。 盡管非常需要唯一的哈希碼,但這還不夠。
假設您有一組密鑰,并且所有密鑰都有唯一的32位哈希碼。 如果您有一個包含40億個存儲桶的數組,則每個鍵都有其自己的存儲桶,并且不會發生沖突。 對于所有散列集合,通常不希望具有如此大的數組。 實際上,對于2 ^ 30或剛剛超過10億的數組,HashMap和HashSet受2大小的最大冪限制。
當您擁有更切合實際的散列集合時,會發生什么? 存儲桶的數量需要更小,并且哈希碼被模塊化為存儲桶的數量。 如果存儲桶數是2的冪,則可以使用最低位的掩碼。
讓我們來看一個示例ftse350.csv。如果將第一列作為鍵或元素,則將獲得352個字符串。 這些字符串具有唯一的String.hashCode(),但是說我們采用這些哈希碼的低位。 我們看到碰撞了嗎?
| 面具 | 屏蔽了String.hashCode() | HashMap.hash( 屏蔽了String.hashCode()) |
| 32位 | 沒有碰撞 | 沒有碰撞 |
| 16位 | 1次碰撞 | 3次碰撞 |
| 15位 | 2次碰撞 | 4次碰撞 |
| 14位 | 6次碰撞 | 6次碰撞 |
| 13位 | 11次碰撞 | 9次碰撞 |
| 12位 | 17次碰撞 | 15次碰撞 |
| 11位 | 29次碰撞 | 25次碰撞 |
| 10位 | 57次碰撞 | 50次碰撞 |
| 9位 | 103次碰撞 | 92次碰撞 |
HashMap的大小(負載因子為0.7)(默認值)是512,它使用低9位的掩碼。 如您所見,即使我們從唯一的哈希碼開始,仍有大約30%的鍵發生沖突。
- HashTesterMain的代碼在這里。
為了減少不良哈希策略的影響,HashMap使用了一種攪拌函數。 在Java 8中,這非常簡單。
從HashMap.hash的源代碼中,您可以閱讀Javadoc以獲得更多詳細信息
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。
| 乘數 | 碰撞 |
| 1個 | 230 |
| 2 | 167 |
| 3 | 113 |
| 4 | 99 |
| 5 | 105 |
| 6 | 102 |
| 7 | 93 |
| 8 | 90 |
| 9 | 100 |
| 10 | 91 |
| 11 | 91 |
您會看到魔術數字的選擇很重要,但是也有很多數字可供嘗試。 我們需要編寫一個測試來嘗試一個好的隨機選擇。 HashSearchMain的來源
| 哈希函數 | 最佳乘數 | 最低的碰撞 | 最差的乘數 | 最高碰撞 |
| hash() | 130795 | 81次碰撞 | 126975 | 250次碰撞 |
| xorShift16(哈希()) | 2104137237 | 68次碰撞 | -1207975937 | 237次碰撞 |
| addShift16(hash()) | 805603055 | 68次碰撞 | -1040130049 | 243次碰撞 |
| xorShift16n9(hash()) | 841248317 | 69次碰撞 | 467648511 | 177次碰撞 |
要看的關鍵代碼是
如您所見,如果您提供一個良好的乘數,或者一個乘數恰好與您的鍵集配合使用,則每個哈希加下一個字符的重復乘數是合理的。 如果將130795作為乘數而不是31作為乘數,則對于測試的密鑰集,您只會得到81次碰撞,而不是103次碰撞。
如果同時使用攪拌功能,則可能發生68次碰撞。 這接近兩倍于數組大小的相同沖突率。 也就是說,無需使用更多內存即可提高碰撞率。
但是,當我們向哈希集合添加新密鑰時會發生什么,我們的幻數仍然對我們有利嗎? 在這里,我著眼于最差的碰撞率,以確定在更大范圍的可能輸入下哪種結構可能產生良好的結果。 hash()的最壞情況是250次碰撞,即70%的鍵碰撞,這非常糟糕。 攪動功能對此有所改善,但是仍然不是很好。 注意:如果添加移位后的值而不是對其進行異或運算,則在這種情況下會得到較差的結果。
但是,如果我們進行兩次移位,不僅要混合最高位和最低位,還要混合生成的哈希碼的四個不同部分的位,我們發現最壞情況下的沖突率要低得多。 這向我表明,如果更改鍵的選擇,則由于結構更好且魔術數字的選擇或輸入的選擇的重要性降低,我們不太可能收到不好的結果。
如果我們在哈希函數中添加而不是xor怎么辦?
在攪拌功能中,使用xor可能比使用add更好。 如果我們改變這個會發生什么
h = multiplier * h + s.charAt(i);與
h = multiplier * h ^ s.charAt(i);| 哈希函數 | 最佳乘數 | 最低的碰撞 | 最差分數 | 最高碰撞 |
| hash() | 1724087 | 78次碰撞 | 247297 | 285次碰撞 |
| xorShift16(哈希()) | 701377257 | 68次碰撞 | -369082367 | 271次碰撞 |
| addShift16(hash()) | -1537823509 | 67次碰撞 | -1409310719 | 290次碰撞 |
| xorShift16n9(hash()) | 1638982843 | 68次碰撞 | 1210040321 | 206次碰撞 |
最佳情況下的數字稍好一些,但是最壞情況下的碰撞率則更高。 這向我表明,幻數的選擇更重要,但這也意味著鍵的選擇更重要。 這似乎是一個冒險的選擇,因為您必須考慮密鑰可能會隨著時間而變化。
為什么我們選擇奇數乘數?
當您乘以奇數時,結果的低位機會等于0或1。這是因為0 * 1 = 0和1 * 1 =1。但是,如果您將其乘以偶數,則低位總是為0。即不再是隨機的。 假設我們重復了先前的測試,但僅使用偶數,這看起來如何?
| 哈希函數 | 最佳乘數 | 最低的碰撞 | 最差分數 | 最高碰撞 |
| hash() | 82598 | 81次碰撞 | 290816 | 325次碰撞 |
| xorShift16(哈希()) | 1294373564 | 68次碰撞 | 1912651776 | 301次碰撞 |
| addShift16(hash()) | 448521724 | 69次碰撞 | 872472576 | 306次碰撞 |
| xorShift16n9(hash()) | 1159351160 | 66次碰撞 | 721551872 | 212次碰撞 |
如果您很幸運,并且為您的魔術數字輸入了正確的結果,則結果與奇數一樣好,但是,如果您很不幸,結果可能會很糟糕。 325次碰撞意味著僅使用了512個鏟斗中的27個。
更多高級哈希策略有何不同?
對于基于City,Murmur,XXHash和Vanilla Hash(我們自己的)的哈希策略
- 散列策略一次讀取64位數據的速度比逐字節讀取數據的速度快。
- 計算的工作值是兩個64位值。
- 工作值減少到64位長。
- 結果,使用了更多的乘法常數。
- 攪拌功能更為復雜。
我們在實現中使用長哈希碼為:
- 我們針對64位處理器進行了優化,
- Java中最長的原始數據類型是64位,并且
- 如果您有大量的哈希集合(即數百萬個),則32位哈希不太可能是唯一的。
綜上所述
通過探索我們如何生成哈希碼,我們找到了將352個鍵的沖突次數從103個沖突減少到68個沖突的方法,但是與更改鍵集相比有一定的信心,我們已經減少了可能產生的影響。
這無需使用更多的內存,甚至不需要更多的處理能力。
我們仍然可以選擇使用更多的內存。
為了進行比較,您可以看到將數組的大小加倍可以改善最佳情況,但是仍然存在一個問題,即鍵集和幻數之間的未命中匹配仍然會具有較高的沖突率。
| 哈希函數 | 最佳乘數 | 最低的碰撞 | 最差分數 | 最高碰撞 |
| hash() | 2924091 | 37次碰撞 | 117759 | 250次碰撞 |
| xorShift16(哈希()) | 543157075 | 25次碰撞 | – 469729279 | 237次碰撞 |
| addShift16(hash()) | -1843751569 | 25次碰撞 | – 1501097607 | 205次碰撞 |
| xorShift16n9(hash()) | -2109862879 | 27次碰撞 | -2082455553 | 172次碰撞 |
結論
在具有穩定密鑰集的情況下,可以通過調整所使用的哈希策略來顯著提高沖突率。 您還需要進行測試,這些測試表明如果密鑰集在不重新優化的情況下發生更改,可能會變得很糟糕。 結合使用這兩種方法,您可以開發新的哈希策略來提高性能,而不必使用更多的內存或更多的CPU。
翻譯自: https://www.javacodegeeks.com/2015/09/an-introduction-to-optimising-a-hashing-strategy.html
總結
- 上一篇: 摆脱困境:将运行时配置作为JSON返回
- 下一篇: 使用此首选项可加快Eclipse m2e