【思考题】:散列表和链表都是如何组合起来使用的?散列表和链表为什么经常放在一起使用?
1、LRU缓存淘汰策略
借助散列表,可以把LRU缓存淘汰算法的时间复杂度降为O(1)。但之前讲过使用链表来实现LRU缓存淘汰算法的时间复杂度是O(n)。维护一个按照访问时间从小到大有序排列的链表结构,因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,直接将链表头部的结点删除。
当要缓存某个数据的时候,先在链表中查找这个数据,如果没找到就直接把数据放在链表的尾部,如果找到了,就把它移动到链表的尾部。因为查找数据需要遍历链表,所以时间复杂度是O(n)。
一个缓存系统主要包括下面几个操作:
- 往缓存中添加一个数据
- 从缓存中删除一个数据
- 在缓存中查找一个数据
这三个操作都要涉及“查找”操作,时间复杂度是O(n),如果将散列表和链表结合使用,可以将这三个操作的时间复杂度都降低为O(1)。具体结构如下图:
从上面的我们可以看到,散列表是借助双向链表来存储数据,链表中每个结点处有存储数据(data),前驱指针(prev),后继指针(next),特殊指针(hnext)。
因为散列表是借助链表来解决散列冲突的,所以每个结点会在两条链中。一个是双向链表,另一个是散列表中的拉链。前驱和后继指针是为了将结点串在双向链表中,hnext指针是为了将结点串在散列表的拉链当中。
在这种散列表和双向链表组合的存储结构下,前面讲到的三个缓存操作的时间复杂度就是O(1)。
在缓存中查找一个数据。前面讲过在散列表中查找一个数据的时间复杂度接近O(1),所以通过散列表,可以快速在缓存中找到一个数据。找到这个数据以后,需要将它移动到双向链表的尾部。
在缓存中删除一个数据。需要找到数据所在的结点,然后将结点删除。借助散列表,我们可以在O(1)的时间复杂度里找到要删除的结点。因为我们用的是双向链表,双向链表可以通过前驱指针O(1)时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要O(1)的时间复杂度。
在缓存中添加一个数据。如果要添加的数据已经在缓存中,将这个数据移动到双向链表的尾部;如果缓存已经满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就把数据直接放到双向链表的尾部。整个过程涉及的查找操作,都可以通过散列表来完成。时间复杂度是O(1)。
2、Redis有序集合
在讲到跳表的时候,有说过Redis有序集合不仅使用了跳表,还使用了散列表。在有序集合中,每个成员有两个重要的属性,key(键值)和score(分值)。我们不仅会通过score来查找数据,也会通过key来查找数据。
细化一下Redis 有序集合的操作:
- 添加一个成员对象
- 按照键值来删除一个成员对象
- 按照键值来查找一个成员对象
- 按照分值区间,比如查找积分在[100,356]之间的成员对象
- 按照分值从小到大排序成员变量
如果仅仅按照分值将成员对象组织成跳表结构,按照键值来删除、查找成员对象就会很慢,所以可以再按照键值构建一个散列表,这样按照key来删除、查找一个成员对象的时间复杂度就变为O(1)。
3、为什么散列表和链表会经常一起使用?
散列表虽然支持高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的,也就无法按照某种顺序快速遍历数据。如果希望按照某种顺序遍历散列表中的数据,需要将散列表中的数据拷贝到数组当中,然后排序,再遍历。
因为散列表是动态的数据结构,不停的有数据的插入、删除,所以我们希望按照顺序遍历散列表的时候,都需要先排序,那效率势必会很低,为了解决这个问题,所以就会经常讲散列表和链表结合在一起使用。