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

本文是学习算法的笔记,《数据结构与算法之美》,极客时间的课程

链表(Linked list)

缓存技术是一种提高数据读取性能的技术,应用广泛。缓存的大小有限,当缓存被用满的时候,哪些数据应该被保留?这需要缓存淘汰策略来决定。

常见的策略有三种:

先进先出策略FIFO(First In First Out)

最少使用策略LFU(Least Frequently Used)

最近最少使用策略LRU(Least Recently Used)

回到今天的正题——如何用链表来实现LRU缓存淘汰策略

与数组相比,链表是稍微复杂一点为的数据结构。链表不需要连续的内存空间,它通过“指针”,将一组零散的内存块串联起来使用。其中我们把内存块称为链表的“结点”。

链表的结构五花八门,这里介绍三种常见的链表结构————单链表、双向链表和循环链表。

单链表

每个链表的结点,除了存储数据之外,还需要记录下一个结点的地址。我们把这个记录下一个结点地址的指针叫作后继指针next。
在这里插入图片描述
第一个节点通常叫头结点,记录链表的基地址,有了它,就可以遍历整个链表。

最后一个节点叫作尾节点,尾节点的指针不是指向下一个节点,而是指向一个空地址NULL。表示这是链表最后一个结点。

与数组一样,链表支持查找、插入和删除操作。

我们知道,数组进行插入、删除操作,为了保持内存的连续性,需要进行大量的数据搬移,所以时间复杂度是O(n)。而链表进行插入和删除,只考虑相邻节点指针的改变,时间复杂度是O(1)。
在这里插入图片描述
但链表要想随机访问第k个元素,就没有数组高效了。要通过指针,从首节点开始,一个一个结点的遍历,直到找到相应的结点。时间复杂度是O(n)。

循环链表

单链表的尾节点指针指向空地址,表示这是最后 的结点。而循环列表的尾节点的指针指向链表的头结点,从而成为一个循环结构,所以叫“循环链表”

双向链表

单向链表只有一个方向,结点只有一个后继指针next指向后面的结点,而双向链表中,每个结点,还有一个前驱指针 prev 指向前面的结点。两个指针就要占用更多的空间,但可以支持双向遍历,也带来操作的灵活性。
在这里插入图片描述
双向链表支持在复杂度为O(1)的情况下找到前驱节点,这使得其删除和插入操作,在某些情况下,比单向链表更简洁、高效。

你在这儿可能就有疑问了,单向链表删除和插入的时间复杂度是O(1),还能怎么简洁呢?

其实上面的说法是有前提的。这里先具体分析下删除操作。

删除无外乎有两种情况:1、删除结点中“值等于某个定值”的结点。2、删除给定指针指向的结点。

对于第一种情况,单向链表和双向链表,都得从头开始遍历,找到给定值的节点,将其删除。删除的时间复杂度是O(1),但遍历查找是主要耗时点,时间复杂度是O(n),删除值等于给定节点对应的链表操作总的时间复杂度是O(n)。

对第二种情况,已经找到的要删除的节点,要删除这个节点,需要知道它的前驱节点。单向链表就需要从头遍历得到前驱节点,时间复杂度为O(n),而双向链表有前驱指针,可以在时间复杂度O(1)的情况下搞定。

插入操作的分析,也是这么一个思路。

java语言中,LinkedHashMap的实现原理,就用到了双向链表的数据结构。

如果循环链表和双向链表结合在一起,就是双向循环链表。

链表与数组的比较

数组和链表是两种截然不同的内存组织方式,因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。
在这里插入图片描述
但在实际的开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。比如数组简单易用,使用的连续内存空间,可以借助CPU的缓存机制,预读数组中的数据,访问效率更高。而链表对CPU缓存不友好,没办法预读。数组的缺点也是这个,若没有足够的连续内存分配给它,导致“内存不足”。

如果你的代码对内存的使用非常苛刻,那数组更合适。因数链表中的每个指针需要额外的存储空间。而且对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片。如果是java语言,可能导致频繁的GC。

所以,在实际的开发中,针对不同的类型的项目,要根据具体的尾部,权衡是选择数组还是链表。

最后,我们再说说开篇的那个问题,怎样基于链表实现LRU淘汰算法

我们的思路是这样的,维护一个有序单链表,越靠近尾部的数据,就是越早访问的。当有新数据被访问时,从头遍历链表
1、数据已经在链表里了,那先删除其对应的节点,再把数据插入到链表头部
2、数据不在列表里,那直接在头部插入该数据,若插入时,链表是满的,把尾部的那个数据删除了再插入。

至于这个思路的优化,以后再说

猜你喜欢

转载自blog.csdn.net/every__day/article/details/83539332