五.链表(上):如何实现LRU缓存淘汰算法

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Milogenius/article/details/86726767

在这里插入图片描述
今天我们来学习链表(Linked List)数据结构,为了更好地学习,我们来讨论一个经典的链表应用场景,就是LRU缓存淘汰算法。
缓存是一种提高数据读取性能的技术,在软件开发中有着广泛的应用。比如常见的CPU缓存,数据库缓存,浏览器缓存等等。
当缓存用满时,决定数据的去留,就会用到缓存淘汰策略来决定.常见的策略有三种:先进先出策略FIFO(First In,First Out),最少使用策略(Least Frequently Used),最近最少使用策略LRU(Least Recently Used).

一.链表结构

相比数组,链表是一种稍微复杂的数据结构.我们先来看看两者区别.
我们首先从底层数据结构来看,从下图中可以看出,数组需要一块连续的内存空间来存储数据,对内存的要求比较高.假如我们申请一个100MB大小的数组,当内存中没有连续的,足够大的空间,即使当前可用内存空间大于100MB,仍然会申请失败.

链表恰恰相反,它并不需要一块连续的内存空间,它通过”指针”将一组零散的内存块串联起来使用,对于刚才的问题,我们申请100MB的空间,根本不会有问题.
在这里插入图片描述
链表结构五花八门,今天我们看看三种最常见的链表结构:单链表、双向链表、循环链表

二.单链表

我们来看单链表,根据刚才讨论的,我们知道链表通过指针将一组零散的内存块串联起来,其中,我们把内存块称为链表的“结点”。为了将所有的结点串联起来,每个链表的结点一方面需要存储数据,一方面需要记录链上的下一个结点的地址,我们将记录下个结点地址的指针称为后继指针
在这里插入图片描述
我们习惯将第一个结点称为头结点,最后一个结点称为尾结点。其中,头结点记录链表的基地址。有了它,就可以遍历得到整条链表。尾节点指针不指向下一个结点,而是指向一个空地址null,表示这是链表上的最后一个结点。

和数组一样,链表也支持数组的查找,插入和删除操作。在前面的课程中,我们介绍过数组为了保持数据的连续性,在进行数组的插入、删除操作时,需要做大量的数据搬移工作。但是在链表中进行这些操作,并不需要搬移数据,因为链表本身就不是连续的,我们只需考虑相邻结点的指针改变即可。所以,在链表中插入和删除一个数据时非常迅速的。

我们知道,在数组中查找元素非常高效,可以直接根据下标访问元素,但是在链表中,由于结构特性,并不能直接根据下标访问元素,需要根据指针一个结点一个结点依次遍历,直到找到相应的结点。

我们可以将链表想象为一支队伍,队伍中每个人只知道自己后面是谁?如果想要知道K位置的人是谁,只能从第一个开始依次询问,直到找到。

综上所述,我们就知道数组查询快,增删慢,链表查询慢,增删快的原理了。

三.循环链表

循环链表是一种特殊的单链表,循环链表区别为单链表的地方:循环链表尾节点指针是指向链表的头结点。从下图中可以很直观看出,就像一个环收尾相连。
在这里插入图片描述
当处理的数据具有环形结构特点时,就适合采用循环链表。比如著名的约瑟夫问题。

四.双向链表

单向链表只有一个方向,结点只有一个后继指针next指向后面的结点。而双向链表,它支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。

在这里插入图片描述
从图中可以看出,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,在存储相同数据的情况下,双向链表需要占用更多的内存空间。

从结构来看,双向链表可以支持O(1)时间复杂度情况下找到前驱结点,所以它比单向链表在某些情况下的删除、插入操作要简单、高效。
我们先来看删除操作,在实际的软件开发中,从链表删除一个值,存在两种情况:
1.删除结点中,值等于某个指定值的结点
2.删除给定指针指向的结点

对于第一种情况,无论是单向链表还是双向链表,为了查找到值等于某个值的结点,都需要从结点开始一个一个依次遍历对比,直到找到值等于某个给定值的结点,在通过指针操作将其删除。

尽管单纯的删除操作时间复杂度为O(1),但是遍历查找的时间为主要耗时点,对应的时间复杂度为O(n)。根据时间复杂度分析中的加法法则,链表操作的时间复杂度为O(n)

对应第二种情况,我们已经找到了要删除的结点,但是删除某个结点q需要知道其前驱结点,而单向链表并不支持直接获取前驱结点,所以,为了找到前驱结点,我们还是需要遍历链表,直到p—>next = q ,说明p就是q的前驱结点。

针对第二种情况,单向链表删除操作需要O(n)时间复杂度,而双向链表只需要在O(1)的时间复杂度就搞定了。
同理,插入操作也是如此,可以类比删除理解。

除了插入,删除操作有优势之外,作为有序链表,双向链表的按值查询效率要比单向链表高一些,因为我们可以记录上次查找的位置p,每次查询时,根据要查找的值和p的大小关系,选择往后查还是往前查,故只需查找一半的数据。

其实在平时开发中,双向链表尽管比较费内存,但还是比双向链表应用更加广泛。在java语言中,LinkedHashMap的实现原理就是基于双向链表。

五.双向循环链表

在这里插入图片描述

六.链表VS 数组性能比拼

通过前面的学习,我们知道数组和链表是两种截然不同的存储组织方式,这也导致他们插入、删除、随机访问的时间复杂度正好相反。
在这里插入图片描述
在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。数组简单易用,在实现上使用的是连续的内存空间,可以借助cpu的缓存机制,预读数组中的数据,访问效率更高。而链表在内存中并不是连续存储,所以对cpu缓存不友好,没办法有效预读。、
数组的缺点是大小固定,一经申明就要占用整块连续内存空间。如果声明的数组过大,系统没有足够的内存,可能导致内存不足。如果声明的数组过小,则可能不够用,需要在申请一个更大的内存空间,将原来的数组拷贝进去,非常费事。但是链表没有大小的限制,天然的动态扩容,这也是他们最大的区别。
除此之外,如果你的代码多内存要求比较高,那数组更合适你。因为链表需要消耗更多的内存,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是java语言,就会导致频繁的GC(垃圾回收)。

七.解答开篇

现在我们来解答开篇问题,实现基于链表的LRU缓存淘汰算法。
我们的思路是这样的:我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。

1.如果此数据之前已经缓存到链表中,我们将其从原来的位置删除,在插入链表的头部。
2.如果此数据没有在缓存链表中,又可以分为两种情况:
-如果此时缓存没有满,将此数据直接插入链表头部。

  • 如果此时缓存已满,则将链表尾部结点删除,将新的结点插入链表的头部
    这样我们就实现了一个LRU缓存淘汰算法。

猜你喜欢

转载自blog.csdn.net/Milogenius/article/details/86726767