LRU、LFU缓存机制的算法设计与实现

知识共享许可协议 版权声明:署名,允许他人基于本文进行创作,且必须基于与原先许可协议相同的许可协议分发本文 (Creative Commons


缓存算法和内存页面置换算法(Page Replacement Algorithm)的核心思想是一样的:给定一个有限的空间,设计一个算法来更新和访问里面的数据。下面提到缓存算法的同时,也指代页面置换算法。


LRU缓存机制

LRU(The Least Recently Used,最近最久未使用算法) 是一种常见的缓存算法,在很多分布式缓存系统(如Redis, Memcached)中都有广泛使用

LRU算法的思想是:如果一个数据在最近一段时间没有被访问到,那么可以认为在将来它被访问的可能性也很小。因此,当空间满时,最久没有访问的数据最先被置换(淘汰)。

LRU算法的描述: 设计一种缓存结构,该结构在构造时确定大小,假设大小为 K,并有两个功能:
  • 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);
 */

基础知识部分参考:《缓存算法(页面置换算法)总结》

猜你喜欢

转载自blog.csdn.net/ZYZMZM_/article/details/90546812