1966年,计算机科学家们在设计操作系统时面临一个核心问题:当内存空间有限而需要存储的数据量无限增长时,应该删除哪些数据?这个看似简单的问题催生了缓存淘汰策略的研究。在众多策略中,LRU(Least Recently Used,最近最少使用)因其简单直观的思想和优秀的实际表现,成为应用最广泛的缓存淘汰算法之一。

今天,LRU缓存不仅出现在操作系统的页面置换中,还广泛应用于数据库的缓冲池管理、Web服务器的缓存系统、浏览器的资源缓存等场景。LeetCode第146题"LRU Cache"更是高频面试题,考察的是对数据结构设计的深刻理解。

为什么需要缓存淘汰策略

缓存的核心价值在于用空间换时间——将频繁访问的数据存储在更快的存储介质中,从而减少访问延迟。但缓存容量永远有限,当新数据到来而缓存已满时,必须决定淘汰哪个旧数据。

选择淘汰策略需要回答一个关键问题:什么样的数据"更有价值",应该保留在缓存中?不同的策略给出了不同的答案:

  • FIFO(First In First Out):先进入缓存的先被淘汰。实现简单,但完全忽略了数据的访问模式。
  • LFU(Least Frequently Used):淘汰访问次数最少的数据。适合访问模式稳定的场景,但对突发性访问不够敏感。
  • LRU(Least Recently Used):淘汰最近最少被访问的数据。假设"最近被访问的数据在未来也更有可能被访问"。

LRU的假设源于程序的局部性原理(Locality of Reference)——程序在一段时间内往往集中访问某一局部区域的数据。这个原理在1968年由Peter Denning正式提出,成为计算机系统设计的基石之一。

LRU算法的核心操作

LRU缓存需要支持两个核心操作:

get(key):查询操作。如果key存在于缓存中,返回其value,并将该key标记为"最近使用";否则返回-1。

put(key, value):插入或更新操作。如果key已存在,更新其value并标记为"最近使用";如果不存在,插入新的键值对。如果插入后超过容量,淘汰"最近最少使用"的key。

关键挑战在于:两个操作的时间复杂度都必须是O(1)

让我们分析要实现O(1)需要哪些条件:

  1. 快速查找:判断key是否存在,并获取其value
  2. 快速删除:淘汰时能快速删除某个元素
  3. 维护顺序:区分"最近使用"和"最近最少使用"

单一数据结构无法同时满足这三个条件。哈希表查找快但没有顺序;链表有序但查找慢。解决方案是组合两种数据结构

数据结构设计:HashMap + 双向链表

核心思想是让两种数据结构各司其职:

  • HashMap:提供O(1)的key查找能力
  • 双向链表:维护访问顺序,支持O(1)的插入和删除
┌─────────────────────────────────────────────────────────────┐
│                        HashMap                              │
│    key → Node pointer (for O(1) lookup)                     │
│                                                             │
│    ┌───┐  ┌───┐  ┌───┐  ┌───┐                               │
│    │ 1 │  │ 2 │  │ 3 │  │ 4 │                               │
│    └─┬─┘  └─┬─┘  └─┬─┘  └─┬─┘                               │
│      │      │      │      │                                 │
│      ▼      ▼      ▼      ▼                                 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │              Doubly Linked List                         │ │
│ │  HEAD ⟷ [1:A] ⟷ [2:B] ⟷ [3:C] ⟷ [4:D] ⟷ TAIL           │ │
│ │   ↑                                  ↑                  │ │
│ │  MRU                                LRU                 │ │
│ │  (Most Recently Used)         (Least Recently Used)     │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

图片来源: Walking in Code - LRU Cache Guide

链表头部(HEAD端)存放"最近使用"的数据,尾部(TAIL端)存放"最近最少使用"的数据。

为什么必须是双向链表

单向链表删除节点时,需要知道前驱节点,这需要从头遍历,时间复杂度为O(n)。双向链表的每个节点同时存储前驱和后继指针,删除操作可以在O(1)内完成——这正是我们需要的。

哑节点的设计技巧

为了简化边界处理,我们引入两个哑节点:headtail,分别代表链表的虚拟头尾。实际数据节点位于head和tail之间。这样,无论链表是否为空,插入和删除操作都可以统一处理,无需特殊判断。

// 初始化
head = new Node(0, 0);
tail = new Node(0, 0);
head.next = tail;
tail.prev = head;

为什么节点需要存储key

这是一个容易被忽略的细节。当缓存满时,我们需要淘汰tail.prev处的节点,同时需要从HashMap中删除对应的key。如果节点只存储value,我们无法知道要删除哪个key。

// 错误示例:无法从HashMap中删除
Node lru = tail.prev;
removeNode(lru);
// map.remove(???)  // 不知道key是什么

// 正确做法:节点存储key
Node lru = tail.prev;
removeNode(lru);
map.remove(lru.key);  // 可以正确删除

LeetCode 146:LRU Cache 完整Java实现

import java.util.HashMap;
import java.util.Map;

// 双向链表节点定义
class DoublyLinkedNode {
    int key;
    int value;
    DoublyLinkedNode prev;
    DoublyLinkedNode next;
    
    public DoublyLinkedNode(int key, int value) {
        this.key = key;
        this.value = value;
    }
}

public class LRUCache {
    private final Map<Integer, DoublyLinkedNode> cacheMap;
    private final DoublyLinkedNode head;  // 哑头节点
    private final DoublyLinkedNode tail;  // 哑尾节点
    private final int capacity;
    private int size;
    
    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.cacheMap = new HashMap<>();
        this.size = 0;
        
        // 初始化哑节点
        head = new DoublyLinkedNode(0, 0);
        tail = new DoublyLinkedNode(0, 0);
        head.next = tail;
        tail.prev = head;
    }
    
    // 获取key对应的value
    public int get(int key) {
        if (!cacheMap.containsKey(key)) {
            return -1;
        }
        DoublyLinkedNode node = cacheMap.get(key);
        moveToHead(node);  // 标记为最近使用
        return node.value;
    }
    
    // 插入或更新键值对
    public void put(int key, int value) {
        if (cacheMap.containsKey(key)) {
            // key已存在,更新value并移到头部
            DoublyLinkedNode node = cacheMap.get(key);
            node.value = value;
            moveToHead(node);
        } else {
            // key不存在,创建新节点
            DoublyLinkedNode newNode = new DoublyLinkedNode(key, value);
            cacheMap.put(key, newNode);
            addToHead(newNode);
            size++;
            
            // 超出容量,淘汰LRU
            if (size > capacity) {
                DoublyLinkedNode lru = removeTail();
                cacheMap.remove(lru.key);
                size--;
            }
        }
    }
    
    // 将节点移到头部(标记为最近使用)
    private void moveToHead(DoublyLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }
    
    // 从链表中删除节点
    private void removeNode(DoublyLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    // 将节点添加到头部
    private void addToHead(DoublyLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }
    
    // 删除尾部节点(LRU节点)
    private DoublyLinkedNode removeTail() {
        DoublyLinkedNode lru = tail.prev;
        removeNode(lru);
        return lru;
    }
}

执行流程示例

以题目示例为例:LRUCache(2),然后依次执行 put(1,1), put(2,2), get(1), put(3,3), get(2), put(4,4), get(1), get(3), get(4)

Step 1: LRUCache(2)
Cache: 空

Step 2: put(1, 1)
Cache: [1:1] (MRU)

Step 3: put(2, 2)
Cache: [2:2] ⟷ [1:1]
        MRU      LRU

Step 4: get(1) → 返回 1
Cache: [1:1] ⟷ [2:2]  // 1被移到头部
        MRU      LRU

Step 5: put(3, 3) → 淘汰key=2
Cache: [3:3] ⟷ [1:1]
        MRU      LRU

Step 6: get(2) → 返回 -1 (已被淘汰)

Step 7: put(4, 4) → 淘汰key=1
Cache: [4:4] ⟷ [3:3]

Step 8: get(1) → 返回 -1 (已被淘汰)
Step 9: get(3) → 返回 3
Step 10: get(4) → 返回 4

时间复杂度与空间复杂度

操作 时间复杂度 说明
get(key) O(1) HashMap查找 + 链表指针操作
put(key, value) O(1) HashMap操作 + 链表指针操作
空间复杂度 O(capacity) 最多存储capacity个节点

Java LinkedHashMap的巧妙实现

Java标准库中的LinkedHashMap本质上就是HashMap与双向链表的结合体。通过覆盖removeEldestEntry方法,可以一行代码实现LRU缓存:

import java.util.LinkedHashMap;

public class LRUCacheSimple extends LinkedHashMap<Integer, Integer> {
    private final int capacity;
    
    public LRUCacheSimple(int capacity) {
        super(capacity, 0.75f, true);  // accessOrder=true表示按访问顺序排序
        this.capacity = capacity;
    }
    
    public int get(int key) {
        return super.getOrDefault(key, -1);
    }
    
    public void put(int key, int value) {
        super.put(key, value);
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;  // 返回true时自动删除最老的条目
    }
}

注意:面试时面试官可能要求手写实现,而非使用内置类。

LRU vs LFU vs FIFO:如何选择

不同的缓存淘汰策略各有优劣,选择取决于具体场景:

策略 淘汰依据 适用场景 缺陷
LRU 最近访问时间 热点数据随时间变化的场景 对突发性大量访问可能缓存污染
LFU 访问频率 热点数据稳定的场景 历史热点可能长期占用缓存
FIFO 进入顺序 访问模式随机或简单场景 完全忽略访问模式

LRU的缺陷:缓存污染

考虑一个场景:顺序扫描一个远大于缓存容量的数据集。每个数据只会被访问一次,但LRU会不断淘汰旧数据装入新数据,而这些都是"一次性"数据,不会再次访问。这种现象称为缓存污染

为此,数据库和操作系统引入了改进版本:

  • LRU-K:考虑最近K次访问的时间,过滤掉一次性访问
  • 2Q(Two Queues):使用两个队列区分热数据和冷数据
  • ARC(Adaptive Replacement Cache):动态调整LRU和LFU的混合比例

LeetCode 460:LFU Cache 进阶

LFU在LRU基础上增加了频率维度的考量:当需要淘汰时,优先淘汰访问频率最低的数据;如果存在多个频率相同的,则淘汰其中最近最少使用的。这要求我们维护两层顺序:频率顺序同一频率内的访问时间顺序

数据结构设计

  • key到节点的映射Map<Integer, Node> keyMap
  • 频率到节点链表的映射Map<Integer, DoubleLinkedList> freqMap
  • 全局最小频率minFreq,用于快速定位淘汰目标

Java实现

import java.util.HashMap;
import java.util.Map;

class Node {
    int key, value, freq;
    Node prev, next;
    
    public Node(int key, int value) {
        this.key = key;
        this.value = value;
        this.freq = 1;
    }
}

class DoubleLinkedList {
    Node head, tail;
    int size;
    
    public DoubleLinkedList() {
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
    }
    
    public void addToHead(Node node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
        size++;
    }
    
    public void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
        size--;
    }
    
    public Node removeTail() {
        if (size == 0) return null;
        Node node = tail.prev;
        removeNode(node);
        return node;
    }
    
    public boolean isEmpty() {
        return size == 0;
    }
}

public class LFUCache {
    private final Map<Integer, Node> keyMap;
    private final Map<Integer, DoubleLinkedList> freqMap;
    private final int capacity;
    private int minFreq;
    
    public LFUCache(int capacity) {
        this.capacity = capacity;
        this.keyMap = new HashMap<>();
        this.freqMap = new HashMap<>();
        this.minFreq = 0;
    }
    
    public int get(int key) {
        if (!keyMap.containsKey(key)) {
            return -1;
        }
        Node node = keyMap.get(key);
        increaseFreq(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        if (capacity == 0) return;
        
        if (keyMap.containsKey(key)) {
            Node node = keyMap.get(key);
            node.value = value;
            increaseFreq(node);
        } else {
            if (keyMap.size() >= capacity) {
                evict();
            }
            Node newNode = new Node(key, value);
            keyMap.put(key, newNode);
            freqMap.computeIfAbsent(1, k -> new DoubleLinkedList()).addToHead(newNode);
            minFreq = 1;
        }
    }
    
    private void increaseFreq(Node node) {
        int oldFreq = node.freq;
        DoubleLinkedList oldList = freqMap.get(oldFreq);
        oldList.removeNode(node);
        
        if (oldFreq == minFreq && oldList.isEmpty()) {
            minFreq++;
        }
        
        node.freq++;
        freqMap.computeIfAbsent(node.freq, k -> new DoubleLinkedList()).addToHead(node);
    }
    
    private void evict() {
        DoubleLinkedList minFreqList = freqMap.get(minFreq);
        Node node = minFreqList.removeTail();
        keyMap.remove(node.key);
    }
}

LFU时间复杂度

操作 时间复杂度
get(key) O(1)
put(key, value) O(1)

虽然LFU比LRU多维护了频率信息,但通过精巧的数据结构设计,依然可以实现O(1)操作。

实际应用:操作系统与数据库中的LRU

操作系统页面置换

当物理内存不足时,操作系统需要将部分内存页换出到磁盘。LRU是一种经典的页面置换策略。但精确实现LRU需要维护每个页面的访问时间戳,开销过大。因此实际系统使用近似算法:

  • Clock算法:环形链表 + 访问位,模拟LRU行为
  • Clock-Pro:引入重用距离的概念,进一步优化

MySQL InnoDB缓冲池

MySQL的InnoDB存储引擎使用变体LRU管理缓冲池。为了避免全表扫描污染缓存,InnoDB将LRU链表分为两部分:

  • Young区域(前5/8):存放频繁访问的热数据
  • Old区域(后3/8):新读取的页先进入这里

只有当数据在Old区域被再次访问,才会晋升到Young区域。这种设计有效防止了一次性扫描数据挤占缓存。

┌─────────────────────────────────────────────────────┐
│                    InnoDB Buffer Pool LRU           │
│                                                     │
│   ┌──────────────────────┐ ┌─────────────────────┐  │
│   │    Young Region      │ │    Old Region       │  │
│   │      (热数据)         │ │    (新数据入口)      │  │
│   │     5/8 容量         │ │     3/8 容量        │  │
│   └──────────────────────┘ └─────────────────────┘  │
│   MRU                      Midpoint               LRU│
└─────────────────────────────────────────────────────┘

图片来源: MySQL Documentation

面试技巧与常见陷阱

常见错误

  1. 忘记在put时移动已存在key的位置:更新value后必须移到头部
  2. 淘汰顺序错误:应该先淘汰再插入新节点,否则可能淘汰刚插入的数据
  3. 指针操作顺序错误:链表操作时先保存后继节点,避免丢失引用

手写代码检查清单

  • 哑节点正确初始化:head.next = tail; tail.prev = head
  • 节点类包含key字段
  • get操作成功时移到头部
  • put更新已存在key时移到头部
  • 淘汰时同时删除链表节点和HashMap条目

相关LeetCode题目一览

题号 题目 难度 核心考点
146 LRU Cache 中等 HashMap + 双向链表
460 LFU Cache 困难 多层频率映射
432 All O`one Data Structure 困难 双向链表 + 嵌套结构
355 Design Twitter 中等 多路归并 + 缓存思想

总结

LRU缓存算法的核心在于HashMap与双向链表的精妙结合:HashMap提供O(1)的查找能力,双向链表维护访问顺序并支持O(1)的插入删除。理解这个设计,不仅是为了通过面试,更是掌握了一种将两种基础数据结构组合解决复杂问题的思维方式。

从操作系统的页面置换到数据库的缓冲池管理,从Web服务器的资源缓存到分布式系统的热点数据调度,LRU的思想无处不在。而LFU等变体算法则展示了如何在此基础上引入新的维度,解决更复杂的问题。

当你下次在面试中遇到"设计一个缓存"这样的问题时,希望这篇文章能帮助你从容应对——不仅写出正确的代码,更能讲清楚背后的设计权衡。


参考资料

  1. O’Neil, E. J., O’Neil, P. E., & Weikum, G. (1993). The LRU-K page replacement algorithm for database disk buffering. ACM SIGMOD Record, 22(2), 297-306.
  2. Denning, P. J. (1968). The working set model for program behavior. Communications of the ACM, 11(5), 323-333.
  3. Cache replacement policies - Wikipedia: https://en.wikipedia.org/wiki/Cache_replacement_policies
  4. MySQL 9.6 Reference Manual - Making the Buffer Pool Scan Resistant: https://dev.mysql.com/doc/refman/9.6/en/innodb-performance-midpoint_insertion.html
  5. GeeksforGeeks - LRU Cache Implementation: https://www.geeksforgeeks.org/dsa/lru-cache-implementation-using-double-linked-lists/
  6. Baeldung - How to Implement LRU Cache in Java: https://www.baeldung.com/java-lru-cache