文章目录
- Tag
- 题目来源
- 题目解读
- 解题思路
- 方法一:排序解法
- 方法二:双哈希表
- 知识回顾
- 双向链表的操作
- 写在最后
Tag
【LFU缓存】【哈希表】【设计数据结构】【2023-09-25】
题目来源
460. LFU 缓存
题目解读
为 LFU 缓存算法设计并实现数据结构。
LRU 缓存算法是一种页面缓存置换算法,有两个基本的操作:get
和 put
。
get
:通过key
获得相应的页面缓存。如果获取的key
存在于缓存中,则返回键的值,否则返回-1
;put
:向缓存中更新或加入缓存页面。如果key
已经存在,则变更其对应的缓存值;如果键不存在,就插入新的缓存页面的键值对。 当缓存达到了容量capacity
时,应该在插入新的缓存页面之前删除最近不经常使用的项。这个最近不经常使用的项指的是使用频次最小,如果遇到使用频次相同的键,则去除最久未使用的键(最近一次使用的时间小的)。
本题要求实现 LFUCache
类:
LFUCache(int capacity)
:用数据结构的容量 capacity 初始化对象;int get(int key)
:通过key
获得相应的页面缓存;void put(int key, int value)
:向缓存中更新或加入缓存页面。
函数 get
和 put
必须以
O
(
1
)
O(1)
O(1) 的平均时间复杂度运行。
解题思路
方法一:排序解法
基础数据结构的设计与选择
get
比较容易实现,使用一个哈希表存放键,值为对应的缓存数据结构。缓存数据结构如何定义呢?因为在插入新的键时可能需要移除最近不经常使用的键,因此缓存的数据结构需要包括:
cnt
:统计缓存的使用次数;time
:缓存的最近一次使用时间;
因为还会牵涉到缓存数据内容的更新,所以需要表示缓存数据内容的 value
。并且需要一个 key
变量,用来表示当前缓存内容的键。
为了统计最近不经常使用的键,需要对 <
运算符进行重载,于是缓存数据结构为:
struct Node {
int cnt, time, key, value;
Node(int _cnt, int _time, int _key, int _value):cnt(_cnt), time(_time), key(_key), value(_value){}
bool operator < (const Node& rhs) const {
return cnt == rhs.cnt ? time < rhs.time : cnt < rhs.cnt;
}
};
于是使用的基本数据包括:
- 哈希表:键为
key
,值为Node
(自己设计的数据结构),我们声明为key_table
; - 集合:保存数据
Node
,方便更新缓存内容的使用频次、时间戳以及具体内容,我们声明为S
。
LFUCache 实现
LFUCache(int _capacity) {
capacity = _capacity; // 缓存容量
time = 0; // 时间戳
key_table.clear();
S.clear();
}
get 实现
如果 capacity = 0
,表示现在没有任何缓存数据,直接返回 -1
。
否则,再判断哈希表中是否有 key
,如果没有,直接返回 -1
。如果有,则表示找到了 key
对应的缓存,我们首先要将删除缓存,更新该缓存的使用频次和最近一次使用时间,然后将更新后的缓存存入哈希表和集合。
put 实现
如果,此时 capacity = 0
,不需要进行任何操作,因为最大缓存容量为空。
否则,表示最大缓存容量非空,可以继续更新或者加入新的缓存:
-
如果哈希表中没有
key
,则需要向缓存中加入新的缓存数据,但是需要先判断当前的缓存容量是否已经达到最大的缓存容量:- 如果达到了,需要删除最近不经常使用的缓存即删除集合中的第一个缓存,并更新哈希表;
- 如果未达到,则新建缓存并加入哈希表和集合。
-
如果哈希表中有
key
,则执行修改缓存内容的操作:- 获得缓存内容,并删除集合中的该缓存;
- 更新缓存的使用频次、时间戳和值;
- 使用新的缓存更新哈希表并加入到集合中。
实现代码
struct Node {
int cnt, time, key, value;
Node(int _cnt, int _time, int _key, int _value):cnt(_cnt), time(_time), key(_key), value(_value){}
bool operator < (const Node& rhs) const {
return cnt == rhs.cnt ? time < rhs.time : cnt < rhs.cnt;
}
};
class LFUCache {
// 缓存容量,时间戳
int capacity, time;
unordered_map<int, Node> key_table;
set<Node> S;
public:
LFUCache(int _capacity) {
capacity = _capacity;
time = 0;
key_table.clear();
S.clear();
}
int get(int key) {
if (capacity == 0) return -1;
auto it = key_table.find(key);
// 如果哈希表中没有键 key,返回 -1
if (it == key_table.end()) return -1;
// 从哈希表中得到旧的缓存
Node cache = it -> second;
// 从平衡二叉树中删除旧的缓存
S.erase(cache);
// 将旧缓存更新
cache.cnt += 1;
cache.time = ++time;
// 将新缓存重新放入哈希表和平衡二叉树中
S.insert(cache);
it -> second = cache;
return cache.value;
}
void put(int key, int value) {
if (capacity == 0) return;
auto it = key_table.find(key);
if (it == key_table.end()) {
// 如果到达缓存容量上限
if (key_table.size() == capacity) {
// 从哈希表和平衡二叉树中删除最近最少使用的缓存
key_table.erase(S.begin() -> key);
S.erase(S.begin());
}
// 创建新的缓存
Node cache = Node(1, ++time, key, value);
// 将新缓存放入哈希表和平衡二叉树中
key_table.insert(make_pair(key, cache));
S.insert(cache);
}
else {
// 这里和 get() 函数类似
Node cache = it -> second;
S.erase(cache);
cache.cnt += 1;
cache.time = ++time;
cache.value = value;
S.insert(cache);
it -> second = cache;
}
}
};
复杂度分析
时间复杂度:get
时间复杂度
O
(
l
o
g
n
)
O(logn)
O(logn),put
时间复杂度
O
(
l
o
g
n
)
O(logn)
O(logn),操作的时间复杂度瓶颈在于平衡二叉树(集合)的插入删除均需要
O
(
l
o
g
n
)
O(logn)
O(logn) 的时间。
空间复杂度:
O
(
c
a
p
a
c
i
t
y
)
O(capacity)
O(capacity),其中
c
a
p
a
c
i
t
y
capacity
capacity 为 LFU
的缓存容量。哈希表和平衡二叉树(集合)不会存放超过缓存容量的键值对。
方法二:双哈希表
引入
该方法需要首先了解 460. LFU 缓存,可以参考 我的题解。
现在,我们假设所有的缓存内容使用的频次都一样,根据 LFU 缓存
的置换页面规则,接下来就会将最近不使用的缓存内容置换出去,就变成了 LRU
缓存问题。
于是想到维护一个哈希表 freq_table
来存放缓存内容的使用频率和双向链表。
每一个使用频率对应一个双向链表,这个链表里存放的是使用频率为 freq
的所有缓存内容。
接下来就是 LRU
缓存机制问题了,使用一个键为 key
索引,每个索引对应的是缓存节点,我们声明这个数据结构为 key_table
。
LRU
中的最经常等价于使用频次最高,最近等价于缓存处于双向链表的头部,置换操作移除的是最久最不经常使用的即为使用频次最小且处于双向链表尾部的缓存内容。
这样我们就能利用两个哈希表来使得两个操作的时间复杂度均为 O ( 1 ) O(1) O(1)。
基础数据结构的设计与选择
自己设计的数据结构为 DLinkedNode
,为对应缓存频次的双向链表。
class DLinkedNode {
public:
int key, value;
int freq = 1; // 使用频次初始为 1
DLinkedNode* prev;
DLinkedNode* next;
DLinkedNode(int k = 0, int v = 0): key(k), value(v) {};
};
选择现有的数据结构有:
- 哈希表
key_table
:存放键与缓存内容,用来保证get
的 O ( 1 ) O(1) O(1) 操作; - 哈希表
freq_table
:存放缓存内容的使用频次和对应频次的使用内容,用来保证put
的 O ( 1 ) O(1) O(1) 操作。
LFUCache 实现
LFUCache(int _capacity): capacity(_capacity) {
min_freq = 0;
key_table.clear();
freq_table.clear();
}
其中的 min_freq
表示最小的缓存使用频次,在删除缓存内容时会使用到。
get 实现
如果 capacity = 0
,表示现在没有任何缓存数据,直接返回 -1
。
否则,再判断哈希表 key_table
中是否有 key
,如果没有,直接返回 -1
。如果有,则表示找到了 key
对应的缓存 node
。接着,我们首先要将缓存内容从双向链表中删除,通过 remove(node)
完成;然后判断:如果删除这个缓存内容后,freq
对应的双向链表为空了,需要从 freq_table
中移除 freq
并更新 min_freq
。最后,更新 node
的使用频次以及在双向链表中的位置(加入到双向链表的头部位置)。
在双向链表中,移除一个节点属于基本操作了,直接贴上代码:
// 删除双向链表中一个节点
void remove(DLinkedNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
put 实现
如果,此时 capacity = 0
,不需要进行任何操作,因为最大缓存容量为空。
否则,表示最大缓存容量非空,可以继续更新或者加入新的缓存:
-
如果哈希表
key_table
中没有key
,则需要向使用频次为1
的双向链表中加入新的缓存数据(通过push_front()
实现),但是需要先判断当前的缓存容量是否已经达到最大的缓存容量:- 如果达到了,需要删除最近不经常使用的缓存即
freq_table[min_freq]
表示的双向链表中头部尾部节点,如果移除后的双向链表空了,还要移除min_freq
这个键值对; - 如果未达到,则新建缓存并加入
key_table
和freq_table
;
- 如果达到了,需要删除最近不经常使用的缓存即
-
此时更新
min_freq = 1
。 -
如果哈希表中有
key
,则执行修改缓存内容的操作:- 删除节点,删除后双向链表空了,要从 freq_table 中移除 freq
- 更新缓存的使用频次、时间戳和值;
- 使用新的缓存更新哈希表
key_table
和freq_table
。
实现代码
class DLinkedNode {
public:
int key, value, freq = 1;
DLinkedNode* prev;
DLinkedNode* next;
DLinkedNode(int k = 0, int v = 0): key(k), value(v) {};
};
class LFUCache {
private:
int min_freq, capacity;
unordered_map<int, DLinkedNode*> key_table; // key 和双向链表映射
unordered_map<int, DLinkedNode*> freq_table; // 使用频次和双向链表映射
DLinkedNode* new_list() {
auto dummy = new DLinkedNode();
dummy->prev = dummy;
dummy->next = dummy;
return dummy;
}
// 在链表头部增加一个节点
void push_front(int freq, DLinkedNode* node) {
auto it = freq_table.find(freq);
if (it == freq_table.end()) { // 没有 freq 对应的双向链表
it = freq_table.emplace(freq, new_list()).first; // 增加一个空的双向链表
}
auto dummy = it->second;
node->prev = dummy;
node->next = dummy->next;
node->prev->next = node;
node->next->prev = node;
}
// 删除双向链表中一个节点
void remove(DLinkedNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
public:
LFUCache(int _capacity): capacity(_capacity) {
min_freq = 0;
key_table.clear();
freq_table.clear();
}
int get(int key) {
if (capacity == 0) return -1;
auto it = key_table.find(key);
if (it == key_table.end()) return -1;
auto node = it->second;
// 删除节点,删除后双向链表空了,要从 freq_table 中移除 freq
remove(node);
auto dummy = freq_table[node->freq];
if (dummy->prev == dummy) {
freq_table.erase(node->freq);
delete dummy;
if (min_freq == node->freq) { // 当前的频次是最少使用的
++min_freq;
}
}
// 更新这个节点的使用频次两个哈希表都要更新,node只是使用频次变了,内容不变,所以可以不更新到key_table
push_front(++node->freq, node);
return node->value;
}
void put(int key, int value) {
if (capacity == 0) return;
auto it = key_table.find(key);
if (it == key_table.end()) {
if (key_table.size() == capacity) { // 缓存的最大容量已经满了
auto dummy = freq_table[min_freq]; // 双向链表的头部节点
auto back_node = dummy->prev; // 双向链表的尾部节点
key_table.erase(back_node->key); // 移除使用频次最小的节点
remove(back_node);
delete back_node;
if (dummy->prev == dummy) { // 移除后空了
freq_table.erase(min_freq);
delete dummy;
}
}
// 放入新书
auto node = new DLinkedNode(key, value);
key_table[key] = node;
push_front(1, node);
min_freq = 1;
}
else {
// 直接修改值
auto node = it->second;
// 删除节点,删除后双向链表空了,要从 freq_table 中移除 freq
remove(node);
auto dummy = freq_table[node->freq];
if (dummy->prev == dummy) {
freq_table.erase(node->freq);
delete dummy;
if (min_freq == node->freq) { // 当前的频次是最少使用的
++min_freq;
}
}
node->value = value;
push_front(++node->freq, node);
key_table[key] = node;
}
}
};
/**
* Your LFUCache object will be instantiated and called as such:
* LFUCache* obj = new LFUCache(capacity);
* int param_1 = obj->get(key);
* obj->put(key,value);
*/
复杂度分析
时间复杂度: O ( 1 ) O(1) O(1)。
空间复杂度: O ( m i n ( p , c a p a c i t y ) ) O(min(p,capacity)) O(min(p,capacity)),其中 p p p 为 p u t put put 的调用次数。
知识回顾
双向链表的操作
不同于 我的题解 知识回顾中有两个伪节点的双向链表操作,现在来讲述一下代码中只使用一个伪节点的双向链表操作。其实明白了原理之后,会发现一个伪节点(实际上是两个相同的伪头部)与两个伪节点的双向链表操作大同小异。
初始化
DLinkedNode* new_list() {
auto dummy = new DLinkedNode();
dummy->prev = dummy;
dummy->next = dummy;
return dummy;
}
不同于有伪头部和伪尾部的构造方法,以上代码中构造的是一个伪节点——伪头部(伪尾部)。
node 头插法
// 在链表头部增加一个节点
void push_front(int freq, DLinkedNode* node) {
auto it = freq_table.find(freq);
if (it == freq_table.end()) { // 没有 freq 对应的双向链表
it = freq_table.emplace(freq, new_list()).first; // 增加一个空的双向链表
}
auto dummy = it->second;
node->prev = dummy;
node->next = dummy->next;
node->prev->next = node;
node->next->prev = node;
}
删除 node
// 删除双向链表中一个节点
void remove(DLinkedNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
判空
使用一个伪节点的双向链表在判空上,只要判断 dummy->prev == dummy
即可。
Tips
当然也可以使用两个伪节点来实现本题的代码。
写在最后
如果文章内容有任何错误或者您对文章有任何疑问,欢迎私信博主或者在评论区指出 💬💬💬。
如果大家有更优的时间、空间复杂度方法,欢迎评论区交流。
最后,感谢您的阅读,如果感到有所收获的话可以给博主点一个 👍 哦。