You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

03 | 哈希检索如何根据用户ID快速查询用户信息

你好,我是陈东。

在实际应用中我们经常会面临需要根据键Key来查询数据的问题。比如说给你一个用户ID要求你查出该用户的具体信息。这样的需求我们应该如何实现呢你可能会想到使用有序数组和二叉检索树都可以来实现。具体来说我们可以将用户ID和用户信息作为一个整体的元素然后以用户ID作为Key来排序存入有序数组或者二叉检索树中这样我们就能通过二分查找算法快速查询到用户信息了。

但是不管是有序数组、二叉检索树还是跳表它们的检索效率都是O(log n)。那有没有更高效的检索方案呢也就是说有没有能实现O(1)级别的查询方案呢?今天,我们就一起来探讨一下这个问题。

使用Hash函数将Key转换为数组下标

在第1讲中我们说过数组具有随机访问的特性。那给定一个用户ID想要查询对应的用户信息我们能否利用数组的随机访问特性来实现呢

我们先来看一个例子。假设系统中的用户ID是从1开始的整数并且随着注册数的增加而增加。如果系统中的用户数是有限的不会大于10万。那么用户的ID范围就会被固定在1到10万之间。在数字范围有限的情况下我们完全可以申请一个长度为10万的数组然后将用户ID作为数组下标从而实现O(1)级别的查询能力。

将用户ID直接作为下标查询 由于数组下标从0开始因此查询时ID要减1

注意刚才我们举的这个例子中有一个假设用户的ID是一个数字并且范围有限。符合这种假设的用户ID才能作为数组下标使用数组的随机访问特性达到O(1)时间代价的高效检索能力。那如果用户的ID数字范围很大数组无法申请这么大的空间该怎么办呢或者用户的ID不是数字而是字符串还能作为数组下标吗

我们假设有一个系统使用字符串作为用户ID。如果有一个用户的ID是“tom”我们该怎么处理呢我们能否将它转换为一个数字来表示呢你可以先想一想解决方案再和我继续往下分析。

我们来考虑这样一种方案字母表是有限的只有26个我们可以用字母在字母表中的位置顺序作为数值。于是就有“t” = 20“o” = 15“m” = 13。我们可以把这个ID看作是26进制的数字那么对于“tom”这个字符串把它转为对应的数值就是20 * 26^2 + 15*26 + 13 =149123这是一个小于26^4 = 456976的数。

如果所有用户的ID都不超过3个字符使用这个方法我们用一个可以存储456976个元素的数组就可以存储所有用户的信息了。实际上工业界有许多更复杂的将字符串转为整数的哈希算法但核心思想都是利用每个字符的编码和位置信息进行计算这里我就不展开了。

那如果内存空间有限我们只能开辟一个存储10000个元素的数组该怎么办呢这个时候我们可以使用“tom”对应的数值149123除以数组长度10000得到余数9123用这个余数作为数组下标。

这种将对象转为有限范围的正整数的表示方法,就叫作Hash,翻译过来叫散列,也可以直接音译为哈希。而我们常说的Hash函数就是指具体转换方法的函数。我们将对象进行Hash用得到的Hash值作为数组下标将对应元素存在数组中。这个数组就叫作哈希表。这样我们就可以利用数组的随机访问特性达到O(1)级别的查询性能。

说到这里你可能会有疑问了Hash函数真的这么神奇吗如果两个对象的哈希值是相同的怎么办事实上任何Hash函数都有可能造成对象不同但Hash值相同的冲突。而且数组空间是有限的只要被Hash的元素个数大于数组上限就一定会产生冲突。

对于哈希冲突这个问题,我们有两类解决方案: 一类是构造尽可能理想的Hash函数使得Hash以后得到的数值尽可能平均分布从而减少冲突发生的概率另一类是在冲突发生以后通过“提供冲突解决方案”来完成存储和查找。最常用的两种冲突解决方案是“开放寻址法”和“链表法”。下面我就来介绍一下这两种方法并且重点看看它们对检索效率的影响。

如何利用开放寻址法解决Hash冲突

所谓“开放寻址法”就是在冲突发生以后最新的元素需要寻找新空闲的数组位置完成插入。那我们该如何寻找新空闲的位置呢我们可以使用一种叫作“线性探查”Linear Probing的方案来进行查找。

“线性探查”的插入逻辑很简单:在当前位置发现有冲突以后,就顺序去查看数组的下一个位置,看看是否空闲。如果有空闲,就插入;如果不是空闲,再顺序去看下一个位置,直到找到空闲位置插入为止。

查询逻辑也和插入逻辑相似。我们先根据Hash值去查找相应数组下标的元素如果该位置不为空但是存储元素的Key和查询的Key不一致那就顺序到数组下一个位置去检索就这样依次比较Key。如果访问元素的Key和查询Key相等我们就在哈希表中找到了对应元素如果遍历到空闲处依然没有元素的Key和查询Key相等则说明哈希表中不存在该元素。

为了帮助你更好地理解,我们来看一个例子。

假设一个哈希表中已经插入了两个Keykey1和key2。其中Hash(key1) = 1, Hash(key2) = 2。这时如果我们要插入一个Hash值为1的key3。根据线性探查的插入逻辑通过3步我们就可以将key3插入到哈希表下标为3的位置中。插入的过程如下

在查找key3的时候因为Hashkey3= 1我们会从哈希表下标为1的位置开始顺序查找经过3步找到key3查询结束。

讲到这里你可能已经发现了一个问题当我们插入一个Key时如果哈希表已经比较满了这个Key就会沿着数组一直顺序遍历直到遇到空位置才会成功插入。查询的时候也一样。但是顺序遍历的代价是O(n),这样的检索性能很差。

更糟糕的是如果我们在插入key1后先插入key3再插入key2那key3就会抢占key2的位置影响key2的插入和查询效率。因此“线性探查”会影响哈希表的整体性能而不只是Hash值冲突的Key

为了解决这个问题我们可以使用“二次探查”Quadratic Probing和“双散列”Double Hash这两个方法进行优化。下面我来分别解释一下这两个方法的优化原理。

二次探查就是将线性探查的步长从i改为i^2第一次探查位置为Hash(key) + 1^2第二次探查位置为Hash(key) +2^2第三次探查位置为Hash(key) + 3^2依此类推。

双散列就是使用多个Hash函数来求下标位置当第一个Hash函数求出来的位置冲突时启用第二个Hash函数算出第二次探查的位置如果还冲突则启用第三个Hash函数算出第三次探查的位置依此类推。

无论是二次探查还是双散列核心思路其实都是在发生冲突的情况下将下个位置尽可能地岔开让数据尽可能地随机分散存储来降低对不相干Key的干扰从而提高整体的检索效率。

但是对于开放寻址法来说无论使用什么优化方案随着插入的元素越多、哈希表越满插入和检索的性能也就下降得越厉害。在极端情况下当哈希表被写满的时候为了保证能插入新元素我们只能重新生成一个更大的哈希表将旧哈希表中的所有数据重新Hash一次写入新的哈希表也就是Re-Hash,这样会造成非常大的额外开销。因此,在数据动态变化的场景下,使用开放寻址法并不是最合适的方案。

如何利用链表法解决Hash冲突

相比开放寻址法还有一种更常见的冲突解决方案链表法。所谓“链表法”就是在数组中不存储一个具体元素而是存储一个链表头。如果一个Key经过Hash函数计算得到了对应的数组下标那么我们就将它加入该位置所存的链表的尾部。

这样做的好处是如果key3和key1发生了冲突那么key3会通过链表的方式链接在key1的后面而不是去占据key2的位置。这样当key2插入时就不会有冲突了。最终效果如下图。

链表法

讲到这里,你可能已经发现了,其实链表法就是将我们前面讲过的数组和链表进行结合,既利用了数组的随机访问特性,又利用了链表的动态修改特性,同时提供了快速查询和动态修改的能力。

想要查询时我们会先根据查询Key的Hash值去查找相应数组下标的链表。如果链表为空则表示不存在该元素如果链表不为空则遍历链表直到找到Key相等的对应元素为止。

但是,如果链表很长,遍历代价还是会很高。那我们有没有更好的检索方案呢?你可以回想一下,在上一讲中我们就是用二叉检索树或跳表代替链表,来提高检索效率的。

实际上在JDK1.8 之后Java中HashMap的实现就是在链表到了一定的长度时将它转为红黑树而当红黑树中的节点低于一定阈值时就将它退化为链表。

链表法:用红黑树来优化长链表

第一个阶段通过Hash函数将要查询的Key转为数组下标去查询对应的位置。这个阶段的查询代价是O(1)级别。

第二阶段将数组下标所存的链表头或树根取出。如果是链表就使用遍历的方式查找这部分查询的时间代价是O(n)。由于链表长度是有上限的因此实际开销并不会很大可以视为常数级别时间。如果是红黑树则用二分查找的方式去查询时间代价是O(log n)。如果哈希表中冲突的元素不多,那么落入红黑树的数据规模也不会太大,红黑树中的检索代价也可以视为常数级别时间。

哈希表有什么缺点?

哈希表既有接近O(1)的检索效率,又能支持动态数据的场景,看起来非常好,那是不是在任何场景下,我们都可以用它来代替有序数组和二叉检索树呢?答案是否定的。前面我们说了这么多哈希表的优点,下面我们就来讲讲它的缺点。

首先哈希表接近O(1)的检索效率是有前提条件的,就是哈希表要足够大和有足够的空闲位置,否则就会非常容易发生冲突。我们一般用**装载因子load factor**来表示哈希表的填充率。装载因子 = 哈希表中元素个数/哈希表的长度。

如果频繁发生冲突,大部分的数据会被持续地添加到链表或二叉检索树中,检索也会发生在链表或者二叉检索树中,这样检索效率就会退化。因此,为了保证哈希表的检索效率,我们需要预估哈希表中的数据量,提前生成足够大的哈希表。按经验来说,我们一般要预留一半以上的空闲位置,哈希表才会有足够优秀的检索效率。这就让哈希表和有序数组、二叉检索树相比,需要的存储空间更多了。

另一方面尽管哈希表使用Hash值直接进行下标访问带来了O(1)级别的查询能力,但是也失去了“有序存储”这个特点。因此,如果我们的查询场景需要遍历数据,或者需要进行范围查询,那么哈希表本身是没有什么加速办法的。比如说,如果我们在一个很大的哈希表中存储了少数的几个元素,为了知道存储了哪些元素,我们只能从哈希表的第一个位置开始遍历,直到结尾,这样性能并不好。

重点回顾

好了关于哈希检索我们就讲到这里。你会看到哈希表的本质是一个数组它通过Hash函数将查询的Key转为数组下标利用数组的随机访问特性使得我们能在O(1)的时间代价内完成检索。

尽管哈希检索没有使用二分查找,但无论是设计理想的哈希函数,还是保证哈希表有足够的空闲位置,包括解决冲突的“二次探查”和“双散列”方案,本质上都是希望数据插入哈希表的时候,分布能均衡,这样检索才能更高效。从这个角度来看,其实哈希检索提高检索效率的原理,和二叉检索树需要平衡左右子树深度的原理是一样的,也就是说,高效的检索需要均匀划分检索空间。

另一方面你会看到复杂的数据结构和检索算法其实都是由最基础的数据结构和算法组成的。比如说JDK1.8中哈希表的实现,就是使用了数组、链表、红黑树这三种数据结构和相应的检索算法。因此,对于这些基础的数据结构,我们需要深刻地理解它们的检索原理和适用场景,这也为我们未来学习更复杂的系统打下了扎实的基础。

课堂讨论

假设一个哈希表是使用开放寻址法实现的,如果我们需要删除其中一个元素,可以直接删除吗?为什么呢?如果这个哈希表是使用链表法实现的会有不同吗?

欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这篇文章分享给你的朋友。