如何優化一個雜湊策略
通過接合兩種雜湊策略可以創造出一種更高效的演算法,且不會有額外的記憶體與CPU開銷。
簡介
對於像 HashMap 或 HashSet 這些經過雜湊排序的集合,key 的雜湊策略對於它們的效能有直接影響。
內建的雜湊演算法是專門設計用於常規的雜湊計算,並且在很多場景下都很適用。但在某些場景下(特別是當我們有一些更好的想法時)我們是否有更好的策略?
一種 hash 策略的測試
在前篇文章中我翻了不少測試 hash 策略的方法,並著重看了為“Orthogonal Bits”特別設計的測試同方法,即:只改變原始輸入的一個 bit,其 hash 結果是否也會改變。
另外,如果需要進行 hash 運算的元素/鍵是已知的,你應該為這種特殊情況進行優化而不是試圖使用常規的解決方案。
減少碰撞
在一個需要進行 hash 運算的容器中,最重要的是避免碰撞。碰撞就是兩個或多個 key 對映到了同一個位置。這也意味著你需要做一些額外工作來檢查某個 key 是否是你需要的那個,因為現在有多個 key 放到了同一個位置上。在理想情況下,每個位置最多隻能有一個 key。
我需要的雜湊碼是惟一的
避免碰撞的一個常見誤區是隻保證雜湊碼惟一就可以了。雖然惟一的雜湊碼確實很需要,但只有它也不夠。
告訴你有一個鍵值的集合,並且它們都有唯一的 32 位雜湊碼。假設你有一個 40 億量的陣列桶(bucket),每一個鍵值都有它自己的桶,不能衝突。對這麼大的陣列構成的雜湊集合通常是很讓人煩的。實際上,HashMap 和 HashSet 容納的量也是有限的,是 2^30,大致剛剛超過 10 億。
當你有一個實際大小的雜湊集合的時候會發生什麼?大量的桶需要更小,雜湊程式碼需要按模計算桶的數量。如果桶的數量是 2 的冪,你可以使用最低位掩碼。
請看這個例子:ftse350.csv。 如果我們把此表的第一列作為 key 或元素,那就是 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 bits | 103 處衝突 | 92 處衝突 |
我們採用裝載因子為 0.7 (預設值)的 HashMap,它的範圍為 512。可以看到,在採用低 9 位的掩碼之後,會產生約 30% 的衝突,即便原始資料都是獨一無二的。
這裡是 HashTesterMain 的程式碼。
為了減少壞的雜湊策略所帶來的影響,HashMap 採用擾動函式。Java 8 的實現比較簡單。我們從 HashMap.hash 的原始碼中摘錄一段,閱讀 Java 文件可以瞭解到更多的細節:
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 規定,我們並沒有多大機會去改動它。但我們的確可以定義一個新雜湊策略。
雜湊策略的組成部分
我會留意雜湊策略裡的兩部分,
- Magic numbers (魔法數字)。你可以嘗試不同的數字以取得最佳效果。
- 程式碼的結構。你想要的程式碼結構應該能提供一個好的結果,無論魔法數字的選取是多麼地喪心病狂。
魔法數字固然重要,但你不會想讓它變得過於重要;因為總有些時候,你所選的數字並不合適。這就是為什麼你同時需要一個即使在魔法數選取很糟糕的情況下最壞情況產出仍然低的程式碼結構。
讓我們用別的乘數來代替 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 collisions | 126975 | 250 collisions |
xorShift16(hash()) | 2104137237 | 68 collisions | -1207975937 | 237 collisions |
addShift16(hash()) | 805603055 | 68 collisions | -1040130049 | 243 collisions |
xorShift16n9(hash()) | 841248317 | 69 collisions | 467648511 | 177 collisions |
程式碼的關鍵部分:
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作乘數僅僅發生了81次衝突,而採用31做乘數則發生了103次。
如果你同時還用的擾動函式,衝突將會減少至約68次。這樣的衝突率已經快要接近將桶陣列所產生的效果了:我們並沒有佔用更多記憶體,卻降低了衝突率。
但是,當我們向雜湊集中新增新的鍵時會發生什麼?我們的魔法數字還能持續奏效嗎?正是在這個前提下,我們研究最壞衝突率,以決定在面對更大範圍的輸入可能時,哪種程式碼結構可能會表現得更好。hash() 的最壞表現是 250 處衝突:70% 的鍵衝突了,表現的確有點糟。擾動函式使得情況有所改進,但仍不夠好。注意:如果我們選擇與被移值相加而非去異或,所得的結果將會更糟。
然而,如果選擇位移兩次 —— 不僅僅是混合高低位兩部分,而是從四個部分hash函式所得雜湊值的四個不同部分進行混合 —— 我們會發現,最壞情況的衝突率大幅下降。由此我想到,在所選鍵集會發生改變的情況下,如果我們的結構夠好,魔法數的影響夠低;我們得到壞結果的可能性就會降低。
如果在雜湊函式中我們選擇了相加而非異或,會發生什麼?
在擾動函式中採用異或而非相加可能會得到更好的結果。那如果我們將
h = multiplier * h + s.charAt(i);
替換成
h = multiplier * h ^ s.charAt(I);
會怎樣?
雜湊函式 | 最佳乘數 | 最低衝突數 | 最差乘數 | 最高衝突數 |
hash() | 1724087 | 78 collisions | 247297 | 285 collisions |
xorShift16(hash()) | 701377257 | 68 collisions | -369082367 | 271 collisions |
addShift16(hash()) | -1537823509 | 67 collisions | -1409310719 | 290 collisions |
xorShift16n9(hash()) | 1638982843 | 68 collisions | 1210040321 | 206 collisions |
最佳情況下的表現稍微變好了些,然而最差情況下的衝突率明顯地變差了。由此我看出,魔法數選取的重要性上升了,也就是說,鍵的選取將會產生更大的影響。考慮到隨著時間的推移鍵的選取可能會發生變化,這種選擇顯得有些危險。
為何選擇奇數作為乘數
當與一個奇數相乘時,結果的地位既可能是0,又可能是1;因為0 * 1 = 0, 1 * 1 = 1. 然而,如果與偶數相乘,最低位將必定是0. 也就是說,這一位不再隨機變化了。讓我們看看,重複先前的測試,但僅僅採用偶數,結果會是什麼樣。
雜湊函式 | 最佳乘數 | 最低衝突數 | 最差乘數 | 最高衝突數 |
hash() | 82598 | 81 collisions | 290816 | 325 collisions |
xorShift16(hash()) | 1294373564 | 68 collisions | 1912651776 | 301 collisions |
addShift16(hash()) | 448521724 | 69 collisions | 872472576 | 306 collisions |
xorShift16n9(hash()) | 1159351160 | 66 collisions | 721551872 | 212 collisions |
如果你夠幸運,魔法數選對了,所得的結果將會和奇數情況下一樣好。然而如果倒黴,結果可能就很糟了。325處衝突即是說,512個桶裡被使用的僅僅只有27個。
更為先進的哈西策略有何差異
對於我們使用的基於 City, Murmur, XXHash,以及 Vanilla Hash(我們自己實現的):
- 該策略一次讀取64為資料,比一位元組一位元組地讀取更快
- 所得的有效值是兩個64位長的值
- 有效值被縮短至64位
- 從結果上來看,採用了更多的常量乘數
- 擾動函式更為複雜
我們在實現中使用長雜湊值,因為:
- 我們為64位處理器做優化
- Java中最長的資料型別是64位的
- 如果你的雜湊集很大(上百萬),32位雜湊值難以保持唯一
總結
通過探索雜湊值的產生過程,我們得以將352個鍵的衝突數量從103處降至68處。同時,我們還有一定信心認為,如果鍵集發生變化,我們也已經降低了變化所可能造成的影響。
這是在沒有使用更多記憶體,甚至更多運算時間的情況下做到的。
我們仍可以選擇去利用更多的記憶體。
作為對比,可以看到,將桶陣列大小翻倍可以提高最佳情況的下的表現,但你還是要面對老問題:魔法數與鍵集的不契合將會帶來高衝突。
雜湊函式 | 最佳情況 | 最低衝突 | 最壞情況 | 最高衝突 |
hash() | 2924091 | 37 collisions | 117759 | 250 collisions |
xorShift16(hash()) | 543157075 | 25 collisions | - 469729279 | 237 collisions |
addShift16(hash()) | -1843751569 | 25 collisions | - 1501097607 | 205 collisions |
xorShift16n9(hash()) | -2109862879 | 27 collisions | -2082455553 | 172 collisions |
結論
在擁有穩定鍵集的情況下,調整雜湊策略可以顯著降低衝突概率。
你同時得測試,在沒有再優化的情況下,如果鍵集改變,情況將會變壞到何種程度。
結合這兩者,你就能夠發展出不需更多記憶體便能提升表現的雜湊策略。
相關文章
- golang 效能優化之累加雜湊Golang優化
- 如何判斷一個雜湊函式的好壞函式
- 通過雜湊聯接進行高階優化優化
- 【閱讀筆記:雜湊表】Javascript任何物件都是一個雜湊表(hash表)!筆記JavaScript物件
- js 雜湊雜湊值的模組JS
- 雜湊表(雜湊表)詳解
- 雜湊
- 雜湊表(雜湊表)原理詳解
- 【尋跡#3】 雜湊與雜湊表
- 查詢(3)--雜湊表(雜湊查詢)
- 樹雜湊
- 雜湊碰撞
- 字串雜湊字串
- 雜湊表
- 你還應該知道的雜湊衝突解決策略
- [CareerCup] 8.10 Implement a Hash Table 實現一個雜湊表
- 如何搭建一個功能複雜的前端配置化框架(一)前端框架
- oracle hash partition雜湊分割槽(一)Oracle
- [Redis]一致性雜湊Redis
- 你知道雜湊演算法,但你知道一致性雜湊嗎?演算法
- 雜湊函式函式
- 字串雜湊表字串
- redis之雜湊Redis
- 雜湊連線
- 6.7雜湊表
- 安全的雜湊
- 雜湊衝突
- 異或雜湊
- 介面優化策略優化
- 幾道和雜湊(雜湊)表有關的面試題面試題
- 雜湊遊戲之雜湊盒子的趨勢未來可期遊戲
- 雜湊的一些知識點
- CF 119D String Transformation(KMP,雜湊,列舉,各種優化)ORMKMP優化
- 加鹽密碼雜湊:如何正確使用密碼
- 如何利用策略模式優化表單驗證模式優化
- PHP優化雜燴PHP優化
- 雜湊技術【雜湊表】查詢演算法 PHP 版演算法PHP
- 深入理解雜湊表(JAVA和Redis雜湊表實現)JavaRedis