关于LRU问题的一些新思考
题目链接
注意:LeetCode的测试数据会更多一点,用146优化一下代码
思路
访问操作get:
-
情况1:不存在 -> 返回-1
-
情况2:存在 -> 更新到头部+返回值
插入操作set/put(k,v):
-
情况1:容量已满 -> 删除尾部元素
-
情况2:容量未满
-
存在:更新(位置到头部+删除原来的位置+修改元素值)
-
不存在:在头部插入该元素
-
针对使用的数据结构的分析
目前没有一个容器可以同时具备查找和更新(增/删)操作都在 \(O(1)\) 的时间复杂度内的。
可使用的数据结构如下:
-
查找 \(O(1)\):哈希表 -> 使用unordered_map [无序+底层哈希表]
-
更新 \(O(1)\):
-
头插/尾插 \(O(1)\):队列、链表(单向、双向)
-
但是除此之外,我们还需要考虑到,如果更新操作是发生在中间结点,那么队列是需要 \(O(n)\) 的时间才能更新的。
-
综上所述,我们应该使用 一个双向链表 + 一个哈希表(存key以及双向链表结点的迭代器) 这两个数据结构来实现LRU。
-
哈希表负责查询+根据key定位链表具体结点(以帮助更新操作)【
unordered_map<int, list<pair<int, int>>::iterator> mp;
】 -
双向链表负责维护元素插入的顺序+更新最近访问的数据到头部+缓存到达上限时删除尾部元素。【最近访问的放头部,最少访问的放尾部】 【
list<pair<int, int>> L;
表示双向链表L的每个结点】
为什么不用队列
如果更新操作是发生在中间结点,那么队列是需要 \(O(n)\) 的时间才能更新的,不满足 \(O(1)\) 的时间要求。
为什么不用单向链表+普通哈希表
以下代码可以能过牛客,leetcode超时TLE【批量put然后再批量get情况下】,比如下列这种数据情况是会TLE的:
结合下面代码来看:
-
如果使用单向链表:在缓存容量达到最大时,如果往里面继续插入元素,需要删除最近最少使用的元素。单向链表因为只存储了头结点、当前结点数据、当前结点指向的下一个结点的原因,导致如果我需要删除最后一个结点的时候,我需要遍历整个链表找到倒数第二个结点,把它的next指针设置为NULL才可以,所以删除尾结点的时间复杂度为 \(O(n)\)。但是双向链表可以在 \(O(1)\)完成该操作。
- 双向链表在这里的优点体现出来了:快速删除最后一个元素+方便在链表头部插入新元素【不需再开辟额外指针】+ 方便删除链表中的任意节点。
-
如果使用普通哈希,也就是定义为
unordered_map<int, int> mp
,如下图所示,会导致list在删除元素时候的remove操作到达 \(O(n)\)。l.remove(key); // *****!!! list如果没有指向删除的元素的迭代器,那么这样删除是需要O(n)的!!!
。-
关键原因:在 C++ 中,
std::list
的erase
、remove
操作的复杂度是 O(1),但需要提供一个指向要删除元素的迭代器。- 详细解释如下: list如果没有指向删除的元素的迭代器,并执行了
l.remove(key)
,那么这样删除是需要O(n)的!!!结合第一点来看,如果哈希表里面只存取了key和value,那么查找上是方便了,但是当我们删除一个结点的时候,我们是无法单单通过这个key去定位到双向链表中的具体结点位置的,如果我们无法直接定位到具体结点位置,意味着我们删除还是需要遍历整个双向链表,时间复杂度就不会达到 \(O(1)\)。另外,std::list
的remove(key)
函数来删除具有特定值(也就是等于key)的所有元素,而不是删除key对应的value。如果要看下详细视频图分析,戳这里 主要看下这里面的图。
- 详细解释如下: list如果没有指向删除的元素的迭代器,并执行了
-
TLE代码:
#include<algorithm>
#include<iostream>
#include<list>
#include<unordered_map>
using namespace std;
class Solution {
list<int> l; // 使用 list<int> 存储值 双向链表
unordered_map<int, int> mp; // 存储键和链表中元素的位置
int cap;
public:
Solution(int capacity) {
cap = capacity;
}
int get(int key) {
if (mp.find(key) == mp.end())
return -1;
// 将值移到链表头部表示最近使用
moveToHead(key);
// return l.front();
return mp[key];
}
void set(int key, int value) {
if (mp.find(key) != mp.end()) // 如果 key 存在,更新值并移动到头部
{
moveToHead(key);
mp[key] = value;
}
else // 如果 key 不存在
{
//插入新节点到头部
l.push_front(key);
// 更新哈希表中的映射关系
mp[key] = value;
if (mp.size() > cap) // 如果容量超限,删除尾部节点
removeTail();
}
}
private:
void moveToHead(int key) {
// 将值移到链表头部
l.remove(key); // *****!!! list如果没有指向删除的元素的迭代器,那么这样删除是需要O(n)的!!!
l.push_front(key);
}
void removeTail() {
// 删除链表尾部节点
int tailKey = l.back();
mp.erase(tailKey);
l.pop_back();
}
};
AC代码 [含结点的双向链表+含迭代器的哈希表]
这个代码牛客和LeetCode都可以AC。
list<int>L
和 list<pair<int, int>> L
都是STL里面双向链表的定义。
list<pair<int, int>> L
:表示双向链表L的每个结点
unordered_map<int, list<pair<int, int>>::iterator> mp;
class LRUCache {
//用链表存,链表头部是最近使用的,尾部是最后使用的,如果要删去,就直接把尾部删去就好
list<pair<int, int>> L;//双向链表doubly linked list
// 这个 unordered_map 存储了一个整数(键)和一个双向链表中元素的位置(通过迭代器表示)的映射关系。
unordered_map<int, list<pair<int, int>>::iterator> mp;
// 键key, 值pair<int,int>链表节点
// mp[key]表示一个迭代器
// *mp[key] key找到的迭代器所指向的值,即 pair<int, int>
// 为什么这样定义:https://www.bilibili.com/video/BV1m34y1r7BC/?spm_id_from=333.337.search-card.all.click&vd_source=ec665d7e55ad181a7a6ebfccfac90052
int cap;
public:
LRUCache(int capacity) {
cap = capacity;
}
// 访问
// 情况1:不存在 -> 返回-1
// 情况2:存在 -> 更新到头部+返回值
int get(int key)
{
// ******* 情况1 *******
// 检查键是否存在
if (mp.find(key) == mp.end()) return -1; // if(!mp[key])编译错误
// if(mp.count(key)==0) return -1; // 键值的元素数量 //这两种写法都ok
// ******* 情况2 ********
auto tmp = *mp[key]; // 临时存储元素 代表原链表中的一个节点,也就是pair<int, int>
// tmp 的类型是 pair<int, int>。这是因为 mp[key] 返回一个迭代器,而 *mp[key] 解引用这个迭代器,得到了 pair<int, int> 类型的元素。具体地说,*mp[key] 返回的是 list<pair<int, int>>::iterator 类型的迭代器指向的元素,而这个元素是一个 pair<int, int>。所以,tmp 的类型就是 pair<int, int>。
L.erase(mp[key]); // 删除key指向的结点 删除双向链表L中由 mp[key] 迭代器指向的节点。
//map.erase(key);
L.push_front(tmp);// 把该元素更新到头部
mp[key] = L.begin();
return L.front().second; //返回list头部元素对应的值【second属性】
}
// 插入
// 情况1:容量已满 -> 删除尾部元素
// 情况2:容量未满
// 存在:更新(位置到头部+删除原来的位置+修改元素值)
// 不存在:在头部插入该元素
void put(int key, int value)
{
// // ******* 情况1 *******
// if (cap == L.size())
// {
// mp.erase(L.back().first); // 在map中 删除了链表最后一个元素的前驱
// L.pop_back(); // 删除了链表最后一个结点
// // return; 这里不管怎么样,都需要更新数据,进行下面的代码,所以不要在这里return
// // 问题在于,set可能是更新也可能是插入操作,不能在这里立马判断缓存满了就把最后一个元素删去。
// }
// ******* 情况2 *******
if (mp.find(key) != mp.end()) // 存在 -> 更新
{
// mp[key] = L.begin();// mp[key]=value // 更新元素对应的值
// L.push_front(*mp[key]); //放在头部
L.erase(mp[key]); // 删除原来的元素[因为要更新的kv,相当于直接删除了原来的结点]
L.push_front(make_pair(key, value));
mp[key] = L.begin();
return;
}
// 不存在 - 判断缓存是否已满 + 满了删除最后一个元素再头插/未满直接头插
if (cap == L.size())
{
mp.erase(L.back().first); // 在map中 删除了链表最后一个元素的前驱
L.pop_back(); // 删除了链表最后一个结点
}
L.push_front(make_pair(key, value));
// L.push_front(pair<int, int>(key, value));
mp[key] = L.begin(); //第一个迭代器
}
};
/**
* 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);
*/
手写参考
https://leetcode.cn/problems/lru-cache/solutions/12711/lru-ce-lue-xiang-jie-he-shi-xian-by-labuladong/
https://labuladong.gitee.io/algo/di-yi-zhan-da78c/shou-ba-sh-daeca/suan-fa-ji-fb527/#二、思路分析