LeetCode - Hot 100 - LRU 缓存

3 阅读4分钟

T## 📌 题目描述

请你设计并实现一个满足 LRU(最近最少使用)缓存 约束的数据结构。

实现 LRUCache 类:

  • LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value;如果不存在,则向缓存中插入该组 key-value。如果插入操作导致关键字数量超过 capacity,则应逐出最久未使用的关键字

函数 getput 必须以 O(1)O(1) 的平均时间复杂度运行。

示例:

输入:
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出:
[null, null, null, 1, null, -1, null, -1, 3, 4]

🧠 思路分析

要求 getput 都是 O(1)O(1),这就排除了遍历的可能。核心矛盾在于:HashMap 能 O(1)O(1) 查找,但没法记录"谁最近用过";链表能维护使用顺序,但没法 O(1)O(1) 定位。两者结合就完美了——HashMap 负责快速查找,双向链表负责维护使用顺序

设计四个核心操作:

  1. HashMap:key → Node,O(1)O(1) 定位节点
  2. 双向链表:head 端是最近使用,tail 端是最久未使用
  3. 访问节点时:从原位置摘掉,插到 head 后面(moveToHead
  4. 容量满了时:摘掉 tail 前面的节点(removeTail),同时从 Map 中删除

capacity = 2 走一遍示例:

put(1, 1): 新建 node(1,1),放 head 后面
  Map: {1node(1,1)}
  链表: head ↔ [1,1] ↔ tail

put(2, 2): 新建 node(2,2),放 head 后面
  Map: {1node(1,1), 2node(2,2)}
  链表: head ↔ [2,2] ↔ [1,1] ↔ tail

get(1): 找到 node(1,1),移到 head 后面,返回 1
  链表: head ↔ [1,1] ↔ [2,2] ↔ tail

put(3, 3): 3 不存在,新建 node(3,3),放 head 后面
  size=3 > capacity=2,移除 tail 前的 [2,2],从 Map 删除 key=2
  Map: {1node(1,1), 3node(3,3)}
  链表: head ↔ [3,3] ↔ [1,1] ↔ tail

get(2): Map 里没有 2,返回 -1

put(4, 4): 4 不存在,新建 node(4,4),放 head 后面
  size=3 > capacity=2,移除 tail 前的 [1,1],从 Map 删除 key=1
  Map: {3node(3,3), 4node(4,4)}
  链表: head ↔ [4,4] ↔ [3,3] ↔ tail

get(1): Map 里没有 1,返回 -1
get(3): 找到,移到 head 后面,返回 3
get(4): 找到,移到 head 后面,返回 4

🖼️ 图解与执行流程

capacity = 2

put(1,1):  head ↔ [1,1] ↔ tail
put(2,2):  head ↔ [2,2] ↔ [1,1] ↔ tail
get(1):    head ↔ [1,1] ↔ [2,2] ↔ tailreturn 1
put(3,3):  head ↔ [3,3] ↔ [1,1] ↔ tail     → 移除 [2,2]
get(2):    Map 无 2                          → return -1
put(4,4):  head ↔ [4,4] ↔ [3,3] ↔ tail     → 移除 [1,1]
get(1):    Map 无 1                          → return -1
get(3):    head ↔ [3,3] ↔ [4,4] ↔ tailreturn 3
get(4):    head ↔ [4,4] ↔ [3,3] ↔ tailreturn 4

双向链表 + 哨兵节点(dummy head/tail)的好处在于:插入和删除不需要判断空指针,所有操作都是统一的 pre.next / next.pre 操作。HashMap 保证了 O(1)O(1) 查找,双向链表保证了 O(1)O(1) 的插入和删除。


💻 核心代码实现

class LRUCache {

    HashMap<Integer, Node> maps = new HashMap<>();
    int capacity;
    int size = 0;
    Node head = new Node(); // 哨兵头节点
    Node tail = new Node(); // 哨兵尾节点

    public LRUCache(int capacity) {
        this.capacity = capacity;
        head.next = tail;
        tail.pre = head;
    }

    // 将节点移到 head 后面(先摘掉再插入)
    public void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    // 将节点插入到 head 后面
    private void addToHead(Node node) {
        node.next = head.next;
        node.pre = head;
        head.next.pre = node;
        head.next = node;
    }

    // 从链表中摘掉节点
    private void removeNode(Node node) {
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }

    // 移除 tail 前面的节点(最久未使用)
    private Node removeTail() {
        Node last = tail.pre;
        removeNode(last);
        return last;
    }

    public int get(int key) {
        Node node = maps.get(key);
        if (node != null) {
            // 访问了就移到 head 后面,标记为最近使用
            moveToHead(node);
            return node.val;
        } else {
            return -1;
        }
    }

    public void put(int key, int value) {
        Node node = maps.get(key);
        if (node == null) {
            // key 不存在:新建节点,加入 Map 和链表
            node = new Node(key, value);
            maps.put(key, node);
            addToHead(node);
            size++;
            // 超容量就移除最久未使用的节点
            if (size > capacity) {
                Node tail = removeTail();
                maps.remove(tail.key);
                size--;
            }
        } else {
            // key 已存在:更新值,移到 head 后面
            node.val = value;
            removeNode(node);
            addToHead(node);
        }
    }
}

class Node {
    int key;
    int val;
    Node next;
    Node pre;

    Node() {}

    Node(int key, int val) {
        this.key = key;
        this.val = val;
    }
}

代码的核心就是四个链表操作:addToHeadremoveNodemoveToHeadremoveTailgetput 都是在这四个操作的基础上加上 HashMap 的读写。Node 里保存 key 是为了在 removeTail 时能从 Map 中删除对应的 key——这一点很容易被忽略。

复杂度分析

  • 时间复杂度getput 均为 O(1)O(1),HashMap 查找 + 链表操作都是常数时间
  • 空间复杂度O(capacity)O(\text{capacity}),HashMap 和链表最多存储 capacity 个节点

🔗 LeetCode 原题链接

LeetCode 146. LRU 缓存