詳解 HashMap 中的 hash 函數

閱讀本文大概需要16分鐘。

1. 什麼是 hash 函數

hash 函數,即散列函數,或叫哈希函數。它可以將不定長的輸入,通過散列算法轉換成一個定長的輸出,這個輸出就是散列值。需要注意的是,不同的輸入通過散列函數,也可能會得到同一個散列值。因此我們不能使用散列函數來獲取唯一值。

2. HashMap 爲什麼要使用 hash 函數

Java 的 HashMap 中使用的是數組 + 鏈表的結構,但在保存時,一個 K - V 鍵值對應該被存放到數組的哪個位置?不難想到,HashMap 中最常被用到的方法就是 put() 和 get(),如果只是按照存入順序存放,在取值時勢必需要遍歷整個數組,一個個去比較它們的 key 是否相等,這對於性能的損耗無疑是很大的。也許你已經猜到了,解決這個問題的辦法就是散列函數

3. 常見的 hash 算法及衝突的解決

在具體介紹 HashMap 如何使用散列函數之前,先簡單介紹一下常見的 hash 算法,以便於你可以更加系統地瞭解它。

a. 直接定址法:直接以關鍵字k或者k加上某個常數(k+c)作爲哈希地址(H(k)=ak+b)。
b. 數字分析法:提取關鍵字中取值比較均勻的數字作爲哈希地址(如一組出生日期,相較於年-月,月-日的差別要大得多,可以降低衝突概率)
c. 分段疊加法:按照哈希表地址位數將關鍵字分成位數相等的幾部分,其中最後一部分可以比較短。然後將這幾部分相加,捨棄最高進位後的結果就是該關鍵字的哈希地址。
d. 平方取中法:如果關鍵字各個部分分佈都不均勻的話,可以先求出它的平方值,然後按照需求取中間的幾位作爲哈希地址。
e. 僞隨機數法:選擇一隨機函數,取關鍵字的隨機值作爲散列地址,通常用於關鍵字長度不同的場合。
f. 除留餘數法:用關鍵字k除以某個不大於哈希表長度m的數p,將所得餘數作爲哈希表地址(H(k)=k%p, p<=m; p一般取m或素數)。

上文已經說到,不同的輸入通過散列函數,有可能會得到相同的輸出。有人可能會問:既然通過不同的輸入可以得到相同的輸出,那麼衝突了怎麼辦?比如在 HashMap 中,如果兩個不同的 key 計算得出的散列值相同,後一個豈不是會覆蓋前一個的 value?不用擔心,解決 hash 衝突的方法也是有的,常見的有:

a. 鏈地址法:將哈希表的每個單元作爲鏈表的頭結點,所有哈希地址爲 i 的元素構成一個同義詞鏈表。即發生衝突時就把該關鍵字鏈在以該單元爲頭結點的鏈表的尾部。
b. 開放定址法:即發生衝突時,去尋找下一個空的哈希地址。只要哈希表足夠大,總能找到空的哈希地址。
c. 再哈希法:即發生衝突時,由其他的函數再計算一次哈希值。
d. 建立公共溢出區:將哈希表分爲基本表和溢出表,發生衝突時,將衝突的元素放入溢出表。

可能你已經注意到,HashMap 就是使用鏈地址法來解決衝突的(jdk8中採用平衡樹來替代鏈表存儲衝突的元素,但hash() 方法原理相同)。數組中的每一個單元都會指向一個鏈表,如果發生衝突,就將 put 進來的 K- V 插入到鏈表的尾部。

4. HashMap 是如何使用 hash 函數的

首先,我們來看一下在 HashMap 中,最常用的 put() 和 get() 是怎麼使用 hash() 的。以下源碼均爲 jdk7。

// put() 
int hash = hash(key.hashCode());
int i = indexFor(hash, table.length);

// get()
int hash = hash(key.hashCode());
for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
    Object k;
    if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
        return e.value;
}

我們不難觀察到,HashMap 中都是先使用 hash 函數 獲取一個 hash 值,然後利用得到的 hash 值和容器長度計算存放位置(indexFor() 方法)。接下來我們就詳細看一下 hash() 和 indexFor() 兩個方法。

static int hash(int h) {
    return h ^ (h >>> 7) ^ (h >>> 4);
}

static int indexFor(int h, int length) {
    return h & (length-1);
}

通過put() 和 get() 方法,我們可以知道,hash() 方法中的參數 h 就是 key 的 hashCode,hash() 方法對 hashCode 分別無符號右移 (>>>) 7 位和 4 位,並與自身進行異或(^)處理。 這麼做的目的是什麼?

由於 indexFor() 返回的是 h(hash 值) 與 length-1(容器長度-1) 進行與運算的結果,若不進行擾動,即h = hashCode,將會很容易發生衝突。如下圖所示,當低位相同時, h & (length - 1) 結果也會是一樣的。
hashCode&length.png

在經過擾動算法後,結果如下:
hash().png

可以明顯看到,計算出來的 hash 值是不一樣的,即二者不會再發生衝突。

既然已經解決了 hash() 的計算問題,那麼接下來就是計算索引了。HashMap 通過 hash 值與 length-1 (容器長度-1)進行取模(%)運算。可能有人會問:明明源碼中 indexFor() 方法進行的 按位與(&)運算,而非取模運算。實際上,HashMap 中的 indexFor() 方法就是在進行取模運算。利用位運算代替取模運算,可以大大提高程序的計算效率。位運算可以直接對內存數據進行操作,不需要轉換成十進制,因此效率要高得多。

需要注意的是,只有在特定情況下,位運算纔可以轉換成取模運算(當 b = 2^n 時,a % b = a & (b - 1) )。也是因此,HashMap 纔將初始長度設置爲 16,且擴容只能是以 2 的倍數(2^n)擴容。

5. 總結

a. hash 函數並不能保證得到唯一的輸出值,不同的輸入也有可能得到相同的輸出。
b. HashMap 中的 hash() 方法,將 hashCode 的高位和低位混合起來,降低衝突概率。
c. HashMap 中解決衝突的辦法是採用鏈地址法(jdk7)。
d. HashMap 的初始長度爲 16,且每次擴容都必須以 2 的倍數(2^n)擴充。因爲在 HashMap 中,採用按位與運算(&)代替取模運算(&),當 b = 2^n 時,a % b = a & (b - 1) 。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章