缓存置换:用c++实现最不经常使用(LFU)算法

在探讨缓存置换算法时,我们曾详细解读过LRU(Least Recently Used)算法,它凭借 “最近最少使用” 的策略在缓存管理领域大放异彩。今天,让我们将目光聚焦于另一种重要的缓存置换算法 ——LFU(Least Frequently Used 最不经常使用),深入探究它的原理、实现方式、应用场景以及与 LRU 的差异。

一、LFU 算法核心原理

LFU 算法的核心思想是根据数据的访问频率来决定淘汰对象。与 LRU 依据数据最近的访问时间不同,LFU 更关注数据被访问的频繁程度。它认为,在过去一段时间内访问次数最少的数据,在未来被访问的可能性也相对较低,因此当缓存空间不足时,优先淘汰这类数据。

想象一下图书馆的藏书管理,有些书籍经常被借阅,而有些则鲜有人问津。LFU 算法就如同图书馆管理员,会定期清理那些借阅次数最少的书籍,为新购入的书籍腾出空间。这样,图书馆的书架上始终保留着最受欢迎、被借阅可能性最高的书籍,提高了读者找到所需书籍的效率。在缓存管理中,LFU 算法通过记录每个数据的访问次数,在缓存满时淘汰访问次数最少的数据,以此来维持缓存的高效运行。

二、LFU 算法的数据结构实现

实现 LFU 算法需要借助合适的数据结构,我们的代码实现使用的是哈希表和多个双向链表的组合。

  1. 哈希表:哈希表则用于快速定位数据。它以数据的键作为索引,存储对应数据在双向链表中的节点指针。这样,在查找数据时,通过哈希表可以在 O(1) 的时间复杂度内找到对应的数据节点,然后根据节点指针在双向链表中进行操作。例如,在一个基于键值对存储的内存缓存中,通过哈希表可以快速找到特定键对应的数据,提高缓存的查询效率。

不清楚哈希表的可以看我以前的文章c++ 手写STL篇(三)实现hashtable

  1. 双向链表:在缓存管理中,双向链表常被用于维护数据的访问顺序,链表里的每个节点都代表着一个缓存数据。LRU 和 LFU 算法都用到了双向链表,但使用方式有所不同。

    在 LRU 算法里,只有一个双向链表。这个链表有个很清晰的规则:头部的节点代表着最近使用的数据,就好像是你刚刚才翻阅过的资料,被放在了最显眼的位置;而尾部的节点则代表最近最少使用的数据,类似于你很久都没碰过的旧文件,被放在了最角落。

    LFU 算法就不太一样了。在 LFU 中存在多个双向链表,那些访问频次相同的数据节点会被串联在同一个双向链表中。比如说,有一些数据都只被访问过 1 次,它们就会被放在一个双向链表;另外一些都被访问过 3 次的数据,则会在另一个双向链表。为了能快速找到不同访问频次对应的双向链表,LFU 通过哈希表将访问频次和对应的双向链表建立映射关系,这样就能很方便地管理和查找数据了。双向链表的优势在于,插入和删除操作的时间复杂度都是 O(1) ,这对于频繁的缓存操作来说非常高效。例如,在一个频繁读写的数据库缓存中,数据的插入和删除操作会经常发生,双向链表的这种特性可以保证缓存操作的快速执行。

不清楚双向链表的可以看这篇文章 c++ 手写STL篇(二) 用双向链表实现list核心功能_c++ 二维链表手写-CSDN博客

通过哈希表和双向链表的协同工作,LFU 算法能够高效地管理数据的访问次数,并快速找到访问次数最少的数据进行淘汰。

三、LFU 算法的操作流程

  1. 查询操作:当进行查询操作时,首先在哈希表中查找数据的键。如果找到,说明数据在缓存中,将对应数据的访问次数加 1,并调整双向链表以保持堆的有序性(因为访问次数发生了变化)。如果未找到,则说明数据不在缓存中,需要从其他数据源获取数据(如磁盘),然后将数据插入到缓存中。

  2. 插入操作:插入操作分为两种情况。若缓存未满,直接将数据插入到哈希表和双向链表中,并将其访问次数初始化为 1。若缓存已满,则先从访问频次最小的双向链表中取出元素,将其从哈希表和双向链表中删除,然后再将新数据插入到哈希表和双向链表中,并将其访问次数设置为 1。

  3. 删除操作:删除操作相对简单,在哈希表中查找要删除数据的键,找到后从哈希表和双向链表中删除对应的元素即可。

四、LFU 算法的应用场景

  1. CDN 缓存优化:内容分发网络(CDN)的主要任务是将内容缓存到离用户更近的节点,以加快用户的访问速度。在 CDN 中,LFU 算法可用于管理缓存内容。例如,在视频网站的 CDN 系统中,视频片段会被缓存到各个节点。随着用户观看不同的视频,CDN 节点的缓存空间会逐渐被占满。LFU 算法会根据视频片段的访问次数,淘汰那些访问次数最少的片段,为新的热门视频片段腾出空间。这样,用户在访问视频时,CDN 节点能够更快地提供热门视频内容,提升用户体验。(在流媒体中cdn是成本最高环节,因此如何节省资源很重要)

  2. 搜索引擎缓存管理:搜索引擎在处理大量的搜索请求时,会将经常查询的关键词及其搜索结果缓存起来。LFU 算法可以帮助搜索引擎决定淘汰哪些缓存内容。当缓存空间不足时,优先淘汰那些搜索次数最少的关键词及其结果。比如,在某一时间段内,一些热门话题的搜索频率很高,而一些生僻关键词的搜索次数较少。LFU 算法会将这些生僻关键词的缓存结果淘汰,保证缓存中保留的是更有可能被再次搜索的热门关键词及其结果,提高搜索引擎的响应速度。

  3. 游戏资源缓存策略:在游戏运行过程中,会加载大量的资源,如纹理、模型等。为了提高游戏的加载速度和运行效率,游戏会将这些资源缓存到内存中。LFU 算法可以根据资源的使用频率,淘汰那些使用次数最少的资源。例如,在一款开放世界游戏中,玩家在某个区域频繁活动,该区域的游戏资源访问频率较高,而一些偏远区域的资源访问次数较少。LFU 算法会优先淘汰这些偏远区域的资源,为当前区域更需要的资源腾出空间,确保游戏能够流畅运行。

五、LFU 与 LRU 的对比分析

  1. 原理差异:LRU 基于数据的访问时间,优先淘汰最近最少使用的数据;而 LFU 基于数据的访问频率,优先淘汰访问次数最少的数据。这意味着 LRU 更注重数据的时效性,而 LFU 更关注数据的使用频繁程度。

  2. 适用场景差异:LRU 适用于数据访问具有时间局部性的场景,即近期访问过的数据在未来一段时间内再次被访问的概率较高。例如,在浏览器缓存中,用户通常会在短时间内回访之前浏览过的网页,LRU 算法能够很好地适应这种场景。而 LFU 适用于数据访问频率相对稳定的场景,对于那些访问频率变化较大的数据,LFU 可能无法及时适应。比如在电商平台中,商品的热门程度可能会随着时间和促销活动发生较大变化,此时 LFU 算法可能不太适用。

  3. 实现复杂度差异:LFU 算法由于需要记录数据的访问次数并维护一个有序的数据结构,其实现复杂度相对较高。而 LRU 算法通常借助双向链表和哈希表即可实现,相对来说实现较为简单。

六、LFU 算法的优缺点

  1. 优点:能更准确地反映数据的使用频繁程度,在数据访问频率稳定的场景下,能够有效提高缓存命中率,减少数据的重复读取。例如在一些数据访问模式较为固定的企业级应用中,LFU 算法可以充分发挥其优势,提升系统性能。

  2. 缺点:需要额外的空间来记录数据的访问次数,并且维护有序数据结构的操作也会增加时间复杂度;对于过时的热点数据,因为其访问频次高,难以被淘汰;在高并发环境下,频繁更新访问次数和调整有序数据结构可能会带来性能瓶颈;对于刚加入缓存的数据可能应为频次很低而被快速淘汰,即便这项是近期热点数据(短期热点数据无法及时缓存)。

七、用c++实现LFU算法

LFU 缓存置换算法是一种经典且实用的算法,在众多领域都有广泛的应用。通过深入理解其原理、数据结构选择、操作流程以及应用场景,我们可以更好地将其应用到实际项目中,提升系统的性能。接下来,我们将通过手撕代码的方式,详细展示如何实现一个 LFU 缓存。

代码参考自:https://github.com/youngyangyang04/KamaCache

接口类,对缓存置换算法设计统一接口实现隔离 :

#ifndef CACHEPOLICY_H
#define CACHEPOLICY_H
 
template<typename Key, typename Value>
class CachePolicy
{
public:
	virtual ~CachePolicy() {};
	virtual void put(Key key, Value value) = 0;
	virtual bool get(Key key,Value& value) = 0;
	virtual Value get(Key key) = 0;
};
 
#endif

双向链表实现,包含节点结构体实现,用于存储键值对和前向后向节点指针,并且实现了双向链表基本功能,例如判断是否为空,增加、删除节点、获取链表头节点等方法

template<typename Key, typename Value> class LfuCache;

template<typename Key, typename Value>
class FreqList
{
public:	
	struct Node//节点结构体,存储键值对和前向后向节点指针
	{
		int freq;
		Key key;
		Value value;
		std::shared_ptr<Node> pre;
		std::shared_ptr<Node> next;
		
		Node() :
			freq(1), pre(nullptr), next(nullptr) {}
		Node(Key key, Value value) :
			key(key), value(value), freq(1), pre(nullptr), next(nullptr) {}
 	};
 	
 	using NodePtr = std::shared_ptr<Node>;
 	int freq_;
 	NodePtr head_;
 	NodePtr tail_;
 	
public:	
	explicit FreqList(int n)://expicit禁止隐式构造
		freq_(n)
    //构建虚拟头节点和虚拟尾节点,然后头尾相连完成初始化
	{
		head_ = std::make_shared<Node>();
		tail_ = std::make_shared<Node>();
		head_->next = tail_;
		tail_->pre = head_;
	}
	//判空
	bool isEmpty() const
	{
		return head_->next == tail_;
	}
	//增加节点
	void addNode(NodePtr node)
	{
		if(!node || !head_ || !tail_) return;
		node->pre = tail_->pre;
		node->next = tail_;
		tail_->pre->next = node;
		tail_->pre = node;
	}
	//删除节点
	void removeNode(NodePtr node)
	{
		if(!node || !head_ || !tail_) return;
		if(!node->pre || !node->next) return;
		
		node->pre->next = node->next;
		node->next->pre = node->pre;
		node->pre = nullptr;
		node->next = nullptr;
	}
	//获取头节点
	NodePtr getFirstNode() const {return head_->next;}
	//类作为友元,方便LfuCache类直接访问FreqList类的私有成员
	friend class LfuCache<Key,Value>;
};

在 LFU 类中,实现了数据的增删改查功能。这里要着重讲一下计数操作,它是 LFU 算法的关键环节。想象一下,缓存就像是一个热闹的集市,里面的每个摊位(数据节点)都有自己的客流量(访问频次)。如果不对每个摊位的客流量计数加以限制,那些长期火爆的摊位(热数据),客流量计数就会像火箭一样不断飙升。这不仅会占用大量的 “记录空间”,就好比摊位的账本越写越厚,最后可能连存放账本的地方都没有了(占用空间甚至计数溢出)。而且,一些曾经很火但现在已经过时的摊位(过期的热点数据),因为之前积累的客流量太大,很难被挤出这个集市(难以被清除出缓存)。

为了解决这些问题,采用了一种巧妙的办法 —— 设置最大平均访问次数限制。它就像是给集市的热闹程度设定了一个上限。通过curTotalNum_这个 “总客流量计数器”,把集市里所有摊位的客流量都加起来,再除以摊位总数,就能得到平均每个摊位的客流量(平均访问次数curAverageNum_)。当这个平均客流量超过了设定的最大平均客流量maxAverageNum_后,就会启动handleOverMaxAverageNum这个 “摊位热度调整员”。它会把每个摊位的客流量都减去最大平均客流量的一半,这样一来,每个摊位的客流量上限就被控制住了,那些长期赖在集市里的过期热门摊位也终于有机会被清理出去了,集市(缓存)也就能够保持高效的运营状态啦。

template<typename Key, typename Value>
class LfuCache : public CachePolicy<Key,Value>
{
public:
	using Node = typename FreqList<Key,Value>::Node;
	using NodePtr = std::shared_ptr<Node>;
	using NodeMap = std::unordered_map<Key,NodePtr>;
	
	LfuCache(int capacity, int maxAverageNum = 10):
		capacity_(capacity),
		minFreq_(INT8_MAX),
		maxAverageNum_(maxAverageNum),
		curAverageNum_(0),
		curTotalNum_(0) {}
		
	~LfuCache() override = default;
	//存入缓存
	void put(Key key, Value value) override
	{
		if(capacity_ <= 0) return;
		std::lock_guard<std::mutex> lock(mutex_);
		auto it = nodeMap_.find(key);
		if(it != nodeMap_.end())
		{
			it->second->value = value;
			getInternal(it->second,value);
			return;
		}
		
		putInternal(key,value);
	}
	//读取缓存
	bool get(Key key, Value& value) override
	{
		std::lock_guard<std::mutex> lock(mutex_);
		auto it = nodeMap_.find(key);
		if(it != nodeMap_.end())
		{
			getInternal(it->second,value);
			return true;
		}
		return false;
	}
	//get多态
	Value get(Key key) override
	{
		Value value{};
		get(key,value);
		return value;
	}
	//清除所有缓存
	void purge()
	{
		nodeMap_.clear();
		freqToFreqList_.clear();
	}
	
private:
	void putInternal(Key key, Value value);
	void getInternal(NodePtr key,Value& value);
	
	void kickOut();//根据最小访问计数minFreq_清除最不经常使用节点
	
	void removeFromFreqList(NodePtr node);//从双向链表中移除节点
	void addToFreqList(NodePtr node);//向双向链表加入节点
	
	void addFreqNum();//访问频次总数加1并计算当前平均访问次数,如果大于最大值调用节点处理函数
	void decreaseFreqNum(int num);//访问频次总数见num并计算当前平均访问次数
	void handleOverMaxAverageNum();//节点处理函数,对每个节点访问计数减去最大平均访问计数的一半,并调整freqToFreqList_这个哈希表
	void updateMinFreq();//遍历所有节点,找出最小访问计数
	
private:
	int capacity_;//容量
	int minFreq_;//所有节点中访问计数的最小值
	int maxAverageNum_;//最大平均访问次数
	int curAverageNum_;//当前缓存的平均访问计数
	int curTotalNum_;//当前缓存中所有节点的访问计数总和
	std::mutex mutex_;
	NodeMap nodeMap_;//节点key与节点指针映射的哈希表
	std::unordered_map<int,FreqList<Key,Value>*> freqToFreqList_;//访问计数与双向链表映射的哈希表 
};

下面是私有成员方法的实现

template<typename Key, typename Value>
void LfuCache<Key,Value>::getInternal(NodePtr node, Value& value)
{
	value = node->value;
	removeFromFreqList(node);
	node->freq++;
	addToFreqList(node);
//判断并调整最小访问计数值
	if(node->freq - 1 == minFreq_ && freqToFreqList_[node->freq - 1]->isEmpty())
	{
		minFreq_++;
	}
	addFreqNum();
}

template<typename Key, typename Value>
void LfuCache<Key,Value>::putInternal(Key key, Value value)
{
//如果缓存已满,清除最不经常使用节点
	if(nodeMap_.size() >= capacity_)
	{
		kickOut();
	}
	NodePtr node = std::make_shared<Node>(key,value);
	nodeMap_[key] = node;
	addToFreqList(node);
	addFreqNum();
	minFreq_ = std::min(minFreq_,1);
}

template<typename Key, typename Value>
void LfuCache<Key,Value>::kickOut()
{
	NodePtr node = freqToFreqList_[minFreq_]->getFirstNode();
	removeFromFreqList(node);
	nodeMap_.erase(node->key);
	decreaseFreqNum(node->freq);
}

template<typename Key, typename Value>
void LfuCache<Key,Value>::addFreqNum()
{
	curTotalNum_++;
	if(nodeMap_.empty())
	{
		curAverageNum_ = 0;
	}
	else
	{
		curAverageNum_ = curTotalNum_ / nodeMap_.size();
	}
	if(curAverageNum_ > maxAverageNum_)
	{
		handleOverMaxAverageNum();
	}
}

template<typename Key, typename Value>
void LfuCache<Key,Value>::decreaseFreqNum(int num)
{
	curTotalNum_ -= num;
	if(nodeMap_.empty())
	{
		curAverageNum_ = 0;
	}
	else
	{
		curAverageNum_ = curTotalNum_ / nodeMap_.size();
	}
}

template<typename Key, typename Value>
void LfuCache<Key,Value>::removeFromFreqList(NodePtr node)
{
	if(!node) return;
	auto freq = node->freq;
	freqToFreqList_[freq]->removeNode(node);
}

template<typename Key, typename Value>
void LfuCache<Key,Value>::addToFreqList(NodePtr node)
{
	if(!node) return;
	auto freq = node->freq;
	if(freqToFreqList_.find(freq) == freqToFreqList_.end())
	{
		freqToFreqList_[freq] = new FreqList<Key,Value>(node->freq);
	}
	freqToFreqList_[freq]->addNode(node);
}
//这个操作逻辑有点像hashtable的重哈希扩容
template<typename Key, typename Value>
void LfuCache<Key,Value>::handleOverMaxAverageNum()
{
	if(nodeMap_.empty()) return;
	
	for(auto it = nodeMap_.begin(); it != nodeMap_.end(); it++)
	{
		if(!it->second) continue;
		
		NodePtr node = it->second;
        //因为会改变访问计数值,其在freqList中的映射位置也会改变,所以先从双向链表内取出
		removeFromFreqList(node);
		//遍历所有节点,对节点访问计数减去最大平均计数值的一半,如果减后值小于0,则置为1
		node->freq -= maxAverageNum_ / 2;
		if(node->freq < 1) node->freq = 1;
		//插入双向链表
		addToFreqList(node);
	}
	updateMinFreq();
}

template<typename Key, typename Value>
void LfuCache<Key,Value>::updateMinFreq()
{
	minFreq_ = INT8_MAX;
	for(const auto& pair : freqToFreqList_)
	{
		if(pair.second && !pair.second->isEmpty())
		{
			minFreq_ = std::min(minFreq_,pair.first);
		}
	}
	if(minFreq_ == INT8_MAX)
		minFreq_ = 1;
}

通过以上代码也可以看出它和LRU有相同的锁粒度大问题,这样的话高并发下会消耗大量资源用于线程同步等待,这个问题怎么解决呢?其实可以把一个大的缓存池分成几个小的缓存池,就好比多个人都要从一个仓库取货,但仓库每次只能进一个人,如果多个人要取货那就必须得排队等待,哪怕每个人要取的货物不同,既然这样那不如把一个大仓库分成若干个小仓库,每个人取得货物可能分布在不同仓库内,哪怕仍然有好几个人要的货物碰巧在同一个仓库,那队伍也不会排的像原先那么长。这样就实现了分流。

template<typename Key, typename Value>
class HashLfuCache
{
public:
	HashLfuCache(size_t capacity, int sliceNum, int maxAverageNum = 10)
	:	sliceNum_(sliceNum > 0 ? sliceNum : std::thread::hardware_concurrency()),
		capacity_(capacity)
	{
		 size_t sliceSize = std::ceil(capacity_ / static_cast<double>(sliceNum_));
		 for(size_t i = 0; i < sliceNum_; i++)
		 {
		 	lfuSliceCaches_.emplace_back(new LfuCache<Key,Value>(sliceSize,maxAverageNum));
		 }
	}
	
	void put(Key key, Value value)
	{
		size_t sliceIndex = Hash(key) % sliceNum_;
		return lfuSliceCaches_[sliceIndex]->put(key,value);
	}
	
	bool get(Key key, Value& value)
	{
		size_t sliceIndex = Hash(key) % sliceNum_;
		return lfuSliceCaches_[sliceIndex]->get(key,value);
	}
	
	Value get(Key key)
	{
		Value value{};
		get(key,value);
		return value;
	}
	
	void purge()
	{
		for(auto& lfuSliceCache : lfuSliceCaches_)
		{
			lfuSliceCache.purge();
		}
	}
	
private:
	size_t Hash(Key key)
	{
		std::hash<Key> hashFunc;
		return hashFunc(key);
	}
	
private:
	size_t capacity_;
	int sliceNum_;
	std::vector<std::unique_ptr<LfuCache<Key, Value>>> lfuSliceCaches_;
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值