HashMap分析及散列的沖突處理


1,Hashing過程

像二分查找、AVL樹查找,這些查找算法的時間復雜度為O(logn),而對於哈希表而言,我們一般說它的查找時間復雜度為O(1)。那它是怎么實現的呢?這就是一個Hashing過程。

在JAVA中,每個對象都有一個散列碼,它是由Object類的hashCode()方法計算得到的(當然也可以覆蓋Object的hashCode())。而我們可以在散列碼的基礎上,定義一個哈希函數,再對哈希函數計算出的結果求余,最終得到該對象在哈希表的位置。

 1 final int hash(Object k) {
 2         int h = hashSeed;
 3         if (0 != h && k instanceof String) {
 4             return sun.misc.Hashing.stringHash32((String) k);
 5         }
 6 
 7         h ^= k.hashCode();
 8         h ^= (h >>> 20) ^ (h >>> 12);
 9         return h ^ (h >>> 7) ^ (h >>> 4);
10     }

如上,哈希函數hash(Object k) 中用到了hashCode()。然后再經過進一步的特殊處理,得到一個最終的哈希值。哈希函數的定義是需要技藝的,因為它要保證盡量地將所有的Key均勻地分布,因此最好借助前人已實踐的經驗。

當得到哈希值之后,根據該哈希值Mod N(求余)計算出其在哈希表的位置。

static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

indexFor(int h, int length)實際上完成的就是求余操作。只不過求余操作涉及到除法,而這里可以通過移位操作來代替除法。即二者完成的功能都是一樣的,移位的效率更高。

哈希過程為什么需要先根據hashCode得到一個值(又稱散列碼),然后再對該值求余呢?

在JAVA中,Object類的hashCode()方法返回的是由調用對象的內存地址導出的一個值,也即,當沒有覆蓋Object類中的equals() 和 hashCode()時,只有當兩個對象的內存地址一樣時,才認為兩個對象是相等的。這顯然不符合實際情況,比如Person類有 String id、String name.....顯然在現實中是根據id(身份證)不同來判斷兩個人不同。因此,需要進一步根據hashCode()值來封裝(如上面的 hash(Object k)方法),返回一個合理的散列碼。

 

那為什么又需要對得到的散列碼求余呢?---上面的 indexFor(int h, int length)完成的功能

在底層是用數組來存儲<key, value>的,而我們得到的散列碼可能很大(事實上散列碼的范圍非常廣)

而內存是有限的,不能分配為數組分配一塊很大很大的空間,因此,存儲<key, value>的數組空間相對較小。

從而需要把 所有的散列碼都 “約束” 到這個有效的數組空間中。----這也是導致沖突的根源

 

為什么使用HashMap查找是O(1)呢?

T value = hashmap.get(key)

①get(key)時,一步計算出該key所對應的底層數組array的 index  (相當於上面 hash(Object k ) 和 indexFor(int h, int length) 這兩個函數完成的功能)

②value = array[index]

因此,就認為查找的復雜度為O(1)

 

2,沖突處理

沖突處理主要分兩種,一種是開放定址法,另一種是鏈地址法。HashMap的實現中采用的是鏈地址法。

開放定址法有兩種處理方式,一種是線性探測另一種是平方探測。

線性探測:依次探測沖突位置的下一個位置。如,在哈希表的位置2處發生了沖突,則探測位置3處是否被使用了,若被使用了,則探測位置4……直至下一個被探測的位置為空(意味着還有位置可以插入元素---插入成功)或者探測了N-1(N為哈希表的長度)個元素又回到了原始的沖突位置處(意味着已經沒有位置可供新元素插入了---插入失敗)

因此,插入一個元素時,最壞情況下的時間復雜度為O(N),因為它有可能探測了N-1個元素!

平方探測:以平方大小來遞增下一次待探測的位置。如,在哈希表位置2處發生了沖突,則探測 (1^2=1)位置3(2+1),若位置3被使用了,則探測(2^2=4) 位置6(2+4),若位置6被使用了,則探測(3^2=9)位置11(2+9=11)……平方探測法有一個特點:對於任何一個給定的素數N(假設哈希表的長度設置為素數),當計算( h(k) + i ^2 ) MOD N 時,隨着 i 的增長,得到的結果是循環的。

因此,當平方探測重復探測了某一個位置時,說明探測失敗即已經沒有位置可供新元素插入了,盡管此時哈希表並沒有滿。

平方探測是跳着探測的,它忽略了一些位置,而這些位置可能是空的。即在哈希表仍未滿的情況下,已經不能再插入新元素了

最壞情況下,平方探測需要檢測 N/2個位置,因此插入一個元素的最壞時間復雜度為O(N)。

 

鏈地址法

在HashMap的實現中,采用的鏈地址法來解決沖突,它有一個桶的概念:對於Entry數組而言,數組的每個元素處存儲的是鏈表,而不是直接的Value。在鏈表中的每個元素才是真正的<Key, Value>。而一個鏈表,就是一個桶!因此HashMap最多可以有Entry.length 個桶。

public class HashMap<K,V>
    extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable
{
    static final Entry<?,?>[] EMPTY_TABLE = {};
    .....
    .....

HashMap中有一個Entry數組,而Entry類是HashMap的內部類。由Entry類來封裝實際的<Key, Value>

 static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;

 

HashMap中還有兩個變量: int threshold 和 float loadFactor。loadFactor 默認是0.75,threshold作用如下:當HashMap中的元素個數超過threshold時,就會重新調整哈希的大小。

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);

 

而loadFactor作用是:指定threshold,一般情況下,哈希表的大小乘以0.75等於threshold。

 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);

 

在HashMap中,addEntry()方法添加新元素時,總是將新元素添加在鏈表的表頭。而不是鏈表的其它位置。

完。

 


免責聲明!

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



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