爲什麼要重寫 hashcode 和 equals 方法?

引言

以前面試的時候被面試官問到過這樣一個問題:

你有沒有重寫過 hashCode 方法?

心裏想着我沒事重寫哪玩意幹啥,能不寫就不寫。嘴上當然沒敢這麼說,只能略表遺憾的說抱歉,我沒寫過。

撇了面試官一眼,明顯看到他對這個回答不滿意,但是這已經觸及到我的知識盲點了,我也很慚愧,可是確實沒有重寫過,咱也不能胡扯不是。

然後他又問到另外一個問題:

你在用 HashMap 的時候,鍵(Key)部分,有沒有放過自定義對象?

我說我放過,很自信的說我放過(其實我忘了我有沒有放過),但是不能慫啊,第一個都不會了,第二個再說不會哪不是直接拜拜要走人了嗎?

面試官狡猾的笑了,說是你既然沒有重寫過 hashCode 方法,你怎麼把自定義對象放進去的?

我勒個去,原來你在這等着我呢,沒想到這還是個連環炮,惹不起惹不起,認慫三連

不過不會就學,不懂就問,這一直都是咱程序猿優秀的素養,今天就乾脆從 Hash 表學起,講述 HashMap 的存取數據規則,由此來搞定上述問題的答案。

通過 Hash 算法來了解 HashMap 對象的高效性

我們先複習數據結構裏的一個知識點:

在一個長度爲 n(假設是100)的線性表(假設是 ArrayList)裏,存放着無序的數字;如果我們要找一個指定的數字,就不得不通過從頭到尾依次遍歷來查找,這樣的平均查找次數是 n / 2(這裏是50)。

我們再來觀察 Hash 表(這裏所說的 Hash 表純粹是數據結構上的概念,和 Java 無關)。

哈希表就是一種以 鍵-值(key-indexed) 存儲數據的結構,我們只要輸入待查找的值即 key,即可查找到其對應的值。

它的平均查找次數接近於 1,代價相當小。

使用哈希查找有兩個步驟:

  1. 使用哈希函數將被查找的鍵轉換爲數組的索引:在理想的情況下,不同的鍵會被轉換爲不同的索引值,但是在有些情況下我們需要處理多個鍵被哈希到同一個索引值的情況。所以哈希查找的第二個步驟就是處理衝突

  2. 處理哈希碰撞衝突:有很多處理哈希碰撞衝突的方法,本文後面會介紹拉鍊法和線性探測法。

既然哈希查找第一步就是使用哈希函數將鍵映射成索引,那我們就先假設一個 Hash 函數是x * x % 5,(當然實際編程中不可能用這麼簡單的 Hash 函數,一般選擇的哈希函數都是要易於計算並且能夠均勻分佈所有鍵的,這裏純粹爲了說明方便),然後假設 Hash 表是一個長度是 11 的線性表。

接下來如果我們如果要把 6 放入其中,那麼我們首先會對 6 用 Hash 函數計算一下,結果是 1,所以我們就把 6 放入到索引號是 1 這個位置。同樣如果我們要放數字 7,經過 Hash 函數計算,7 的結果是 4,那麼它將被放入索引是 4 的這個位置。

如下如所示:

這樣做的好處非常明顯:比如我們要從中找 6 這個元素,我們可以先通過 Hash 函數計算 6 的索引位置,然後直接從 1 號索引裏找到它了。不過我們有可能會遇到Hash值衝突這個問題,比如經過 Hash 函數計算後,7 和 8 會有相同的 Hash 值,此時我們就需要了解一下解決哈希碰撞的幾種常見方式:

開放地址法

使用某種探查(亦稱探測)技術在散列表中形成一個探查序列。沿此序列逐個單元地查找,直到找到給定的關鍵字,或者碰到一個開放的地址(即該地址單元爲空)爲止(若要插入,在探查到開放的地址,則可將待插入的新結點存入該地址單元)。

按照形成探查序列的方法不同,可將開放定址法區分爲線性探查法、線性補償探測法以及隨機探測等。限於篇幅,我們此處只討論線性探查法。

線性探查法

該方法基本思想是:

將散列表 T[0..m-1] 看成是一個循環向量,若初始探查的地址爲d(即h(key)=d),則最長的探查序列爲:

d,d+l,d+2,…,m-1,0,1,…,d-1

即 : 探查時從地址 d 開始,首先探查 T[d],然後依次探查 T[d+1],…,直到 T[m-1],此後又循環到 T[0],T[1],…,直到探查到 T[d-1] 爲止。 探查過程終止於三種情況:

  • 若當前探查的單元爲空,則表示查找失敗(若是插入則將 key 寫入其中);

  • 若當前探查的單元中含有 key,則查找成功,但對於插入意味着失敗;

  • 若探查到 T[d-1] 時仍未發現空單元也未找到 key,則無論是查找還是插入均意味着失敗(此時表滿)。

     

利用開放地址法的一般形式,線性探查法的探查序列爲:

 hi = (h(key)+i)%m 0≤i≤m-1 // 即di=i

用線性探測法處理衝突,思路清晰,算法簡單,但存在下列缺點:

  1. 處理溢出需另編程序。一般可另外設立一個溢出表,專門用來存放上述哈希表中放不下的記錄。此溢出表最簡單的結構是順序表,查找方法可用順序查找。

  2. 按上述算法建立起來的哈希表,刪除工作非常困難。假如要從哈希表 HT 中刪除一個記錄,按理應將這個記錄所在位置置爲空,但我們不能這樣做,而只能標上已被刪除的標記,否則,將會影響以後的查找。

  3. 線性探測法很容易產生堆聚現象。所謂堆聚現象,就是存入哈希表的記錄在表中連成一片。按照線性探測法處理衝突,如果生成哈希地址的連續序列愈長 ( 即不同關鍵字值的哈希地址相鄰在一起愈長 ) ,則當新的記錄加入該表時,與這個序列發生衝突的可能性愈大。因此,哈希地址的較長連續序列比較短連續序列生長得快,這就意味着,一旦出現堆聚 ( 伴隨着衝突 ) ,就將引起進一步的堆聚。

在使用了上述線性探查法的情況下,則 7 和 8 在存儲的時候,因爲兩者哈希後得到的索引一致,並且 7 已經存到了哈希表中,哪麼 8 在找到索引 4 的時候會發現已經有值了,則它繼續開始往後查找,此時找到索引爲 5 的位置發現爲空,它就會把 8 放到索引爲 5 的位置上,如下:

鏈地址法

拉鍊法解決衝突的做法是:將所有關鍵字爲同義詞的結點鏈接在同一個單鏈表中。若選定的散列表長度爲 m,則可將散列表定義爲一個由 m 個頭指針組成的指針數 組 T[0..m-1]。凡是散列地址爲 i 的結點,均插入到以 T[i] 爲頭指針的單鏈表中。T 中各分量的初值均應爲空指針。在拉鍊法中,裝填因子 α 可以大於 1,但一般均取 α≤1。

與開放定址法相比,拉鍊法有如下幾個優點:

  1. 拉鍊法處理衝突簡單,且無堆積現象,即非同義詞決不會發生衝突,因此平均查找長度較短;

  2. 由於拉鍊法中各鏈表上的結點空間是動態申請的,故它更適合於造表前無法確定表長的情況;

  3. 開放定址法爲減少衝突,要求裝填因子 α 較小,故當結點規模較大時會浪費很多空間。而拉鍊法中可取 α≥1,且結點較大時,拉鍊法中增加的指針域可忽略不計,因此節省空間;

  4. 在用拉鍊法構造的散列表中,刪除結點的操作易於實現。只要簡單地刪去鏈表上相應的結點即可。而對開放地址法構造的散列表,刪除結點不能簡單地將被刪結 點的空間置爲空,否則將截斷在它之後填人散列表的同義詞結點的查找路徑。這是因爲各種開放地址法中,空地址單元(即開放地址)都是查找失敗的條件。因此在 用開放地址法處理衝突的散列表上執行刪除操作,只能在被刪結點上做刪除標記,而不能真正刪除結點。

使用拉鍊法的時候 7 和 8 的時候具體的做法是:爲所有 Hash 值是 i 的對象建立一個同義詞鏈表。假設我們在放入 8 的時候,發現 4 號位置已經被佔,那麼就會新建一個鏈表結點放入 8。同樣,如果我們要找 8,那麼發現 4 號索引裏不是 8,那會沿着鏈表依次查找。

存儲位置如下:

 

Java 中的 HashMap 對象採用的是鏈地址法的解決方案。

雖然我們還是無法徹底避免 Hash 值衝突的問題,但是 Hash 函數設計合理,仍能保證同義詞鏈表的長度被控制在一個合理的範圍裏。這裏講的理論知識並非無的放矢,大家能在後文裏清晰地瞭解到重寫 hashCode 方法的重要性。

2 爲什麼要重寫 equals 和 hashCode 方法

當我們用 HashMap 存入自定義的類時,如果不重寫這個自定義類的 equals 和 hashCode 方法,得到的結果會和我們預期的不一樣。

我們來看一個例子,定義一個 HashMapKey.java 的類,這個類只有一個屬性 id :

public class HashMapKey {
​
    private Integer id;
​
    public HashMapKey(Integer id) {
        this.id = id;
    }
​
    public Integer getId() {
        return id;
    }
}

 

測試類如下:

public class TestHashMap {
​
    public static void main(String[] args) {
        HashMapKey k1 = new HashMapKey(1);
        HashMapKey k2 = new HashMapKey(1);
        HashMap<HashMapKey, String> map = new HashMap<>();
        map.put(k1, "程序猿雜貨鋪");
        System.out.println("map.get(k2) : " + map.get(k2));
    }
​
}

 

在 main 函數裏,我們定義了兩個 HashMapKey 對象,它們的 id 都是 1,然後創建了一個 HashMap 對象,緊接着我們通過 put 方法把 k1 和一串字符放入到 map裏,最後用 k2 去從 HashMap 裏得到值,因爲 k1 和 k2 值是一樣的,理論上我們是可以用這個鍵獲取到對應的值的,看似符合邏輯,實則不然,它的執行結果是:

map.get(k2) : null

其實出現這個情況的原因有兩個:

  • 沒有重寫 hashCode 方法

  • 沒有重寫 equals 方法。

當我們往 HashMap 裏放 k1 時,首先會調用 HashMapKey 這個類的 hashCode 方法計算它的 hash 值,隨後把 k1 放入 hash 值所指引的內存位置。

但是我們沒有在 HashMapKey 裏重寫 hashCode 方法,所以這裏調用的是 Object 類的 hashCode 方法,而 Object 類的 hashCode 方法返回的 hash 值其實是 k1 對象的內存地址(假設是 0x100)。

如果我們隨後是調用 map.get(k1),那麼我們會再次調用 hashCode 方法(還是返回 k1 的地址 0x100),隨後根據得到的 hash 值,能很快地找到 k1。

但我們這裏的代碼是 map.get(k2),當我們調用Object類的 hashCode方法(因爲 HashMapKey 裏沒定義)計算 k2 的 hash值時,其實得到的是 k2 的內存地址(假設是 0x200)。由於 k1 和 k2 是兩個不同的對象,所以它們的內存地址一定不會相同,也就是說它們的 hash 值一定不同,這就是我們無法用 k2 的 hash 值去拿 k1 的原因。

接下來我們在類 HashMapKey 中重寫 hashCode 方法

@Override
public int hashCode() {
   return id.hashCode();
}

 

此時因爲 hashCode 方法返回的是 id 的 hash值,所以此處 k1 和 k2 這兩個對象的 hash 值就變得相等了。

但是問題還沒有結束,我們再來更正一下存 k1 和 取 k2 的動作。存 k1 時,是根據它 id 的 hash 值,假設這裏是 103,把 k1 對象放入到對應的位置。而取 k2 時,是先計算它的 hash 值(由於 k2 的 id 也是 1,這個值也是 103),隨後到這個位置去找。但運行結果還是會出乎我們意料:

map.get(k2) : null

明明 103號位置已經有 k1,但打印輸出結果依然是 null。

其實原因就是沒有重寫 HashMapKey 對象的 equals 方法。

HashMap 是用鏈地址法來處理衝突,也就是說,在 103號位置上,有可能存在着多個用鏈表形式存儲的對象。它們通過 hashCode 方法返回的 hash 值都是 103。

當我們通過 k2 的 hashCode 到 103號位置查找時,確實會得到 k1。但 k1 有可能僅僅是和 k2 具有相同的 hash值,但未必和 k2 相等,這個時候,就需要調用 HashMapKey 對象的 equals 方法來判斷兩者是否相等了。

由於我們在 HashMapKey 對象裏沒有定義 equals 方法,系統就不得不調用 Object 類的 equals 方法,同理由於 Object 的固有方法是根據兩個對象的內存地址來判斷,所以 k1 和 k2 一定不會相等,這就是爲什麼通過 map.get(k2) 依然得到 null 的原因。

爲了解決這個問題,我們繼續重寫 equals 方法,在這個方法裏,只要兩個對象都是 Key 類型,而且它們的 id 相等,它們就相等。

@Override
public boolean equals(Object o) {
     if (o == null || !(o instanceof HashMapKey)) {
         return false;
     } else {
         return this.getId().equals(((HashMapKey) o).getId());
     }
}

 

至此,問題已經解決。

總結

我們平時在項目開發中經常會用到 HashMap,雖然很多時候我們都會儘可能避免去在鍵值存放自定義對象,但是正因爲如此,一旦碰到需要存放自定義對象了就容易出問題,重申一遍:如果你需要要在 HashMap 的“鍵”部分存放自定義的對象,一定要重寫 equals 和 hashCode 方法。

其實 這個問題本身不難,只要我們平時稍微注意以下就可以避免,本文也是大概總結了以下,避免大家以後碰到了踩坑,希望對你有所幫助,保不齊下次面試也有人問你同樣的問題。

 

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