【从零实现LFU缓存淘汰算法】C++核心数据结构与O(1)操作详解
2026/4/18 12:07:36 网站建设 项目流程

1. 为什么需要LFU缓存淘汰算法

想象你正在管理一家网红奶茶店,店里只有10个座位(缓存容量)。每当有新顾客到来时,就需要决定让哪位现有顾客离开。最直观的策略可能是让坐得最久的顾客离开(FIFO),或者让最近没点单的顾客离开(LRU)。但更聪明的做法是统计每位顾客的点单频率,让总消费次数最少且最近没加单的顾客离开——这就是LFU算法的现实映射。

在计算机系统中,LFU(Least 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. 访问频次变更时(如节点从频次1升级到2),能快速找到目标链表
  2. 淘汰数据时能直接定位到最小频次对应的链表
  3. 相同频次的节点集合中,头部是最久未访问的淘汰候选

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需要递增。例如原minFreq=2的链表在移除节点后为空,说明当前最小频次已变为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_map<int, Node*> keyToNode; unordered_map<int, 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,但在高并发场景可能引发性能问题。可以考虑:

  1. 对象池预分配节点
  2. 智能指针自动管理生命周期
  3. 延迟删除策略
// 使用unique_ptr的改进版本 unordered_map<int, unique_ptr<Node>> keyToNode; void safeRemove(Node* node) { freqToLists[node->freq]->remove(node); keyToNode.erase(node->key); // 自动释放内存 }

5.2 并发安全扩展

基础实现非线程安全,可通过以下方式改造:

  1. 细粒度锁(每个频次链表独立加锁)
  2. 读写分离(读多写少场景适用)
  3. 无锁队列(CAS原子操作)
class ConcurrentLFU { mutable shared_mutex globalMutex; unordered_map<int, shared_mutex> freqMutexes; int get(int key) { shared_lock<shared_mutex> readLock(globalMutex); if (!keyToNode.count(key)) return -1; Node* node = keyToNode[key]; { unique_lock<shared_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的内存开销问题。在实际工程中,往往需要根据监控指标动态调整淘汰策略。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询