【哈希表】【双向链表】【设计数据结构】【2023-09-24】
146. LRU 缓存
LRU 是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少使用的页面予以淘汰。
本题需要设计实现 LRUCache
类,具体地需要实现:
LRUCache(int capacity)
:以正整数作为容量 capacity
初始化 LRU
缓存;int get(int key)
:如果关键字 key
存在于缓存中,则返回关键字的值,否则返回 -1
。void put(int key, int value)
:如果关键字 key
已经存在,则变更其数据值 value
;如果不存在,则向缓存中插入该组 key-value
。如果插入操作导致关键字数量超过 capacity
,则应该 逐出 最久未使用的关键字。要求函数 get()
和 put()
必须以 O ( 1 ) O(1) O(1) 的平均复杂度运行。
今天又是认真学习研究 LRU缓存机制 官方题解的一天!
使用什么样的数据结构?
对于设计这种题目,要明确每个步骤的时间复杂度要求,如果数据给定的操作是常数级别的,那么这个操作可用 O ( n ) O(n) O(n) 的算法;否则就要往 O ( 1 ) O(1) O(1) 或者 O ( l o g n ) O(logn) O(logn) 去考虑;
LRUCacheCreate
,只有一次操作,一般就是 O ( n ) O(n) O(n) 了;LRUCacheGet
,如果要求 O ( 1 ) O(1) O(1),一般就是数组和哈希表了(大概率就是哈希表了);LRUCachePut
,如果要求 O ( 1 ) O(1) O(1),数组放入最后一个位置和链表放入第一个元素的操作都是 O ( 1 ) O(1) O(1);如果插入的关键字数量超过 capacity
,那么就应该逐出最久未使用的关键字。这表明插入和删除操作要在头部和尾部进行,能够在头部和尾部进行插入和删除操作的是队列,但是双向链表最佳。
具体实现
最终使用的数据结构是双向链表和哈希表。具体地:
key
,对应的值为 key
在双向链表中的位置;这样,我们可以先通过哈希表来确定某一个 key
在缓存中的位置,访问了这个 key
之后,这个 key
就成为了最近访问的,就需要 移动到双向链表的头部,对应的操作就是 get
操作。具体如下:
key
不存在,则返回 -1
;key
存在,则 key
对应的链表节点就是最近被使用的节点。需要将其在双向链表中的位置移动到头部,最后要返回该节点的值。对于 put
操作,首先需要判断 key
是否存在:
key
不存在,需要使用 key
和 value
创建一个新的节点,将新建的节点加入到双向链表的头部(表示最近使用的),并将 key
和该节点加入到哈希表中。加入了一个新的双向链表节点之后需要判断是否超出了缓存的容量,如果超出了需要将双向链表的尾部节点删除(表示删除最近未使用的),并删除哈希表中的对应项;key
存在,需要先通过哈希表定位,再将对应的节点值更新为 value
,并将该节点移动到双向链表的头部。上述各项操作中,访问哈希表的时间复杂度为 O ( 1 ) O(1) O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O ( 1 ) O(1) O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O ( 1 ) O(1) O(1) 时间内完成。
实现代码
struct DLinkedNode {
int key, value;
DLinkedNode* prev;
DLinkedNode* next;
DLinkedNode(): key(0), value(0), prev(nullptr), next(nullptr) {};
DLinkedNode(int _key, int _value): key(_key), value(_value), prev(nullptr), next(nullptr) {};
};
class LRUCache {
private:
unordered_map<int, DLinkedNode*> cache;
DLinkedNode* head, *tail;
int size;
int capacity;
public:
LRUCache(int _capacity): capacity(_capacity), size(0) {
// 使用伪头部和伪尾部节点
head = new DLinkedNode();
tail = new DLinkedNode();
head->next = tail;
tail->prev = head;
}
int get(int key) {
if (!cache.count(key)) {
return -1;
}
// key 存在,定位,移到头部
DLinkedNode* node = cache[key];
moveToHead(node);
return node->value;
}
void put(int key, int value) {
// key 不存在,创建,加入哈希表,加入到头部,判断是否超容
if (!cache.count(key)) {
DLinkedNode* node = new DLinkedNode(key, value);
cache[key] = node;
addToHead(node);
++size;
if (size > capacity) { // 超容,删尾,删哈希
DLinkedNode* removed = removeTail();
cache.erase(removed->key);
delete removed;
--size;
}
}
else { // key 存在,定位,修改,移到头部
DLinkedNode* node = cache[key];
node->value = value;
moveToHead(node);
}
}
// 将节点 node 移动到双向链表头部
void moveToHead(DLinkedNode* node) {
removeNode(node);
addToHead(node);
}
// 将节点 node 加入到双向链表头部
void addToHead(DLinkedNode* node) {
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node;
}
// 删除双向链表的尾节点并返回删除的尾节点
DLinkedNode* removeTail() {
DLinkedNode* node = tail->prev;
removeNode(node);
return node;
}
// 移除节点 node
void removeNode(DLinkedNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
};
/**
* 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);
*/
复杂度分析
时间复杂度:对于 put
和 get
都是 O ( 1 ) O(1) O(1);
空间复杂度: O ( c a p a c i t y ) O(capacity) O(capacity),因为哈希表和双向链表最多存储 c a p a c i t y + 1 capacity+1 capacity+1 个元素。
接下来以图示的方式,来介绍一下上述成员方法实现中的一些双链表操作,包括:
node
增加到双向链表头部;node
;初始化
在双向链表的实现中,使用一个伪头部和伪尾部来标记界限,这样在增加节点和删除节点的时候就不要检查相邻两个节点是否存在了。
struct DLinkedNode {
int value;
DLinkedNode* prev;
DLinkedNode* next;
DLinkedNode(): value(0), prev(nullptr), next(nullptr) {}
DLinkedNode(int _value): key(_key), value(_value), prev(nullptr), next(nullptr) {}
};
class UseDLinkedNode {
public:
UseDLinkedNode() {
// 使用伪头部和伪尾部节点
head = new DLinkedNode();
tail = new DLinkedNode();
head->next = tail;
tail->prev = head;
}
void addToHead(DLinkedNode* node); // node 头插
void removeNode(DLinkedNode* node); // 删除 node
void moveToHead(DLinkedNode* node);
DLinkedNode* removeTail();
};
将节点 node
增加到双向链表头部
该操作就是将 node
插入到 dummy head
和 dummy tail
之间:
(1)首先将 node
的 prev
和 next
指针更新好,即 node->prev = head
,node->next = head->next
;
(2)设置伪头部下一个节点的 prev
(现在伪头节点下一个节点为伪尾部)节点,首先定位到伪头部下一个节点即 head->next
;
(3)伪头部下一个节点的 prev
为 node
;
(4)连接伪头部的下一个节点;
(5)最后,将节点 node
加入到双向链表头部即 node
的头插操作完成。
在双向链表中移除节点 node
在双向链表中移除某个节点,只需要修改指针的指向,使得双链表跳过该节点。
void UseDLinkedNode::removeNode(DLinkedNode* node) { // 删除 node
node->prev->next = node->next;
node->next->prev = node->prev;
}
(1)修改 node->prev
的下一个节点的指向即 node->prev->next = node->next
;
(2)修改 node->next
的前一个节点的指向即 node->next->prev = node->prev
;
(3)最后删除 node
后的结果如下图所示。
移除尾结点
DLinkedNode* UseDLinkedNode::removeTail() {
DLinkedNode* node = tail->prev; // 先找到
removeNode(node); // 再移除
return node; // 最后返回被移除的尾节点
}
移动节点到头部
void UseDLinkedNode::moveToHead(DLinkedNode* node) {
removeNode(node); // 先移除
addToHead(node); // 加到头部
}
如果文章内容有任何错误或者您对文章有任何疑问,欢迎私信博主或者在评论区指出 。
如果大家有更优的时间、空间复杂度方法,欢迎评论区交流。
最后,感谢您的阅读,如果感到有所收获的话可以给博主点一个 哦。