面試官問:為什麼HashMap底層樹化的元素是 8

程式設計碼農發表於2021-11-16

雜湊表

雜湊表是一種鍵值對映的資料結構。雜湊表中,資料以陣列格式儲存,其中每個資料值都有自己唯一的索引值,索引值通過雜湊表的雜湊函式計算得到。

hashTable

下面兩步將鍵雜湊值轉化成雜湊表的索引值。

  • 雜湊值 = 雜湊函式(鍵)
  • 索引值 = 雜湊值 % 雜湊表長度

衝突解決方法

有限長度下的雜湊表,衝突不可避免。解決衝突的兩種方法,拉鍊法開放定址

拉鍊法

將衝突位置的元素構造成連結串列。新增資料發生衝突時,將元素追加到連結串列。如下圖,當新增 "Sandra Dee"時,計算出索引值為152與“John Smith” 發生衝突,然後將它追加到連結串列。

hash.png

開放定址

以當前衝突位置為起點,按照一定規則探測空位置把元素插進去。比較簡單的方式是線性探測,它會按照固定的間隔(通常是1)迴圈進行查詢。

如下圖,“Sandra Dee”新增時與 “John Smith” 相沖突,通過探測空位置插入到153,然後新增“Ted Baker”發現與“Sandra Dee”相沖突,往後探測154空位置插入。

hash

效能

負載因子

負載因子的值是條目數佔用雜湊桶比例,當負載因子超過理想值時,雜湊表會進行擴容。比如雜湊表理想值 0.75,初始容量 16,當條目超過 12 後雜湊表會進行擴容重新雜湊。0.6 和 0.75 是通常合理的負載因子。

$$ {\displaystyle loadfactor\ (\alpha )={\frac {n}{k}}} $$

  • $n$ 雜湊表中的條目數。
  • $k$ 桶的數量。

影響雜湊表效能的兩個主要因素

  • 快取丟失。隨著負載因子的增加快取丟失數量上升,而搜尋和插入效能會因此大幅下降。
  • 擴容重新雜湊。調整大小是一項極其耗時的任務。設定合適的負載因子可以控制擴容次數。

下圖展示了隨著負載因子增加,快取丟失的數量開始上升,0.8後開始迅速攀升。

load factor

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 方法和衝突樹化。

相關文章