Java數據結構之HashMap和HashSet

1、認識 HashMap 和 HashSet

在上期中,學習到 TreeMap 和 TreeSet,因為他們實現瞭 SortedMap 和 SortedSet 接口(本質是 實現瞭 NavigableMap 和 NavigableSet),表示你創建的 TreeMap 或 TreeSet,必須是可排序的,也就是裡面的元素是可比較的。

HashSet 的底層也是 HashMap,跟上期 TreeSet 大同小異,感興趣可以去看看源碼。

本期的 HashMap 和 HashSet 並沒有繼承上述所說的倆個接口,也即表示 HashMap 和 HashSet 中的 key 可以不重寫 compareTo 方法,由此也能得出,HashMap 與 HashSet 不關於 key 有序!

因為 Set 的底層就是 Map,那麼這裡我們就來驗證下 TreeSet 關於 key 有序,而 HashSet 不關於 key 有序。

public class Test {
    public static void main(String[] args) {
        Set<String> treeSet = new TreeSet<>();
        Set<String> hashSet = new HashSet<>();;
        treeSet.add("0012");
        treeSet.add("0083");
        treeSet.add("0032");
        treeSet.add("0057");
        System.out.print("TreeSet: ");
        for (String s : treeSet) {
            System.out.print(s + " ");
        }
        hashSet.add("0012");
        hashSet.add("0083");
        hashSet.add("0032");
        hashSet.add("0057");
        System.out.print("\nHashSet: ");
        for (String s : hashSet) {
            System.out.print(s + " ");
        }
    }
}

打印結果

為什麼 HashMap 和 HashSet 不關於 key 有序呢?隨著往文章後續學習,你就會明白。

2、哈希表

2.1 什麼是哈希表

之前的學習中,如果我們要查找一個元素,肯定是要經過比較的,那有沒有一種辦法,可以不用經過比較,直接就能拿到呢?

如果我們能構造一種存儲結構,通過一種函數 (hashFunc) 使元素的存儲位置與函數得出的關鍵碼之間能夠建立一一映射的關系,那麼在查找某個元素的時候,就能通過這個函數來很快的找到該元素所在的位置。

這種函數就叫做哈希(散列)函數,上述所說構造出來的結構,叫做哈希表或者稱為散列表。

插入元素的時候:根據元素的關鍵碼,Person中關鍵碼可能是 age,這個具體情況具體分析,上述例子隻是在插入整型的時候,通過關鍵碼通過哈希函數得到的 index 就是要插入的位置。

搜索元素的時候:對元素的關鍵碼,通過哈希函數得出的 index 位置,與該位置的關鍵碼比較,若倆個關鍵碼相同,則搜索成功。

采取上面的方法,確實能避免多次關鍵碼的比較,搜索的效率也提高的,但是問題來瞭,拿上述圖的情況來舉例子的話,我接著還要插入一個元素 15,該怎麼辦呢?

2.2 哈希沖突

2.2.1 概念

接著上面的例子來說,如果要插入 15,使用哈希函數出來的 index = 5,但是此時的 5,下標的位置已經有元素存在瞭,這種情況我們就稱為哈希沖突!

簡單來說,不同的關鍵字通過相同的哈希函數計算出相同的哈希地址(前面我們稱為 index),這種現象就被稱為哈希沖突或哈希碰撞! 

2.2.2 設計合理哈希函數 – 避免沖突

如果哈希函數設計的不夠合理,是可能會引起哈希沖突的。

哈希函數的定義域,必須包括所需存儲的全部關鍵碼,哈希函數計算出來的地址,應能均勻的分佈在整個空間中,哈希函數不能設計太過於復雜。(工作中一般用不著我們親自設計)

常見的哈希函數:

直接定制法:取關鍵字的某個線性函數作為哈希地址:hash = A * key + B, 這樣比較簡單,但是需要事先知道關鍵字的分佈情況,適合於查找比較小且連續的情況。

除留餘數法:設哈希表中允許的地址數為 m,取一個不大於 m 的數,但接近或等於 m 的質數 p 作為除數,即哈希函數:hash = key % p,(p <= m)。

還有其他的方法感興趣可以查閱下相關資料,這兩個隻是比較常見的方法瞭,當然,就算哈希函數設計的再優秀,隻是產生哈希的沖突概率沒那麼高,但是仍然避免不瞭哈希沖突的問題,於是又有瞭一個降低沖突概率的辦法,調節負載因子。

2.2.3 調節負載因子 – 避免沖突

負載因子 α = 哈希表中元素個數 / 哈希表的長度

哈希表的長度沒有擴容是定長的,即 α 與 元素的個數是成正比的,當 α 越大,即代碼哈希表中的元素個數越多,元素越多,發生哈希沖突的概率就增加瞭,因此 α 越小,哈希沖突的概率也就越小。所以我們應該嚴格控制負載因子的大小,在 Java 中,限制瞭負載因子最大為 0.75,當超過瞭這個值,就要進行擴容哈希表,重新哈希(重新將各個元素放在新的位置上)。

所以負載因子越大,沖突率越高,我們就需要降低負載因子來變相的降低沖突率,哈希表中元素個數不能改變,所以隻能給哈希表擴容瞭。

2.2.4 Java中解決哈希沖突 – 開散列/哈希桶

上面講述的都是避免沖突的方法,其實還往往不夠,不管怎麼避免,還是會有沖突的可能性,那麼通常我們解決哈希沖突有兩種辦法,分別是 閉散列 和 開散列 那麼今天我們主要介紹 Java 中的開散列是如何解決的,感興趣可以下來自己瞭解下閉散列。

開散列又叫鏈地址法,或開鏈法,其實簡單來說就是一個數組加鏈表的結構。如圖:

如上圖我們可得出,通過哈希函數得出的地址相同的關鍵碼放在一起,這樣的每個子集合稱為桶,接著桶中的元素用鏈表的結構組織起來,,每個鏈表的頭節點存放在哈希表中。

3、HashMap 的部分源碼解讀

3.1 HashMap 的構造方法

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
 
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}
 
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
 
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

這幾個構造方法相信都不難理解,第一個無參構造方法,默認負載因子是 0.75,第二個構造方法是你可以傳一個 Map 進去,構造出一個新的 Map,負載因子仍然是默認的 0.75, 第三個構造方法就是指定開辟的容量瞭(並不是你想象那樣簡單哦,面試題講解),這個很簡單,第四個構造方法指定容量並且自定義負載因子。

在 HashMap 中,實例化對象的時候並不會默認開辟空間(指定空間除外)。

3.2 HashMap 是如何插入元素的?

前面對 HashMap 的講解,已經大概瞭解瞭一點,但是 HashMap 底層的哈希函數是如何設定的呢?而且 Map 中不能有重復值的情況,是利用什麼來判斷這兩個值是相同的值呢?

這裡我們通過查看 put 方法的源碼,來帶大傢一層層揭開這個面紗:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

進入到 put 方法,隨之調用瞭 putVal 方法,而第一個參數就是我們哈希函數的實現瞭,我們轉到hash() 方法中:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

通過哈希函數,能看出,首先調用 key 的hashCode 方法,接著無符號右移 16 位,即將 key 的高16位 與 低 16位 異或得到的 hash 值,通過這個也就在說明,我們如果是自定義類型的話,比如 Person,那麼一定要重寫 hashCode 方法,不然則是調用 Object 裡的 hashCode 方法瞭。

接著我們再往下看,putVal 方法裡面的邏輯,這裡代碼比較長,我們分段演示:

這裡就是判斷哈希表是否為有元素,如果表為 null 或者 哈希表的長度為 0,就會初始化數組(Node類型的數組),即調用 resize() 方法。初始哈希表的長度是 16,臨界閾值為 16 * 0.75 = 12,也就是數組元素達到 12個即會擴容。往後走代碼:

這裡作用是通過 hash 值得到索引 i,也就是數組的下標,判斷這個位置是否有元素瞭,如果沒有則直接 new 一個節點放到該處。反之走 else 就表示該數組下標已經有元素瞭。

如果得到的 i 索引處有元素,則需要判斷當前下標元素的 hash 值是否與我們要插入的元素 hash 值相同,如果相同,接著判斷 下標元素的 key 與我們要插入元素的 key一樣,或者通過 equals 方法比較是一樣瞭,則表示是同一個元素,則不用插入瞭,e 保存這個已經存在的元素。到這裡也能發現,這其實就是 Map 底層不能有重復 key 的原因瞭。

那如果他們不相同的情況下,就會判斷該下標 i 的位置值是不是紅黑樹的節點(到瞭一定條件哈希桶中的元素會采用紅黑樹來組織數據),如果是,則采用紅黑樹的方式進行插入元素,這裡我們就不往裡面看瞭。

最後都不滿足的情況,也就是元素不相同,並且不是紅黑樹結構,則走後面的 else,首先這個 else 裡面是個死循環,要想退出,必須滿足兩個條件,① 找到瞭可以插入的地方,② 在哈希桶中發現瞭相同的元素。

比較該數組索引 i 下標的下一個節點,如果為 null,則直接插入該節點,如果鏈表長度大於8,則可能需要樹化,也就是轉換成紅黑樹,如果找到瞭相同的 key,則不用插入直接跳出循環。

上面的 else 走完後,如果存在添加相同的元素的時候,e 則不為 null,隻需要將待添加元素的 value 值賦值給原本存在元素的 value 值即可,也就是更新一下元素的 value 值。afterNodeAccess 方法不用管,是使用 LinkedHashMap 實現的一個操作,這裡並沒有使用。

最後部分:

這裡有效元素個數自增,如果超過瞭數組長度,則重新判斷是否擴容(兩倍擴容)。 afterNodeInsertion 也不用管,LinkedHashMap中的,這裡也沒有實際用途。

總結:HashMap 的 put 方法,是根據 key 的 hashCode 來計算 hash 值的,即我們要在自定義類型中重寫 HashCode 方法,再者,是根據 equals 來判斷 key 是否相同,也表示著我們同時需要去重寫 equals 方法。

3.3 哈希表下的鏈表,何時會樹化?

在上述講解 put 方法的時候,發現插入元素的時候,數組索引位置的鏈表不止一個元素的時候會判斷是否要轉換成紅黑樹,那麼具體要達到什麼條件才能轉換成紅黑樹呢?我們直接看上述的 treeifyBin 方法即可。

樹化的前提是,鏈表的長度大於等於 8,就會樹化,因為是從 binCount 是從 0 開始的,所以 TREEIFY_THRESHOLD – 1。那麼鏈表的長度大於等於 8,一定會從鏈表變成紅黑樹嗎?我們往後看:

第一個 if 當哈希表為空,或者表(數組)的長度小於64,隻進行擴容即可,不會轉換成樹,當哈希表的長度大於 64,才有可能轉換成紅黑樹。

所以我們最終得出,HashMap 中哈希表下的鏈表,當鏈表長度大於等於 8,並且哈希表的長度大於等於 64 則會將此鏈表轉換成紅黑樹。 

4、相關面試題

4.1 鏈表轉換成紅黑樹如何比較?

前面我們學過 TreeMap TreeSet 底層就是紅黑樹,裡面的元素必須是可比較的,那哈希表下的鏈表轉換成紅黑樹之後,沒有重寫 compareTo 怎麼辦呢?這裡會按照 key 的哈希值來進行比較,所以就算轉換成紅黑樹後,也不會關於 key 有序,再者可能隻是哈希表其中一個索引位置下的結構是紅黑樹,其他的仍然可能是鏈表。

4.2 hashCode 和 equals 的區別

hashCode 是虛擬出對象的地址,底層是 native 封裝的,即 C/C++實現的,而 equals 則是比較兩個對象是否相同。

當我們重寫 hashCode 的時候,是調用 Objects 裡面的 hash 方法:

@Override
public int hashCode() {
    return Objects.hash(name, age);
}

舉個例子,person1 和 person2 兩個對象的 hashCode 完全不一樣,但通過 hash 函數得到的 hash 值是相同的!而 hashCode 也是通過 hash 出來的,即對象的 hashCode 可以稱為 hash 值,所以得出,兩個對象的 hashCode 相同,但是這兩個對象不一定相同。

對於 persong1.equals(person2) 的值為 true 的情況,則代表這兩個對象裡面的值都是一樣的,所以 equals 為 ture,兩個一模一樣的對象,最終的 hash 值肯定也是一樣的,並且 HashMap 也是調用 key 中的 equals() 方式來進行判斷是否有重復的 key 值。

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Person person = (Person) o;
    return age == person.age &&
            Objects.equals(name, person.name);
}

總結:

兩個對象的 HashCode 相同,不代表這兩個對象完全相同。

兩個對象的 equals 的結果為 true,則代表這兩個對象完全相同。

4.3 以下代碼分配的內存是多少?

public static void main(String[] args) {
    Map<Integer, Integer> map = new HashMap<>(25);
}

如果你天真的回答是 25 個數組元素大小,那就錯瞭,我們點進去構造方法,發現是調用另一個構造方法,在接著點進去,看到最後一行代碼即 tableSizeFor 方法:

這裡就沒必要慢慢算瞭,實際就是給你找到一個離 cap 最近的 2 的 n 次方數,取值范圍得大於等於 cap,例如上述 25 則實際開辟的就是 1  2  4  8  16  32  64….,那麼上述代碼實際開辟的大小就是 32 個數組空間大小。

5、性能分析

雖然哈希表一直在和沖突做鬥爭,但在實際使用過程中,我們認為哈希表的沖突率是不高的,沖突個數是可控的, 也就是每個桶中的鏈表的長度是一個常數,所以,通常意義下,我們認為哈希表的插入/刪除/查找時間復雜度是 O(1) 。 

以上就是Java數據結構之HashMap和HashSet的詳細內容,更多關於HashMap和HashSet的資料請關註WalkonNet其它相關文章!

推薦閱讀: