06 | 链表(上):如何实现 LRU 缓存淘汰算法?
什么是链表?
为了充分利用存储空间和提高运行效率,线性表可以采用另一种存储结构 --- 链接式存储结构。线性表的链接式存储结构简称为链表(LinkList)。
经典的链表应用场景 --- LRU 缓存淘汰算法
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。
缓存的大小有限,当缓存被占满时,哪些数据应该被清理,这就需要缓存淘汰策略来决定。常见的策略有以下三种:
- 先进先出策略 FIFO(First In, First Out)
- 最少使用策略 LFU(Least Frequently Used)
- 最近最少使用策略 LRU(Least Recently Used)
如何用链表实现 LRU 缓存淘汰策略呢?
带着这个问题先来学习链表。
链表结构
较数组稍复杂,对比来看:
从图中可以看出,链表和数组的底层存储结构不同,数组需要一块连续的内存空间来存储,对内存的要求比较高。比如我们申请 100MB 大小的数组,当内存中没有连续的足够大的存储空间时,即使内存剩余的总可用大于 100MB ,仍然会申请失败。
而链表相反,它不需要一块连续的内存空间。它通过“指针”将一组零散的内存块串联起来使用。
- 按照链表内存分配实现的方式,分为动态链表和静态链表(应用不广泛);
- 按照链接的方式,常见的有单链表、循环链表、双向链表。它们都是动态链表。
单链表
链表通过指针将一组零散的内存块串联在一起。其中,内存块成为链表的“结点”。为了将所有的结点串起来,每个链表的节点除了存储数据以外,还需记录该链上的下一个结点的地址。这个记录下个结点地址的指针叫作后继指针 next。
从上方单链表图可发现,第一个结点和最后一个结点较为特殊,习惯性的称它们为头结点和尾结点。头结点用来记录链表的基地址,通过它可以遍历得到整条链表。尾结点特殊在,它的后继指针指向的是一个空地址 NULL。
与数组一样,链表也支持数据的查找、插入和删除操作。不同的是,在链表中插入或者删除一个数据,并不需要为了保持内存的连续性而迁移其他结点,因为链表的存储空间本身就不是连续的。因此,在链表中插入和删除数据是非常快的。
从下图可以看出,针对链表的插入和删除操作,我们只需要考虑相邻结点的指针改变。所以对应的时间复杂度是O(1)
。
但是,也因此,链表如果想通过下标随机访问第n
个元素,就不如数组高效了。链表中的数据并非连续存储,无法像数组那样根据首地址和下标,通过寻址公式计算出对应下标的内存地址,而是需要根据指针依次遍历,直到找到相应结点。对应时间复杂度是O(n)
。
循环链表
循环链表是一种首尾相连的链表,它是一种特殊的单链表。它跟单链表唯一的区别在于尾结点。循环链表的尾结点后继指针指向链表的头结点。
循环链表的优点是:从链表中任何一个结点开始都可以遍历整个链表。
当要处理的数据具有环形结构特点时,就适合选用循环链表,比如著名的约瑟夫问题
双向链表
顾名思义,双向链表有两个指向。每个结点不只有一个后继指针 next,还有一个前驱指针 prev指向前面的结点。
由上图可以看出,双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,存储同样多的数据时,双向链表要比单链表占用更多的内存空间。
双向链表的优点是:支持双向遍历。
删除操作
删除不外乎以下两种情况:
- 删除结点中“值等于某个给定值”的结点
- 删除给定指针指向的结点
对于情况 1 ,不论是单链表还是双向链表,都需要从头结点开始依次遍历,直到找到值等于给定值的结点,再进行删除。删除操作的时间复杂度是 O(1),但遍历查找的时间复杂度是 O(n) ,所以对于情况 1 的删除,根据时间复杂度的加法法则,单链表和双向链表的时间复杂度均为 O(n) 。
对于情况 2 ,已知要删除结点 q 的指针,但是删除结点需要知道其前驱结点 p ,而单链表并不能直接获取到前驱结点,需要先通过遍历查找到 p -> next = q ,说明 p 是 q 的前驱结点。所以单链表对于情况 2 的删除操作,时间复杂度仍为 O(n) 。而 双向链表中的结点已经保存了其前驱结点的指针,无需再遍历查找,所以双向链表对于情况 2 的删除操作,时间复杂度是 O(1) 。
同理,如果需要在链表的某个指定结点前面插入一个结点,双向链表也是更有优势的,时间复杂度 O(1)。而单链表则是 O(n) 。
综上可见,双向链表比单链表更加高效。在实际应用中,双向链表尽管会占用更多内存,但还是比单链表应用更广泛,这就是用空间换时间的设计思想。当内存空间充足,我们更加追求代码的执行速度时,就可以选用空间复杂度相对较高,但时间复杂度相对低的算法或数据结构。如果内存较小,就反之要用时间换空间。开篇提到的缓存实际上就是典型的利用空间换时间设计思想的例子。
双向循环链表
双向链表与循环链表的结合。
链表、数组性能比较
时间复杂度 | 数组 | 链表 |
---|---|---|
插入/删除 | O(n) | O(1) |
随机访问 | O(1) | O(n) |
然而,数组与链表的对比不能局限于时间复杂度,实际应用中,应根据具体情况进行多方面考虑,选择适合当前场景的数据结构。
数组使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,访问效率更高。而链表在内存中不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。
数组大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间,导致内存不足(out of memory)。如果声明的数组过小,万一不够用,还需申请更大的空间,再把原数组拷贝过去,非常耗时。而链表(动态链表)则是天然的支持动态扩容。
由于链表中的每个结点都需要消耗额外的存储空间去存储指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表频繁的插入、删除操作,会导致频繁的内存申请和释放,容易造成内存碎片。
解答开篇
如何用链表实现 LRU 缓存淘汰策略(最近最少使用策略)
思路:维护一个有序单链表,越靠近链表尾部的结点是越早访问的。当有一个新的数据被访问时,从链表头结点开始顺序遍历链表:
- 如果发现此数据之前已经被缓存在链表中了,遍历得到这个数据对应的结点,将其从原来位置删除,再插入到链表的头部
- 如果此数据没有缓存在链表中,此时:
- 缓存未满,将此结点直接插入到链表头部
- 缓存已满,将链表尾结点删除,再将新数据插入到链表头部
这种基于链表实现的 LRU 缓存淘汰策略,缓存访问的时间复杂度为 O(n)
课后思考
- 尝试用数组来实现 LRU 缓存淘汰策略
- 如果一个字符串是通过单链表来存储的,如何判断是否是回文字符串?相应的时间复杂度是多少?
- 维护一个数组,越靠近数组首部的数据是越早访问的。当有一个新数据被访问时,遍历数组:
如果找到这条数据已经缓存在数组中,删除对应位置的数据,再将该数据push到数组最后一位;如果此数据没有缓存过,此时:
缓存未满,直接将该数据push到数组最后一位
缓存已满,删除数组第一项,再将该数据push到数组最后一位
基于数组实现 LRU 缓存淘汰策略,缓存访问的时间复杂度为O(n)
,(数组删除元素涉及到数据的迁移,时间复杂度也是O(n)
)
- 思路是先遍历链表,用快慢指针找到中间结点,将链表前半段反转,遍历与链表后半段一一比较(如果是奇数个结点,中间结点可以不考虑,因为它永远与自己相等),遍历了两次链表,但每次都只遍历一半,时间复杂度是
O(n/2)
,所以总时间复杂度是O(n)
。可以通过下方链接去亲自写代码实现一下哦~
leetcode判断回文链表的简单题目