访问时序
来淘汰来看到leetcode.146题
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache 类:
LRUCache(int capacity) 以 正整数 作为容量 capacity 初始化 LRU 缓存
int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;
如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该逐出最久未使用的关键字。
首先题目要接收一个capacity
参数作为缓存的最大容量,然后实现两个API,一个是put(key,val)
和get(key)
,方法获得key所对应的val,如果key不存在则返回-1
注意,使用遍历暴力模拟的方式是过不了这道题的,必须使得get和put的时间复杂度为O(1)
输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]
解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1); // 返回 1 {2=2, 1=1},因为访问了键1,所以2会提到队头
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3},会把队尾元素出队
lRUCache.get(2); // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1); // 返回 -1 (未找到)
lRUCache.get(3); // 返回 3
lRUCache.get(4); // 返回 4
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/lru-cache
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
任意位置快速插入的和删除元素
LinkedHashMap
作为我们的数据结构,JAVA中已经有这个数据结构了,如果是其他语言的话还需要自己实现一下,其具体的结构如下[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EQzH6Wrd-1661227302574)(./image/146-1.png)]
哈希链表:可以根据key值快速查找到节点,在遍历时也可以根据插入的顺序来保证遍历顺序的唯一性。
如果每次默认从链表尾部添加元素,那么显然越靠近尾部的元素就越是最近使用的,越靠近头部的元素就是越久未使用的
对于某一个key,可以通过哈希表快速定位到链表中节点,从而取到对应的val
链表显然是支持在任意位置的快速插入和删除的,只需要修改指针即可,只不过传统的链表无法安装索引快速访问某一个位置的元素,而这里借助了哈希表,可以通过key快速映射到任意一个链表节点,然后进行插入和删除
我们首先从零开始实现一个双向链表
//定义双链表的节点类,注意双链表的话是需要有两个指针,一个指针指向前面,一个指针指向后面
class Node {
public int key;
public int val;
public Node next,prev;
public Node(int k,int v){
this.key=k;
this.val=v;
}
}
//依靠Node类型构建双链表
class DoubleList{
//头尾的虚节点
private Node head,tail;
//链表的元素个数
private int size;
public DoubleList(){
//初始化双向链表的数据
head = new Node(0,0);
tail = new Node(0,0);
head.next=tail;
//头结点没有上一个节点,下一个节点是尾结点
tail.prev=head;
//尾结点有上一个节点,没有下一个节点
this.size = 0;
}
//在链表尾部添加节点x,时间复杂度为O(1)
public void addList(Node x){
//修改指针
//x节点的上一个节点设置为tail的上一个节点
x.prev = tail.prev;
//x节点的下一个节点设置为tail
x.next = tail;
//tail节点的上一个节点的下一个节点属性设置为x
tail.prev.next = x;
//tail节点的上一个节点设置为x
tail.prev =x;
//链表长度+1
this.size++;
}
//删除链表中的x节点(x一定存在)
//由于是双链表而且给的是目标Node节点,时间复杂度是O(1)
public void remove(Node x){
//先处理之前节点的关系
//使得x的上一个节点连接x的下一个节点
x.prev.next = x.next;
//使得x的下一个节点连接x的上一个节点
x.next.prev = x.prev;
this.size--;
}
//删除链表中的第一个节点,并返回该节点,时间复杂度为O(1)
public Node removeFirstNode(){
if(head.next == tail){
return null;
}
Node x = head.next;
//头节点的下一个节点指向第一个节点的下一个节点
head.next = x.next;
//第一个节点的下一个节点的上一个节点属性指向头结点
x.next.prev = head;
this.size--;
return x;
}
//获取链表总长度
public int getSize(){
return this.size;
}
}
删除API
的时间复杂度是O(1)而我们想要进行删除操作,那么就得到目标节点的时候,我们也希望能够以O(1)的方式得到其前驱节点,其前驱节点在单链表中是无法得到的,要得到的话只能通过O(n)的时间复杂度遍历得到,那么我们维护一个前驱指针来便于我们得到其前驱节点public class LRU {
//key->Node(key,val)
private HashMap<Integer, Node> map;
//Node(k1,v1)<->Node(k2,v2)
private DoubleList cache;
//最大容量
private int cap;
public LRU(int cap){
this.cap = cap;
map = new HashMap<>();
cache = new DoubleList();
}
}
cache
和一个哈希表map
,很容易漏掉一些操作,比如删除某个key的时候,在cache
中删除了对应的node
,但是却忘记在map
中删除key
get
和put
避免直接操作map
和cache
的细节,简单来说,就是你不是会忘记这个忘记那个的嘛,那就把Cache和map的操作合并在一起就行public class LRU {
//key->Node(key,val)
private HashMap<Integer, Node> map;
//Node(k1,v1)<->Node(k2,v2)
private DoubleList cache;
//最大容量
private int cap;
public LRU(int cap){
this.cap = cap;
map = new HashMap<>();
cache = new DoubleList();
}
/*将某个key提升为最近使用*/
private void makeRecently(int key){
Node x= map.get(key);
//先从链表中删除该节点
cache.remove(x);
//将该节点插入到表尾
cache.addList(x);
}
/**
* 添加最近使用的元素
* @param key
* @param val
*/
private void addRecently(int key,int val){
Node x = new Node(key,val);
//链表尾部就是最近使用的元素
cache.addList(x);
//别忘了在map中添加key的映射
map.put(key,x);
}
/**
* 删除某一个key
* @param key
*/
private void deleteKey(int key){
Node x = map.get(key);
//从链表中删除该key所对应的节点
cache.remove(x);
//从map中删除
map.remove(x);
}
/**
* 删除最久没有使用的元素
*/
public void removeLeastRecently(){
//链表头部的第一个元素就是最久未使用的
Node deleteNode = cache.removeFirstNode();
//同时从map中删除它的key
int key = deleteNode.key;
map.remove(key);
}
}
写完代码后来理一下思路
[问题2] 我们需要封装什么API?
将指定节点移动到链表的尾部
,这时候只需要操作cache将指定节点移动到链表尾部并且在cache中添加数据
将链表头部节点删除,并且在cache中将其删除
[问题3] 既然哈希表中已经存了key,为什么链表中还要存key和val呢?只存val不就行了嘛?
/**
* 外部取cache的值
* @param key
* @return
*/
public int get(int key){
if(!map.containsKey(key)){
return -1;
}
//将该数据提升为最近使用的
makeRecently(key);
return map.get(key).val;
}
/**
*
* @param key
* @param val
*/
public void put(int key,int val){
if(map.containsKey(key)){
//如果有相同的key值,那么就删除旧的数据
deleteKey(key);
//新插入的数据为最近使用的数据
this.addRecently(key,val);
return;
}
//如果是新的数据,那么就需要淘汰一页后插入
if(cap == cache.getSize()){
//删除最久未使用的元素
removeLeastRecently();
}
//添加最近使用的元素
addRecently(key, val);
}
import java.util.LinkedHashMap;
class LRUCache {
private LinkedHashMap<Integer,Integer> cache = new LinkedHashMap<>();
int cap;
public LRUCache(int capacity) {
this.cap = capacity;
}
public int get(int key) {
if(!cache.containsKey(key)){
return -1;
}
//将key变为最近使用
//1.将cache中的key对应的key在删除前保存下来
int val = cache.get(key);
//2.删除该key
cache.remove(key);
//3.将该key放到链表的尾部
cache.put(key,val);
return val;
}
public void put(int key, int value) {
//首先检查cache中是否有该key,如果有,则覆盖
if(cache.containsKey(key)){
cache.remove(key);
cache.put(key,value);
return;
}
//如果cache中没有该key,则先检查是否需要置换
if(cache.size() >= this.cap){
//此时需要置换,需要删除链表的最后一个元素
int oldKey = cache.keySet().iterator().next();//取到链表的头部
cache.remove(oldKey);
}
cache.put(key,value);
}
}
/**
* 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);
*/
LFU
算法:每次淘汰那些使用次数最少的数据,LRU算法相当于把数据按照时间进行排序,这个需求借助链表很自然就能够实现,一直从链表头部加入元素的话,就可以实现这个需求,其越接近头部的话就是越新的数据,越接近尾部的元素就越是旧的数据,进行缓存淘汰的时候只需要简单将尾部的元素淘汰即可。
LFU算法相当于把数据按照访问频次进行排序,如果多个数据拥有相同的访问频次就应该删除最早插入的那个数据,也就是说LFU算法是淘汰访问频次最低的数据,如果访问频次最低的数据有多条,需要淘汰最旧的数据。
请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。
实现 LFUCache 类:
LFUCache(int capacity) - 用数据结构的容量 capacity 初始化对象
int get(int key) - 如果键 key 存在于缓存中,则获取键的值,否则返回 -1 。
void put(int key, int value) - 如果键 key 已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量 capacity 时,则应该在插入新项之前,移除最不经常使用的项。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除最近最久未使用的键。
为了确定最不常使用的键,可以为缓存中的每个键维护一个使用计数器 。使用计数最小的键是最久未使用的键。
当一个键首次插入到缓存中时,它的使用计数器被设置为 1 (由于 put 操作)。对缓存中的键执行 get 或 put 操作,使用计数器的值将会递增。
函数 get 和 put 必须以 O(1) 的平均时间复杂度运行。
示例:
输入:
["LFUCache", "put", "put", "get", "put", "get", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [3], [4, 4], [1], [3], [4]]
输出:
[null, null, null, 1, null, -1, 3, null, -1, 3, 4]
解释:
// cnt(x) = 键 x 的使用计数
// cache=[] 将显示最后一次使用的顺序(最左边的元素是最近的)
LFUCache lfu = new LFUCache(2);
lfu.put(1, 1); // cache=[1,_], cnt(1)=1
lfu.put(2, 2); // cache=[2,1], cnt(2)=1, cnt(1)=1
lfu.get(1); // 返回 1
// cache=[1,2], cnt(2)=1, cnt(1)=2
lfu.put(3, 3); // 去除键 2 ,因为 cnt(2)=1 ,使用计数最小
// cache=[3,1], cnt(3)=1, cnt(1)=2
lfu.get(2); // 返回 -1(未找到)
lfu.get(3); // 返回 3
// cache=[3,1], cnt(3)=2, cnt(1)=2
lfu.put(4, 4); // 去除键 1 ,1 和 3 的 cnt 相同,但 1 最久未使用
// cache=[4,3], cnt(4)=1, cnt(3)=2
lfu.get(1); // 返回 -1(未找到)
lfu.get(3); // 返回 3
// cache=[3,4], cnt(4)=1, cnt(3)=3
lfu.get(4); // 返回 4
// cache=[3,4], cnt(4)=2, cnt(3)=3
get(key)
方法去缓存中查询键key,如果 key已经存在,则返回key所对应的val,否则返回-1put(key,val)
方法插入或者修改缓存,如果key已经存在,则将它对应的值改为val,如果key不存在,则插入键值对(key,val)
capacity
的时候,则应该在插入新的键值对之前,删除使用频次(下面使用freq
来表示)最低的键值对,如果freq
最低的键值有对多个,则删除最旧的那个HashMap keyToVal
,就可以快速计算get(key)
HashMap keyToFreq
就可以快速操作key对应的freqfreq
到key
的映射freq
最小的key删除,那就应该快速得到当前所有key
最小的freq
是多少。想要时间复杂度为O(1)
的话,肯定不能进行遍历的,对于这种需求,我们可以维护一个临时变量(备忘录思想)minFreq
来记录当前最小的freq
key
拥有相同的freq
,所以freq
对key
是一对多的关系,也就是freq
对应一个key
列表freq
对应的key的列表是存在时序,便于快速查找并删除最旧的keykey
列表中的任意一个key,因为如果频次为freq
的某个key被访问了,那么它的频次就应该变成freq+1
,就应该从freq
对应key
列表中删除,加到freq+1
对应的列表中 HashMap<Integer,Integer> keyToVal;//用于快速获取key=>val的值
HashMap<Integer,Integer> keyToFreq;//用于快速获取每个值的访问频率
HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;//用于形成一张表,该表中保存着每个频率所对应的key值
//LinkedHashSet是链表和哈希集合的结合体,链表不能快速访问链表节点,需要遍历执行,无法达到我们想要快速删除目标值的目的
//哈希集合中的元素无需,但是可以对元素快速的访问和删除
int minFreq = 0;
//临时维护的变量,用来记录最小的频次
int cap = 0;
//记录LFU缓存的最大容量
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-41eUnghk-1661227302575)(./image/LFU-01.png)]
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
/**
* 即将要维护KV表,KF表,FK表三个映射
*/
class LFUCache {
HashMap<Integer,Integer> keyToVal;//用于快速获取key=>val的值
HashMap<Integer,Integer> keyToFreq;//用于快速获取每个值的访问频率
HashMap<Integer, LinkedHashSet<Integer>> freqToKeys;//用于形成一张表,该表中保存着每个频率所对应的key值
//LinkedHashSet是链表和哈希集合的结合体,链表不能快速访问链表节点,需要遍历执行,无法达到我们想要快速删除目标值的目的
//哈希集合中的元素无需,但是可以对元素快速的访问和删除
int minFreq ;
//临时维护的变量,用来记录最小的频次
int cap ;
//记录LFU缓存的最大容量
public LFUCache(int capacity) {
keyToVal = new HashMap<>();
keyToFreq = new HashMap<>();
freqToKeys = new HashMap<>();
this.cap = capacity;
this.minFreq = 0;//一开始还没有进行访问,故先初始化为0
}
public int get(int key) {
//如果K-V表没有这个key,直接返回-1
if(!keyToVal.containsKey(key)){
return -1;
}
//否则就是有这个key,返回值之外,还需要对频率进行修改
increase(key);
return keyToVal.get(key);
}
public void put(int key, int value) {
//根据算法思路图转换为代码
if(this.cap<=0){
return ;
}
//1.检查key是否存在,以KV表为准
if(keyToVal.containsKey(key)){
//当存在该key的时候
keyToVal.put(key,value);
//同时更新频次相关信息
increase(key);
return;
}
//2.如果key不存在,检查当前容量是否已经满了
//FK KV KF
if(keyToVal.size() >= this.cap){
//如果已经满了,则需要置换
/**LFU置换算法**/
//首先获取freq最小的key列表
LinkedHashSet<Integer> keys = freqToKeys.get(this.minFreq);
//这些key都是待淘汰的,我们直接将最久没有使用过的key给它淘汰掉
int oldest = keys.iterator().next();//这里获取的是链式集合头部的元素,越后面的就是越先插入的,淘汰最头部的
//1.更新频次相关信息=>我们将FK表的信息给更新了先
keys.remove(oldest);
if(keys.isEmpty()){//如果删除了这个频次的相关信息后,再也没有这个频次所对应的key表了,直接就直接删除这个key表
freqToKeys.remove(this.minFreq);
//此处是否应该需要更新minFreq
//假如说需要更新minFreq,那么该操作的时间复杂度就不是O(1)了,而是O(n)
//我们将算法流程走一遍下来哈
//首先就是什么时候会走这个分支?
//是当需要进行置换,而且插入一个新key的时候才会被调用
//那么插入新key的时候,最小的访问频率是不是一定是1?
//那么就没有必要更新minFreq了
}
//更新key-val表,已经预处理完毕
keyToVal.remove(oldest);
//更新KF表
keyToFreq.remove(oldest);
}
//否则未满则直接插入即可
keyToVal.put(key,value);//将对应的值插入到主表
keyToFreq.put(key,1);//初次访问设置为1
//同时设置fk表
freqToKeys.putIfAbsent(1,new LinkedHashSet<>());
freqToKeys.get(1).add(key);
//插入新key后的最小freq肯定是1
this.minFreq = 1;
}
void increase(int key){
//1.修改key=>freq表
int freq = keyToFreq.get(key);
keyToFreq.put(key,freq+1);
//2.修改freq=>key表
//2.1 将freq=>对应的key删除掉
LinkedHashSet<Integer> keys = freqToKeys.get(freq);
keys.remove(key);
//将key加入到freq+1对应的列表里
freqToKeys.putIfAbsent(freq+1,new LinkedHashSet<>());
freqToKeys.get(freq+1).add(key);
if(keys.isEmpty()){
//那么就移除这个表
freqToKeys.remove(freq);
//如果这个freq恰好就是最小的,已经没有比它更小的了,符合逻辑,删除
if(freq == this.minFreq){
this.minFreq++;
}
}
}
}