线性表-链表
问题引入:
缓存大小有限,当缓存被用满时,需要决定哪些数据应该被清理或保留。
缓存淘汰策略:
- FIFO(First In,First Out) 先进先出策略 (队列?
- LFU(Least Frequently Used) 最少使用策略
- LRU(Least Recently Used) 最近最少使用策略(链表)
链表与数组在存储结构上的对比:
数组需要一块连续的内存空间来存储,如果我们申请一个100MB大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大小100MB时,仍然会申请失败。链表则是通过指针将一组零散的内存块串联起来使用,所以不会存在这个问题。
- 数组占用连续内存空间,无法动态扩容,进行扩容时需进行申请更大空间、整体拷贝,所以更费时。而链表本身没有大小的限制,天然支持动态扩容。
- 链表每个结点需要消耗额外的空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。(即空间换时间)
- 链表在进行频繁的插入和删除操作时,导致频繁的内存申请和释放,容易造成内存碎片,也导致频繁的GC(垃圾回收)
常见的链表结构:
- 单链表:只有Next,尾结点指向Null
- 双向链表:Next+Prev,尾结点指向NUll。与单链表相比,在删除结点时更快。(因为在删除某个结点q时,实际是修改其前驱结点的后继指针,而单链表需要遍历才能获取前驱结点。)=>空间换时间。
- 循环链表:Next,尾结点指向头结点。与单链表相比,从链尾到链头更方便,更适合处理具有环形结构特点的数据(代码会更整洁)。
链表的基本概念:
- 结点:链表通过指针将一组零散的内存块串联在一起。在这里面的内存块成为结点。
- 后继指针:记录链上下一结点的地址的指针称为后继指针。
- 头结点:链表的第一个结点,记录链表的基地址。
- 尾结点:链表的最后一个结点,指针指向空地址NULL。
- 结点的作用:存储数据;记录链上下一结点的地址。
-->data;next-->data;next-->data;next-->Null
实践
1. 使用循环链表解决“约瑟夫问题”。
约瑟夫问题:设编号为1、2、...n的n个人围坐在一圈,约定编号为k(1<=k<=n)的人从1开始报数,数到m的哪个人出列。它的下一位又从1开始报数,数到m的那个人又出列。一次类推,直到所有人都出列为止,由此产生一个出列编号的序列。
2. 如何基于链表使用LRU缓存淘汰算法?
维护一个有序单链表,越靠近尾结点的数据表示越早之前访问。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
0. 新数据被访问时,从链表头开始遍历链表
- 数据已经被缓存在链表中,通过遍历得到对应的结点,并将其从原来的删除,在插入到头节点;
- 如果未被缓存
2.1 缓存未满,插入到头节点
2.2 插入已满,删除尾结点,插入新的数据结点到头节点
优化:使用散列表,记录每个数据的位置。