LRUCache的设计,实现和调试

把设计思路梳理一遍+实现思路梳理1遍+调试方法梳理1遍

预备知识

2020年3月下旬【算法讨论2】链表 #17

主要回答的问题

  1. 设计
    1.1 数据结构怎么选择的。
    1.2 数据结构的每个字段都是怎么来的。

  2. 实现

  3. 调试方法

两种题型

一种是leetcode题目, 直接让你实现put,get
一种是自己设计并实现LRUCache。第一种题是第二种题的一部分。

  • LRU说的是一种淘汰策略。
    缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?
    这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。

设计(讨论过程梳理)

明确需求

  • 主要功能
    存放缓存,然后满的时候需要淘汰一些缓存,淘汰策略是LRU。

  • 设计第一步: Scenario Testing(场景测试) / 使用测试
    模拟一个LRU的使用过程明确一下我们要实现什么东西。// 这个步骤是做很多软件设计的重要步骤,比如接口设计和框架API设计等。
    假如缓存的容量是2。
    put1-1, put2-2, put3-3(淘汰1)
    put1-1, put2-2, get1-1/put1-11, put3-3(淘汰2)

  • 由此梳理出该数据结构的2个设计要点
    1, 是一个具有put,get操作的集合。其实还有delete操作删除指定key(比如put1-11,我们既可以移动旧节点到头节点,也可以删除旧节点然后创建新节点插入头)。
    2, 这个集合需要有某种顺序,使得我们在淘汰时能够确定要淘汰的元素,这里就是把要淘汰的元素放在末尾了,淘汰是直接淘汰即可。
    3, 缓存淘汰策略最近最少使用和缓存提升策略是最近使用。

选什么数据结构

1. 为什么选链表

从场景测试可以看出,在做put/get操作,有很多插入集合头部,删除集合尾部的操作。我们的存储结构无非就是数组,链表,树,
数组的插入头O(n)删除O(n),链表的插入头O(1)删除O(n)+O(1),树好像没有头尾节点一说,而且我们一般从易到难进行选取,这里链表似乎就满足需求了。
链表又分单链表,双链表,循环单链表,循环双链表,跳表。
此时产生第一版设计:先选择单链表。

2. 为什么引入map

链表是先O(n)查找再O(1)删除。可以通过引入map查找表来优化为O(1)删除。
此时产生第二版设计:map{缓存key->链表节点指针}+单链表{val,next}。

3. 为什么选双向链表

使用第二版设计进行删除操作,我们是需要知道被删除节点的pre节点的,所以需要把pre节点保存到某个地方。

  1. 保存到map中,那么map的key就是{(缓存key,pre节点)->节点指针},链表节点设计{val,next} // 这个需要新增一个结构体,并且key是结构体的话很奇怪,废弃该方案。
  2. 保存到链表节点中: map设计{缓存key->节点指针}, 链表节点设计{pre,val,next},这个也就是双向链表了。

此时产生第三版设计:map{缓存key->链表节点指针}+双链表{pre, val, next}

4. 为什么map中已经保存了key,链表节点还需要保存key

考虑这样一种情况: 当缓存满了,我们需要在链表中删除节点,也需要在map中删除对应的key。大概步骤如下:

1. 定位到尾节点,删除链表尾节点。
因为需要"定位到尾节点", 所以该数据结构需要引入1个tail来记录尾节点的地址。
第1步O(1)。
2. 定位尾节点在map中的键值对并删除该键值对。
"定位尾节点对应的键值", 因为链表节点没有保存key, 所以我们哪不到key, 也就不能直接delete(map,key), 而是需要遍历map来查找尾节点(v==tail的节点)然后删除。
第2步是O(n)。
所以此时的设计删除操作是O(n)的,因为需要遍历map,我们可以把key存放到链表节点中消除这个遍历过程。直接通过delete(map,tail.key)可以直接删除,而不必遍历map。 这样第1,2步就都是O(1)的。

此时产生第四版设计:[tail + map{缓存key->链表节点指针}] + 双链表{pre, key, val, next}。

5. 为什么引入head和tail

之前我们都在考虑删除节点的情况,现在来考虑头插法插入头节点的情况。
很明显我们需要1个head指针来快速定位头节点。

此时产生第五版设计:[head + tail + map{缓存key->链表节点指针}] + 双链表{pre, key, val, next}。

6. 为什么需要head,tail需要是dummy节点

我们知道单链表的删除引入dummyhead就是为了统一头节点和中间节点的删除逻辑。这里引入dummyhead也是同样的道理。
至于为什么还要引入dummytail,考虑[head<->1<->2]和[head<->1<->nil], 可以看到如果没有tail的话,在删除尾节点时就有head<-nil的异常, 那么删除中间节点和删除尾节点也是2套逻辑,所以也是引入dummytail来统一为中间节点的删除逻辑[head<->tail]。

此时产生第六版设计:[dummyhead + dummytail + map{缓存key->链表节点指针}] + 双向循环链表{pre, key, val, next}。

6.5 双向循环链表的另一个好处

双向循环链表初始化为[head<->tail],之后不管我们对链表进行怎样的插入和删除,整个链表是不存在nil节点,这样我们就不用担心访问空节点而导致异常的情况了。

7. 增加缓存容量字段

此时产生第七版设计:[dummyhead + dummytail + map{缓存key->链表节点指针} + cap] + 双链表{pre, key, val, next}。

直觉实现代码

很多点没考虑到,写不走,上述7个版本的设计都是事后归因来总结的。

代码实现 (先一起来写一下)

这里需要关注,我们是如何提取出remove和add这样共性的基础操作。
在另外1个思路里面,还提取了moveToHead这样的共性操作。

  • put
  • get

调试

通过打印关键信息来调试,比debug调试效率更高。
打印代码编写。

速记

  1. leetcode刷题:3点
    1.1 map+双向循环链表。初始化[head<->tail]
    1.2 put, get, remove, add, 把2个操作拆分为4个操作
    1.3 put操作根据exist, full 分4种情况讨论,然后看情况合并一些情况即可。

  2. 面试中: 这个手写有点费时间,一般不会考,主要说设计的思路,应该不会有7版这么多,但是我们需要答到:// 这个我也拿不准,需要讨论。
    为啥引入map,不选数组选链表,选双向链表,双向循环链表和dummy节点。
    2个节点的字段都是为什么添加进去的,各自的功能是什么?

参考

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

猜你喜欢

转载自www.cnblogs.com/yudidi/p/12622296.html