题目介绍
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 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) 的平均时间复杂度运行。
题目链接
题解
-
分析上面的操作过程
- 要让 put 和 get 方法的时间复杂度为 O(1)
- 可以总结出 cache 这个数据结构必要的条件:查找快,插入快,删除快,有顺序之分
-
显然 cache 必须有顺序之分
- 以区分最近使用的和久未使用的数据
-
而且我们要在 cache 中查找键是否已存在
-
如果容量满了要删除最后一个数据
-
每次访问还要把数据插入到队头
-
那么
-
什么数据结构同时符合上述条件呢?
- 哈希表查找快,但是数据无固定顺序
- 链表有顺序之分,插入删除快,但是查找慢
-
所以结合一下
- 形成一种新的数据结构:哈希链表。
-
LRU 缓存算法的核心数据结构就是哈希链表
- 双向链表和哈希表的结合体
- 这个数据结构长这样:
这里要感谢一下labuladong博主,我第一次接触算法是两年前的时候,越往深处走就越抽象,最后卡在了dp,一直不得要领,后来偶然看到了一篇题解,觉得讲的真好啊,然后就了解到了labuladong,真的很赞!
回归正题。
- 为什么要是双向链表,单链表行不行?
- 另外,既然哈希表中已经存了 key,为什么链表中还要存键值对呢,只存值不就行了?
- 首先手写实现双端链表
class LRUCache {
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
// 构造函数,传参为空这个,是用来构造head和tail的
public DLinkedNode() {}
public DLinkedNode(int _key, int _value) {
key = _key;
value = _value;
}
}
private DLinkedNode head, tail;
private int size;
private int capacity;
public LRUCache(int capacity) {
this.size = 0;
this.capacity = capacity;
head = new DLinkedNode();
tail = new DLinkedNode();
head.next = tail;
tail.prev = head;
}
private void addToHead(DLinkedNode node) {
// 四步,因为是双端链表
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void moveToHead(DLinkedNode node) {
// 两步操作
// 删除该节点
// 将该节点加入到head后面
removeNode(node);
addToHead(node);
}
// 删除并返回该节点
private DLinkedNode removeTail(){
// 找到tail节点前指针指向的节点
// 移除掉他
DLinkedNode res = tail.prev;
removeNode(res);
return res;
}
private void removeNode(DLinkedNode node) {
// 这里只修改了两个指向,不过已经够了
node.prev.next = node.next;
node.next.prev = node.prev;
}
}
-
简单说一下:
- LRUCache这个数据结构就是一个双端链表
- 它下面的四个方法操作无非就是修改节点的前后指针
-
那哈希表在哪儿用到了?
- 在查找的时候用到了。
private Map<Integer, DLinkedNode> cache = new HashMap<Integer, DLinkedNode>();
public int get(int key) {
DLinkedNode node = cache.get(key);
if (node == null)
return -1;
// 如果key存在,先通过哈希表定位,在移动到头部
moveToHead(node);
return node.value;
}
public void put(int key, int value) {
DLinkedNode node = cache.get(key);
if (node == null) {
// 如果key不存在,创建一个新节点
DLinkedNode newNode = new DLinkedNode(key, value);
// 添加进哈希表
cache.put(key, newNode);
// 添加至双向链表的头部
addToHead(newNode);
++size;
if (size > capacity) {
// 如果超出容量,删除双向链表的尾节点
DLinkedNode tail = removeTail();
// 删除哈希表中对应的项
cache.remove(tail.key);
--size;
}
}
else {
// 如果key存在,先通过哈希表定位,在修改value,并移动到头部
node.value = value;
moveToHead(node);
}
}
- 可以看到
- cache的数据结构作用就在于查找
- 能够一下就查找到key对应的DLinkedNode节点
回答刚才“为什么必须要用双向链表”的问题了
- 因为我们需要删除操作
- 删除一个节点不光要得到该节点本身的指针
- 也需要操作其前驱节点的指针
- 而双向链表才能支持直接查找前驱
- 保证操作的时间复杂度 O(1)
最后回答之前的问答题“为什么要在链表中同时存储 key 和 val,而不是只存储 val”
注意这段代码:
if (size > capacity) {
// 如果超出容量,删除双向链表的尾节点
DLinkedNode tail = removeTail();
// 删除哈希表中对应的项
cache.remove(tail.key);
--size;
}
- 当缓存容量已满
- 我们不仅仅要删除最后一个 Node 节点
- 还要把 map 中映射到该节点的 key 同时删除
- 而这个 key 只能由 Node 得到
- 如果 Node 结构中只存储 val
- 那么我们就无法得知 key 是什么
- 就无法删除 map 中的键,造成错误