LRU和LFU算法
LRU算法
LRU 的全称是 Least Recently Used,也就是说我们认为最近使用过的数据应该是「有用的」,很久都没用过的数据应该是无用的,内存满了就优先删那些很久没用过的数据。
LRU 缓存算法的核心数据结构就是哈希链表LinkedHashMap
,双向链表和哈希表的结合体。使用该数据结构,就可以确保get
和 put
方法都是 O(1)
的时间复杂度。
、
力扣第 146 题「LRU缓存机制」就是让你设计数据结构。
在使用LinkedHashMap
实现LRU算法前,我们先手动使用双链表和HashMap实现以下LRU算法,加深对算法的理解。
使用双链表和HashMap实现
1)首先先把双链表的节点类写出来
public class Node {
public int key, val;
public Node next, prev;
public Node(int key, int val) {
this.key = key;
this.val = val;
}
}
2)构建双链表,实现几个 LRU 算法必须的 API
public class DoubleList {
private Node head, tail;
private int size;
public DoubleList() {
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;
size = 0;
}
// 在链表尾部添加节点 x,时间 O(1)
public void addLast(Node x) {
x.prev = tail.prev;
x.next = tail;
tail.prev.next = x;
tail.prev = x;
size++;
}
// 删除链表中的 x 节点(x 一定存在)
// 由于是双链表且给的是目标 Node 节点,时间 O(1)
public void remove(Node x) {
x.prev.next = x.next;
x.next.prev = x.prev;
size--;
}
// 删除链表中第一个节点,并返回该节点,时间 O(1)
public Node removeFirst() {
if (head.next == null) {
return null;
}
Node first = head.next;
remove(first);
return first;
}
// 返回链表长度,时间 O(1)
public int size() {
return size;
}
}
为什么必须使用双向链表?因为我们需要删除操作。删除一个节点不光要得到该节点本身的指针,也需要操作其前驱节点的指针,而双向链表才能支持直接查找前驱,保证操作的时间复杂度 O(1)。
注意我们实现的双链表 API 只能从尾部插入,也就是说靠尾部的数据是最近使用的,靠头部的数据是最久为使用的。
3)LRU算法框架
public class LRUCache {
private HashMap<Integer, Node> map;
private DoubleList cache;
private int cap;
public LRUCache(int cap) {
this.cap = cap;
map = new HashMap<>();
cache = new DoubleList();
}
}
4)提供API避免直接操作细节
由于我们要同时维护一个双链表 cache
和一个哈希表 map
,很容易漏掉一些操作,比如说删除某个 key
时,在 cache
中删除了对应的 Node
,但是却忘记在 map
中删除 key
。解决这种问题的有效方法是:在这两种数据结构之上提供一层抽象 API。
/* 将某个 key 提升为最近使用的 */
private void makeRecently(int key) {
Node x = map.get(key);
// 先从链表中删除这个节点
cache.remove(x);
// 重新插到队尾
cache.addLast(x);
}
/* 添加最近使用的元素 */
private void addRecently(int key, int val) {
Node x = new Node(key, val);
// 链表尾部就是最近使用的元素
cache.addLast(x);
// 别忘了在 map 中添加 key 的映射
map.put(key, x);
}
/* 删除某一个 key */
private void deleteKey(int key) {
Node x = map.get(key);
// 从链表中删除
cache.remove(x);
// 从 map 中删除
map.remove(key);
}
/* 删除最久未使用的元素 */
private void removeLeastRecently() {
// 链表头部的第一个元素就是最久未使用的
Node deleteNode = cache.removeFirst();
// 同时别忘了从 map 中删除它的 key
int deleteKey = deleteNode.key;
map.remove(deleteKey);
}
5)实现get和put方法
public int get(int key) {
if (!map.containsKey(key)) {
return -1;
}
makeRecently(key);
return map.get(key).val;
}
public void put(int key, int val) {
if (map.containsKey(key)) {
// 删除旧的数据
deleteKey(key);
// 新插入的数据为最近使用的数据
addRecently(key, val);
return;
}
if (cap == cache.size()) {
// 删除最久未使用的元素
removeLeastRecently();
}
// 添加为最近使用的元素
addRecently(key, val);
}
至此,经过层层拆解,LRU 算法就完成了。
使用LinkedHashMap实现
public class LRUCache_146 {
int cap;
LinkedHashMap<Integer, Integer> cache = new LinkedHashMap<>();
public LRUCache_146(int capacity) {
this.cap = capacity;
}
public int get(int key) {
if (!cache.containsKey(key)) {
return -1;
}
// 将 key 变为最近使用
makeRecently(key);
return cache.get(key);
}
public void put(int key, int value) {
if (cache.containsKey(key)) {
// 修改 key 的值
cache.put(key, value);
// 将 key 变为最近使用
makeRecently(key);
return;
}
if (cache.size() >= this.cap) {
// 链表头部就是最久未使用的 key
int oldestKey = cache.keySet().iterator().next();
cache.remove(oldestKey);
}
// 将新的 key 添加链表尾部
cache.put(key, value);
}
private void makeRecently(int key) {
int val = cache.get(key);
// 删除 key,重新插入到队尾
cache.remove(key);
cache.put(key, val);
}
}
LFU算法
LFU 算法相当于是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。把数据按照访问频次进行排序,而且频次还会不断变化,这可不容易实现。
在动手写代码之前,我们必须对LFU算法的一些特性了解清楚,不然无法写出正确的代码:
- 调用
get(key)
方法时,要返回该key
对应的val
。 - 只要用
get
或者put
方法访问一次某个key
,该key
的freq
就要加一。 - 如果在容量满了的时候进行插入,则需要将
freq
最小的key
删除,如果最小的freq
对应多个key
,则删除其中最旧的那一个。(隐含条件:希望能够快速删除key
列表中的任何一个key
,因为频次为freq
的某个key
被访问,那么它的频次就会变成freq+1
,就应该从freq
对应的key
列表中删除,加到freq+1
对应的key
的列表中。)
了解了这些特性之后,我们就发现光是普通的链表LinkedList
和HashMap
满足不了我们快速删减key
的需求,因此这里我们选择使用LinkedHashSet
。LinkedHashSet
顾名思义,是链表和哈希集合的结合体。链表不能快速访问链表节点,但是插入元素具有时序;哈希集合中的元素无序,但是可以对元素进行快速的访问和删除,它俩结合起来就兼具了哈希集合和链表的特性。
综上,我们可以写出 LFU 算法的基本数据结构:
//使用LinkedHashSet
public class LFUCache {
// key 到 val 的映射
HashMap<Integer, Integer> keyToVal;
// key 到 freq 的映射
HashMap<Integer, Integer> keyToFreq;
// freq 到 key 列表的映射
HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;
// 记录最小的频次
int minFreq;
// 记录 LFU 缓存的最大容量
int cap;
public LFUCache(int capacity) {
keyToVal = new HashMap<>();
keyToFreq = new HashMap<>();
freqToKeys = new HashMap<>();
this.cap = capacity;// 记录 LFU 缓存的最大容量
this.minFreq = 0;// 记录最小的频次
}
public int get(int key) {}
public void put(int key, int val) {}
}
LinkedHashSet实现
1)实现get和put方法框架
下面我们先来实现get(key)
方法,逻辑很简单,返回key
对应的val
,然后增加key
对应的freq
:
public int get(int key) {
if (!keyToVal.containsKey(key)) {
return -1;
}
// 增加 key 对应的 freq
increaseFreq(key);
return keyToVal.get(key);
}
这里需要注意的是,increaseFreq()
方法的作用是增加 key
对应的 freq
,具体实现后面来做。
写出put
方法的逻辑:
public void put(int key, int value) {
// 如果没有执行初始化直接执行 put ,就会出错
if (this.cap == 0) return;
/* 若 key 已存在,修改对应的 val 即可 */
if (keyToVal.containsKey(key)) {
keyToVal.put(key, value);
increaseFreq(key);
return;
}
/* key 不存在,需要插入 */
/* 容量已满的话需要淘汰一个 freq 最小的 key */
if (this.cap <= keyToVal.size()) {
removeMinFreqKey();
}
/* 插入 key 和 val,对应的 freq 为 1 */
keyToVal.put(key, value);
keyToFreq.put(key, 1);
freqToKeys.putIfAbsent(1, new LinkedHashSet<>());
freqToKeys.get(1).add(key);
this.minFreq = 1;
}
同理,removeMinFreqKey()
方法交由后面来实现。
2)实现两个方法
首先来实现removeMinFreqKey
函数:
/* 移除频率最少的key */
private void removeMinFreqKey() {
// freq 最小的 key 列表
LinkedHashSet<Integer> keyList = freqToKeys.get(this.minFreq);
// 其中最先被插入的那个 key 就是该被淘汰的 key
int deleteKey = keyList.iterator().next();
keyList.remove(deleteKey);
// 删除后如果该频率 key 列表为空,则删除该频率
// 无须更新minFreq,因为调用该方法的时候只有 put 方法, 新增元素会将 freq 置为 1
if (keyList.isEmpty()) {
freqToKeys.remove(this.minFreq);
}
keyToVal.remove(deleteKey);
keyToFreq.remove(deleteKey);
}
下面来实现increaseFreq
函数:
private void increaseFreq(int key) {
int freq = keyToFreq.get(key);
//更新两表
keyToFreq.put(key, freq + 1);
freqToKeys.get(freq).remove(key);
// putIfAbsent: 如果传入key对应的value已经存在,就返回存在的value,不进行替换。
// 如果不存在,就添加key和value,返回null
// 将 key 加入 freq + 1 对应的列表中
freqToKeys.putIfAbsent(freq + 1, new LinkedHashSet<>());
freqToKeys.get(freq + 1).add(key);
// 如果 freq 对应的列表空了,移除这个 freq
if (freqToKeys.get(freq).isEmpty()) {
freqToKeys.remove(freq);
// 如果这个 freq 恰好是 minFreq,更新 minFreq
if (freq == this.minFreq) {
this.minFreq++;
}
}
}
至此,经过层层拆解,LFU 算法就完成了。