前言
面试的时候考了一道 LRU 缓存机制的题,当时想到在力扣中做过类似的题,然而巧妙的方法早已遗忘。因此用了自己想的拙劣方法,即用 vector 容器的方法来写代码。面试官听了我的思路后表示这方法时间复杂度高了,自己后面去想 O(1) 的方法。面试结束后简直泪崩,只怪自己基础不够扎实。写这个博客不仅是复习旧知识,也是警醒自己要注意基础。另外把 LFU 的笔记也写在这里,方便一起对比着学习。
正文
LRU (Least Recently Used) ,即最近最少使用,着重点在于最近有没有用,新数据来了容量不够了就淘汰最久未使用的。注意不要被这个最少给搞混了,以为是数量上的最少,下面一个才是着重数量的,LRU在于最久。
LFU (Least Frequently Used) ,即最不经常使用,着重点在于使用次数,用的次数多说明经常用,别管是多久前用的,新数据来了容量不够了就淘汰用的最少的。
这里只是简单提一下,对于这两种缓存机制的具体描述在这里就不展开了。
力扣中的相关题目对原理的描述很清晰,这里就直接借用力扣题目。
LRU缓存机制
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/lru-cache
运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制。它应该支持以下操作: 获取数据 get 和 写入数据 put 。
获取数据 get(key) - 如果关键字 (key) 存在于缓存中,则获取关键字的值(总是正数),否则返回 -1。
写入数据 put(key, value) - 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字/值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:
你是否可以在 O(1) 时间复杂度内完成这两种操作?
示例:
LRUCache cache = new LRUCache( 2 /* 缓存容量 */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 该操作会使得关键字 2 作废
cache.get(2); // 返回 -1 (未找到)
cache.put(4, 4); // 该操作会使得关键字 1 作废
cache.get(1); // 返回 -1 (未找到)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
思路:
就如同我上面写的,我的第一个思路是使用vector容器存放[key, value]对。put的时候判断缓存大小,若超了就删除最久未使用的数据值,即头部元素,之后把新值push_back。get的时候就是遍历整个向量,找到后把对应元素移动尾后。这种方法时间复杂度较高,特别是get方法需要遍历整个向量,vector内部的删除元素也不太方便。put操作中vector头部的删除复杂度是O(n),也比较高。
比较好的方法是使用双向链表+哈希表。使用链表节点存储数据,全体数据存储在双向链表中,哈希表提供关键字key到数据节点的映射,可以帮助快速找到数据,双向链表可以实现快速的插删操作。因此可以在 O(1) 时间复杂度内完成这两种操作。双向链表我直接用C++标准库的list
实现,哈希表就用C++标准库unordered_map
实现,当然也可以手写双向链表,也不难,但感觉标准库容器还是比较好用的。具体操作与上述方法相似,这里换成在头部插入尾部删除了,即在双向链表头部的是多用的,尾部的是少用的。因为取list的迭代器取头部方便一些,取尾部的不能减1,就比较麻烦了。
结构示意图:
C++代码:
class LRUCache {
private:
int max_num;
list<pair<int,int>> DoubleList;
unordered_map<int, list<pair<int,int>>::iterator> hashmap;
public:
LRUCache(int capacity) {
max_num = capacity;
}
int get(int key) {
if(hashmap.find(key) != hashmap.end()){
auto it = hashmap[key];
auto node = make_pair(key, it->second);
DoubleList.erase(it);
DoubleList.push_front(node);
hashmap[key] = DoubleList.begin();
return node.second;
}
else return -1;
}
void put(int key, int value) {
if(hashmap.find(key) != hashmap.end()){
auto it = hashmap[key];
auto node = make_pair(key, value);
DoubleList.erase(it);
DoubleList.push_front(node);
hashmap[key] = DoubleList.begin();
}
else{
if(hashmap.size() >= max_num)//满了删除尾节点
{
auto node = DoubleList.back();
DoubleList.pop_back();
auto it = hashmap.find(node.first);
hashmap.erase(it);
}
auto node = make_pair(key, value);
DoubleList.push_front(node);
hashmap[key] = DoubleList.begin();
}
}
};
LFU缓存
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/lfu-cache
请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。它应该支持以下操作:get 和 put。
get(key) - 如果键存在于缓存中,则获取键的值(总是正数),否则返回 -1。
put(key, value) - 如果键已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量时,则应该在插入新项之前,使最不经常使用的项无效。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除最久未使用的键。
「项的使用次数」就是自插入该项以来对其调用 get 和 put 函数的次数之和。使用次数会在对应项被移除后置为 0 。
进阶:
你是否可以在 O(1) 时间复杂度内执行两项操作?
示例:
LFUCache cache = new LFUCache( 2 /* capacity (缓存容量) */ );
cache.put(1, 1);
cache.put(2, 2);
cache.get(1); // 返回 1
cache.put(3, 3); // 去除 key 2
cache.get(2); // 返回 -1 (未找到key 2)
cache.get(3); // 返回 3
cache.put(4, 4); // 去除 key 1
cache.get(1); // 返回 -1 (未找到 key 1)
cache.get(3); // 返回 3
cache.get(4); // 返回 4
思路:LFU比LRU麻烦就麻烦在这个使用次数上,删除的时候要删除使用次数少的,如果次数相等就删除最久未使用的。如果依旧只是用一个双向链表,那么我们要在每个节点存放[key, value]对外还要保存一个使用次数,同时在在每次使用时调整节点在链表中的位置时还要给它找个合理的位置(往前搜索到使用次数比它多1的最前面的节点,插到它前面),每次新加入也挺麻烦,要找到使用次数为1的最前面一个节点。
为此,我们不妨再用一个哈希表,索引为使用次数,每个位置存放对应使用次数的 [key, value] 对组成的双向链表,每个双向链表中越新使用的[key, value]对在越前面,方便删除。这样就节省了寻找使用次数为某个值的且最近用过的[key, value]对的时间。另一个哈希表就和 LRU 中的一样,索引为题目所描述的键值,每个键值存储指向对应[key, value]对的指针(用 C++ 标准库的list就是迭代器)。另外为了在 put 的时候找到已经存在的 [key, value] 对的时候能快速找到对应的使用频次,还是应该在每个节点保存保存一个使用次数。
结构示意图:
C++代码:
和LRU相比每个节点多保存了一个使用频次,另外双向链表变成哈希+双向链表,如果把LRU搞懂了,看这个应该方便一些,不然容易绕晕。另外写代码前最好打个草稿模拟一下。
struct DoubleListNode {//节点
int key, val, freq;
DoubleListNode(int x, int y, int z): key(x), val(y), freq(z){};
};
class LFUCache {
int max_num;
int min_freq;//最小的访问次数
unordered_map<int, list<DoubleListNode>::iterator> Cache;
unordered_map<int, list<DoubleListNode>> HashDoubleList;
public:
LFUCache(int capacity) {
min_freq = 0;
max_num = capacity;
}
int get(int key) {
if(Cache.find(key) != Cache.end()){
auto it = Cache[key];
int freq_tmp = it->freq;
int val = it->val;
HashDoubleList[freq_tmp].erase(it);
//freq_tmp对应的节点都没了,删去
if (HashDoubleList[freq_tmp].size() == 0) {
HashDoubleList.erase(freq_tmp);
//如果最少访问次数就是freq_tmp,则要加1
if (min_freq == freq_tmp) min_freq += 1;
}
HashDoubleList[freq_tmp+1].push_front(DoubleListNode(key, val, freq_tmp + 1));
Cache[key] = HashDoubleList[freq_tmp+1].begin();
return val;
}
else return -1;
}
void put(int key, int value) {
if(Cache.find(key) != Cache.end()){
auto it = Cache[key];
int freq_tmp = it->freq;
HashDoubleList[freq_tmp].erase(it);
//freq_tmp对应的节点都没了,删去
if (HashDoubleList[freq_tmp].size() == 0) {
HashDoubleList.erase(freq_tmp);
//如果最少访问次数就是freq_tmp,则要加1
if (min_freq == freq_tmp) min_freq += 1;
}
HashDoubleList[freq_tmp+1].push_front(DoubleListNode(key, value, freq_tmp + 1));
Cache[key] = HashDoubleList[freq_tmp+1].begin();
}
else{
if(Cache.size() >= max_num){
if(Cache.size() == 0) return;//测试用例中有容量为0还往里加的情况,这时候直接返回
auto node = HashDoubleList[min_freq].back();
HashDoubleList[min_freq].pop_back();
auto it = Cache.find(node.key);
Cache.erase(it);
}
min_freq = 1;
HashDoubleList[1].push_front(DoubleListNode(key, value, 1));
Cache[key] = HashDoubleList[1].begin();
}
}
};