缓存算法和内存页面置换算法(Page Replacement Algorithm)的核心思想是一样的:给定一个有限的空间,设计一个算法来更新和访问里面的数据。下面提到缓存算法的同时,也指代页面置换算法。
LRU缓存机制
LRU(The Least Recently Used,最近最久未使用算法) 是一种常见的缓存算法,在很多分布式缓存系统(如Redis, Memcached)中都有广泛使用。
LRU算法的思想是:如果一个数据在最近一段时间没有被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最久没有访问的数据最先被置换(淘汰)。
- set(key,value):将记录(key,value)插入该结构。当缓存满时,将最久未使用的数据置换掉。
- get(key):返回key对应的value值。
实现:最朴素的思想就是用数组+时间戳的方式,不过这样做效率较低。因此,我们可以用 双向链表(LinkedList)+哈希表(HashMap) 实现(链表用来表示位置,哈希表用来存储和查找)。
LFU缓存机制
LFU(Least Frequently Used ,最近最少使用算法) 也是一种常见的缓存算法。
LFU算法的思想是:如果一个数据在最近一段时间很少被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最小频率访问的数据最先被淘汰。
LFU 算法的描述:
设计一种缓存结构,该结构在构造时确定大小,假设大小为 K,并有两个功能:
- set(key,value):将记录(key,value)插入该结构。当缓存满时,将访问频率最低的数据置换掉。
- get(key):返回key对应的value值。
算法实现策略:考虑到 LFU 会淘汰访问频率最小的数据,我们需要一种合适的方法按大小顺序维护数据访问的频率。
其他缓存机制
FIFO缓存机制
FIFO 算法的核心思想是先进先出(FIFO,队列),这是最简单、最公平的一种思想,即如果一个数据是最先进入的,那么可以认为在将来它被访问的可能性很小。空间满的时候,最先进入的数据会被最早置换(淘汰)掉。
FIFO 算法的描述:设计一种缓存结构,该结构在构造时确定大小,假设大小为 K,并有两个功能:
- set(key,value):将记录(key,value)插入该结构。当缓存满时,将最先进入缓存的数据置换掉。
- get(key):返回key对应的value值。
实现:维护一个FIFO队列,按照时间顺序将各数据(已分配页面)链接起来组成队列,并将置换指针指向队列的队首。再进行置换时,只需把置换指针所指的数据(页面)顺次换出,并把新加入的数据插到队尾即可。
缺点:判断一个页面置换算法优劣的指标就是缺页率,而FIFO算法的一个显著的缺点是,在某些特定的时刻,缺页率反而会随着分配页面的增加而增加,这称为Belady现象。产生Belady现象现象的原因是,FIFO置换算法与进程访问内存的动态特征是不相容的,被置换的内存页面往往是被频繁访问的,或者没有给进程分配足够的页面,因此FIFO算法会使一些页面频繁地被替换和重新申请内存,从而导致缺页率增加。因此,现在不常使用FIFO算法。
OPT缓存机制
最佳页面置换算法(OPT,Bélády’s Algorithm)是一种理论上最佳的页面置换算法。它的思想是,试图淘汰掉以后永远也用不到的页面,如果没有则淘汰最久以后再用到的页面。因为这种算法必须知道进程访问页面的序列,而这是无法实现的,因此仅有理论意义。
LRU算法设计与实现
设计实现LRU算法,我们在这里使用哈希表 + 双向链表的方式:
- list 主要是表示<key-value>的位置关系,最近使用的在链表首部,最近最少使用的在链表尾部,在进行put、get操作时,都会更新对应的<key-value>在list中的位置,具体是直接放入链表首部
- 哈希表主要是用来存储和查找。
class LRUCache {
private:
/* 存储key 与 listQueue的映射*/
unordered_map<int, list<pair<int, int>>::iterator> map;
/* 存储key-value:首部是最近使用的,尾部是最近最久未使用的 */
list<pair<int, int>> listQueue;
/* 缓存容量 */
int capacity;
/* 使用容量 */
int count;
public:
LRUCache(int capacity) {
/* 初始化缓存容量 */
this->capacity = capacity;
/* 初始化使用容量 */
count = 0;
}
/* 获取key所对应的value */
int get(int key) {
int val = -1;
auto it = map.find(key);
if (it != map.end())
{
/* 获取key对应的value */
val = it->second->second;
/* 在listQueue删除对应的key-value */
listQueue.erase(it->second);
/* 将key-value添加到listQueue首部 */
listQueue.push_front(make_pair(key, val));
/* 更新map中key所对应的listQueue的位置 */
map[key] = listQueue.begin();
}
return val;
}
/* 添加key-value */
void put(int key, int value) {
auto it = map.find(key);
if (it != map.end())
{
/* 在listQueue中删除对应的key-value,准备更新 */
listQueue.erase(it->second);
}
/* 容量已满,需要进行删除,删除最近最久未使用的,即链表末尾元素 */
else if (count == capacity)
{
/* 拿到链表末尾元素,为即将要删除的元素 */
auto del = listQueue.back().first;
/* 删除对应的元素 */
listQueue.pop_back();
/* 在map中删除对应的元素 */
map.erase(del);
}
else
{
/* 容量增加 */
count++;
}
/* 将key-value添加到listQueue首部 */
listQueue.push_front(make_pair(key, value));
/* 更新map中key所对应的listQueue的位置 */
map[key] = listQueue.begin();
}
};
/**
* 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);
*/
在上述程序中,我们使用了count表示当前缓存的使用量,其实没有必要,我们可以直接使用map的长度即可表示缓存使用情况,另外,我们使用list的splice()方法使代码更加简洁,层次清晰,优化后的程序如下:
class LRUCache {
private:
/* 存储key 与 listQueue的映射*/
unordered_map<int, list<pair<int, int>>::iterator> map;
/* 存储key-value:首部是最近使用的,尾部是最近最久未使用的 */
list<pair<int, int>> listQueue;
/* 缓存容量 */
int capacity;
public:
LRUCache(int capacity) {
/* 初始化缓存容量 */
this->capacity = capacity;
}
int get(int key) {
auto it = map.find(key);
if (it == map.end())
return -1;
/* 将it->second所对应的key-value插入到listQueue的首部中 */
listQueue.splice(listQueue.begin(), listQueue, it->second);
/* 更新map中key所对应的listQueue的位置 */
map[key] = listQueue.begin();
return it->second->second;
}
void put(int key, int value) {
auto it = map.find(key);
if (it != map.end())
{
/* 在listQueue中删除对应的key-value,准备更新 */
listQueue.erase(it->second);
}
/* 将key-value添加到listQueue首部 */
listQueue.push_front(make_pair(key, value));
/* 更新map中key所对应的listQueue的位置 */
map[key] = listQueue.begin();
/* 容量已满,需要进行删除,删除最近最久未使用的,即链表末尾元素 */
if (map.size() > capacity)
{
int delKey = listQueue.back().first;
listQueue.pop_back();
map.erase(delKey);
}
}
};
/**
* 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的元素LFUItem:key - value键值对
- LFU结点LFUNode:存储频数、以及该频数的所有key - value键值对组成的list
- 我们还需要一个 freList 链表,根据频数升序,将LFUNode连接起来,便于我们后续的缓存淘汰,以达到O(1)时间的删除
- 我们需要一个来存储key 与 LFUItem、LFUNode的映射关系,以达到O(1)时间的查找
// key - value
struct LFUItem
{
int key;
int val;
LFUItem(int key, int val)
{
this->key = key;
this->val = val;
}
};
// fre - list<node>
struct LFUNode
{
/* 使用频率 */
int frequency;
/* 使用频率为frequency的所有<key-value> */
list<LFUItem> lists;
LFUNode(int frequency)
{
this->frequency = frequency;
}
};
class LFUCache {
private:
typedef list<LFUNode> ::iterator listNode;
typedef list<LFUItem> ::iterator listItem;
/* 按使用频率的升序将node连接起来 */
list<LFUNode> freList;
/* 存储key与<listNode, listItem>的映射 */
unordered_map<int, pair<listNode, listItem>> map;
/* 缓存最大容量 */
int capacity;
public:
LFUCache(int capacity) {
this->capacity = capacity;
}
/* 通过key获取value */
int get(int key) {
/* 没有找到相应的key,直接返回-1 */
if (map.find(key) == map.end())
{
return -1;
}
listNode node;
listItem item;
tie(node, item) = map[key];
int value = item->val;
/* 更新频率 + 1 */
updateFrequency(key, value);
return value;
}
/* 插入key-value */
void put(int key, int value) {
if (capacity <= 0)
return;
/* 新插入的<key-value> */
if (map.find(key) == map.end())
{
/* map已满,需要删除使用频率最少的 */
if (map.size() >= capacity)
remove();
listNode node = freList.begin();
if (node == freList.end() || node->frequency != 1)
{
/* 新结点插入到链表首部,频率为 1 */
node = freList.insert(freList.begin(), LFUNode(1));
}
/* 更新map中的映射关系 */
map[key] = insert(node, key, value);
}
else
{
/* <key-value>已经存在,只更新频率即可 */
updateFrequency(key, value);
}
}
/* 更新map,在node结点的lists尾部插入key-val*/
pair<listNode, listItem> insert(listNode node, int key, int val)
{
/* node中的lists存储的是具有相同频率的key-value集合 */
listItem item = node->lists.insert(node->lists.end(), LFUItem(key, val));
return { node, item };
}
void updateFrequency(int key, int value)
{
listNode node;
listItem item;
tie(node, item) = map[key];
int fre = node->frequency + 1;
/**
* 拿到下一个node节点,待更新的结点将要放在这里
*(其频率肯定是大于等于待更新结点的原frequency的)
*/
listNode nextNode = next(node);
/**
* 由于item结点的频率改变,因此从原list中删除该结点
*(原lists肯定比item结点频率小 1)
*/
node->lists.erase(item);
/* 删除后原lists为空了,则将该list从freList中删除 */
if (node->lists.empty())
{
freList.erase(node);
}
/* nextNode的频率不同于带更新结点频率、或者走向list末尾,需要插入到nextNode位置 */
if (nextNode == freList.end() || nextNode->frequency != fre)
{
nextNode = freList.insert(nextNode, LFUNode(fre));
map[key] = insert(nextNode, key, value);
}
else
{
/**
* nextNode 与 带更新结点频率相同,那么直接更新即可
* 具体做法就是将待更新结点插入到nextNode的lists链表末尾即可
*/
map[key] = insert(nextNode, key, value);
}
}
/* 移除结点:freList的首部是使用频率最少的 */
void remove()
{
listNode node = freList.begin();
listItem item = node->lists.begin();
int key = item->key;
int val = item->val;
/* 直接将使用频率最少的list首部元素删除 */
node->lists.erase(item);
/* 更新map,map中存储着key所对应的LFUNode与LFUItem */
map.erase(key);
/* 删除后若lists为空,则从freList中将其移除 */
if (node->lists.empty())
{
freList.erase(node);
}
}
};
/**
* Your LFUCache object will be instantiated and called as such:
* LFUCache* obj = new LFUCache(capacity);
* int param_1 = obj->get(key);
* obj->put(key,value);
*/
基础知识部分参考:《缓存算法(页面置换算法)总结》