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 算法,讲得很是好,建议你们感兴趣的话通篇阅读。

相关文章
相关标签/搜索