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

链表的一个景点应用场景 --- LRU缓存淘汰算法

1. 缓存

  • 什么是缓存
    缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。

  • 缓存淘汰策略
    缓存大小有限,当缓存被用满是,那些数据应该被清理出去,那些数据被保留,这就是缓存淘汰策略来决定。
    常见的策略有三种:
    先进先出策略 FIFO (First In, First Out)
    最少使用策略 LFU (Least Frequently Used)
    最近最少使用策略 LRU(Least Recently Used)

2. 链表结构

  • 数据与链表区别

    从图中可以看到,数组需要一块连续的内存空间来存储,对内存的要求比较高。比如我们申请了一个100MB大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于100MB,仍然会申请失败。
    而链表恰恰相反,它并不需要一块连续的内存空间,它通过"指针"将一组零散的内存块串联起来使用,所以如果我们申请的是100MB大小的链表,根本不会有问题。

  • 常见的链表结构
    单链表
    双向链表
    循环链表

3. 单链表

  • 优点:插入、删除
    数组:时间复杂度是O(n)
    链表:时间复杂度是O(1)

  • 缺点:不支持随机访问
    数组:时间复杂度O(1),根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址。
    链表:时间复杂度O(n),数据并非连续存储,需要根据指针依次遍历节点,直到找到。

4. 循环链表

循环链表是一种特殊的单链表。与单链表唯一的区别就是在尾节点指针指向链表的头结点。

优点:从链表尾到链表头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。

5. 双向链表

  • 与单向链表相比
    缺点:如果存储同样多的数据,占用更过的内存空间。
    优点:支持双向遍历,操作更灵活。

  • 适用场景
    在某些情况下的插入、删除等操作都要比单链表简单、高效。
    删除操作有两种情况:
    a. 删除节点中"值等于某个给定值"的节点
    b. 删除给定指针指向的节点

    对于a:
    删除操作时间复杂度O(1),但不管是单链表还是双向链表遍历查找的时间复杂度为O(n),删除值等于给定值的节点对应的链表操作的总时间复杂度为O(n)

    对于b:
    单链表:已经找到了要删除的节点,但是删除某个节点 q 需要知道其前驱节点,儿而单链表并不支持直接获取其前驱节点,所以,为了找到前驱节点,还要从头节点开始遍历链表,知道 p->next=q,说明 p 是 q 的前驱节点。所以时间复杂度为O(n)
    双向链表:因为双向链表中的节点已经保存了前驱节点的指针,不需要像单链表那样遍历。所以时间复杂度为O(1)

    插入同理。

    除了插入、删除操作有优势之外,对于一个有序链表,双向链表的查询效率也比单链表高一些。因为,可以记录上次查找的位置,每次查询时,要根据要查找的值与 p 的大小关系,决定是往前还是往后查找,所以平均只需要查找一半的数据。

    所以,双向链表尽管比较耗费内存,但还是比单链表更加高效。如果深入研究LinkedHashMap的实现原理,就会发现其中就用到了双向链表这种数据结构。

  • 空间换时间设计思想
    对于执行较慢的程序,可以通过空间换时间来进行优化;
    而消耗过多内存的程序,可以通过时间换控空间来降低内存消耗。

6. 双向循环链表

循环链表与双向链表的整合

7. 链表 VS 数组性能大比拼

  • 时间复杂度

  • 有效缓存
    数组在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不好(CPU每次从内存读取数据并不是只读取那个特定要访问的地址,而是读取一个数据块),没办法有效预读。

  • 动态扩容
    数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致"内存不足(out of memory)"。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常耗时。而链表本身没有大小限制,天然地支持动态扩容。

    总结
    如果对内存的使用非常苛刻,那数组更适合你。因为链表中的每个节点都需要消耗额外的存储空间去存储一份指向下一个节点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是Java语言,就有可能会导致频繁的GC(Garbage Collection,垃圾回收)。

8. 解答开篇

如何基本链表实现LRU缓存淘汰算法?

思路:
维护一个有序单链表,越靠近链表尾部的节点是越早之前访问的,当有一个新数据被访问时,从链表头开始顺序遍历链表。

  1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的节点,并将其从原来的位置删除,然后再插入到链表的头部。

  2. 如果此数据没有在缓存列表中,分为以下两种情况:

    • 如果此时缓存未满,则将此节点直接插入到链表的头部;
    • 如果此时缓存已满,则链表尾节点删除,将新的数据节点输入链表的头部。

缓存访问的时间复杂度为O(n):因为不管缓存有没有满,我们都需要遍历一遍链表。

代码请戳:基本链表实现LRU缓存淘汰算法

实际上,可以继续优化这个实现思路,比如引入散列表(Hash table)来记录每个数据的位置,将缓存访问的时间复杂度降到O(1)

思考

如果字符串是通过单链表来存储的,如何判断一个字符串是否为会问字符串(比如 上海自来水来自海上),时间复杂度是多少?
方案一
1)前提:字符串以单个字符的形式存储在单链表中。
2)遍历链表,判断字符个数是否为奇数,若为偶数,则不是。
3)将链表中的字符倒序存储一份在另一个链表中。
4)同步遍历2个链表,比较对应的字符是否相等,若相等,则是水仙花字串,否则,不是。
方案二
1)遍历链表将元素放入数组A中
2)利用数组随机访问特性,分别从头和尾往中间遍历,并将元素进行比较,直到有元素不相等或者数组A的中点结束。
此方案的空间复杂度为O(n)。
方案三
1)快慢两个指针定位链表中点,同时逆序前半部分链表
2)已逆序的前半部分与后半部分进行比较
3)再次逆序前半部分链表
此方案的空间复杂度为O(1)。

猜你喜欢

转载自juejin.im/post/5bc2d57c5188255c4258510b