缓存的应用场景和范围十分广泛,下面给出其十分常见的两种应用背景:
首先,在操作系统内部,由于内存资源十分有限,而每个进程又都希望独享一块很大的内存空间。所以诞生了一种“虚拟内存”机制,它将进程的一部分内容暂留在磁盘中,在需要时再进行数据交换将其放入内存,这个过程就需要用到缓存算法机制进行置换。
其次,对于各类应用项目开发而言,在巨大的数据量面前,Cache 是不可或缺的。因为无论是针对本地端的浏览器缓存,还是针对服务器端的缓存(例如,redis 内存数据库缓存),Cache 都是提高性能的最常用的一种方式。它不仅可以加速用户的访问,同时也可以降低服务器的负载和压力。
先进先出,这是最简单、最公平的一种算法,它认为一个数据最早进入缓存,在将来该数据被访问的可能性最小。其原理是最早进入缓存的数据应该最早被淘汰掉,即当缓存空间被占满时,最先进入的数据会被最早被淘汰。
最近最少使用,它的设计原则借鉴了时间局部性原理,该算法认为如果数据最近被访问过,那么将来被访问的几率也更高,反之亦然。其原理是将数据按照其被访问的时间形成一个有序序列,最久未被使用的数据应该最早被淘汰掉,即当缓存空间被占满时,缓存内最长时间未被使用的数据将被淘汰掉。
详细原理及C++实现可参考我的另一篇博客,应对面试这一篇就够了 LRU详解及C++实现
最不经常使用,它的设计原则使用了概率思想,该算法认为如果一个对象的被访问频率很低,那么再次被访问的概率也越低。其原理是缓存空间中被访问次数最少的数据应该最早被淘汰掉,即当缓存空间被占满时,缓存内被访问频率最少的数据将被置换走。
建议不了解 LRU 的同学可以先参考我上节给出的另一篇详细讲解 LRU 的博客进行学习后,再来研究 LFU,不然理解本算法会有些难度
在实际应用场景中,我们依然希望上述所有操作的平均时间复杂度均可以控制在 O(1) 内,以保证缓存的高效运行,下面给出具体数据结构选择及实现。
算法实现过程中的难点依旧在于函数 get 和 put 必须以 O(1) 的平均时间复杂度运行,下面我们先来分析一下 LRU 与 LFU 二者的区别:
就是说LFU淘汰的时候会选择两个维度,先比较频数,选择访问频率最小的元素;如果频率相同,则按时间维度淘汰掉最久远的那个元素。LRU的实现是 1 个哈希表加上 1 个双链表,与LRU类似,想要完成上述条件,LFU 仍需要结合哈希表和双向链表这两个结构进行操作,不过需要用 2 个哈希表再加上 N 个双链表才能实现先按照频数再按照时间两个纬度的淘汰策略,具体 LFU 数据结构参考下图。
实现 LFU 用到了 2 个哈希表,分别用来记录 key - Node 及 freq - freqList 两种映射关系,下面对于这两个哈希表给出详细解释:
unordered_map hashNode;
unordered_map hashFreq;
(1)首先,我们先来定义一下双向链表中的节点结构 Node
//双向链表节点
struct Node{
int key;
int value;
int freq;//为每个双向链表的节点增加 freq 值,用于记录其被访问频数
Node *pre,*next;
Node(int key,int value,int freq)
{
this->key=key;
this->value=value;
this->freq=freq;
pre=nullptr;
next=nullptr;
}
};
(2)其次,定义一下双向链表的结构 hashFreq
//双向链表
struct FreqList{
int freq;//标识双向链表中节点的共同访问频数
Node *L,*R;
FreqList(int freq)//双向链表构造函数
{
this->freq=freq;
L=new Node(-1,-1,1);
R=new Node(-1,-1,1);
L->next=R;
R->pre=L;
}
};
(3)再定义缓存容量 n、最小访问频数 minFreq、节点哈希表 hashNode 和频数双向链表哈希表 hashFreq
int n;//缓存空间大小
/*
整个缓存中的节点最小访问频数, LFU 中为每一个频数构造一个双向链表,
当缓存空间满了时,首先需要知道当前缓存中最小的频数是多少,再需要找到
该最小频数下最久未使用的数据淘汰。想要在 O(1) 时间复杂度下完成上述
操作,处理使用双向链表结构,还需要动态记录维护缓存空间中最小访问频数 minFreq
*/
int minFreq;
unordered_map hashNode;//节点哈希表,用于快速获取数据中 key 对应的节点信息
unordered_map hashFreq;//频数双向链表哈希表,为每个访问频数构造一个双向链表,并且用哈希表联系二者关系
其中 minFreq 是一个尤为关键的变量,它是整个缓存中的节点最小访问频数, LFU 中为每一个频数构造一个双向链表,当缓存空间满了时,首先需要知道当前缓存中最小的频数是多少,再需要找到该最小频数下最久未使用的数据淘汰。想要在 O(1) 时间复杂度下完成上述操作,处理使用双向链表结构,还需要动态记录维护缓存空间中最小访问频数 minFreq
(4)下面,来实现一下 LFU 缓存空间初始化函数
// LFU 缓存构造函数
LFUCache(int capacity) {
n=capacity;//初始化缓存空间
minFreq=0;//初始化最小访问频数为 0
}
(5)再来实现一下访问缓存数据的 get 函数
//访问缓存数据
int get(int key) {
if(hashNode.find(key)!=hashNode.end())//缓存中存在该 key
{
Node *node=hashNode[key];//利用节点哈希表,O(1) 时间复杂度下定位到该节点
//每次 get 操作会将该节点访问频数 +1,所以需要将它从原来频数对应的双向链表中删除
deleteFromList(node);
node->freq++;
/*
下面这个操作是为了防止当前 node 对应的是最小频数双向链表里的唯一节点,具体情况可分两种讨论
情况 ① 如果当前 node 对应的是最小频数双向链表里的唯一节点,那么在进行对其的 get操作后,它的频数 freq++,
原双向链表节点数目变为 0,则最小频数 minFreq++,即执行这个 if 操作
情况 ② 如果当前 node 对应的不是最小频数双向链表里的唯一节点,那么无需更新 minFreq
*/
if(hashFreq[minFreq]->L->next==hashFreq[minFreq]->R) minFreq++;
append(node);//加入新的频数对应的双向链表
return node->value;//返回该 key 对应的 value 值
}
else return -1;//缓存中不存在该 key
}
(6)再来实现一下更新缓存数据的 put 函数
//更新缓存数据
void put(int key, int value) {
if(n==0) return;//缓存空间为 0 ,不可以加入任何数据
if(get(key)!=-1)//缓存中已经存在该 key,复用一个 get 操作,就可以完成该节点对应双向链表的更新
hashNode[key]->value=value;//把该节点在节点哈希表 hashNode 中更新
else//缓存中不存在该 key,需要把新节点插入到缓存空间中
{
if(hashNode.size()==n)//缓存空间已满
{
Node *node=hashFreq[minFreq]->L->next;//找到最小频数 minFreq 对应的双向链表的最久未使用的节点
deleteFromList(node);//在双向链表中删除该节点
hashNode.erase(node->key);//在节点哈希表中删除该节点
}
//缓存空间未满 and 已满两种情况,均需要把新节点加入缓存(双向链表和节点哈希表均需插入)
Node *node=new Node(key,value,1);//构造新节点,它的节点频数为 1
hashNode[key]=node;//插入节点哈希表
minFreq=1;//新插入的节点频数为 1,故最小频数应当变为 1
append(node);//插入频数为 1 对应的双向链表中
}
}
(7)get 和 put 函数涉及到两个新的函数 deleteFromList 和 append,分别用于移除和插入双向链表中的对应节点数据,下面实现一下这两个函数
void deleteFromList(Node *node)
{
Node *pre=node->pre;
Node *next=node->next;
pre->next=next;
next->pre=pre;
}
void append(Node *node)
{
int freq=node->freq;
if(hashFreq.find(freq)==hashFreq.end())
hashFreq[freq]=new FreqList(freq);
FreqList *curList=hashFreq[freq];
Node *pre=curList->R->pre;
Node *next=curList->R;
pre->next=node;
node->next=next;
next->pre=node;
node->pre=pre;
}
(8)完整代码及注释如下,以供大家参考,完成上述内容学习,顺便大家还可以解决一下 LeetCode 460 题
class LFUCache {
private:
//双向链表节点
struct Node{
int key;
int value;
int freq;//为每个双向链表的节点增加 freq 值,用于记录其被访问频数
Node *pre,*next;
Node(int key,int value,int freq)
{
this->key=key;
this->value=value;
this->freq=freq;
pre=nullptr;
next=nullptr;
}
};
//双向链表
struct FreqList{
int freq;//标识双向链表中节点的共同访问频数
Node *L,*R;
FreqList(int freq)//双向链表构造函数
{
this->freq=freq;
L=new Node(-1,-1,1);
R=new Node(-1,-1,1);
L->next=R;
R->pre=L;
}
};
int n;//缓存空间大小
/*
整个缓存中的节点最小访问频数, LFU 中为每一个频数构造一个双向链表,
当缓存空间满了时,首先需要知道当前缓存中最小的频数是多少,再需要找到
该最小频数下最久未使用的数据淘汰。想要在 O(1) 时间复杂度下完成上述
操作,处理使用双向链表结构,还需要动态记录维护缓存空间中最小访问频数 minFreq
*/
int minFreq;
unordered_map hashNode;//节点哈希表,用于快速获取数据中 key 对应的节点信息
unordered_map hashFreq;//频数双向链表哈希表,为每个访问频数构造一个双向链表,并且用哈希表联系二者关系
void deleteFromList(Node *node)
{
Node *pre=node->pre;
Node *next=node->next;
pre->next=next;
next->pre=pre;
}
void append(Node *node)
{
int freq=node->freq;
if(hashFreq.find(freq)==hashFreq.end())
hashFreq[freq]=new FreqList(freq);
FreqList *curList=hashFreq[freq];
Node *pre=curList->R->pre;
Node *next=curList->R;
pre->next=node;
node->next=next;
next->pre=node;
node->pre=pre;
}
public:
// LFU 缓存构造函数
LFUCache(int capacity) {
n=capacity;//初始化缓存空间
minFreq=0;//初始化最小访问频数为 0
}
//访问缓存数据
int get(int key) {
if(hashNode.find(key)!=hashNode.end())//缓存中存在该 key
{
Node *node=hashNode[key];//利用节点哈希表,O(1) 时间复杂度下定位到该节点
//每次 get 操作会将该节点访问频数 +1,所以需要将它从原来频数对应的双向链表中删除
deleteFromList(node);
node->freq++;
/*
下面这个操作是为了防止当前 node 对应的是最小频数双向链表里的唯一节点,具体情况可分两种讨论
情况 ① 如果当前 node 对应的是最小频数双向链表里的唯一节点,那么在进行对其的 get操作后,它的频数 freq++,
原双向链表节点数目变为 0,则最小频数 minFreq++,即执行这个 if 操作
情况 ② 如果当前 node 对应的不是最小频数双向链表里的唯一节点,那么无需更新 minFreq
*/
if(hashFreq[minFreq]->L->next==hashFreq[minFreq]->R) minFreq++;
append(node);//加入新的频数对应的双向链表
return node->value;//返回该 key 对应的 value 值
}
else return -1;//缓存中不存在该 key
}
//更新缓存数据
void put(int key, int value) {
if(n==0) return;//缓存空间为 0 ,不可以加入任何数据
if(get(key)!=-1)//缓存中已经存在该 key,复用一个 get 操作,就可以完成该节点对应双向链表的更新
hashNode[key]->value=value;//把该节点在节点哈希表 hashNode 中更新
else//缓存中不存在该 key,需要把新节点插入到缓存空间中
{
if(hashNode.size()==n)//缓存空间已满
{
Node *node=hashFreq[minFreq]->L->next;//找到最小频数 minFreq 对应的双向链表的最久未使用的节点
deleteFromList(node);//在双向链表中删除该节点
hashNode.erase(node->key);//在节点哈希表中删除该节点
}
//缓存空间未满 and 已满两种情况,均需要把新节点加入缓存(双向链表和节点哈希表均需插入)
Node *node=new Node(key,value,1);//构造新节点,它的节点频数为 1
hashNode[key]=node;//插入节点哈希表
minFreq=1;//新插入的节点频数为 1,故最小频数应当变为 1
append(node);//插入频数为 1 对应的双向链表中
}
}
};