摘要:注意这里我说的是一般情况下,因为哈希算法需要兼顾性能与准确性,是有一定概率出现重复的情况的。哈希算法实际上是数学家和计算机基础科学家研究的领域。
背景
做了几年 CRUD 工程师,深感自己的计算机基础薄弱,在看了几篇大牛的分享文章之后,发现很多人都是通过刷 LeetCode 来提高自己的算法水平。的确,通过分析解决实际的问题,比自己潜心研究书本效率还是要高一些。
一直以来遇到底层自己无法解决的问题,都是通过在 Google、GitHub 上搜索组件、博客来进行解决。这样虽然挺快,但是也让自己成为了一个“Ctrl+C/Ctrl+V”程序员。从来不花时间思考技术的内在原理。
直到我刷了 Leetcode 第一道题目 Two Sum,接触到了 HashMap 的妙用,才激发起我去了解 HashMap 原理的兴趣。
Two Sum(两数之和)TwoSum 是 Leetcode 中的第一道题,题干如下:
给定一个整数数组nums和一个目标值target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。
示例:
给定 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) { Mapmap = 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 NodegetNode(int hash, Object key) { Node [] tab; Node 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 )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的数据),这里对数组的长度n和hash值,做&运算(至于为什么要进行这样的&运算,是与 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 算法,讲得非常好,建议大家感兴趣的话通篇阅读。
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/73144.html
摘要:技能点中结构知识点声明语句添加内容鉴定存在本例是把作为找到下标根据返回下标返回数组最终代码建立在哈希表中遍历每个元素,找到可能与之匹配成的下标 问题: 给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的两个下标。你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。 思路 首先遍历一次整数数组,将数组下标和值建立哈希表,再从...
摘要:题意给出一串二进制数组,求数组中最长的连续的个数思路遍历数组判断,然后将值添加到长度保存数组中,取保存数组最大值。本题要考虑输入的数组为的状况。代码题意给出一个,从里面获取两个数。 485 Max Consecutive Ones题意:给出一串二进制数组,求数组中最长的连续1的个数思路:遍历数组判断,然后将值添加到长度保存数组中,取保存数组最大值。本题要考虑输入的数组为[0],[1]的...
摘要:解题思路题目要求两个数和等于,返回其题目说明不会有重复情况,所以我们一旦发现符合情况的,就可以直接结束循环并返回。特殊情况就是正好等于,那肯定是最接近的情况,直接返回即可。 Two SumGiven an array of integers, return indices of the two numbers such that they add up to a specific ta...
摘要:两个循环嵌套暴力计算时间复杂度达到了两个指针首尾并行时间复杂度这种方法可以满足这道题的要求,因为题目中明确说明了,但是当答案不止一个时,如为时,就不能用这种方法了用到循环遍历数组,对每个元素计算和的差,如果该差在中,返回两个位置,如果该差不 Easy 001 Two Sum Description: Given an array of integers, return indices ...
摘要:返回这两个元素的位置。每个输入都有且仅有一组满足条件的元素。想法如果使用蛮力法可以简单的解决这个问题。但是需要两层循环,效率低。用来存储,每一个元素和目标元素的差值和这个元素的位置。因为里的对象也是键值对。 题目详情 Given an array of integers, return indices of the two numbers such that they add up t...
阅读 1612·2021-11-23 09:51
阅读 3085·2021-09-26 10:21
阅读 756·2021-09-09 09:32
阅读 834·2019-08-29 16:06
阅读 3261·2019-08-26 13:36
阅读 749·2019-08-26 10:56
阅读 2534·2019-08-26 10:44
阅读 1114·2019-08-23 14:04