Tag
【哈希表】【双向链表】【设计数据结构】【2023-09-24】
题目来源
题目解读
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
;
![](https://i-blog.csdnimg.cn/blog_migrate/8fc82e2a4a7d95f373c568f5b99f17bc.gif)
(2)设置伪头部下一个节点的 prev
(现在伪头节点下一个节点为伪尾部)节点,首先定位到伪头部下一个节点即 head->next
;
![](https://i-blog.csdnimg.cn/blog_migrate/f4aae6f11813694087506683beea4af6.gif)
(3)伪头部下一个节点的 prev
为 node
;
![](https://i-blog.csdnimg.cn/blog_migrate/bd40126fdaf5f9f54b9ec320ed543cd2.gif)
(4)连接伪头部的下一个节点;
![](https://i-blog.csdnimg.cn/blog_migrate/6aca3d38184329a7f049589a53d1f692.gif)
(5)最后,将节点 node
加入到双向链表头部即 node
的头插操作完成。
![](https://i-blog.csdnimg.cn/blog_migrate/f8280778732a499129a7de38cded6796.gif)
在双向链表中移除节点 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
;
![](https://i-blog.csdnimg.cn/blog_migrate/70e30d1aad7cc33e264173a9f1d3ba22.gif)
(2)修改 node->next
的前一个节点的指向即 node->next->prev = node->prev
;
![](https://i-blog.csdnimg.cn/blog_migrate/558d68d14c5aed987627fda956c3dda7.gif)
(3)最后删除 node
后的结果如下图所示。
![](https://i-blog.csdnimg.cn/blog_migrate/2f26d3879aa16294bb986fa22677526b.gif)
移除尾结点
DLinkedNode* UseDLinkedNode::removeTail() {
DLinkedNode* node = tail->prev; // 先找到
removeNode(node); // 再移除
return node; // 最后返回被移除的尾节点
}
移动节点到头部
void UseDLinkedNode::moveToHead(DLinkedNode* node) {
removeNode(node); // 先移除
addToHead(node); // 加到头部
}
写在最后
如果文章内容有任何错误或者您对文章有任何疑问,欢迎私信博主或者在评论区指出 💬💬💬。
如果大家有更优的时间、空间复杂度方法,欢迎评论区交流。
最后,感谢您的阅读,如果感到有所收获的话可以给博主点一个 👍 哦。