前言
- 相信有的伙伴在面试的过程中,或多或少的会被问到redis的内存淘汰策略,可能大部分人都知道都有哪些对应的策略,毕竟对于八股文的套路大家肯定早已铭记于心。但是当面试官问你如何实现或者让你去写一个对应策略的算法时,可能就顿时一脸蒙蔽了:不对啊,套路不是这样的啊!!如果单纯的让你直接写对应的算法还好,要是再更深入一点让你说一下你的思考过程或者说如果让你来设计你会怎么去做,这个可能就上升到了一个架构的思维(如果你打算面试架构死,不妨提前锻炼一下这样的思维),对于平时没有这方面准备的伙伴来讲,那无疑就是当头一棒:与自己心仪的offer就要失之交臂了。
- 这篇文章我将从如何设计LRU Cache算法,跟大家一起来看一下整体的设计过程,包括我们应该如何去思考,如何去解决,提前锻炼一下自己初步的架构设计能力。当然在这里只是浅谈一下我的思考过程,为大家抛砖引玉,欢迎在留言区分享你的想法。目前博主个人博客已经搭建发布,后期相关文章也会发布在上面,大家有兴趣可以去上面学习,点击即可前往文青乐园
LRU Cache基本概述
LRU 是什么
- 基本概述
LRU(Least Recently Used) ,即最近最少使用,它是一种缓存逐出策略 cache eviction policies。LRU 算法是假设最近最少使用的那些信息,将来被使用的概率也不大,所以在容量有限的情况下,就可以把这些不常用的信息清理掉。 - 使用场景
比如有热点新闻时,所有人都在搜索这个信息,那刚被一个人搜过的信息接下来被其他人搜索的概率也大,之前的新闻被搜索的概率就会小,所以我们把很久没有用过的信息清理掉,也就是把 Least Recently Used 的信息清理掉。
我们举个例子,假设内存容量为 5,现在有 1-5 五个数,存储顺序如下:
数据从左到右边其使用最新程度逐渐增加,比如1就是最早使用的数据,5就是最近使用的数据,我们现在想加入一个新的数6,可是容量已经满了,所以需要清理其中的某一个,那按照什么规则清理呢?目前主要有如下缓存逐出策略:
- LFU (Least Frequently Used) :这个是计算每个信息的访问次数,清除掉访问次数最少的那个;如果访问次数一样,就清除掉好久没用过的那个。这个算法其实很高效,但是耗资源,所以一般不用。
- LRU (Least Recently Used) :这是目前最常用了,把很长时间没有用过的清除掉,那它的隐含假设就是,认为最近用到的信息以后用到的概率会更大。
那我们这个例子中就是把最老的 1 清理掉,变成:
如此不断地进行迭代。
Cache 是什么
- 基本概述
- 简单理解就是:把一些可以重复使用的信息存起来,以便之后需要时可以快速拿到。至于它存在哪里就不一定了,最常见的是存在内存里,也就是 memory cache,但也可以不存在内存里。
- 使用场景
- Spring 中有 @Cacheable 等支持 Cache 的一系列注解,使用它大大减少了 call 某服务器的次数,解决了一个性能上的问题。
- 在进行数据库查询的时候,不想每次请求都去 call 数据库,那我们就在内存里存一些常用的数据,来提高访问性能。这种设计思想其实是遵循了著名的“二八定律”。在读写数据库时,每次的 I/O 过程消耗很大,但其实 80% 的 request 都是在用那 20% 的数据,所以把这 20% 的数据放在内存里,就能够极大的提高整体的效率。
总之,Cache 的目的是存一些可以复用的信息,方便将来的请求快速获得。
那我们知道了 LRU,了解了 Cache,合起来就是 LRU Cache 了:当 Cache 储存满了的时候,使用 LRU 算法把老数据清理出去。接下来我们看看具体的实现思路。
LRU Cache设计思路详解
其实很多伙伴都知道设计这个算法需要使用 HashMap + Doubly Linked List,或者说用 Java 中现成的 LinkedHashMap,但是,你有思考过下面的问题么?
- 为什么是使用上面的数据结构?
- 你是怎么想到用这两个数据结构的?
如果真的问到这个,面试的时候不讲清楚这个,不说清楚思考过程,代码写对了也没用。其实这个和在工作中的设计思路类似,没有人会告诉我们要用什么数据结构,一般的思路是:
- 先想有哪些操作
- 然后根据这些操作,再去看哪些数据结构合适
- 定义数据结构内容
接下来我们就从上面的思路出发进行设计。
分析 Operations
对于这个 LRU Cache 需要有哪些操作呢?我们来分析一下:
- 首先最基本的操作就是能够从里面读信息,不然之后快速获取是咋来的;
- 还得能加入新的信息,新的信息进来就是 most recently used 了;
- 在加新信息之前,还得看看有没有空位,如果没有空间了,得先把老的清理掉,那就需要能够找到那个老的数据并且删除它;
- 如果加入的新信息是缓存里已经有的,那意思就是 key 已经有了,要更新 value,那就只需要调整一下这条信息的 priority,将它从上一次被使用升级为最新使用。
找寻数据结构
第一个操作很明显,我们需要一个能够快速查找的数据结构,非 HashMap 莫属,还不了解 HashMap 原理和设计规则的大家可以去百度查查,这里不做赘述。可是发现后面的操作 HashMap 就不顶用了。这里我们先来数一遍基本的数据结构:Array, LinkedList, Stack, Queue, Tree, BST, Heap, HashMap。在做这种数据结构的题目时,就这样把所有的数据结构列出来,一个个来分析,有时候不是因为这个数据结构不行,而是因为其他的数据结构更好。我们做出如下的分析:
- Array, Stack, Queue :这三种本质上都是 Array 实现的(当然 Stack, Queue 也可以用 LinkedList 来实现。。),一会插入新的,一会删除老的,一会调整下顺序,array 不是不能做,时间复杂度 O(n) 啊,不可行;
- BST :同理,时间复杂度是 O(logn);
- Heap: 即便可以,也是 O(logn);
- LinkedList:有点可以哦,按照从老到新的顺序,排排站,删除、插入、移动,都可以是 O(1) 。但是删除时我还需要一个 previous pointer 才能删掉,所以我需要一个 Doubly LinkedList。
最后我们数据结构选定为HashMap + Double LinkedList。
定义数据结构的内容
选好了数据结构之后,还需要定义清楚每个数据结构具体存储的是是什么,HashMap + Doubly LinkedList两个数据结构是如何联系的,这才是核心问题。我们先想个场景,在搜索引擎里,你输入问题 Questions,谷歌给你返回答案 Answer。那我们就先假设这两个数据结构存的都是 <Q, A>,现在我们的 HashMap 和 LinkedList 长这样:
然后我们进行以下操作:
- 直接从 HashMap 里读取 Answer 即可,O(1),没问题;
- 新加入一组 Q&A,两个数据结构都得加,那先要判断一下当前的缓存里有没有这个 Q,那我们用 HashMap 判断,如果没有这个 Q,加进来,都没问题;
- 如果已经有这个 Q,HashMap 这里要更新一下 Answer,然后我们还要把 LinkedList 的那个 node 移动到最后或者最前,因为它变成了最新被使用的了嘛。
可是,怎么找 LinkedList 的这个 node 呢?一个个遍历去找并不是我们想要的,因为要 O(n) 的时间嘛,我们想用 O(1) 的时间操作。那也就是说这样记录是不行的,还需要记录 LinkedList 中每个 ListNode 的位置,这就是设计的关键所在。怎么设计呢?自然是在 HashMap 里记录 ListNode 的位置这个信息了,也就是存一下每个 ListNode 的 reference。想想其实也是,HashMap 里没有必要记录 Answer,Answer 只需要在 LinkedList 里记录就可以了。之后我们更新、移动每个 node 时,它的 reference 也不需要变,所以 HashMap 也不用改动,动的只是 previous, next pointer。那再一想,其实 LinkedList 里也没必要记录 Question,反正 HashMap 里有。这两个数据结构是相互配合来用的,不需要记录一样的信息。更新后的数据结构如下:
这样,我们才分析出来用什么数据结构,每个数据结构里存的是什么,物理意义是什么。
那我们再用图来总结一下:
画图的时候边讲边写,每一步都从 high level 到 detail 再到代码,把代码模块化。
- 比如“Welcome”是要把这个新的信息加入到 HashMap 和 LinkedList 里,那我会用一个单独的 add() method 来写这块内容,那在下面的代码里我取名为 appendHead(),更精准;
- “踢走老的”这里我也是用一个单独的 remove() method 来写的。
有了上面的分析,接下里直接给出设计的代码。
LRU Cache算法实现
class LRUCache {
// HashMap: <key = Question, value = ListNode>
// LinkedList: <Answer>
public static class Node {
int key;
int val;
Node next;
Node prev;
public Node(int key, int val) {
this.key = key;
this.val = val;
}
}
Map<Integer, Node> map = new HashMap<>();
private Node head;
private Node tail;
private int cap;
public LRUCache(int capacity) {
cap = capacity;
}
public int get(int key) {
Node node = map.get(key);
if(node == null) {
return -1;
} else {
int res = node.val;
remove(node);
appendHead(node);
return res;
}
}
public void put(int key, int value) {
// 先 check 有没有这个 key
Node node = map.get(key);
if(node != null) {
node.val = value;
// 把这个node放在最前面去
remove(node);
appendHead(node);
} else {
node = new Node(key, value);
if(map.size() < cap) {
appendHead(node);
map.put(key, node);
} else {
// 踢走老的
map.remove(tail.key);
remove(tail);
appendHead(node);
map.put(key, node);
}
}
}
private void appendHead(Node node) {
if(head == null) {
head = tail = node;
} else {
node.next = head;
head.prev = node;
head = node;
}
}
private void remove(Node node) {
if(head == tail) {
head = tail = null;
} else {
if(head == node) {
head = head.next;
node.next = null;
} else if (tail == node) {
tail = tail.prev;
tail.next = null;
node.prev = null;
} else {
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev = null;
node.next = null;
}
}
}
}
/**
* 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);
*/