雜湊表
雜湊表是一種鍵值對映的資料結構。雜湊表中,資料以陣列格式儲存,其中每個資料值都有自己唯一的索引值,索引值通過雜湊表的雜湊函式計算得到。
下面兩步將鍵雜湊值轉化成雜湊表的索引值。
- 雜湊值 = 雜湊函式(鍵)
- 索引值 = 雜湊值 % 雜湊表長度
衝突解決方法
有限長度下的雜湊表,衝突不可避免。解決衝突的兩種方法,拉鍊法和開放定址。
拉鍊法
將衝突位置的元素構造成連結串列。新增資料發生衝突時,將元素追加到連結串列。如下圖,當新增 "Sandra Dee"時,計算出索引值為152與“John Smith” 發生衝突,然後將它追加到連結串列。
開放定址
以當前衝突位置為起點,按照一定規則探測空位置把元素插進去。比較簡單的方式是線性探測,它會按照固定的間隔(通常是1)迴圈進行查詢。
如下圖,“Sandra Dee”新增時與 “John Smith” 相沖突,通過探測空位置插入到153,然後新增“Ted Baker”發現與“Sandra Dee”相沖突,往後探測154空位置插入。
效能
負載因子
負載因子的值是條目數佔用雜湊桶比例,當負載因子超過理想值時,雜湊表會進行擴容。比如雜湊表理想值 0.75,初始容量 16,當條目超過 12 後雜湊表會進行擴容重新雜湊。0.6 和 0.75 是通常合理的負載因子。
$$ {\displaystyle loadfactor\ (\alpha )={\frac {n}{k}}} $$
- $n$ 雜湊表中的條目數。
- $k$ 桶的數量。
影響雜湊表效能的兩個主要因素
- 快取丟失。隨著負載因子的增加快取丟失數量上升,而搜尋和插入效能會因此大幅下降。
- 擴容重新雜湊。調整大小是一項極其耗時的任務。設定合適的負載因子可以控制擴容次數。
下圖展示了隨著負載因子增加,快取丟失的數量開始上升,0.8後開始迅速攀升。
HashMap
關於 HashMap 解讀一下它的 hash
方法和衝突樹化兩個地方。
關於hash()
取key的hashCode值,然後將高16位與低16位進行異或、最後取模運算。
static final int hash(Object key) { //jdk1.8 & jdk1.7
int h;
// h = key.hashCode() 取hashCode值
// h ^ (h >>> 16) 將高16位與低16位進行異或
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// jdk1.7
static int indexFor(int h, int length) {
return h & (length-1);
}
// jdk1.8
(n - 1) & hash
高16位與低16位進行異或是為了加大低位的隨機性。
關於隨機性,網上有個測試例子:他隨機選取了352個字串,測試不同長度陣列下的碰撞概率。
結果顯示,當HashMap陣列長度為 2^9 = 512
的時候,直接取hashCode衝突103次,進行高低異或後衝突92次。
https://www.todaysoftmag.com/...
衝突樹化
HashMap解決衝突使用拉鍊法。jdk1.8 中,當一個桶連結串列節點超過TREEIFY_THRESHOLD=8
後,連結串列會轉換為紅黑樹,當桶中節點移除或重新雜湊少於 UNTREEIFY_THRESHOLD=6
時,紅黑樹會轉變為普通的連結串列。
連結串列取元素是從頭結點一直遍歷到對應的結點,時間複雜度是O(N) ,紅黑樹基於二叉樹結構,時間複雜度為O(logN) ,所以當元素個數過多時,用紅黑樹儲存可以提高搜尋的效率。但是單個樹節點需要佔用的空間大約是普通節點的兩倍,所以使用樹和連結串列是時空權衡的結果。
樹化閥值為什麼是 8 ?
HashMap 文件有這麼一段描述。大體意思是,雜湊桶上的連結串列節點數量呈現泊松分佈。
Ideally, under random hashCodes, the frequency of * nodes in bins follows a Poisson distribution * (http://en.wikipedia.org/wiki/Poisson_distribution) with a * parameter of about 0.5 on average for the default resizing * threshold of 0.75, although with a large variance because of * resizing granularity. Ignoring variance, the expected * occurrences of list size k are (exp(-0.5) * pow(0.5, k) / * factorial(k)). The first values are: * * 0: 0.60653066 * 1: 0.30326533 * 2: 0.07581633 * 3: 0.01263606 * 4: 0.00157952 * 5: 0.00015795 * 6: 0.00001316 * 7: 0.00000094 * 8: 0.00000006 * more: less than 1 in ten million
什麼是泊松分佈?
泊松分佈就是描述某段時間內,事件具體的發生概率。柏鬆分佈可以通過平均數估算出某個事件的出現概率。
$$ P(N(t) = n) = \frac{(\lambda t)^n e^{-\lambda t}}{n!} $$
- $P$ 概率;
- $N$ 某種函式關係;
- $t$ 時間;
- $n$ 出現的數量;
比如,一個程式設計師每天平均寫3個Bug,表示為\( P(N(1) = 3) \)。由此還可以得到下面:
他明天寫1個Bug的概率:0.1493612051
他明天寫2個Bug的概率:0.2240418077
他明天寫3個Bug的概率:0.2240418077
他明天寫10個Bug的概率:0.0008101512
/**
* @param n 節點數量
* @param r 平均數量
*/
public static String poisson(int n, double r) {
double value = Math.exp(-r) * Math.pow(r, n) / IntMath.factorial(n);
return new BigDecimal(value).setScale(10, ROUND_HALF_UP).toPlainString();
}
假設HashMap有 \( n \) 條資料,負載因子為 \( k \) ,那麼HashMap長度最小值為 \( \frac{n}{k} \) ,最大約為 \( \frac{2n}{k} \) (容量必須是 2 的冪) 所以平均值是 \( \frac{3n}{2k} \),每個桶的平均節點數量為
$$ n \div(\frac{3n}{2k})= \frac{2k}{3} = \frac{2\times 0.75}{3} = 0.5 $$
HashMap 預設負載因子為 0.75,所以每個桶的平均節點數量 0.5,代入柏鬆公式得到下面資料
1個桶中出現1個節點的概率:0.3032653299 1個桶中出現2個節點的概率:0.0758163325 1個桶中出現3個節點的概率:0.0126360554 1個桶中出現4個節點的概率:0.0015795069 1個桶中出現5個節點的概率:0.0001579507 1個桶中出現6個節點的概率:0.0000131626 1個桶中出現7個節點的概率:0.0000009402 1個桶中出現8個節點的概率:0.0000000588
樹化是雜湊極度糟糕下不得已而為之的做法,而一個桶出現 8 個節點的概率不到千萬分之一,所以將TREEIFY_THRESHOLD=8 。
小結
雜湊表是一種鍵值對映的資料結構。解決衝突有兩種方法拉鍊法和開放定址。合理設定負載因子和初始容量避免過多的擴容操作和快取丟失。 理解HashMap的 hash
方法和衝突樹化。