几个常见的缓存淘汰策略

缓存是提高数据读取性能的技术,在硬件软件领域都有广泛的应用。如 CPU 缓存,数据库缓存,浏览器缓存...

缓存的大小空间占满的时候,需要作出清理,需要一个清理数据的规则。

常见的缓存清理有三种方式:

  • 先进先出。理解为时间排序,时间最早的最先清理。
  • 最少使用。按照出现频次清理。
  • 最近最少使用,一个范围内最少使用清理。

避免出现英文名称不认识,也方便记忆,对应的英文为:

FIFO(First in, sirst out)

LFU(Least Frequently Used)

「LRU(Least Recently Used)」

可联想实际的收藏品对比策略,如小时候的玩具丢弃决策,收藏书丢弃决策...

这是一个引入使用场景,可以用单链表来解决这个问题。

链表结构

链表结构,与数组的关键性区别:

  • 存储结构
  • 访问和操作复杂度

数组的存储需要的是一块连续的内存空间,对内存的要求是比较高的。创建一个指定大小的数组,单有相应大小的存储空间是不够的,还必须是连续的内存空间(内存地址连续),否则也会创建失败。

而链表,则不需要连续,内存可以零散,因为每一个存储元素不仅会存储数据本身,还会存储(前)后结点的地址,以将多个数据串联起来。

根据链表对存储地址的差别,也造成了各有特性的多种链表,对应的访问和操作复杂度也不一样:

单链表及插入和删除

链表将一组零散的内存块串联,每个内存块单元成为结点,每个节点不仅有数据本身,还有链上下一个结点的地址,称为后继指针 next。

4. 链表和缓存淘汰策略_缓存淘汰策略

两个特殊结点,头结点和尾结点,头结点记录起始地址,尾结点指向空地址null,表示是最后一个结点。

链表也支持数据的查找,插入和删除。

在数组插入和删除的时候,为了保证内存空间的连续,需要做大量的数组搬移。

而对比在链表中插入或者删除元素的时候,因为内存地址本身不连续,所以就不需要元素搬移。以插入结点为例子,

插入包含数据的结点 newnode 位置到 Km 结点和 Kn 个结点之间,那么操作就是:Km 结点的 next 指向 newnode,newnode 的 next 指向 Kn。

4. 链表和缓存淘汰策略_缓存淘汰策略_02

对应的删除,修改上一个元素的 next 指向,直接指向原本的下一个结点。

4. 链表和缓存淘汰策略_缓存淘汰策略_03

「插入和删除只需要修改相邻节点的指针改变,所以时间复杂度就是O(1)」。

「链表的数据的内存地址不连续,也就导致了链表无法通过寻址公式来访问元素,只能根据每个元素的 next 指针挨个遍历访问」。

场景:从一个没有编号的队伍找出某人,每个人只知道后面的人是谁。

那么只能从第一个人开始报后一个人的名字,挨个进行,才能找到目标。

「访问元素的时候只能挨个遍历,所以链表的随机访问的时间复杂度就是O(n)」。

循环链表

循环链表是一种特殊的单链表,区别就是尾结点指向了链表的头结点。

也就是说,对循环链表,从链表的头结点进,按照每个结点的nex t指针遍历,会一直循环访问链表中的元素。

双向链表

单链表只有下一个元素的指针,那么要找前面的元素就会很麻烦。所以,就有了双向链表,对应的除了后继指针 next,还有一个前驱指针 prev,指向前一个元素结点。

将单链表的访问从单向变成了双向,使得在链表中找到前驱结点的时间复杂度也是O(1)。

实际问题

单链表的插入和删除的时间复杂度是O(1),但是这只是在单纯的讨论这一个删除和插入操作。实际的情况一般是

  • 删除“值等于某个值”的结点
  • 删除给点指针指向的结点

对于情况一,需要遍历判断找到满足条件的值,才能进行删除。而链表遍历的复杂度是O(n),删除操作是O(1),所以实际的总时间复杂度是O(n),不论是单链表和双向链表。

而对于情况二,单链表依然需要遍历,找到 p->next == q ,找到前驱结点,才能改变前驱结点的后继指针来完成删除,复杂度依然是O(n)。双链表则可以直接找到前驱结点,然后修改后继指针实现删除,复杂度直接将为O(1)。

同样的,插入操作也是一样。综合来说,双向链表虽然每个结点要存储两个指针,但时间效率要比单链表高。

这其实也是一种「空间换取时间」的思想。

对应的还有一种双向循环列表,头结点的 prev 指针指向尾结点,尾结点的 next 指针指向头结点。

链表 & 数组对比

对比两种数据结构

时间复杂度 数组 链表
插入删除 O(n) O(1)
随机访问 O(1) O(n)

数组使用连续内存空间,CPU缓存机制可以预读数据,访问效率高。但可能存在需要扩容的情况,对数据操作也不够友好。

而链表不需要整块内存,支持动态扩容,数据操作更方便。但招用内存更高。两者各有优缺。

链表版本的 LRU 缓存淘汰策略

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

  1. 如果数据已经在链表的话,删除对应的结点,并重新插入到链表头部。
  2. 如果没有在链表中的话:
  • 缓存未满的时候,插入到链表的头部(表示最近使用一条数据)
  • 缓存已满的时候,先删除链表尾结点,再将新数据插入到链表头部

每次访问新数据的时候,都必然伴随着一次遍历,然后才是删除和插入操作,所以复杂度就是O(n)。

链表只是引入这个问题模型,后续使用「散列表」,对应的复杂度是O(1)。

- END -