CategoryResourceRepost/极客时间专栏/数据结构与算法之美/基础篇/20 | 散列表(下):为什么散列表和链表经常会一起使用?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

171 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="20 | 散列表(下):为什么散列表和链表经常会一起使用?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/3d/ffd1fac2d1ba11d199ed5232afe3ea3d.mp3"></audio>
我们已经学习了20节内容你有没有发现有两种数据结构散列表和链表经常会被放在一起使用。你还记得前面的章节中都有哪些地方讲到散列表和链表的组合使用吗我带你一起回忆一下。
在链表那一节我讲到如何用链表来实现LRU缓存淘汰算法但是链表实现的LRU缓存淘汰算法的时间复杂度是O(n)当时我也提到了通过散列表可以将这个时间复杂度降低到O(1)。
在跳表那一节我提到Redis的有序集合是使用跳表来实现的跳表可以看作一种改进版的链表。当时我们也提到Redis有序集合不仅使用了跳表还用到了散列表。
除此之外如果你熟悉Java编程语言你会发现LinkedHashMap这样一个常用的容器也用到了散列表和链表两种数据结构。
今天,我们就来看看,在这几个问题中,散列表和链表都是如何组合起来使用的,以及为什么散列表和链表会经常放到一块使用。
## LRU缓存淘汰算法
在链表那一节中我提到借助散列表我们可以把LRU缓存淘汰算法的时间复杂度降低为O(1)。现在,我们就来看看它是如何做到的。
首先我们来回顾一下当时我们是如何通过链表实现LRU缓存淘汰算法的。
我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。
当要缓存某个数据的时候先在链表中查找这个数据。如果没有找到则直接将数据放到链表的尾部如果找到了我们就把它移动到链表的尾部。因为查找数据需要遍历链表所以单纯用链表实现的LRU缓存淘汰算法的时间复杂很高是O(n)。
实际上我总结一下一个缓存cache系统主要包含下面这几个操作
<li>
往缓存中添加一个数据;
</li>
<li>
从缓存中删除一个数据;
</li>
<li>
在缓存中查找一个数据。
</li>
这三个操作都要涉及“查找”操作如果单纯地采用链表的话时间复杂度只能是O(n)。如果我们将散列表和链表两种数据结构组合使用可以将这三个操作的时间复杂度都降低到O(1)。具体的结构就是下面这个样子:
<img src="https://static001.geekbang.org/resource/image/ea/6e/eaefd5f4028cc7d4cfbb56b24ce8ae6e.jpg" alt="">
我们使用双向链表存储数据链表中的每个结点处理存储数据data、前驱指针prev、后继指针next之外还新增了一个特殊的字段hnext。这个hnext有什么作用呢
因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的**双向链表**,另一个链是散列表中的**拉链**。**前驱和后继指针是为了将结点串在双向链表中hnext指针是为了将结点串在散列表的拉链中**。
了解了这个散列表和双向链表的组合存储结构之后我们再来看前面讲到的缓存的三个操作是如何做到时间复杂度是O(1)的?
首先,我们来看**如何查找一个数据**。我们前面讲过散列表中查找数据的时间复杂度接近O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。当找到数据之后,我们还需要将它移动到双向链表的尾部。
其次,我们来看**如何删除一个数据**。我们需要找到数据所在的结点然后将结点删除。借助散列表我们可以在O(1)时间复杂度里找到要删除的结点。因为我们的链表是双向链表双向链表可以通过前驱指针O(1)时间复杂度获取前驱结点所以在双向链表中删除结点只需要O(1)的时间复杂度。
最后,我们来看**如何添加一个数据**。添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。
这整个过程涉及的查找操作都可以通过散列表来完成。其他的操作比如删除头结点、链表尾部插入数据等都可以在O(1)的时间复杂度内完成。所以这三个操作的时间复杂度都是O(1)。至此我们就通过散列表和双向链表的组合使用实现了一个高效的、支持LRU缓存淘汰算法的缓存系统原型。
## Redis有序集合
在跳表那一节,讲到有序集合的操作时,我稍微做了些简化。实际上,在有序集合中,每个成员对象有两个重要的属性,**key**(键值)和**score**分值。我们不仅会通过score来查找数据还会通过key来查找数据。
举个例子比如用户积分排行榜有这样一个功能我们可以通过用户的ID来查找积分信息也可以通过积分区间来查找用户ID或者姓名信息。这里包含ID、姓名和积分的用户信息就是成员对象用户ID就是key积分就是score。
所以如果我们细化一下Redis有序集合的操作那就是下面这样
<li>
添加一个成员对象;
</li>
<li>
按照键值来删除一个成员对象;
</li>
<li>
按照键值来查找一个成员对象;
</li>
<li>
按照分值区间查找数据,比如查找积分在[100, 356]之间的成员对象;
</li>
<li>
按照分值从小到大排序成员变量;
</li>
如果我们仅仅按照分值将成员对象组织成跳表的结构那按照键值来删除、查询成员对象就会很慢解决方法与LRU缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表这样按照key来删除、查找一个成员对象的时间复杂度就变成了O(1)。同时,借助跳表结构,其他操作也非常高效。
实际上Redis有序集合的操作还有另外一类也就是查找成员对象的排名Rank或者根据排名区间查找成员对象。这个功能单纯用刚刚讲的这种组合结构就无法高效实现了。这块内容我后面的章节再讲。
## Java LinkedHashMap
前面我们讲了两个散列表和链表结合的例子现在我们再来看另外一个Java中的LinkedHashMap这种容器。
如果你熟悉Java那你几乎天天会用到这个容器。我们之前讲过HashMap底层是通过散列表这种数据结构实现的。而LinkedHashMap前面比HashMap多了一个“Linked”这里的“Linked”是不是说LinkedHashMap是一个通过链表法解决散列冲突的散列表呢
实际上LinkedHashMap并没有这么简单其中的“Linked”也并不仅仅代表它是通过链表法解决散列冲突的。关于这一点在我是初学者的时候也误解了很久。
我们先来看一段代码。你觉得这段代码会以什么样的顺序打印3152这几个key呢原因又是什么呢
```
HashMap&lt;Integer, Integer&gt; m = new LinkedHashMap&lt;&gt;();
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
```
我先告诉你答案上面的代码会按照数据插入的顺序依次来打印也就是说打印的顺序就是3152。你有没有觉得奇怪散列表中数据是经过散列函数打乱之后无规律存储的这里是如何实现按照数据的插入顺序来遍历打印的呢
你可能已经猜到了LinkedHashMap也是通过散列表和链表组合在一起实现的。实际上它不仅支持按照插入顺序遍历数据还支持按照访问顺序来遍历数据。你可以看下面这段代码
```
// 10是初始大小0.75是装载因子true是表示按照访问时间排序
HashMap&lt;Integer, Integer&gt; m = new LinkedHashMap&lt;&gt;(10, 0.75f, true);
m.put(3, 11);
m.put(1, 12);
m.put(5, 23);
m.put(2, 22);
m.put(3, 26);
m.get(5);
for (Map.Entry e : m.entrySet()) {
System.out.println(e.getKey());
}
```
这段代码打印的结果是1235。我来具体分析一下为什么这段代码会按照这样顺序来打印。
每次调用put()函数往LinkedHashMap中添加数据的时候都会将数据添加到链表的尾部所以在前四个操作完成之后链表中的数据是下面这样
<img src="https://static001.geekbang.org/resource/image/17/98/17ac41d9dac454e454dcb289100bf198.jpg" alt="">
在第8行代码中再次将键值为3的数据放入到LinkedHashMap的时候会先查找这个键值是否已经有了然后再将已经存在的(3,11)删除,并且将新的(3,26)放到链表的尾部。所以,这个时候链表中的数据就是下面这样:
<img src="https://static001.geekbang.org/resource/image/fe/8c/fe313ed327bcf234c73ba738d975b18c.jpg" alt="">
当第9行代码访问到key为5的数据的时候我们将被访问到的数据移动到链表的尾部。所以第9行代码之后链表中的数据是下面这样
<img src="https://static001.geekbang.org/resource/image/b5/11/b5e07bb34d532d46d127f4fcc4b78f11.jpg" alt="">
所以最后打印出来的数据是1235。从上面的分析你有没有发现按照访问时间排序的LinkedHashMap本身就是一个支持LRU缓存淘汰策略的缓存系统实际上它们两个的实现原理也是一模一样的。我也就不再啰嗦了。
我现在来总结一下,实际上,**LinkedHashMap是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap中的“Linked”实际上是指的是双向链表并非指用链表法解决散列冲突**。
## 解答开篇&amp;内容小结
弄懂刚刚我讲的这三个例子,开篇的问题也就不言而喻了。我这里总结一下,为什么散列表和链表经常一块使用?
散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。
因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。
## 课后思考
<li>
今天讲的几个散列表和链表结合使用的例子里,我们用的都是双向链表。如果把双向链表改成单链表,还能否正常工作呢?为什么呢?
</li>
<li>
假设猎聘网有10万名猎头每个猎头都可以通过做任务比如发布职位来积累积分然后通过积分来下载简历。假设你是猎聘网的一名工程师如何在内存中存储这10万个猎头ID和积分信息让它能够支持这样几个操作
</li>
<li>
根据猎头的ID快速查找、删除、更新这个猎头的积分信息
</li>
<li>
查找积分在某个区间的猎头ID列表
</li>
<li>
查找按照积分从小到大排名在第x位到第y位之间的猎头ID列表。
</li>
欢迎留言和我分享,我会第一时间给你反馈。