【从零实现LFU缓存淘汰算法】C++核心数据结构与O(1)操作详解

张开发
2026/4/18 12:07:42 15 分钟阅读

分享文章

【从零实现LFU缓存淘汰算法】C++核心数据结构与O(1)操作详解
1. 为什么需要LFU缓存淘汰算法想象你正在管理一家网红奶茶店店里只有10个座位缓存容量。每当有新顾客到来时就需要决定让哪位现有顾客离开。最直观的策略可能是让坐得最久的顾客离开FIFO或者让最近没点单的顾客离开LRU。但更聪明的做法是统计每位顾客的点单频率让总消费次数最少且最近没加单的顾客离开——这就是LFU算法的现实映射。在计算机系统中LFULeast Frequently Used算法通过统计数据的访问频率来决定淘汰优先级。与LRU只关注最近访问时间不同LFU额外记录了每个数据的热度值。当缓存满时会优先淘汰访问频率最低的旧数据。这种策略特别适合热点数据分布明显的场景比如电商系统的商品详情缓存爆款商品会被反复访问内容推荐系统的用户画像缓存活跃用户数据更常被调用数据库查询缓存高频查询语句结果应该保留传统实现方式如果用普通哈希表优先队列get操作会退化为O(logN)。而我们将要实现的双哈希表多级双向链表结构能在保持统计功能的同时让所有操作稳定在O(1)时间复杂度。2. 核心数据结构设计2.1 数据结构全景图要实现O(1)复杂度的LFU需要两个关键哈希表和若干双向链表协同工作哈希表1 (key到节点): { A: NodeA, B: NodeB } 哈希表2 (频率到链表): { 1: 双向链表1 [NodeB - NodeC], 2: 双向链表2 [NodeA] }节点结构体需要包含五个关键字段struct Node { int key; int value; int freq; // 访问计数器 Node* prev; Node* next; };频率链表则维护相同访问频次的节点集合采用头尾哨兵节点设计简化边界处理struct FreqList { int freq; // 该链表的访问频次标识 Node* head; // 虚拟头节点 Node* tail; // 虚拟尾节点 };2.2 为什么需要双哈希表第一个哈希表keyToNode实现常规的键值查询保证get操作是O(1)。第二个哈希表freqToLists则是LFU特有的设计键是访问频次如1,2,3...值是对应的双向链表其中节点按访问时间排序这种结构带来三个关键优势访问频次变更时如节点从频次1升级到2能快速找到目标链表淘汰数据时能直接定位到最小频次对应的链表相同频次的节点集合中头部是最久未访问的淘汰候选3. O(1)操作的关键实现3.1 get操作的精细处理当查询一个存在的key时需要完成以下步骤int get(int key) { if (!keyToNode.count(key)) return -1; Node* node keyToNode[key]; // 从原频次链表移除 removeFromFreqList(node); // 频次提升后维护minFreq if (node-freq minFreq freqToLists[minFreq]-isEmpty()) { minFreq; } node-freq; addToFreqList(node); // 加入新频次链表 return node-value; }这里有个精妙细节当节点是原minFreq链表的最后一个节点时minFreq需要递增。例如原minFreq2的链表在移除节点后为空说明当前最小频次已变为3。3.2 put操作的双重逻辑put操作需要处理key存在和不存在两种情况void put(int key, int value) { if (capacity 0) return; if (get(key) ! -1) { // 复用get逻辑 keyToNode[key]-value value; return; } if (keyToNode.size() capacity) { // 淘汰minFreq链表的头节点 Node* victim freqToLists[minFreq]-head-next; freqToLists[minFreq]-remove(victim); keyToNode.erase(victim-key); delete victim; } // 新增节点初始频次为1 Node* newNode new Node(key, value, 1); keyToNode[key] newNode; minFreq 1; // 新节点必然重置minFreq addToFreqList(newNode); }特别注意当插入新节点时minFreq必定重置为1因为这是系统中最小的访问频次。4. 完整C实现与测试4.1 核心类定义class LFUCache { private: struct Node { int key, value, freq; Node *prev, *next; Node(int k, int v, int f): key(k), value(v), freq(f) {} }; struct FreqList { Node *head, *tail; FreqList() { head new Node(-1,-1,-1); tail new Node(-1,-1,-1); head-next tail; tail-prev head; } bool isEmpty() { return head-next tail; } void remove(Node* node) { node-prev-next node-next; node-next-prev node-prev; } void append(Node* node) { node-prev tail-prev; node-next tail; tail-prev-next node; tail-prev node; } }; int capacity; int minFreq; unordered_mapint, Node* keyToNode; unordered_mapint, FreqList* freqToLists; void addToFreqList(Node* node) { if (!freqToLists.count(node-freq)) { freqToLists[node-freq] new FreqList(); } freqToLists[node-freq]-append(node); } public: LFUCache(int cap) : capacity(cap), minFreq(0) {} // get/put方法实现见前文 };4.2 边界情况测试void testLFU() { LFUCache cache(2); cache.put(1, 1); // freq: 1-1 cache.put(2, 2); // freq: 1-1,2 assert(cache.get(1) 1); // freq: 1-2; 2-1 cache.put(3, 3); // 淘汰key2 assert(cache.get(2) -1); assert(cache.get(3) 3); // freq: 1-3; 2-1 cache.put(4, 4); // 淘汰key1 assert(cache.get(1) -1); assert(cache.get(4) 4); // freq: 1-4; 2-3 }当缓存容量为0时的特殊处理、相同频次下的LRU淘汰策略等边界场景都需要特别注意。在LeetCode 460题的测试集中就包含这些容易忽略的case。5. 性能优化实践5.1 内存管理改进原始实现中节点删除后直接delete但在高并发场景可能引发性能问题。可以考虑对象池预分配节点智能指针自动管理生命周期延迟删除策略// 使用unique_ptr的改进版本 unordered_mapint, unique_ptrNode keyToNode; void safeRemove(Node* node) { freqToLists[node-freq]-remove(node); keyToNode.erase(node-key); // 自动释放内存 }5.2 并发安全扩展基础实现非线程安全可通过以下方式改造细粒度锁每个频次链表独立加锁读写分离读多写少场景适用无锁队列CAS原子操作class ConcurrentLFU { mutable shared_mutex globalMutex; unordered_mapint, shared_mutex freqMutexes; int get(int key) { shared_lockshared_mutex readLock(globalMutex); if (!keyToNode.count(key)) return -1; Node* node keyToNode[key]; { unique_lockshared_mutex writeLock(freqMutexes[node-freq]); removeFromFreqList(node); } // ...其余逻辑类似 } };6. 与其他缓存算法对比6.1 LFU vs LRU实战选择通过一个内容分发网络的案例对比场景特征推荐算法原因分析突发流量热点LRULFU需要积累统计信息长尾稳定访问LFU准确识别高频内容数据大小差异大LRULFU可能保留大体积低频数据缓存污染风险LFU对扫描攻击有更好抵抗6.2 混合策略创新现代系统常采用折中方案TinyLFU使用Count-Min Sketch近似统计频率W-TinyLFU窗口机制LFU分层LRFU结合访问时间和频率的权重公式例如Redis的allkeys-lfu策略就经过优化避免经典LFU的内存开销问题。在实际工程中往往需要根据监控指标动态调整淘汰策略。

更多文章