跳到主要内容缓存算法 LRU 与 LFU 详解及代码实现 | 极客日志Javajava算法
缓存算法 LRU 与 LFU 详解及代码实现
详细解析了 LRU 和 LFU 两种常见的缓存淘汰算法。LRU 采用哈希表结合双向链表结构,确保 O(1) 时间复杂度的读写操作,通过移动节点至头部维护访问顺序。LFU 则基于访问频率淘汰,提供了哈希表加平衡二叉树以及双哈希表加双向链表两种实现方式,后者进一步优化为纯 O(1) 操作。文章包含完整的 Java 代码示例及核心逻辑分析,适合面试准备与技术深入。
城市逃兵1 浏览 一、LRU 缓存算法
1.哈希表 + 双向链表
1.题目链接:LRU 缓存
2.题目描述:

3.算法思路:
1.双向链表 + 哈希表 组合:
双向链表(带哑头 / 哑尾节点):维护缓存节点的访问顺序,最近使用的节点放在链表头部,最少使用的节点放在链表尾部(淘汰时直接删尾部);
哈希表(cache):实现 key 到节点的 O(1) 快速查找,解决链表遍历查找慢的问题;
2.哑头 / 哑尾节点:简化链表边界处理(无需判断'节点是否为头 / 尾''链表是否为空'等特殊情况);
3.核心规则:
访问 / 更新节点(get/ 更新式 put):将节点移到链表头部(标记为'最近使用');
新增节点:添加到链表头部,若缓存容量超限,删除链表尾部节点(最少使用),并同步删除哈希表中的映射。
4.代码实现:
class LRUCache {
class DLinkedNode {
int key;
int value;
DLinkedNode prev;
DLinkedNode next;
public DLinkedNode() {}
public DLinkedNode(int _key, int _value) {
this.key = _key;
this.value = _value;
}
}
private Map<Integer, DLinkedNode> cache = new HashMap<>();
size;
capacity;
DLinkedNode head;
DLinkedNode tail;
{
.size = ;
.capacity = capacity;
head = ();
tail = ();
head.next = tail;
tail.prev = head;
}
{
cache.get(key);
(node == ) {
-;
}
moveToHead(node);
node.value;
}
{
cache.get(key);
(node == ) {
(key, value);
cache.put(key, newNode);
addToHead(newNode);
size++;
(size > capacity) {
removeTail();
cache.remove(tailNode.key);
size--;
}
}
{
node.value = value;
moveToHead(node);
}
}
{
node.prev.next = node.next;
node.next.prev = node.prev;
}
{
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
{
removeNode(node);
addToHead(node);
}
DLinkedNode {
tail.prev;
removeNode(res);
res;
}
}
微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
private
int
private
int
private
private
public
LRUCache
(int capacity)
this
0
this
new
DLinkedNode
new
DLinkedNode
public
int
get
(int key)
DLinkedNode
node
=
if
null
return
1
return
public
void
put
(int key, int value)
DLinkedNode
node
=
if
null
DLinkedNode
newNode
=
new
DLinkedNode
if
DLinkedNode
tailNode
=
else
private
void
removeNode
(DLinkedNode node)
private
void
addToHead
(DLinkedNode node)
private
void
moveToHead
(DLinkedNode node)
private
removeTail
()
DLinkedNode
res
=
return
(1)DLinkedNode 内部类(双向链表节点):
| 部分 | 作用 |
|---|
| key/value | value 存储缓存值;key 是核心 —— 淘汰节点时,需要通过 key 删除哈希表中的映射(仅存 value 无法做到); |
| prev/next | 双向链表的前后指针,支持 O(1) 时间的节点增删; |
| 构造方法 | 无参构造用于创建哑头 / 哑尾(哨兵节点),有参构造用于创建真实缓存节点; |
| 变量名 | 作用 |
|---|
| cache | 哈希表(HashMap),key→节点 的映射,解决链表遍历查找慢的问题,实现 O(1) 查找; |
| size | 记录当前缓存中的节点数,用于判断是否超出容量; |
| capacity | 缓存最大容量,是淘汰逻辑的触发条件; |
| head/tail | 哑头 / 哑尾节点(哨兵),避免处理'链表为空''节点是头 / 尾'等边界问题,让增删逻辑统一; |
(3)核心方法:
构造方法 LRUCache(int capacity)
初始化缓存容量、节点数,以及哑头 / 哑尾节点,形成一个'空的双向链表'(哑头→哑尾,哑尾→哑头)。
get(int key)(获取缓存)
核心逻辑:查哈希表 → 不存在返回 -1 → 存在则将节点移到头部 → 返回值;
关键:moveToHead 保证'最近使用的节点在头部',是 LRU 规则的核心体现。
put(int key, int value)(添加 / 更新缓存)
分两种场景:
key 不存在(新增):创建节点 → 哈希表 + 链表头部添加 → 节点数 + 1 → 超容量则删尾部节点(同步删哈希表);
key 存在(更新):更新节点值 → 移到链表头部(标记为最近使用);
关键:新增节点必加在头部,超容时删尾部(最少使用),完全符合 LRU 淘汰规则。
(4)辅助方法(链表操作)
| 方法名 | 作用 |
|---|
| removeNode | 通用的节点删除方法,通过修改前后指针跳过目标节点,O(1) 时间完成; |
| addToHead | 头插法:将节点添加到哑头之后(链表头部),标记为'最近使用'; |
| moveToHead | 组合 removeNode + addToHead,实现'节点移到头部'的逻辑; |
| removeTail | 删除链表最后一个真实节点(哑尾的前驱),返回该节点用于同步删除哈希表; |
(5)总结:
核心优势:哈希表(O(1) 查找)+ 双向链表(O(1) 增删),实现所有操作的 O(1) 时间复杂度,是 LRU 缓存的最优实现;
核心规则:
最近使用的节点在链表头部,最少使用的在尾部;
访问 / 更新节点 → 移到头部;新增节点 → 加在头部;超容 → 删尾部;
关键细节:
哑头 / 哑尾简化了链表边界处理,让增删逻辑无需区分特殊情况;
节点中存储 key 是淘汰时同步删除哈希表的关键(仅存 value 无法反向查找 key)
二、LFU 缓存算法
1、哈希表 + 平衡二叉树
1.核心设计思路:
支持 get(获取缓存值)和 put(添加 / 更新缓存)操作;
当缓存容量满时,优先淘汰访问频率最低的元素;若频率相同,则淘汰最早访问(时间戳最小)的元素;
借助 HashMap 实现 O(1) 时间复杂度的节点查找,借助 TreeSet 实现节点的有序存储(按频率 + 时间戳排序),保证淘汰操作的效率。
2.代码实现:
class LFUCache {
class Node implements Comparable<Node> {
int cnt;
int time;
int key;
int value;
Node(int cnt, int time, int key, int value) {
this.cnt = cnt;
this.time = time;
this.key = key;
this.value = value;
}
public boolean equals(Object anObject) {
if (this == anObject) {
return true;
}
if (anObject instanceof Node) {
Node rhs = (Node) anObject;
return this.cnt == rhs.cnt && this.time == rhs.time;
}
return false;
}
public int compareTo(Node rhs) {
return cnt == rhs.cnt ? time - rhs.time : cnt - rhs.cnt;
}
public int hashCode() {
return cnt * 1000000007 + time;
}
}
int capacity;
int time;
Map<Integer, Node> key_table;
TreeSet<Node> S;
public LFUCache(int capacity) {
this.capacity = capacity;
this.time = 0;
key_table = new HashMap<>();
S = new TreeSet<>();
}
public int get(int key) {
if (capacity == 0) {
return -1;
}
if (!key_table.containsKey(key)) {
return -1;
}
Node cache = key_table.get(key);
S.remove(cache);
cache.cnt++;
cache.time = ++time;
S.add(cache);
key_table.put(key, cache);
return cache.value;
}
public void put(int key, int value) {
if (capacity == 0) {
return;
}
if (!key_table.containsKey(key)) {
if (key_table.size() == capacity) {
key_table.remove(S.first().key);
S.remove(S.first());
}
Node cache = new Node(1, time++, key, value);
key_table.put(key, cache);
S.add(cache);
}
else {
Node cache = key_table.get(key);
S.remove(cache);
cache.cnt++;
cache.time = ++time;
cache.value = value;
S.add(cache);
key_table.put(key, cache);
}
}
}
3.代码分析:
(1)Node 内部类(核心数据结构)
| 部分 | 作用 |
|---|
| 成员变量 cnt/time | cnt 记录节点被访问的频率,time 记录最后一次访问的时间戳,是排序核心; |
| 构造方法 | 初始化节点的频率、时间戳、键、值; |
| equals() | 定义节点'相等'的规则(按 cnt+time),保证 TreeSet 能正确判断节点; |
| compareTo() | 定义 TreeSet 的排序规则:先按频率升序,频率相同按时间戳升序,这是 LFU 核心逻辑; |
| hashCode() | 配合 equals(),保证相等的节点哈希值相同,避免 TreeSet/HashMap 哈希冲突; |
| 变量名 | 类型 | 作用 |
|---|
| capacity | int | 缓存的最大容量,超过容量时触发淘汰逻辑; |
| time | int | 全局时间戳,每次访问 / 更新节点时递增,用于标记节点的访问顺序; |
| key_table | HashMap<Integer, Node> | 键 -> 节点的映射,实现 O(1) 时间复杂度查找节点; |
| S | TreeSet | 有序集合,按 compareTo() 规则排序,快速找到「待淘汰节点」(第一个元素); |
(3)核心方法
构造方法 LFUCache(int capacity)
初始化缓存容量、全局时间戳、哈希表 key_table、有序集合 S,为后续操作做准备。
get(int key)(获取缓存)
核心逻辑:
边界校验:容量为 0/key 不存在,返回 -1;
存在则先移除旧节点(因为属性要更新,排序位置会变);
更新节点的频率和时间戳,重新加入有序集合;
返回节点的值。
put(int key, int value)(添加 / 更新缓存)
分两种情况:
key 不存在:若缓存已满,先淘汰 TreeSet 第一个节点(频率最低 + 最早访问),再创建新节点(初始频率为 1)加入哈希表和有序集合;
key 已存在:和 get 逻辑类似,更新节点的频率、时间戳、值,重新加入有序集合。
(4)总结
核心逻辑:通过 HashMap 实现 O(1) 查找,通过 TreeSet 实现节点按「频率 + 时间戳」有序存储,保证 LFU 淘汰规则;
排序规则:TreeSet 按「频率升序、时间戳升序」排序,第一个元素就是待淘汰的节点;
关键操作:每次更新节点(get/put)时,必须先从 TreeSet 移除旧节点,更新属性后重新加入,否则排序会失效。
2、双哈希表
1.算法设计:
双层哈希表 + 双向链表:
keyTable:键→节点的哈希表,实现 O(1) 时间查找节点;
freqTable:频率→双向链表的哈希表,同一频率的所有节点存在同一个双向链表中;
双向链表内按 LRU 规则排序(新访问的节点放链表头部,淘汰时删尾部);
minfreq 优化:记录当前最小访问频率,直接定位「待淘汰节点所在的频率组」,无需遍历所有频率;
淘汰规则:缓存满时,删除 minfreq 对应链表的尾节点(该频率下最久未使用的节点);
频率更新规则:节点被访问(get/put)时,频率 + 1,从原频率链表移除,加入新频率链表;若原频率链表为空,则删除该频率映射,且更新 minfreq(仅当原频率 = minfreq 时)。
2.代码实例:
class LFUCache {
int minfreq;
int capacity;
Map<Integer, Node> keyTable;
Map<Integer, DoublyLinkedList> freqTable;
public LFUCache(int capacity) {
this.minfreq = 0;
this.capacity = capacity;
keyTable = new HashMap<>();
freqTable = new HashMap<>();
}
public int get(int key) {
if (capacity == 0) {
return -1;
}
if (!keyTable.containsKey(key)) {
return -1;
}
Node node = keyTable.get(key);
int val = node.val;
int freq = node.freq;
freqTable.get(freq).remove(node);
if (freqTable.get(freq).size == 0) {
freqTable.remove(freq);
if (minfreq == freq) {
minfreq += 1;
}
}
DoublyLinkedList list = freqTable.getOrDefault(freq + 1, new DoublyLinkedList());
list.addFirst(new Node(key, val, freq + 1));
freqTable.put(freq + 1, list);
keyTable.put(key, freqTable.get(freq + 1).getHead());
return val;
}
public void put(int key, int value) {
if (capacity == 0) {
return;
}
if (!keyTable.containsKey(key)) {
if (keyTable.size() == capacity) {
Node node = freqTable.get(minfreq).getTail();
keyTable.remove(node.key);
freqTable.get(minfreq).remove(node);
if (freqTable.get(minfreq).size == 0) {
freqTable.remove(minfreq);
}
}
DoublyLinkedList list = freqTable.getOrDefault(1, new DoublyLinkedList());
list.addFirst(new Node(key, value, 1));
freqTable.put(1, list);
keyTable.put(key, freqTable.get(1).getHead());
minfreq = 1;
}
else {
Node node = keyTable.get(key);
int freq = node.freq;
freqTable.get(freq).remove(node);
if (freqTable.get(freq).size == 0) {
freqTable.remove(freq);
if (minfreq == freq) {
minfreq += 1;
}
}
DoublyLinkedList list = freqTable.getOrDefault(freq + 1, new DoublyLinkedList());
list.addFirst(new Node(key, value, freq + 1));
freqTable.put(freq + 1, list);
keyTable.put(key, freqTable.get(freq + 1).getHead());
}
}
}
class Node {
int key;
int val;
int freq;
Node prev;
Node next;
Node() {
this(-1, -1, 0);
}
Node(int key, int val, int freq) {
this.key = key;
this.val = val;
this.freq = freq;
}
}
class DoublyLinkedList {
Node dummyHead;
Node dummyTail;
int size;
public DoublyLinkedList() {
dummyHead = new Node();
dummyTail = new Node();
dummyHead.next = dummyTail;
dummyTail.prev = dummyHead;
size = 0;
}
public void addFirst(Node node) {
Node prevHead = dummyHead.next;
node.prev = dummyHead;
dummyHead.next = node;
node.next = prevHead;
prevHead.prev = node;
size++;
}
public void remove(Node node) {
Node prev = node.prev;
Node next = node.next;
prev.next = next;
next.prev = prev;
size--;
}
public Node getHead() {
return dummyHead.next;
}
public Node getTail() {
return dummyTail.prev;
}
}
3.代码分析:
(1)Node 类(双向链表节点)
| 部分 | 作用 |
|---|
| 成员变量 key/val | 存储缓存的键和值,淘汰时需要通过 key 删除 keyTable 中的映射; |
| 成员变量 freq | 记录该节点被访问的频率,是 freqTable 的核心索引; |
| 成员变量 prev/next | 双向链表的前后指针,实现节点的快速增删; |
| 构造方法 | 无参构造用于创建哑节点(哨兵),有参构造用于创建真实缓存节点; |
(2)DoublyLinkedList 类(双向链表)
| 方法 / 变量 | 作用 |
|---|
| dummyHead/dummyTail | 哑头 / 哑尾节点(哨兵),避免处理「链表为空 / 只有一个节点」的边界问题; |
| size | 记录链表节点数,快速判断链表是否为空; |
| addFirst(Node) | 头插法:新访问的节点放链表头部(LRU 规则,最新使用的在头部); |
| remove(Node) | 删除指定节点:O(1) 时间复杂度,双向链表的核心优势; |
| getHead() | 获取链表第一个真实节点(最新访问的节点); |
| getTail() | 获取链表最后一个真实节点(最久未使用的节点,淘汰时删这个); |
| 变量名 | 作用 |
|---|
| minfreq | 记录当前最小访问频率,核心优化点:无需遍历所有频率,直接定位待淘汰的频率组; |
| capacity | 缓存最大容量,超过时触发淘汰逻辑 |
| keyTable | 键→节点的映射,O(1) 时间查找节点,是缓存快速访问的基础; |
| freqTable | 频率→链表的映射,将同一频率的节点分组,实现「按频率淘汰」的核心逻辑; |
构造方法
初始化所有成员变量,为缓存操作做准备;minfreq 初始为 0,因为还没有任何节点。
get(int key) 方法(获取缓存)
核心流程:
边界校验:容量为 0/key 不存在 → 返回 -1;
取出节点,记录其值和当前频率;
从原频率链表移除节点,若链表为空则删除该频率映射,并更新 minfreq;
将节点(频率 + 1)加入新频率链表的头部;
更新 keyTable 映射,返回缓存值。
put(int key, int value) 方法(添加 / 更新缓存)
分两种核心场景:
场景 1:key 不存在(新增节点)
缓存已满:删除 minfreq 对应链表的尾节点(最久未使用),再新增节点;
缓存未满:直接创建频率为 1 的新节点,加入频率 1 的链表,重置 minfreq=1;
场景 2:key 已存在(更新节点)
逻辑和 get 基本一致,唯一区别是更新节点的 value 值;
(4)总结
核心优势:相比 TreeSet 实现,该方案所有操作(增删改查)都是纯 O(1) 时间复杂度(TreeSet 的增删是 O(logn)),是 LFU 缓存的最优实现;
核心逻辑:
按频率分组:每个频率对应一个双向链表,链表内按 LRU 排序;
minfreq 快速定位:淘汰时直接取 minfreq 对应链表的尾节点;
频率更新:节点访问后频率 + 1,从原频率链表移到新频率链表;
关键细节:
哑节点简化链表边界处理;
头插法保证链表内 LRU 规则;
只有原频率 = minfreq 且链表为空时,才更新 minfreq。
三、总结