每天学一个算法--缓存淘汰策略(LRU / LFU · 结构与复杂度)

📘 教案 31:缓存淘汰策略(LRU / LFU · 结构与复杂度)


一、问题模型(精确定义)

容量受限缓存 ©,最大容纳 (K) 个键值对。操作:

  • get(k):若存在返回值并更新访问状态
  • put(k, v):写入;若满则淘汰一个键

目标(经典假设:未来未知):

在在线(online)条件下,最小化未命中(miss)次数。

这在理论上等价于近似最优的离线算法(Belady,淘汰未来最晚使用项)


二、LRU(Least Recently Used)

1. 语义

淘汰"最近最久未被访问"的键。


2. 数据结构设计(O(1) 实现)

需要同时支持:

  • 按 key 查找:(O(1))
  • 更新"最近使用"顺序:(O(1))
  • 删除最旧元素:(O(1))

结构:HashMap + 双向链表

  • map: key → node*
  • list: 头=最近使用(MRU),尾=最久未用(LRU)

3. 不变量(必须维持)

  • 链表顺序严格表示访问新旧
  • map 中的指针始终指向链表中的有效节点

4. 操作流程

get(k)
  1. 若不存在:返回 miss
  2. 若存在:将节点移动到表头(MRU),返回值
put(k, v)
  1. 若已存在:更新值,移动到表头

  2. 若不存在:

    • 若未满:头插新节点
    • 若已满:删除尾节点(LRU),再头插

5. 复杂度

  • get / put:(O(1))(均摊)
  • 空间:(O(K))

6. 代码骨架(Python)

python 复制代码
class Node:
    __slots__ = ("k","v","prev","next")
    def __init__(self,k,v):
        self.k,self.v=k,v
        self.prev=self.next=None

class LRUCache:
    def __init__(self, capacity):
        self.cap=capacity
        self.map={}
        # 哨兵节点
        self.head=Node(0,0)
        self.tail=Node(0,0)
        self.head.next=self.tail; self.tail.prev=self.head

    def _add_front(self, x):
        x.next=self.head.next; x.prev=self.head
        self.head.next.prev=x; self.head.next=x

    def _remove(self, x):
        x.prev.next=x.next; x.next.prev=x.prev

    def _move_to_front(self, x):
        self._remove(x); self._add_front(x)

    def get(self, k):
        if k not in self.map: return -1
        x=self.map[k]
        self._move_to_front(x)
        return x.v

    def put(self, k, v):
        if k in self.map:
            x=self.map[k]; x.v=v
            self._move_to_front(x); return
        if len(self.map)==self.cap:
            lru=self.tail.prev
            self._remove(lru)
            del self.map[lru.k]
        x=Node(k,v)
        self._add_front(x)
        self.map[k]=x

7. LRU 的问题(必须知道)

  • 对"扫描型访问"(scan)不友好:一次线性扫描会把热点挤出
  • 不区分"频率":短时间内多次访问与一次访问效果相同(都会被刷新到 MRU)

三、LFU(Least Frequently Used)

1. 语义

淘汰"访问频率最低"的键;若频率相同,通常再用 LRU 作为次序。


2. O(1) 设计(难点)

需要:

  • get/put:(O(1))
  • 按频率分桶,支持频率递增与最小频率定位

结构:

  • key_map: key → (value, freq, node*)
  • freq_map: freq → 双向链表(该频率的键,按最近使用排序)
  • min_freq: 当前最小频率

3. 不变量

  • 每个 key 只在一个 freq 桶中
  • min_freq 指向非空的最小频率桶

4. 操作流程

get(k)
  1. 不存在:miss

  2. 存在:

    • freq = f 的链表中删除该节点
    • 若该链表为空且 f == min_freq,则 min_freq += 1
    • 将节点插入 freq = f+1 的链表头(MRU)
    • 返回值
put(k, v)
  1. 若容量为 0:直接返回

  2. 若已存在:更新值,执行 get(k) 的"频率提升"流程

  3. 若不存在:

    • 若已满:在 freq = min_freq 的链表中删除尾节点(LRU)
    • 插入新节点到 freq = 1 的链表头,设 min_freq = 1

5. 复杂度

  • get / put:(O(1))(均摊)
  • 空间:(O(K))

6. 关键代码骨架(精简)

python 复制代码
from collections import defaultdict

class Node:
    __slots__=("k","v","f","prev","next")
    def __init__(self,k,v):
        self.k,self.v,self.f=k,v,1
        self.prev=self.next=None

class DL:
    def __init__(self):
        self.h=Node(0,0); self.t=Node(0,0)
        self.h.next=self.t; self.t.prev=self.h
        self.sz=0
    def add_front(self,x):
        x.next=self.h.next; x.prev=self.h
        self.h.next.prev=x; self.h.next=x; self.sz+=1
    def remove(self,x):
        x.prev.next=x.next; x.next.prev=x.prev; self.sz-=1
    def pop_tail(self):
        if self.sz==0: return None
        x=self.t.prev; self.remove(x); return x

class LFUCache:
    def __init__(self, cap):
        self.cap=cap; self.size=0
        self.key_map={}
        self.freq_map=defaultdict(DL)
        self.minf=0

    def _inc(self, x):
        f=x.f
        self.freq_map[f].remove(x)
        if f==self.minf and self.freq_map[f].sz==0:
            self.minf+=1
        x.f+=1
        self.freq_map[x.f].add_front(x)

    def get(self,k):
        if k not in self.key_map: return -1
        x=self.key_map[k]; self._inc(x); return x.v

    def put(self,k,v):
        if self.cap==0: return
        if k in self.key_map:
            x=self.key_map[k]; x.v=v; self._inc(x); return
        if self.size==self.cap:
            x=self.freq_map[self.minf].pop_tail()
            del self.key_map[x.k]; self.size-=1
        x=Node(k,v)
        self.key_map[k]=x
        self.freq_map[1].add_front(x)
        self.minf=1; self.size+=1

7. LFU 的问题

  • 维护成本高(常数大)
  • 对"短期突发热点"反应慢(历史频率拖累)

四、工程级改进(真实系统使用)


1. LRU-K

记录最近 K 次访问时间 ,淘汰"第 K 次访问最早"的项。

近似"频率 + 新近性"的折中,复杂度高于 LRU。


2. 2Q

将缓存分为:

  • A1(短期队列)
  • Am(长期队列)

新进入先到 A1,命中后晋升到 Am。
目的:抵抗扫描污染。


3. ARC(Adaptive Replacement Cache)

同时维护:

  • T1(近期)
  • T2(频繁)
  • B1/B2(历史幽灵队列)

动态调整 T1/T2 比例,自适应负载

(专利曾限制使用,但思想重要)


4. TinyLFU(现代主流,Caffeine/Redis 变体)

核心组件:

  • Count-Min Sketch:近似统计频率(极低内存)
  • Admission 策略:新元素只有在"频率超过被淘汰候选"时才进入
  • Window LRU + Main Segmented LRU

优点:

  • 低内存频率统计
  • 对扫描与突发都鲁棒

五、分布式与系统细节


1. 分片(Sharding)

  • 一致性哈希将 key 分配到不同节点
  • 每个节点内部独立执行 LRU/LFU

2. 过期与淘汰的关系

  • TTL(时间过期)容量淘汰是两条路径
  • 实现上常采用:惰性删除 + 定期采样

3. 并发控制

  • 分段锁(segmented lock)或无锁结构(CAS)
  • 读多写少场景下避免全局锁

4. 命中率(Hit Ratio)

核心指标:

\\text{Hit Ratio} = \\frac{\\text{hits}}{\\text{requests}}

评估不同策略(LRU/LFU/TinyLFU)的实际效果。


六、选择建议(工程结论)

场景 建议
通用缓存 LRU(简单、稳定)
强热点、稳定访问 LFU / TinyLFU
扫描/突发混合 2Q / TinyLFU
高并发大规模 TinyLFU(如 Caffeine 思路)

七、结论性表述

缓存淘汰策略通过对"访问新近性与访问频率"的建模,在有限容量约束下选择被驱逐对象,从而最大化命中率。其工程实现依赖于常数时间的数据结构与对实际访问分布的鲁棒性设计(如对扫描与突发的抵抗能力)。

相关推荐
回忆2012初秋2 小时前
.NET 实战:Redis 缓存穿透、击穿与雪崩的原理剖析与解决方案
redis·缓存·.net
大熊背2 小时前
一套为硬件加速设计的经典边缘检测流水线(一)----边缘细化原理
人工智能·算法·计算机视觉·梯度计算
MicroTech20252 小时前
微算法科技(NASDAQ :MLGO)基于线检测掩模的量子算法与新型增强量子表示(NEQR)技术
科技·算法·量子计算
yongui478343 小时前
NSGA-II求解多目标柔性作业车间调度算法(含甘特图绘制)
算法·甘特图
故事和你914 小时前
洛谷-算法2-1-前缀和、差分与离散化1
开发语言·数据结构·c++·算法·深度优先·动态规划·图论
wuqingshun31415910 小时前
说说mybatis的缓存机制
java·缓存·mybatis
知识浅谈10 小时前
DeepSeek V4 和 GPT-5.5 在同一天发布了??我也很懵,但对比完我悟了
算法
DeepModel11 小时前
通俗易懂讲透 Q-Learning:从零学会强化学习核心算法
人工智能·学习·算法·机器学习
田梓燊11 小时前
力扣:19.删除链表的倒数第 N 个结点
算法·leetcode·链表