一、缓存淘汰算法:从 LRU 到 LFU

缓存这东西,说白了就是用空间换时间。你想想看,CPU 要读数据,如果每次都去磁盘翻,那速度慢得让人抓狂。所以我们在内存里划一块区域,把常用的数据先存着。但内存也不是无限的,满了怎么办?就得把一些「不太重要」的数据踢出去。

那问题来了:怎么定义「不太重要」?这就引出了两种经典策略——LRU 和 LFU。

1.1 LRU:最近最少使用

LRU 的全称是 Least Recently Used。它的核心思想很简单:如果数据最近被访问过,那么将来被访问的概率也更高。说白了,就是淘汰那些「很久没人碰」的数据。

我刚开始做缓存系统时,觉得 LRU 不就是个队列嘛,先进先出不就完了?后来才发现,这玩意儿比我想象的复杂。因为 LRU 不仅要记录「谁先来」,还要记录「谁被访问了」——每次访问都要把数据挪到最前面。

核心操作:
  • get(key):如果 key 存在,把该节点移到链表头部,返回 value;否则返回 -1。
  • put(key, value):如果 key 存在,更新 value 并移到头部;如果不存在,插入头部。若容量已满,先删除尾部节点。

1.2 LFU:最不经常使用

LFU 的全称是 Least Frequently Used。它看的是「使用频率」——淘汰那些被访问次数最少的数据。你可能会问:那 LRU 和 LFU 到底选哪个?

嗯,这里有个坑。我记得有一次在项目中做热点数据缓存,用了 LRU。结果有个定时任务每隔几分钟扫一次全表,把缓存里的热点数据全给挤出去了。换成 LFU 之后,情况就好多了——因为热点数据访问次数高,不会被低频的扫描任务冲掉。

但 LFU 也有自己的问题:频率衰减。如果一个数据曾经很热,后来凉了,它的访问次数还很高,就会一直占着位置不走。所以实际工程中,往往会给 LFU 加一个时间窗口,或者定期衰减计数。

二、经典例题:实现 LRUCache

这道题在面试里出现的频率,高得离谱。我面过的大厂,几乎每家都考过。说白了,就是让你手写一个 LRU 缓存。

2.1 数据结构选型

要实现 O(1) 的 get 和 put,你需要两个东西:

  • 哈希表:用于 O(1) 查找 key 对应的节点。
  • 双向链表:用于 O(1) 插入和删除节点。

为什么是双向链表?因为你要删除一个节点时,需要知道它的前驱和后继。单向链表做不到 O(1) 删除。

我的习惯:在链表头尾各加一个哨兵节点(dummy head 和 dummy tail),这样就不用处理边界情况了。代码写起来清爽很多。

2.2 代码实现

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<>();
    private int size;
    private int capacity;
    private DLinkedNode head, tail;

    public LRUCache(int capacity) {
        this.size = 0;
        this.capacity = capacity;
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head.next = tail;
        tail.prev = head;
    }

    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1;
        }
        // 如果 key 存在,先通过哈希表定位,再移到头部
        moveToHead(node);
        return node.value;
    }

    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        if (node == null) {
            // 如果 key 不存在,创建一个新的节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            cache.put(key, newNode);
            addToHead(newNode);
            size++;
            if (size > capacity) {
                // 如果超出容量,删除双向链表的尾部节点
                DLinkedNode tail = removeTail();
                cache.remove(tail.key);
                size--;
            }
        } else {
            // 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
            node.value = value;
            moveToHead(node);
        }
    }

    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }

    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }

    private DLinkedNode removeTail() {
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }
}

2.3 复杂度分析

操作 时间复杂度 空间复杂度
get(key) O(1) O(capacity)
put(key, value) O(1) O(capacity)
我曾经踩过的坑:在 put 操作中,如果 key 已存在,一定要先更新 value 再移到头部。我一开始先移到了头部,再更新 value,结果在并发场景下出现了数据不一致。虽然单线程没问题,但养成好习惯总没错。

三、LRU 与 LFU 的核心逻辑对比

下面这张图,帮你快速理清两种算法的核心逻辑:

LRU 缓存 最近最少使用 Head K1 K2 Tail 淘汰方向 → 访问 K2 → 移到头部 LFU 缓存 最不经常使用 频率表 freq=1 freq=2 freq=3 数据节点 K1 (f=1) K2 (f=2) K3 (f=3) 淘汰频率最低的节点 freq=1 的 K1 被淘汰

四、面试中的常见追问

面试官不会只满足于你写出 LRU。他们通常会追问:

  1. 为什么用双向链表不用单向?——因为删除节点需要 O(1) 找到前驱。
  2. 能不能用 LinkedHashMap 实现?——可以,但面试官一般希望你手写,考察基本功。
  3. LRU 在 Redis 里是怎么实现的?——Redis 用的是近似 LRU,不是精确 LRU。它随机采样几个 key,淘汰其中最久没用的那个。这样省内存。
  4. LFU 怎么实现 O(1)?——需要维护一个频率到双向链表的映射,以及一个最小频率变量。每次访问时,把节点从当前频率链表移到下一个频率链表。
一个小技巧:如果你在面试中写 LRU,可以先画个图,跟面试官说清楚你的数据结构设计。这样他还没看代码,就知道你思路清晰了。我每次面试都这么干,效果不错。

五、总结

LRU 和 LFU 是缓存淘汰算法里最基础的两个。LRU 关注「时间局部性」,LFU 关注「频率局部性」。实际工程中,往往需要根据业务场景来选——甚至把两者结合起来用。

嗯,LRU 的实现其实不难,关键是把哈希表和双向链表配合好。写代码的时候,记得画图辅助思考。我当年第一次写 LRU,就是靠画图才把指针绕清楚的。


公众号:蓝海资料掘金营,微信deep3321