1. 程式人生 > >Java資料結構和演算法:HashMap,雜湊表,雜湊函式

Java資料結構和演算法:HashMap,雜湊表,雜湊函式

1. HashMap概述

HashMap是基於雜湊表的Map介面的非同步實現(Hashtable跟HashMap很像,唯一的區別是Hashtalbe中的方法是執行緒安全的,也就是同步的)。此實現提供所有可選的對映操作,並允許使用null值和null鍵。此類不保證對映的順序,特別是它不保證該順序恆久不變。

2. 四個關注點在HashMap上的答案

關注點 結論
HashMap是否允許空 Key和Value都允許為空
HashMap是否允許重複資料 Key重複會覆蓋、Value允許重複
HashMap是否有序 無序,特別說明這個無序指的是遍歷HashMap的時候,得到的元素的順序基本不可能是put的順序
HashMap是否執行緒安全 非執行緒安全

3. HashMap的資料結構

在java程式語言中,最基本的結構就是兩種,一個是陣列,另外一個是模擬指標(引用),所有的資料結構都可以用這兩個基本結構來構造的,HashMap也不例外。HashMap實際上是一個“連結串列的陣列”的資料結構,每個元素存放連結串列頭結點的陣列,即陣列和連結串列的結合體。

hashmap

從上圖中可以看出,HashMap底層就是一個數組結構,陣列中的每一項又是一個連結串列。當新建一個HashMap的時候,就會初始化一個數組。原始碼如下:

/**
 * The table, resized as necessary. Length MUST Always be a power of two.
 */
transient Entry[] table; static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; final int hash; …… }

可以看出,Entry就是陣列中的元素,每個 Map.Entry 其實就是一個key-value對,它持有一個指向下一個元素的引用,這就構成了連結串列。

HashMap的底層主要是基於陣列和連結串列來實現的,它之所以有相當快的查詢速度主要是因為它是通過計算雜湊碼來決定儲存的位置。HashMap中主要是通過key的hashCode來計算hash值的,只要hashCode相同,計算出來的hash值就一樣。如果儲存的物件對多了,就有可能不同的物件所算出來的hash值是相同的,這就出現了所謂的hash衝突。學過資料結構的同學都知道,解決hash衝突的方法有很多,HashMap底層是通過連結串列來解決hash衝突的。

hashmap

圖中,紫色部分即代表雜湊表,也稱為雜湊陣列,陣列的每個元素都是一個單鏈表的頭節點,連結串列是用來解決衝突的,如果不同的key對映到了陣列的同一位置處,就將其放入單鏈表中。

對於 HashMap 及其子類而言,它們採用 Hash 演算法來決定集合中元素的儲存位置。當系統開始初始化 HashMap 時,系統會建立一個長度為 capacity 的 Entry 陣列,這個數組裡可以儲存元素的位置被稱為“桶(bucket)”,每個 bucket 都有其指定索引,系統可以根據其索引快速訪問該 bucket 裡儲存的元素。

無論何時,HashMap 的每個“桶”只儲存一個元素(也就是一個 Entry),由於 Entry 物件可以包含一個引用變數(就是 Entry 構造器的的最後一個引數)用於指向下一個 Entry,因此可能出現的情況是:HashMap 的 bucket 中只有一個 Entry,但這個 Entry 指向另一個 Entry ——這就形成了一個 Entry 鏈。

hashmap

4. HashMap的建構函式

HashMap提供了三個建構函式:

  • HashMap():構造一個具有預設初始容量 (16) 和預設載入因子 (0.75) 的空 HashMap。
  • HashMap(int initialCapacity):構造一個帶指定初始容量和預設載入因子 (0.75) 的空 HashMap。
  • HashMap(int initialCapacity, float loadFactor):構造一個帶指定初始容量和載入因子的空 HashMap。

在這裡提到了兩個引數:初始容量,載入因子。這兩個引數是影響HashMap效能的重要引數,其中容量表示雜湊表中桶的數量,初始容量是建立雜湊表時的容量,載入因子是雜湊表在其容量自動增加之前可以達到多滿的一種尺度,它衡量的是一個散列表的空間的使用程度,負載因子越大表示散列表的裝填程度越高,反之愈小。對於使用連結串列法的散列表來說,查詢一個元素的平均時間是O(1+a),因此如果負載因子越大,對空間的利用更充分,然而後果是查詢效率的降低;如果負載因子太小,那麼散列表的資料將過於稀疏,對空間造成嚴重浪費。系統預設負載因子為0.75,一般情況下我們是無需修改的。

若:載入因子越大,填滿的元素越多,好處是,空間利用率高了,但:衝突的機會加大了.連結串列長度會越來越長,查詢效率降低。
反之,載入因子越小,填滿的元素越少,好處是:衝突的機會減小了,但:空間浪費多了.表中的資料將過於稀疏(很多空間還沒用,就開始擴容了)

當雜湊表中條目數超出了當前容量*載入因子(其實就是HashMap的實際容量)時,則對該雜湊表進行rehash操作,將雜湊表擴充至兩倍的桶數。

5. HashMap的存取實現

5.1 儲存

public V put(K key, V value) {
    //當key為null,呼叫putForNullKey方法,儲存null與table第一個位置中,這是HashMap允許為null的原因
    if (key == null)
        return putForNullKey(value);
    //計算key的hash值
    int hash = hash(key.hashCode());                  ------(1)
    //計算key hash 值在 table 陣列中的位置
    int i = indexFor(hash, table.length);             ------(2)
    //從i出開始迭代 e,找到 key 儲存的位置
    for (Entry<K, V> e = table[i]; e != null; e = e.next) {
        Object k;
        //判斷該條鏈上是否存在相同的key值
        //若存在相同,則直接覆蓋value,返回舊value
        if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
            V oldValue = e.value;    //舊值 = 新值
              e.value = value;
            e.recordAccess(this);
            return oldValue;     //返回舊值
        }
    }
    //修改次數增加1
    modCount++;
    //將key、value新增至i位置處
     addEntry(hash, key, value, i);
    return null;
}

通過原始碼我們可以清晰看到HashMap儲存資料的過程為:首先判斷key是否為null,若為null,則直接呼叫putForNullKey方法,將value放置在陣列第一個位置上。若不為空則根據key的hashCode重新計算hash值,然後根據hash值得到這個元素在table陣列中的位置(即下標),如果table陣列在該位置處已經存放有其他元素了,則通過比較是否存在相同的key,若存在則覆蓋原來key的value,否則將該元素儲存在鏈頭(最先儲存的元素放在鏈尾)。若table在該處沒有元素,就直接將該元素放到此陣列中的該位置上。這個過程看似比較簡單,其實深有內幕。有如下幾點:

1、先看迭代處。此處迭代原因就是為了防止存在相同的key值,若發現兩個key值相同時,HashMap的處理方式是用新value替換舊value,這裡並沒有處理key,這就解釋了HashMap中沒有兩個相同的key。另外,注意一點,對比Key是否相同,是先比HashCode是否相同,HashCode相同再判斷equals是否為true,這樣大大增加了HashMap的效率。

2、在看(1)、(2)處。這裡是HashMap的精華所在。首先是hash方法,該方法為一個純粹的數學計算,就是計算h的hash值。此演算法加入了高位計算,防止低位不變,高位變化時,造成的hash衝突。

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

為什麼要經過這樣的運算呢?這就是HashMap的高明之處。先看個例子,一個十進位制數32768(二進位制1000 0000 0000 0000),經過上述公式運算之後的結果是35080(二進位制1000 1001 0000 1000)。看出來了嗎?或許這樣還看不出什麼,再舉個數字61440(二進位制1111 0000 0000 0000),運算結果是65263(二進位制1111 1110 1110 1111),現在應該很明顯了,它的目的是讓“1”變的均勻一點,雜湊的本意就是要儘量均勻分佈。

我們知道對於HashMap的table而言,資料分佈需要均勻(最好每項都只有一個元素,這樣就可以直接找到),不能太緊也不能太鬆,太緊會導致查詢速度慢,太鬆則浪費空間。計算hash值後,怎麼才能保證table元素分佈均與呢?我們會想到取模,但是由於取模的消耗較大,HashMap是這樣處理的:呼叫indexFor方法。

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

HashMap的底層陣列長度總是2的n次方,在建構函式中存在:capacity <<= 1;這樣做總是能夠保證HashMap的底層陣列長度為2的n次方。當length為2的n次方時,h&(length - 1)就相當於對length取模,也就是h%length,但是&比%具有更高的效率,速度比直接取模快得多,這是HashMap在速度上的一個優化。至於為什麼是2的n次方下面解釋。

我們回到indexFor方法,該方法僅有一條語句:h&(length - 1),這句話除了上面的取模運算外還有一個非常重要的責任:均勻分佈table資料和充分利用空間。這裡我們假設length為16(2^n)和15,h為5、6、7。

hashmap

當length=15時,6和7的結果一樣,這樣表示他們在table儲存的位置是相同的,也就是產生了碰撞,6、7就會在一個位置形成連結串列,這樣就會導致查詢速度降低。誠然這裡只分析三個數字不是很多,那麼我們就看0-15。

hashmap

從上面的圖表中我們看到總共發生了8此碰撞,同時發現浪費的空間非常大,有1、3、5、7、9、11、13、15處沒有記錄,也就是沒有存放資料。這是因為他們在與14進行&運算時,得到的結果最後一位永遠都是0,即0001、0011、0101、0111、1001、1011、1101、1111位置處是不可能儲存資料的,空間減少,進一步增加碰撞機率,這樣就會導致查詢速度慢。而當陣列長度為16時,即為2的n次方時,2n-1得到的二進位制數的每個位上的值都為1(比如(24-1)2=1111),這使得在低位上&時,得到的和原hash的低位相同,加之hash(int h)方法對key的hashCode的進一步優化,加入了高位計算,就使得只有相同的hash值的兩個值才會被放到陣列中的同一個位置上形成連結串列。所以說當length = 2^n時,不同的hash值發生碰撞的概率比較小,這樣就會使得資料在table陣列中分佈較均勻,查詢速度也較快。

這裡我們再來複習put的流程:當我們想一個HashMap中新增一對key-value時,系統首先會計算key的hash值,然後根據hash值確認在table中儲存的位置。若該位置沒有元素,則直接插入。否則迭代該處元素連結串列並依此比較其key的hash值。如果兩個hash值相等且key值相等(e.hash == hash && ((k = e.key) == key || key.equals(k))),則用新的Entry的value覆蓋原來節點的value。如果兩個hash值相等但key值不等 ,則將該節點插入該連結串列的鏈頭。具體的實現過程見addEntry方法,如下:

void addEntry(int hash, K key, V value, int bucketIndex) {
    //獲取bucketIndex處的Entry
    Entry<K, V> e = table[bucketIndex];
    //將新建立的 Entry 放入 bucketIndex 索引處,並讓新的 Entry 指向原來的 Entry 
    table[bucketIndex] = new Entry<K, V>(hash, key, value, e);
    //若HashMap中元素的個數超過極限了,則容量擴大兩倍
    if (size++ >= threshold)
        resize(2 * table.length);
}

這個方法中有兩點需要注意:

6. 鏈的產生

這是一個非常優雅的設計。系統總是將新的Entry物件新增到bucketIndex處。如果bucketIndex處已經有了物件,那麼新新增的Entry物件將指向原有的Entry物件,形成一條Entry鏈,但是若bucketIndex處沒有Entry物件,也就是e==null,那麼新新增的Entry物件指向null,也就不會產生Entry鏈了。

7. 擴容問題

隨著HashMap中元素的數量越來越多,發生碰撞的概率就越來越大,所產生的連結串列長度就會越來越長,這樣勢必會影響HashMap的速度,為了保證HashMap的效率,系統必須要在某個臨界點進行擴容處理。該臨界點在當HashMap中元素的數量等於table陣列長度*載入因子。但是擴容是一個非常耗時的過程,因為它需要重新計算這些資料在新table陣列中的位置並進行復制處理。所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的效能。

根據上面 put 方法的原始碼可以看出,當程式試圖將一個 key-value 對放入 HashMap 中時,程式首先根據該 key 的 hashCode() 返回值決定該 Entry 的儲存位置:如果兩個 Entry 的 key 的 hashCode() 返回值相同,那它們的儲存位置相同。如果這兩個 Entry 的 key 通過 equals 比較返回 true,新新增 Entry 的 value 將覆蓋集合中原有 Entry 的 value,但 key 不會覆蓋。如果這兩個 Entry 的 key 通過 equals 比較返回 false,新新增的 Entry 將與集合中原有 Entry 形成 Entry 鏈,而且新新增的 Entry 位於 Entry 鏈的頭部。

8. 讀取

相對於HashMap的存而言,取就顯得比較簡單了。通過key的hash值找到在table陣列中的索引處的Entry,然後返回該key對應的value即可。

public V get(Object key) {
    // 若為null,呼叫getForNullKey方法返回相對應的value
    if (key == null)
        return getForNullKey();
    // 根據該 key 的 hashCode 值計算它的 hash 碼  
    int hash = hash(key.hashCode());
    // 取出 table 陣列中指定索引處的值
    for (Entry<K, V> e = table[indexFor(hash, table.length)]; e != null; e = e.next) {
        Object k;
        //若搜尋的key與查詢的key相同,則返回相對應的value
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    return null;
}

有了上面儲存時的hash演算法作為基礎,理解起來這段程式碼就很容易了。從上面的原始碼中可以看出:從HashMap中get元素時,首先計算key的hashCode,找到陣列中對應位置的某一元素,然後通過key的equals方法在對應位置的連結串列中找到需要的元素。

當 HashMap 的每個 bucket 裡儲存的 Entry 只是單個 Entry ——也就是沒有通過指標產生 Entry 鏈時,此時的 HashMap 具有最好的效能:當程式通過 key 取出對應 value 時,系統只要先計算出該 key 的 hashCode() 返回值,在根據該 hashCode 返回值找出該 key 在 table 陣列中的索引,然後取出該索引處的 Entry,最後返回該 key 對應的 value 即可。

從上面程式碼中可以看出,如果 HashMap 的每個 bucket 裡只有一個 Entry 時,HashMap 可以根據索引、快速地取出該 bucket 裡的 Entry;在發生“Hash 衝突”的情況下,單個 bucket 裡儲存的不是一個 Entry,而是一個 Entry 鏈,系統只能必須按順序遍歷每個 Entry,直到找到想搜尋的 Entry 為止——如果恰好要搜尋的 Entry 位於該 Entry 鏈的最末端(該 Entry 是最早放入該 bucket 中),那系統必須迴圈到最後才能找到該元素。

3) 歸納起來簡單地說,HashMap 在底層將 key-value 當成一個整體進行處理,這個整體就是一個 Entry 物件。HashMap 底層採用一個 Entry[] 陣列來儲存所有的 key-value 對,當需要儲存一個 Entry 物件時,會根據hash演算法來決定其在陣列中的儲存位置,在根據equals方法決定其在該陣列位置上的連結串列中的儲存位置;當需要取出一個Entry時,也會根據hash演算法找到其在陣列中的儲存位置,再根據equals方法從該位置上的連結串列中取出該Entry。

9. 再談HashCode的重要性

前面講到了,HashMap中對Key的HashCode要做一次rehash,防止一些糟糕的Hash演算法生成的糟糕的HashCode,那麼為什麼要防止糟糕的HashCode?

糟糕的HashCode意味著的是Hash衝突,即多個不同的Key可能得到的是同一個HashCode,糟糕的Hash演算法意味著的就是Hash衝突的概率增大,這意味著HashMap的效能將下降,表現在兩方面:

(1)、有10個Key,可能6個Key的HashCode都相同,另外四個Key所在的Entry均勻分佈在table的位置上,而某一個位置上卻連線了6個Entry。這就失去了HashMap的意義,HashMap這種資料結構性高效能的前提是,Entry均勻地分佈在table位置上,但現在確是1 1 1 1 6的分佈。所以,我們要求HashCode有很強的隨機性,這樣就儘可能地可以保證了Entry分佈的隨機性,提升了HashMap的效率。

(2)、HashMap在一個某個table位置上遍歷連結串列的時候的程式碼:

if (e.hash == hash && ((k = e.key) == key || key.equals(k)))

看到,由於採用了”&&”運算子,因此先比較HashCode,HashCode都不相同就直接pass了,不會再進行equals比較了。HashCode因為是int值,比較速度非常快,而equals方法往往會對比一系列的內容,速度會慢一些。Hash衝突的概率大,意味著equals比較的次數勢必增多,必然降低了HashMap的效率了。

資料結構與演算法