由HashMap哈希算法引出的求余%和與運算&轉換問題


1、引出問題

  在前面講解 HashMap  的源碼實現時,有如下幾點:

  ①、初始容量為 1<<4,也就是24 = 16

  

  ②、負載因子是0.75,當存入HashMap的元素占比超過整個容量的75%時,進行擴容,而且在不超過int類型的范圍時,進行2次冪的擴展(指長度擴為原來2倍)

  

  擴大一倍

  

  ③、新添加一個元素時,計算這個元素在HashMap中的位置,也就是本篇文章的主角 哈希運算。分為三步:

  第一步:取 hashCode 值: key.hashCode()

  第二步:高位參與運算:h>>>16

  第三步:取模運算:(n-1) & hash

1     static final int hash(Object key) {
2         int h;
3         return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
4     }
5 
6     tab[i = (n - 1) & hash];

  ps:第 6 行代碼是我自己加的。

  我們知道一個好的 哈希算法能夠使得元素分布的更加均勻,從而減少哈希沖突。HashMap 在這塊的處理就很巧妙:

  首先第一步取得 hashCode,該方法是一個用native修飾的本地方法,返回的是一個 int 類型的值(根據內存地址換算出來的一個值),通常我們都會重寫該方法。

  第二步將取得的哈希值無符號右移16位,高位補0。並與前面第一步獲得的hash碼進行按位異或^ 運算。這樣做有什么用呢?這其實也是擾動函數,為了降低哈希碼的沖突。右位移16位,正好是32bit的一半,高半區和低半區做異或,就是為了混合原始哈希碼的高位和低位,以此來加大低位的隨機性。而且混合后的低位摻雜了高位的部分特征,這樣高位的信息也被變相保留下來。也就是保證考慮到高低Bit位都參與到Hash的計算中。

  有興趣的可以看看JDK1.7中,其實是做了4次擾動,在JDK1.8中只做了一次,我猜測是為了在降低沖突的同時保證效率。

  

  本文的重點是第三步,將經過前面兩步獲取的 hash 值,與HashMap的集合長度減 1 進行按位與 & 運算:(n-1) & hash。但是其實很多哈希算法,為了使元素分布均勻,都是用的取模運算,用一個值去模上總長度,即 n%hash。我們知道在計算機中 & 的效率比 % 高很多,那么如何將 % 轉換為 & 運算呢?在HashMap 中,是用的 (n - 1) & hash 進行運算的,那么這是為什么呢?

  這就是本篇博客我們將要明白的問題。

2、結論

  我們先給出結論:

  當 lenth = 2n 時,X % length = X & (length - 1)

  也就是說,長度為2的n次冪時,模運算 % 可以變換為按位與 & 運算。

  比如:9 % 4 = 1,9的二進制是 1001 ,4-1 = 3,3的二進制是 0011。 9 & 3 = 1001 & 0011 = 0001 = 1

  再比如:12 % 8 = 4,12的二進制是 1100,8-1 = 7,7的二進制是 0111。12 & 7 = 1100 & 0111 = 0100 = 4

  上面兩個例子4和8都是2的n次冪,結論是成立的,那么當長度不為2的n次冪呢?

  比如:9 % 5 = 4,9的二進制是 1001,5-1 = 4,4的二進制是0100。9 & 4 = 1001 & 0100 = 0000 = 0。顯然是不成立的。

  為什么是這樣?下面我們來詳細分析。

3、分析過程

  首先我們要知道如下規則:

  ①、"<<" 左移:右邊空出的位上補0,左邊的位將從字頭擠掉,左移一位其值相當於乘2。

  ②、">>"右移:右邊的位被擠掉,右移一位其值相當於除以2。對於左邊移出的空位,如果是正數則空位補0,若為負數,可能補0或補1,這取決於所用的計算機系統。

  ③、">>>"無符號右移,右邊的位被擠掉,對於左邊移出的空位一概補上0。

  根據二進制數的特點,相信大家很好理解。

  對於給定一個任意的十進制數XnXn-1Xn-2....X1X0,我們將其用二進制的表示方法分解:

  XnXn-1Xn-2....X1X0   = Xn*2n+Xn-1*2n-1+......+X1*21+X0*20                       3-1公式

  這里的十進制數只有三位,同理當有N位時,后面2的冪次方依次從 0 開始遞增到 N 。

  回到上面的結論: lenth = 2n 時,X % length = X & (length - 1)

  以及對於除法,被除數是滿足分配率的(除數不滿足):

  成立:(a+b)÷c=a÷c+b÷c                                                                          3-2公式

  不成立:a÷(b+c)≠a÷c+b÷c

  通過 3-1公式以及 3-2 公式,我們可以得出當任意一個十進制除以一個2k的數時,我們可以將這個十進制轉換成3-1公式的表示形式:

  (XnXn-1Xn-2....X1X0)  / 2k   =  (Xn*2n+Xn-1*2n-1+......+X1*21+X0*20) / 2k = Xn*2n /  2k +Xn-1*2n-1 /  2k  +......+  X1*2/  2+ X0*20 /  2k

  如果我們想求上面公式的余數,相信大家一眼就能看出來:

  ①、當 0<= k <= n 時,余數為 Xk*2k+Xk-1*2k-1+......+X1*21+X0*20   ,也就是說 比 k 大的 n次冪,我們都舍掉了(大的都能整除 2k),比k小的我們都留下來了(小的不能整除2k)。那么留來下來即為余數。

  ②、當 k > n 時,余數即為整個十進制數。

  看到這里,我們離證明結論已經很近了。再回到上面說的二進制的移位操作,向右移 n 位,表示除以 2n 次方,由此我們得到一個很重要的結論:

  一個十進制數對一個2n 的數取余,我們可以將這個十進制轉換為二進制數,將這個二進制數右移n位,移掉的這 n 位數即是余數。

  知道怎么算余數了,那么我們怎么去獲取這移掉的 n 為數呢?

  我們再看20,21,22....2用二進制表示如下:

  0001,0010,0100,1000,10000......

  我們把上面的數字減一:

  0000,0001,0011,0111,01111......

  根據與運算符&的規律,當位上都是 1 時,結果才是 1,否則為 0。所以任意一個二進制數對 2k 取余時,我們可以將這個二進制數與(2k-1)進行按位與運算,保留的即使余數。

  這就完美的證明了前面給出的結論:

  當 lenth = 2n 時,X % length = X & (length - 1)

  注意,一定要是2n次方,才滿足上面的公式,否則就是錯誤的。

4、總結

  通過上面的分析過程了,我們完美了證明了公式的正確性。在回到 HashMap 的實現過程,我們知道HashMap的初始容量為啥是 1<<4 了吧,而且每次擴容都是擴大一倍。因為必須要完美的滿足 hash 算法。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM