1. 项目概述与LFU核心思想
最近在整理一个C++缓存系统的学习项目,前面几篇聊了缓存的基本概念和LRU的实现,这次咱们来啃一块硬骨头——LFU(Least Frequently Used,最不经常使用)淘汰算法的代码实现。如果你做过LeetCode上那道著名的 460. LFU 缓存 ,就知道这玩意儿比LRU要复杂不少,但它的思想在实际系统中非常有用,比如数据库的查询缓存、操作系统的页面置换,甚至是一些内容分发网络(CDN)的边缘节点,都会用到LFU或其变种来提升热点数据的命中率。
LFU的核心逻辑很简单:当缓存空间不足时,淘汰掉那个被访问频率最低的数据。听起来比LRU的“淘汰最久未使用”更合理对吧?毕竟一个被频繁访问的“热数据”,理应比一个偶尔被访问一次的“冷数据”更有资格留在缓存里。但实现起来,麻烦就出在这个“频率”上。它不是一个静态值,而是一个动态变化的计数器。每次访问一个键,它的频率就要加一。当需要淘汰时,我们得从所有最低频率的键中,再按照LRU或者FIFO的规则挑一个出来扔掉(通常选择最久未访问的,以解决“历史频率高但近期冷”的问题)。这就意味着,我们需要一种数据结构,能同时高效地支持以下操作:
- 根据键(key)快速获取值(value)和其当前访问频率。
- 根据频率(freq)快速找到所有处于该频率的键,并且能从中移除一个(通常是最早加入的)。
- 当某个键被访问,频率增加时,能将它从一个频率链表移动到更高一级的频率链表中。
这比LRU只需要维护一个按访问时间排序的双向链表要复杂得多。网上很多简单的LFU实现,在get和put操作时时间复杂度是O(N)的,这在数据量大的时候根本不可用。我们这次的目标,是实现一个get和put操作时间复杂度都在O(1)的LFU缓存。这需要精心设计数据结构的组合。
2. 数据结构设计:O(1)复杂度的关键
要实现O(1)的操作,我们必须摒弃遍历。核心思路是使用三个哈希表(unordered_map)和一组双向链表(list)进行组合。下面我们来拆解每个部分的作用。
2.1 核心数据结构拆解
key_table(键到节点的映射)- 类型:
unordered_map<int, Node> - 键:用户传入的键(key)。
- 值:一个
Node结构体,里面至少包含key,value,freq(当前访问频率)。 - 作用:这是最基础的映射。通过
key,我们可以在O(1)时间内找到对应的值(value)和其当前的频率(freq)。没有它,get操作就无法快速完成。
- 类型:
freq_table(频率到链表的映射)- 类型:
unordered_map<int, list<Node>> - 键:访问频率(freq)。
- 值:一个双向链表(
std::list),里面存放所有处于该频率的Node。这个链表的顺序就是访问时间顺序,链表头部是最新访问的,尾部是最久未访问的。 - 作用:这是实现LFU淘汰的关键。它把相同频率的节点组织在一起。当我们需要淘汰时,直接找到
min_freq对应的链表,移除其尾部的节点(最久未访问),就是O(1)操作。当某个节点频率增加时,我们也需要把它从旧频率链表移动到新频率链表。
- 类型:
min_freq(当前最小频率)- 类型:
int - 作用:一个整型变量,记录当前缓存中所有键的最低访问频率。淘汰操作依赖它。维护这个变量是保证O(1)淘汰的关键,否则我们可能需要遍历
freq_table来寻找最小频率。
- 类型:
capacity(缓存容量)- 类型:
size_t - 作用:缓存的最大容量,在构造函数中传入。
- 类型:
2.2 Node结构体与迭代器存储
这里有一个非常重要的细节。当我们把一个Node放入std::list后,如果后续要移动或删除它,我们需要知道它在链表中的确切位置(迭代器)。但list的迭代器在元素被插入后才会确定,并且如果我们将Node对象直接存入list,再通过key_table找到这个Node,我们无法直接获取到它在freq_table对应链表里的迭代器。
因此,常见的优化做法是:
key_table不直接存储Node对象,而是存储一个包含value,freq以及迭代器的结构。- 这个迭代器指向该节点在
freq_table[freq]这个链表中的位置。
我们定义两个核心结构:
// 链表节点,存储键值对和频率 struct Node { int key; int value; int freq; // 访问频率 // 构造函数方便初始化 Node(int k, int v, int f) : key(k), value(v), freq(f) {} }; // 在键表中存储的条目,包含值和指向频率链表中位置的迭代器 struct KeyEntry { int value; int freq; std::list<Node>::iterator it; // 指向对应频率链表中的节点 };这样,key_table的类型就变成了unordered_map<int, KeyEntry>。通过key找到KeyEntry后,我们立刻能拿到value、freq以及它在链表中的位置it。这个设计是连接key_table和freq_table的桥梁,是实现O(1)移动和删除的核心。
3. 核心操作流程与代码实现
有了上面的设计图,我们来看get和put这两个核心方法如何实现。我们先给出LFUCache类的整体框架。
#include <unordered_map> #include <list> #include <iostream> class LFUCache { private: int capacity; // 缓存容量 int minFreq; // 当前最小频率 std::unordered_map<int, KeyEntry> keyTable; // 键到条目的映射 std::unordered_map<int, std::list<Node>> freqTable; // 频率到节点链表的映射 // 辅助函数:增加某个键的频率 void increaseFreq(int key) { // 具体实现见下文 } public: LFUCache(int capacity) : capacity(capacity), minFreq(0) { // 构造函数 } int get(int key) { // 具体实现见下文 } void put(int key, int value) { // 具体实现见下文 } };3.1get操作:查询并更新频率
get操作的逻辑是:
- 如果
key不存在于keyTable中,直接返回-1。 - 如果存在: a. 通过
keyTable[key]找到对应的KeyEntry,拿到value和freq。 b.调用increaseFreq(key)函数,更新该键的频率。这是LFU的核心。 c. 返回value。
int get(int key) { if (capacity == 0) return -1; // 边界情况处理 auto it = keyTable.find(key); if (it == keyTable.end()) { return -1; // 键不存在 } // 找到条目 KeyEntry& entry = it->second; int value = entry.value; // 提升该键的频率 increaseFreq(key); return value; }3.2put操作:插入或更新
put操作的逻辑更复杂一些,需要处理插入新键和更新旧键两种情况,以及缓存满时的淘汰。
- 如果
key已存在:更新其value,并调用increaseFreq(key)提升其频率。这相当于一次访问。 - 如果
key不存在: a.如果缓存已满(keyTable.size() >= capacity),则需要进行淘汰。 i. 找到minFreq对应的链表freqTable[minFreq]。 ii. 该链表的尾部节点就是最不经常使用且最久未访问的节点,将其移除。 iii. 同时,从keyTable中也删除这个键。 iv.注意:移除节点后,如果freqTable[minFreq]链表变空,理论上可以删除这个空链表,并且minFreq需要更新。但在本次插入后,新键的频率为1,minFreq必然会被设置为1。所以我们可以选择不立即更新minFreq,而是在increaseFreq或下次淘汰时处理。一种更清晰的写法是在淘汰后,如果链表为空,就删除freqTable[minFreq]这个键,但minFreq可以暂时不变,因为紧接着要插入频率为1的新节点。 b.创建新节点:频率freq初始化为1。 c. 将新节点插入到freqTable[1]链表的头部(表示最新访问)。 d. 在keyTable中记录这个新键,其KeyEntry包含value、freq=1以及上一步插入节点后返回的迭代器。 e.将minFreq重置为1。因为新加入的键频率最低,就是1。
void put(int key, int value) { if (capacity == 0) return; // 边界情况处理 auto it = keyTable.find(key); if (it != keyTable.end()) { // 键已存在,更新值并提升频率 KeyEntry& entry = it->second; entry.value = value; // 更新值 increaseFreq(key); // 提升频率 return; } // 键不存在,需要插入 // 检查容量是否已满 if (keyTable.size() >= capacity) { // 缓存已满,需要淘汰 // 找到最小频率对应的链表 auto& minFreqList = freqTable[minFreq]; // 链表尾部的节点是最久未访问的 Node nodeToRemove = minFreqList.back(); minFreqList.pop_back(); // 从链表中移除 keyTable.erase(nodeToRemove.key); // 从键表中移除 // 如果移除后链表变空,可以清理这个频率桶(可选) if (minFreqList.empty()) { freqTable.erase(minFreq); // 注意:此时minFreq可能失效,但接下来我们会插入freq=1的节点,所以直接设为1即可 } } // 插入新节点,频率为1 int newFreq = 1; // 将新节点插入频率1的链表头部 freqTable[newFreq].push_front(Node(key, value, newFreq)); // 获取刚插入节点的迭代器 auto newIt = freqTable[newFreq].begin(); // 在键表中记录 keyTable[key] = {value, newFreq, newIt}; // 新插入节点频率为1,更新最小频率 minFreq = 1; }3.3increaseFreq辅助函数:频率提升的核心
这是整个LFU实现中最精妙的部分,它负责将一个键从一个频率链表移动到更高一级的频率链表。
步骤:
- 通过
keyTable[key]找到对应的KeyEntry,获取当前的freq和链表迭代器it。 - 从
freqTable[freq]链表中,通过迭代器it删除该节点。 - 重要检查:如果删除节点后,
freqTable[freq]链表变空了,并且当前的freq恰好等于minFreq,那么说明这个频率层级已经没有节点了,最小频率minFreq需要增加(minFreq++)。因为接下来这个节点的频率会变成freq+1,而freq这个频率已经不存在任何节点了。 - 将节点的频率
freq加一。 - 将更新后的节点插入到
freqTable[freq+1]链表的头部。 - 更新
keyTable[key]中的freq和迭代器it。
void increaseFreq(int key) { KeyEntry& entry = keyTable[key]; int oldFreq = entry.freq; auto oldIt = entry.it; // 1. 从旧频率链表中移除节点 freqTable[oldFreq].erase(oldIt); // 2. 检查旧频率链表是否变空,并且是否是最小频率 if (freqTable[oldFreq].empty()) { freqTable.erase(oldFreq); // 清理空链表 if (oldFreq == minFreq) { minFreq++; // 最小频率需要提升 } } // 3. 提升频率 int newFreq = oldFreq + 1; // 4. 将节点插入新频率链表的头部 // 注意:我们需要更新节点的freq,但Node是存储在list里的,我们需要修改它 // 更优的做法是:在list中删除旧节点,插入一个全新的Node。 // 但为了清晰,我们修改原Node的freq,然后重新插入。 // 实际上,在erase后,旧的Node对象已经被销毁。我们需要基于key和value新建一个。 // 因此,我们需要从entry中取出value。 int value = entry.value; freqTable[newFreq].push_front(Node(key, value, newFreq)); auto newIt = freqTable[newFreq].begin(); // 5. 更新键表中的记录 entry.freq = newFreq; entry.it = newIt; }4. 完整代码与测试案例
将上述所有部分组合起来,就得到了一个完整的、O(1)时间复杂度的LFU缓存实现。
#include <unordered_map> #include <list> using namespace std; class LFUCache { private: struct Node { int key, value, freq; Node(int k, int v, int f) : key(k), value(v), freq(f) {} }; struct KeyEntry { int value, freq; list<Node>::iterator it; }; int cap; int minFreq; unordered_map<int, KeyEntry> keyTable; // key -> {value, freq, iterator} unordered_map<int, list<Node>> freqTable; // freq -> list of Nodes void increaseFreq(int key) { KeyEntry& entry = keyTable[key]; int oldFreq = entry.freq; auto oldIt = entry.it; // 从旧链表删除 freqTable[oldFreq].erase(oldIt); // 如果旧链表变空,清理并更新minFreq if (freqTable[oldFreq].empty()) { freqTable.erase(oldFreq); if (oldFreq == minFreq) { minFreq++; } } // 频率增加 int newFreq = oldFreq + 1; // 插入新链表头部 freqTable[newFreq].push_front(Node(key, entry.value, newFreq)); auto newIt = freqTable[newFreq].begin(); // 更新键表记录 entry.freq = newFreq; entry.it = newIt; } public: LFUCache(int capacity) : cap(capacity), minFreq(0) {} int get(int key) { if (cap == 0) return -1; auto it = keyTable.find(key); if (it == keyTable.end()) return -1; increaseFreq(key); return it->second.value; } void put(int key, int value) { if (cap == 0) return; auto it = keyTable.find(key); if (it != keyTable.end()) { // 键存在,更新值并提升频率 it->second.value = value; increaseFreq(key); return; } // 键不存在,插入新节点 if (keyTable.size() >= cap) { // 缓存满,淘汰 auto& minList = freqTable[minFreq]; Node nodeToDel = minList.back(); minList.pop_back(); keyTable.erase(nodeToDel.key); if (minList.empty()) { freqTable.erase(minFreq); // 注意:这里minFreq可能失效,但下面会置为1 } } // 插入新节点,频率为1 int newFreq = 1; freqTable[newFreq].push_front(Node(key, value, newFreq)); auto newIt = freqTable[newFreq].begin(); keyTable[key] = {value, newFreq, newIt}; minFreq = 1; // 新插入节点,最小频率必为1 } };我们来跑一个简单的测试,模拟LeetCode的用例:
int main() { LFUCache lfu(2); lfu.put(1, 1); lfu.put(2, 2); cout << lfu.get(1) << endl; // 返回 1, key=1 freq=2 lfu.put(3, 3); // 容量已满,移除key=2 (freq=1), 插入key=3 cout << lfu.get(2) << endl; // 返回 -1 (未找到) cout << lfu.get(3) << endl; // 返回 3, key=3 freq=2 lfu.put(4, 4); // 容量已满,此时key=1 freq=2, key=3 freq=2 // 两者频率相同,移除最久未使用的,即key=1 cout << lfu.get(1) << endl; // 返回 -1 (未找到) cout << lfu.get(3) << endl; // 返回 3, key=3 freq=3 cout << lfu.get(4) << endl; // 返回 4, key=4 freq=2 return 0; }输出应该为:
1 -1 3 -1 3 45. 实现细节剖析与避坑指南
在实现过程中,有几个细节容易出错,也是面试官喜欢追问的地方。
5.1 迭代器失效问题
这是使用STL容器,特别是结合list和unordered_map时最需要小心的问题。在我们的设计里,keyTable中存储了指向list的迭代器。当我们在increaseFreq中调用freqTable[oldFreq].erase(oldIt);后,oldIt这个迭代器就立即失效了。之后我们绝不能再次使用它。这就是为什么我们需要在删除前,就从KeyEntry里把需要的value信息取出来(int value = entry.value;),因为删除后,原来的Node对象就不复存在了。后续我们创建新的Node插入到新的链表中。
5.2minFreq的更新时机
minFreq的维护是保证淘汰O(1)的关键,逻辑必须清晰:
- 何时增加:只在
increaseFreq函数中,当某个键从当前minFreq对应的链表中被移走,并且移走后该链表变空了,此时才需要将minFreq++。因为剩下的所有键的频率都至少是minFreq+1。 - 何时重置为1:在
put一个新键时。因为新键的频率永远是1,所以此时整个缓存中的最小频率必然是1。 - 淘汰时:在淘汰一个键之后,如果其所在的链表(即
freqTable[minFreq])变空,我们可以选择删除这个空链表条目。但此时minFreq变量暂时处于一个“无效”状态(因为该频率已无节点)。不过紧接着,如果是插入新键,我们会把minFreq设为1;如果是更新已有键,minFreq可能会在后续的increaseFreq中被修正。一种更严谨的做法是在淘汰后,如果链表空,就freqTable.erase(minFreq);,但先不更新minFreq,等待下次get或put触发increaseFreq时,由其中的判断逻辑来更新。我们的代码采用了在插入新键时直接重置的策略,逻辑上是正确的。
5.3 链表顺序与淘汰策略
我们约定,在每一个频率对应的双向链表中,头部是最近访问的,尾部是最久未访问的。这个“访问”指的是get或put(更新)该键。这样,当需要从同一频率的多个键中淘汰一个时,我们淘汰链表尾部的节点,这就实现了LFU + LRU的复合策略:先淘汰频率最低的,如果频率最低的有多个,则淘汰其中最久未使用的。这是一种更公平、更实用的策略,能防止一个历史上频繁访问但近期不再使用的“老热点”数据长期霸占缓存。
5.4 容量为0的边界情况
这是一个简单的边界条件,但很重要。如果缓存容量为0,那么get永远返回-1,put操作什么都不做。在构造函数和两个主函数开头进行判断即可。
6. 性能分析与应用场景思考
6.1 时间复杂度
get(int key): O(1)。哈希表查找O(1),increaseFreq中的链表删除、插入也都是O(1)。put(int key, int value): O(1)。哈希表查找、插入O(1),淘汰时链表尾部删除O(1),新节点链表头部插入O(1)。- 空间复杂度: O(capacity)。用于存储
keyTable和freqTable。
6.2 与LRU的对比
- 优势:LFU能更好地抓住“热点”数据。对于访问模式相对稳定、热点集中的场景(如新闻热点排行、某款商品详情),LFU的命中率通常高于LRU。因为它保护了频繁访问的数据,即使它们有一段时间没被访问。
- 劣势:
- 实现复杂:需要维护频率信息,数据结构比LRU复杂。
- 对突发流量不友好:如果一个新数据突然被大量访问(突发热点),LRU会立刻将其放到头部保护起来。而LFU中,新数据初始频率低,在缓存满时很容易被淘汰掉,即使它正在被疯狂访问。这就是“缓存污染”问题。
- 历史频率负担:一个数据过去被访问很多次,但未来不再需要。LFU会因为其历史高频率而长期保留它,占用空间。
6.3 实际应用与变种
纯粹的LFU在实际大型系统中较少直接使用,正是因为上述缺点。但它的思想被广泛应用,并衍生出许多改进算法:
- LFU-Aging:给每个频率记录引入一个“年龄”或衰减机制,定期降低所有数据的频率,让旧的热点数据能逐渐被淘汰。
- Window-LFU:只统计最近一段时间窗口内的访问频率,结合了LRU和LFU的思想。
- TinyLFU:一种非常著名的现代近似LFU算法,用Count-Min Sketch等概率数据结构以极小的空间估算频率,并结合一个准入过滤器(通常是一个LRU队列)来决定新数据是否值得放入缓存。Caffeine缓存库就使用了TinyLFU。
对于我们的学习项目而言,实现这个标准的、O(1)的LFU已经足够深入理解其精髓。下次可以尝试在此基础上实现一个简单的LFU-Aging,或者对比测试一下LFU和LRU在不同访问模式下的命中率,那会更有意思。