今天我们来聊聊“链表”这个数据结构,链表一个经典的应用场景就是LRU缓存淘汰算法。。。
缓存是一种提高数据读取性能的技术如常见的CPU缓存、数据库缓存、浏览器缓存。但是缓存的大小是有限的,当缓存满了之后需要对其中的数据进行处理,这个时候需要缓存淘汰策略来决定。。常见的策略有三种:先进先出FIFO、最少使用策略LFU、最近最少使用策略LRU。。
五花八门的链表结构
相比数组链表是一种稍微复杂一点的数据结构,先来看看和数组的差别:先从底层的存储结构上来看一看。
下图中可以看出数组需要一块连续的内存空间来存储,对内存要求比较高。但是链表却恰恰相反它并不需要一块连续的内存空间,通过“指针”将一组零散的内存块串联起来使用。。。
链表结构非常多,今天主要介绍三种常见的:单链表、双向链表和循环链表。
先来看看单链表,刚才说到链表通过指针将一组零散的内存块串联在一起,把内存块称为链表的“结点”。为了将所有的结点串联起来每个链表的结点除了存储数据之外,还需要记录链上的下一个结点的地址叫做后继指针next。
从上图中可以看出其中两个结点是比较特殊的分别是第一个和最后一个。第一个叫做头结点,最后一个叫做尾结点。其中头结点用来记录链表的基地址,可以遍历得到整条链表。尾结点特殊的地方:指针不是指向下一个结点,而是指向一个空地址NULL,表示这是最后一个结点。。。
链表也是支持数据的查找、插入和删除操作的。数组在插入、删除操作时为了保持内存数据的连续性,需要做大量数据的搬移,因此时间复杂度为O(n),但是链表中就不需要做这些。因此链表删除和插入数据是非常迅速的。。。
链表插入和删除操作只需要考虑相邻结点的指针改变,因此对应时间复杂度为O(1)。
访问元素时链表无法向数组那样根据首地址和下标通过寻址公式就能直接计算出对应内存地址,而需要根据指针一个结点一个结点依次遍历直到找到相应的结点。。。
链表的随机访问性能没有数组好,需要O(n)的时间复杂度。。。
下面来看两个复杂的升级版:双向链表和循环链表。。。
循环链表是一种特殊的单链表。和单链表唯一的区别在于尾结点,循环链表的尾结点指向链表的头结点。
和单链表相比,循环链表优点是从链尾到链头比较方便。当要处理的数据具有环形结构特点时很适合循环链表。比如著名的约瑟夫问题,尽管可以使用单链表但是如果使用循环链表的话代码就会简介的多。
单向链表只有一个方向,结点只有一个后继指针next指向后面的结点。而双向链表支持两个方向,每个结点不止有一个后继指针next指向后面的结点,还有一个前驱指针prev指向前面的结点。。。
双向链表需要额外两个空间来存储后继结点和前驱结点的地址。存储同样多的数据双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间但是可以支持双向遍历带来了灵活性。那么双向链表适合解决什么问题呢???
结构上来看双向链表可以支持O(1)时间复杂度的情况下找到前驱结点,使双向链表在某些情况下的插入、删除等操作要比单链表简单高效。。。
分析一个链表的两个操作,先来看看删除操作:
- 删除结点中”值等于某个给定值“的结点
- 删除给定指针指向的结点
第一种情况单链表还是双向链表为了查找到给定值的结点需要从头结点开始一个一个一次遍历对比直到找到为止然后再将其删除。。尽管单纯删除操作时间复杂度是O(1),但是遍历查找时间是主要的耗时点,对应的时间复杂度为O(n)。根据时间复杂度的加法法则删除值等于给定值的结点对应的链表操作的总时间复杂度是O(n)。
第二种情况已经找到了要删除的结点但是删除某个结点q需要知道前驱结点单链表并不支持直接获取前驱结点,因此为了找到前驱结点要从头结点开始遍历链表,直到 p->next=q,说明p是q的前驱结点。
对于双向链表情况比较好,双向链表中的结点保存了前驱结点的指针不要像单链表那样遍历。第二种情况单链表删除操作需要O(n)的时间复杂度双向链表只需要在O(1)时间复杂度就可以了。。。
如果希望在链表的某个指定结点前面插入一个节点双向链表具有很大优势。双向链表可以在 O(1) 时间复杂度搞定,而单向链表需要 O(n) 的时间复杂度。除了插入删除操作有优势对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。因为可以记录上次查找的位置p,每次查询时根据要查找的值与p的大小关系决定是往前还是往后查找,所以平均只需要查找一半的数据。。
还有一个更重要的知识点就是用空间换时间。前提是你的内存空间充足,那么我们就可以选择空间复杂度高,时间复杂度低的操作。
链表VS数组性能比拼
数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果没有足够的连续空间,就会OOM所以声明空间的时候很浪费时间。
这时你会说ArrayList不是动态的吗,然而我们也知道,他在分配空间的时候,都是先让你往里面add,到了极限空间的时候,再插入,就会申请一片区域给你扩容,这时很浪费资源的。
除了上面说的这些以外,如果你的代码对于内存的连续性和容量有高标准,那是可以是使用数组的。因为**链表需要消耗额外的存储空间存储下一个结点的指针,所以内存消耗会翻倍。**而且,频繁插入,更是会产生空间碎片,频繁的GC会让吞吐量下降,这也是很不好的。
如何实现LRU缓存淘汰算法
假如我们有一个有序单链表,越靠近链表尾部的结点就是越早之前访问的。当有一个新的数据被访问的时候,我们就从链表头开始顺序遍历链表。
-
如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的
位置删除,然后再插入到链表的头部 -
如果此数据没有在缓存链表中,又可以分为两种情况:
缓存未满,头插法
缓存已满,尾结点删除,然后头插
这样就实现了一个LRU缓存。。。