核心:掌握链表的基础知识、写出链表代码
链表基础知识
LRU缓存淘汰算法
链表(LinkedList)的一个经典应用场景就是LRU缓存淘汰算法。
LRU最近最少未使用算法。在OS虚拟存储器那部分用到了该算法,根据程序的局部性原理,设计出了虚拟储存器,内个进程只被分配了几个内存块,当进程的内存块被占完之后,从硬盘上又重新读取了一个内存块的数据量。此时,哪个内存块的内容被换出去比较合适呢?如果置换算法采取的不好,就会导致缺页率上升,进程频繁的发生缺页中断。OS就会频繁的在内存和外存之间替换页面,影响了进程的执行效率,这种现象又称之为‘抖动’。【LRU置换算法的缺页率相对比较低!】
缓存是一种提高数据读取性能的技术。常见的有CPU缓存、数据库缓存、浏览器缓存等等
CPU缓存又可以分为寄存器,一级缓存,二级缓存,三级缓存
数据库缓存:数据库本身产品就自带缓存,redis也可以作位数据库缓存
浏览器缓存:Cookie本质上就是一个文件
缓存的大小有限,当缓存被用满时,数据的清理需要缓存淘汰策略来决定!常见的三种策略:先进先出策略(FIFO,first in first out)、最少使用策略(LFU,least frequently used)、最近最少使用策略(LRU,least recently used)
FIFO:刚买了一堆书,堆在一起了,ok,先看放在上面的书,把看完的放到另一堆里(双堆栈结构,像浏览器前进后退一样)。
LFU:不常用的书。这么多书也不一定全都是好书,读完鉴别过了之后。。。丢了吧。
LRU:最近不常用的书。把好书坏书筛选一遍后,剩下的好书有的看起来对现在的我来说用处没那么大,那就先放一边,以后再翻阅。
链表结构:单链表、双向链表和循环链表
数组:连续的内存空间来存储,对内存要求较高
链表:不需要一块连续的内存空间,通过指针将一组零散得内存串联起来
单链表:单链表中的每一个节点都必须具备两个功能:一个功能是存储数据,另外一个功能是记录下一个节点的地址。
单链表的插入和删除:
循环链表:循环链表和单链表的区别,单链表最后一个节点里面存储下一个节点的内存地址为空,循环链表最后一个节点里面存储下一个节点的内存地址为链表头节点的内存地址
双向链表:双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。
双向链表一般而言比较高效(单链表的插入、删除操作时间复杂度已经是O(1)了,为什么双向链表还是比较高效?)
删除操作:
- 删除节点中‘值等于某个给定值’的节点
- 删除给定指针指向的节点
第一种情况,需要遍历,时间复杂度为O(n)。第二种情况,双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历。所以,针对第二种情况,单链表删除操作需要 O(n) 的时间复杂度,而双向链表只需要在 O(1) 的时间复杂度内就搞定了!
这就是为什么在实际的软件开发中,双向链表尽管比较费内存,但还是比单链表的应用更加广泛的原因。Java中,LinkedList、LinkedHashMap就用到了双向链表这种数据结构。用空间换时间的设计思想
链表 VS 数组性能大比拼
数组和链表是两种截然不同的内存组织方式。正是因为内存存储的区别,它们插入、删除、随机访问操作的时间复杂度正好相反。
不过,数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。
数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。
数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,也是它与数组最大的区别。
Java 中的 ArrayList 容器,也可以支持动态扩容啊?支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。用 ArrayList 存储了了 1GB 大小的数据,这个时候已经没有空闲空间了,当我们再插入数据的时候,ArrayList 会申请一个 1.5GB 大小的存储空间,并且把原来那 1GB 的数据拷贝到新申请的空间上。听起来是不是就很耗时?除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。所以,在我们实际的开发中,针对不同类型的项目,要根据具体情况,权衡究竟是选择数组还是链表。
基于链表实现 LRU 缓存淘汰算法
维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。
- 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
- 如果此数据没有在缓存链表中,又可以分为两种情况:
- 如果此时缓存未满,则将此结点直接插入到链表的头部;
- 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
Leetcode 146. LRU 缓存机制
class LRUCache {
/**
分析:这是一个设计题,难度较大。
对数据进行增删改操作,并且时间复杂度为O(1)====》双向链表
对数据进行查找操作,并且时间复杂度为O(1)====》哈希表
最后选择的数据结构是 哈希表+双向链表(伪头部和伪尾部法),这个伪头部和伪尾部法具有巨大的威力
主要的业务逻辑,在代码注释中说明
*/
// 数据结构为 双向链表,包含了键值对,前驱后驱节点,构造函数
class DLinkedNode{
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
DLinkedNode(){
}
DLinkedNode(int _key,int _value){
key = _key;
value = _value;
}
}
// 数据结构为 哈希表,用来查找
HashMap<Integer,DLinkedNode> cache = new HashMap<>();
// 定义链表的长度,伪头部,伪尾部
private int size;
private DLinkedNode head,tail;
// 定义LRUCache的容量(哈希表里面是不需要设置容量的,整体设置就行)
private int capacity;
// LRUCache的构造函数
public LRUCache(int capacity) {
// 初始化的时候,让链表长度为0
this.size = 0;
// 初始化capacity
this.capacity = capacity;
// 初始化伪头部和伪尾部信息
head = new DLinkedNode();
tail = new DLinkedNode();
// 伪头部和伪尾部进行连接
head.next = tail;
tail.prev = head;
}
/**
核心思想:
在哈希表中查找,找到就返回相应的节点值(同时将该节点移动到链表头部),未找到就返回-1
*/
public int get(int key) {
// 调用哈希表的查找函数
DLinkedNode res = cache.get(key);
if(res==null){
// 未找到节点
return -1;
}
// 调用内部类的移动到头部函数(自己写,返回void),注意:原有节点也要删除
moveToHead(res);
// 返回节点值
return res.key;
}
public void put(int key, int value) {
/**
核心思想:
在哈希表中查找,找到就更新相应的节点值(同时将该节点移动到链表头部)
未找到就新建一个(同时将该节点移动到链表头部)
若缓存容量达到上限,就删除尾部的哈希节点(对应到链表节点中)
*/
// 调用哈希表的查找函数(自己写,返归节点)
DLinkedNode res = cache.get(key);
if(res!=null){
// 找到节点,更新节点信息,将节点移动到头部去
res.value = value;
moveToHead(res);
}else{
// 未找到节点,则新建一个
DLinkedNode newNode= new DLinkedNode(key,value);
// 链表长度+1
size++;
// 添加到头部
addToHead(newNode);
// 添加到哈希表中
cache.put(key,newNode);
// 判断size和capacity的关系
if(size>capacity){
// 移除尾部的节点信息
DLinkedNode tailNode = moveTailNode();
// 删除哈希表中的项
cache.remove(tailNode.key);
// size-1
size--;
}
}
}
// 内部添加到头部函数
public void addToHead(DLinkedNode node){
// 移动节点到头部
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
// 内部移动到头部函数
public void moveToHead(DLinkedNode node){
// 先删除原有节点
node.prev.next = node.next;
node.next.prev = node.prev;
// 再移动节点到头部
addToHead(node);
}
// 内部移除尾部的节点信息
public DLinkedNode moveTailNode(){
DLinkedNode node = tail.prev;
node.prev.next = node.next;
node.next.prev = node.prev;
return node;
}
}
/**
* Your LRUCache object will be instantiated and called as such:
* LRUCache obj = new LRUCache(capacity);
* int param_1 = obj.get(key);
* obj.put(key,value);
*/