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


大家好,我是爱好编程的斌斌。

和数组一样,链表也是一种简单、基础的数据结构。为什么要学习链表?链表有什么用?就那链表最经典的使用场景,LRU缓存淘汰算法。

下面是对缓存的说明,可看可不看。

缓存是为了加快读取性能,但缓存大小有限,也就意味着会被用完。用完之后,哪些数据应该留下,哪些数据应该删除?这就涉及到缓存淘汰策略了,主要有这几个:先进先出策略FIFO(First In,First Out)、最少使用策略LFU(Least Frequently Used)、最近最少使用策略LRU(Least Recently Used).可类比一下,假如你在搞大扫除,发现书柜上的书太多了,你绝对扔掉一些。思考一下,你的想法是不是和上面的一样?

一、五花八门的链表

链表的结构五花八门,主要有三种:单链表、双向链表、循环链表。

1.链表和数组的区别

从存储结构出发,如图所示,数组需要一块连续的内存空间,对内存的要求比较高。如果我们申请一个100MB大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于100MB,仍然会申请失败。

而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是100MB大小的链表,根本不会有问题。

在这里插入图片描述

2.单链表

链表是通过指针将临时的内存块串连起来,这里的内存块被称为节点,也就意味着一个节点除了要存储自己的数据,还要存储指向下一个节点的指针。如图所示,把这个记录下个结点地址的指针叫作后继指next。

在这里插入图片描述
单链表也就意味着单方向,从图中可以看出,单链表有两个比较特殊的节点,第一个节点,也叫头结点,只要有这个节点,就可以遍历得到整条链表;最后一个节点,也叫尾节点,它后面没有元素,所以next值为null,表示最后一个节点。

与数组一样,链表也支持插入、删除和查找操作,二者的时间复杂度正好相反。链表的插入和删除数据只需要简单得变更指针的值即可,如图所示,时间复杂度为O(1)。而数组需要搬移大量数据,时间复杂度为O(n)。
在这里插入图片描述
有利就有弊,链表要想随机访问第k个元素,就得根据指针一个结点一个结点地依次遍历,直到找到相应的结点。可以把链表看作成一个队伍,每个人只知道后一个人是谁,要想知道第k个人是谁,得先知道第(k - 1)个人是谁。所以,链表随机访问的时间复杂度为O(n)。

3.循环链表

循环链表,在单链表的基础上,把尾节点的next指针指向了头结点。如图所示,这种结构是不是非常像一个环?
在这里插入图片描述
和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环型结构特点时,就特别适合采用循环链表。比如著名的约瑟夫问题。

4.双向链表

在这里插入图片描述
从上面的图可以看出,双向链表在单链表的基础上,每个节点还存储了前驱节点的地址,这就意味着占用了更多的内存空间。但是,它也使得链表变得灵活,可以在时间复杂度为O(1)的情况下找到前驱节点。使得插入、删除比单链表更高效。你可能有疑惑,单链表插入和删除的时间复杂度已经是O(1)了,双向链表还能优化?

首先,你要明确的是,在删除节点之前,你得找到该节点的位置,不管是要删除指定值的节点,还是删除某个节点的指针指向的节点,查找的时间复杂度都为O(n),经过链表单纯删除的时间复杂度为O(1)。双向链表在第二种情况的时候,就比较有优势了。这种情况你已经找到了要删除的节点了,但是你要的是它的前驱。在单链表中查找一个节点的前驱时间复杂度为O(n),而双向链表节点中就保存了前驱,所以只要O(1)就可以找到。用空间换时间的思想在这里体现得伶俐精致,消耗更多的内存,使得操作更省时间。利用这个特点,双向链表在获取某个节点的前驱也相当得快。

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

3.双向循环链表

在这里插入图片描述
在循环链表的基础上进行了改造,使得可以从尾节点找到头结点,从头结点找到尾节点。

二、链表 V.S. 数组

前面提到过,数组和链表的操作都包括插入、删除和随机访问,且时间复杂度正好相反,这与他们的数据存储方式息息相关。
在这里插入图片描述
不过,数组和链表的对比,不能局限于时间复杂度。并且,在实际的软件开发中,使用哪种数据结构不能仅仅由时间复杂度决定。

数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效预读。链表与数组最大的区别是,链表天然支持动态扩容,而数组一经创建,大小就固定死了,如果存满,就只能申请一个更大的数组,并且把已有数据拷贝过去。这里就有人会说Java中的ArrayList支持动态扩容,你要清楚一点,只是人家帮你实现了扩容,并不是没有了。

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

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

三、解答开篇

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

首先,我们需要实现一个有序链表,链表中的数据越靠近后面,就越是之前访问过的数据。当有一个数据被访问时,

  1. 如果链表中存在该数据,就将该节点的数据返回,并将该节点删掉,插入到链表头部。
  2. 如果链表中不存在该节点,这里又分了两种情况
  • 如果链表没有存满,直接将数据插入到链表头部
  • 如果链表满了,就删除链表尾部元素,然后将新增数据插入链表头部
    这里需要说明:基于链表实现的LRU缓存淘汰算法性能不好,所以会用更加高级的动态数据结构来实现。

四、总结

链表是一种跟数组“相反”的数据结构,跟数组一样,也是非常基础、非常常用。不过链表要比数组稍微复杂,从普通的单链表衍生出来好几种链表结构,比如双向链表、循环链表、双向循环链表。和数组相比,链表更适合插入、删除操作频繁的场景,查询的时间复杂度较高。不过,在具体软件开发中,要对数组和链表的各种性能进行对比,综合来选择使用两者中的哪一个。

五、课后思考

如果字符串是通过单链表来存储的,那该如何来判断是一个回文串呢?

思路:利用快慢指针找到链表的中间元素,期间将遍历过的元素反转,然后比较前后两部分值是否相等,如果相等就是回文字符串,否则不是。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值