HashMap 淺析 —— LeetCode Two Sum 刷題總結

背景

作了幾年 CRUD 工程師,深感本身的計算機基礎薄弱,在看了幾篇大牛的分享文章以後,發現不少人都是經過刷 LeetCode 來提升本身的算法水平。的確,經過分析解決實際的問題,比本身潛心研究書本效率仍是要高一些。java

一直以來遇到底層本身沒法解決的問題,都是經過在 Google、GitHub 上搜索組件、博客來進行解決。這樣雖然挺快,可是也讓本身成爲了一個「Ctrl+C/Ctrl+V」程序員。歷來不花時間思考技術的內在原理。node

直到我刷了 Leetcode 第一道題目 Two Sum,接觸到了 HashMap 的妙用,才激發起我去了解 HashMap 原理的興趣。程序員

Two Sum(兩數之和)

TwoSum 是 Leetcode 中的第一道題,題幹以下:算法

給定一個整數數組nums和一個目標值target,請你在該數組中找出和爲目標值的那兩個整數,並返回他們的數組下標。數據庫

你能夠假設每種輸入只會對應一個答案。可是,你不能重複利用這個數組中一樣的元素。編程

示例:segmentfault

給定 nums = [2, 7, 11, 15], target = 9

由於 nums[0] + nums[1] = 2 + 7 = 9
因此返回 [0, 1]

初看這道題的時候,我固然是使用最簡單的array遍從來解決了:數組

public int[] twoSum(int[] nums, int target) {
    for (int i = 0; i < nums.length; i++) {
        for (int j = i + 1; j < nums.length; j++) {
            if (nums[j] == target - nums[i]) {
                return new int[] { i, j };
            }
        }
    }
    throw new IllegalArgumentException("No two sum solution");
}

這個解法在官方稱爲「暴力法」。數據結構

經過這個「暴力法」咱們能夠看到裏面有個咱們在編程中常常遇到的一個場景:檢查數組中是否存在某元素。架構

官方的解析中提到,哈希表能夠保持數組中每一個元素與其索引相互對應,因此若是咱們使用哈希表來解決這個問題,能夠有效地下降算法的時間複雜度。(不瞭解哈希表和時間複雜度的的朋友別急,下文會詳細說明)

使用哈希表的解法是這樣的:

public int[] twoSum(int[] nums, int target) {
    Map<Integer, Integer> map = new HashMap<>();
    for (int i = 0; i < nums.length; i++) {
        int complement = target - nums[i];
        if (map.containsKey(complement)) {
            return new int[] { map.get(complement), i };
        }
        map.put(nums[i], i);
    }
    throw new IllegalArgumentException("No two sum solution");
}

即便咱們不是很會算時間複雜度,也可以明顯看到,原來的雙重循環,在哈希表解法裏,變成了單重循環,代碼的效率很明顯提高了。

可是使人好奇的是map.containsKey()究竟是用了什麼樣的魔力,實現快速判斷元素complement是否存在呢?

這裏就要引出本篇文章的主角 —— HashMap。

HashMap

注:如下內容基於JDK 1.8進行講解

在瞭解map.containsKey()這個方法以前,咱們仍是得補習一下基礎,畢竟筆者在看到這裏得時候,對於哈希表、哈希值得概念也都忘得一乾二淨了。

什麼是哈希表呢?

哈希表是根據鍵(Key)而直接訪問在內存存儲位置的數據結構

維基上的解釋比較抽象。咱們能夠把一張哈希表理解成一個數組。數組中能夠存儲Object,當咱們要保存一個Object到數組中時,咱們經過必定的算法,計算出來Object的哈希值(Hash Code),而後把哈希值做爲下標,Object做爲值保存到數組中。咱們就獲得了一張哈希表。

看到這裏,咱們前文中說到的哈希表能夠保持數組中每一個元素與其索引相互對應,應該就很好理解了吧。

回到 Leetcode 的代示例,map.containsKey()中顯然是經過獲取 Key 的哈希值,而後判斷哈希值是否存在,間接判斷 Key 是否已經存在的。

到了這裏,若是咱們僅僅是想要可以明白 HashMap 的使用原理,基本上已經足夠了。可是相信有很多朋友對它的哈希算法感興趣。下面我詳細解釋一下。

map.containsKey()解析

咱們查看 JDK 的源碼,能夠看到map.containsKey()中最關鍵的代碼是這段:

/**
     * Implements Map.get and related methods
     *
     * @param hash hash for key
     * @param key the key
     * @return the node, or null if none
     */
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

一上來看不懂不要緊,其實這段代碼最關鍵的部分就只有這一句:first = tab[(n - 1) & hash]) != null

其中tab是 HashMap 的 Node 數組(每一個 Node 是一個 Key&value 鍵值對,用來存在 HashMap的數據),這裏對數組的長度nhash值,作&運算(至於爲何要進行這樣的&運算,是與 HashMap 的哈希算法有關的,具體要看java.util.HashMap.hash()這個方法,哈希算法是數學家和計算機基礎科學家研究的領域,這裏不作深刻研究),獲得一個數組下標,這個下標對應的數組數據,通常狀況下就是咱們要找的節點。

注意這裏我說的是通常狀況下,由於哈希算法須要兼顧性能與準確性,是有必定機率出現重複的狀況的。咱們能夠看到上文getNode方法,有一段遍歷的代碼:

do {
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        return e;
} while ((e = e.next) != null);

就是爲了處理極端狀況下哈希算法獲得的哈希值沒有命中,須要進行遍歷的狀況。在這個時候,時間複雜度是O(n),而在這種極端狀況之外,時間複雜度是O(1),這也就是map.containsKey()效率比遍歷高的奧祕。

Tips:

看到這裏,若是有人問你:兩個對象,其哈希值(hash code)相等,他們必定是同一個對象嗎?相信你必定有答案了。(若是兩個對象不一樣,但哈希值相等,這種狀況叫哈希衝突)

HashMap 應用:實現SQL JOIN

組合兩個 List 的數據是編程中常見的一個場景。爲何不直接使用 SQL JOIN 呢?在當下流行的微服務架構下,每一個微服務可能有一個單獨的數據庫,各個微服務之間的數據庫是不容許進行 SQL JOIN 的。例如:一個訂單查詢的需求,咱們須要查詢訂單中心,用戶中心,支付中心,合併各個中心返回的結果造成一個表單。

一個高效實現 SQL JOIN 的方法就是使用 HashMap,這裏我更新在了另外一篇文章裏,歡迎各位點擊查看:HashMap 常見應用:實現 SQL JOIN

哈希算法

經過前文咱們能夠發現,HashMap 之因此可以高效地根據元素找到其索引,是藉助了哈希表的魔力,而哈希算法是 哈希表的靈魂。

哈希算法其實是數學家和計算機基礎科學家研究的領域。對於咱們普通程序員來講,並不須要研究太透徹。可是若是咱們可以搞清楚其實現原理,相信對於從此的程序涉及大有裨益。

按筆者的理解,哈希算法是爲了給對象生成一個儘量獨特的Code,以方便內存尋址。此外其做爲一個底層的算法,須要同時兼顧性能與準確性。

爲了更好地理解 hash 算法,咱們拿java.lang.String的hash 算法來舉例。

java.lang.String hashCode方法:

public int hashCode() {
    int h = hash;
    if (h == 0 && value.length > 0) {
        char val[] = value;

        for (int i = 0; i < value.length; i++) {
            h = 31 * h + val[i];
        }
        hash = h;
    }
    return h;
}

相信這段代碼你們應該都看得懂,使用 String 的 char 數組的數字每次乘以 31 再疊加最後返回,所以,每一個不一樣的字符串,返回的 hashCode 確定不同。那麼爲何使用 31 呢?

在名著 《Effective Java》第 42 頁就有對 hashCode 爲何採用 31 作了說明:

之因此使用 31, 是由於他是一個奇素數。若是乘數是偶數,而且乘法溢出的話,信息就會丟失,由於與2相乘等價於移位運算(低位補0)。使用素數的好處並不很明顯,可是習慣上使用素數來計算散列結果。 31 有個很好的性能,即用移位和減法來代替乘法,能夠獲得更好的性能: 31 * i == (i << 5) - i, 現代的 VM 能夠自動完成這種優化。這個公式能夠很簡單的推導出來。

能夠看到,使用 31 最主要的仍是爲了性能。固然用 63 也能夠。可是 63 的溢出風險就更大了。那麼15 呢?仔細想一想也能夠。

在《Effective Java》也說道:編寫這種散列函數是個研究課題,最好留給數學家和理論方面的計算機科學家來完成。咱們這次最重要的是知道了爲何使用 31。

java.util.HashMap hash 算法實現原理相對複雜一些,這篇文章:深刻理解 hashcode 和 hash 算法,講得很是好,建議你們感興趣的話通篇閱讀。

相關文章
相關標籤/搜索