【力扣100题】21. LRU 缓存

一、题目描述

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

实现 LRUCache 类:

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

函数 get 和 put 必须以 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]

解释:
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

提示:

  • 1 <= capacity <= 3000
  • 0 <= key <= 10000
  • 0 <= value <= 10^5
  • 最多调用 2 * 10^5 次 get 和 put

二、解题思路总览

核心问题: 如何在 O(1) 时间复杂度内完成 get 和 put 操作?

解决方案:哈希表 + 双向链表

数据结构 作用
哈希表 unordered_map 存储 key -> 节点的映射,实现 O(1) 查找
双向链表(循环) 按访问顺序存储节点,最近使用的在头部,最久未使用的在尾部
操作 哈希表操作 链表操作
get(key) O(1) 查找节点 O(1) 移动到头部
put(key, value) O(1) 插入/更新 O(1) 插入头部,O(1) 删除尾部

时间复杂度:O(1) for both get and put


三、完整代码

cpp 复制代码
struct Node {
    int key;
    int value;
    Node* prev;
    Node* next;

    Node(int k = 0, int v = 0) : key(k), value(v) {}
};

class LRUCache {
private:
    int _capacity;
    Node* dummy;
    unordered_map<int, Node*> key_to_node;

    // 将节点从双向链表中删除
    void remove(Node* x) {
        x->prev->next = x->next;
        x->next->prev = x->prev;
    }

    // 将节点插入到链表头部(最近使用)
    void push_front(Node* x) {
        x->prev = dummy;
        x->next = dummy->next;
        x->prev->next = x;
        x->next->prev = x;
    }

    // 获取节点,并将节点移到头部
    Node* get_node(int key) {
        auto it = key_to_node.find(key);
        if (it == key_to_node.end()) return NULL;

        Node* node = it->second;
        remove(node);
        push_front(node);
        return node;
    }

public:
    LRUCache(int capacity) : _capacity(capacity), dummy(new Node()) {
        dummy->prev = dummy;
        dummy->next = dummy;
    }

    int get(int key) {
        Node* node = get_node(key);
        return node ? node->value : -1;
    }

    void put(int key, int value) {
        Node* node = get_node(key);
        if (node) {
            node->value = value;
            return;
        }

        // key 不存在,新建节点并插入头部
        key_to_node[key] = node = new Node(key, value);
        push_front(node);

        // 容量超限时,删除最久未使用的节点(尾部)
        if (key_to_node.size() > _capacity) {
            Node* back_node = dummy->prev;
            key_to_node.erase(back_node->key);
            remove(back_node);
            delete back_node;
        }
    }
};

四、算法流程图

4.1 get 操作流程

复制代码
输入:key

[Step 1] 在哈希表中查找 key
         |
         v
    key_to_node.find(key) 找到?
      |否                |是
      v                 v
  返回 NULL          [Step 2] 获取节点 node
                           |
                           v
                     【从链表中删除 node】
                           |
                           v
                     remove(node)
                           |
                           v
                     【将 node 插入头部】
                           |
                           v
                     push_front(node)
                           |
                           v
                     返回 node->value

4.2 put 操作流程

复制代码
输入:key, value

[Step 1] 调用 get_node(key) 查找节点
         |
         v
    node 存在?
      |是                      |否
      v                       v
  [Step 2] 更新值            [Step 3] 新建节点
      |                          |
      v                          v
  node->value = value        key_to_node[key] = node
      |                          |                = new Node(key, value)
      v                          v
  【返回】                   push_front(node)
                                 |
                                 v
                          [Step 4] 判断容量
                                 |
                                 v
                          key_to_node.size() > _capacity ?
                               |否
                               v
                          【返回】              |
                                               v
                                          [Step 5] 删除最久未使用
                                               |
                                               v
                                          back_node = dummy->prev
                                               |
                                               v
                                          key_to_node.erase(back_node->key)
                                               |
                                               v
                                          remove(back_node)
                                               |
                                               v
                                          delete back_node
                                               |
                                               v
                                          【返回】

4.3 remove 节点操作流程

复制代码
输入:待删除节点 x

操作前链表状态:
... -> x->prev -> x -> x->next -> ...

[Step 1] x->prev->next = x->next
         将 x 的前驱节点的 next 指向 x 的后继节点
         |
         v
操作后(中间状态):
... -> x->prev ----> x->next -> ...

[Step 2] x->next->prev = x->prev
         将 x 的后继节点的 prev 指向 x 的前驱节点
         |
         v
操作后(完成):
... -> x->prev -> x->next -> ...

x 节点已从链表中完全脱离

4.4 push_front 插入头部操作流程

复制代码
输入:待插入节点 x

操作前链表状态:
dummy -> nodeA -> nodeB -> ... -> tail
          ^
          |
         x(待插入)

[Step 1] x->prev = dummy
         x->next = dummy->next
         将 x 的前驱指向 dummy,后继指向原第一个节点
         |
         v
[Step 2] x->prev->next = x
         即 dummy->next = x
         |
         v
[Step 3] x->next->prev = x
         即原来第一个节点的 prev 指向 x
         |
         v
操作后:
dummy -> x -> nodeA -> nodeB -> ... -> tail

4.5 整体数据流

复制代码
初始状态(capacity = 2):
dummy -> dummy(空链表)

put(1, 1) 后:
dummy -> [key=1, val=1] -> dummy(循环)

put(2, 2) 后:
dummy -> [key=2, val=2] -> [key=1, val=1] -> dummy(循环)

get(1) 后(1 被移到头部):
dummy -> [key=1, val=1] -> [key=2, val=2] -> dummy(循环)

put(3, 3) 后(容量已满,删除最久未使用的 2):
dummy -> [key=3, val=3] -> [key=1, val=1] -> dummy(循环)

put(4, 4) 后(容量已满,删除最久未使用的 1):
dummy -> [key=4, val=4] -> [key=3, val=3] -> dummy(循环)

get(1) 后(1 不存在):
返回 -1

五、逐行解析

5.1 Node 结构体

cpp 复制代码
struct Node {
    int key;
    int value;
    Node* prev;
    Node* next;

    Node(int k = 0, int v = 0) : key(k), value(v) {}
};

为什么要存储 key?

当需要删除尾部节点时,不仅要从链表中删除,还要从哈希表中删除对应条目。而哈希表的 key 就是通过 Node::key 来获取的。


5.2 remove 节点

cpp 复制代码
void remove(Node* x) {
    x->prev->next = x->next;
    x->next->prev = x->prev;
}

原理: 标准的双向链表删除节点操作。通过调整前后节点的指针,将 x 从链表中脱离。


5.3 push_front 插入头部

cpp 复制代码
void push_front(Node* x) {
    x->prev = dummy;
    x->next = dummy->next;
    x->prev->next = x;
    x->next->prev = x;
}

原理: 将新节点插入 dummy 和 dummy->next 之间。由于是循环链表,dummy->next 始终指向最近使用的节点。


5.4 get_node 获取并标记为最近使用

cpp 复制代码
Node* get_node(int key) {
    auto it = key_to_node.find(key);
    if (it == key_to_node.end()) return NULL;

    Node* node = it->second;
    remove(node);
    push_front(node);
    return node;
}

原理: 先在哈希表中查找 key,如果不存在返回 NULL。如果存在,先从链表中删除该节点,再插入到头部,表示最近使用过。


5.5 get 获取值

cpp 复制代码
int get(int key) {
    Node* node = get_node(key);
    return node ? node->value : -1;
}

原理: 调用 get_node,如果返回 NULL 则说明 key 不存在,返回 -1;否则返回节点的值。


5.6 put 插入或更新

cpp 复制代码
void put(int key, int value) {
    Node* node = get_node(key);
    if (node) {
        node->value = value;
        return;
    }

    // key 不存在,新建节点
    key_to_node[key] = node = new Node(key, value);
    push_front(node);

    // 容量超限时删除最久未使用的
    if (key_to_node.size() > _capacity) {
        Node* back_node = dummy->prev;
        key_to_node.erase(back_node->key);
        remove(back_node);
        delete back_node;
    }
}

两种情况:

情况 处理逻辑
key 已存在 更新 value,然后将节点移到头部
key 不存在 新建节点插入头部,如果超容量则删除尾部最久未使用的

5.7 构造函数初始化循环链表

cpp 复制代码
LRUCache(int capacity) : _capacity(capacity), dummy(new Node()) {
    dummy->prev = dummy;
    dummy->next = dummy;
}

为什么用循环链表?

dummy 是一个哑节点,prev 和 next 都指向自己形成循环。这样无论链表是否为空,都有一个统一的头节点,插入和删除操作不需要特殊判断边界情况。

dummy->prev 指向谁?

dummy->prev 指向最久未使用的节点,也就是链表的尾部。


六、复杂度分析

操作 时间复杂度 空间复杂度
get O(1) -
put O(1) -
整体 - O(capacity)

空间复杂度: O(capacity),哈希表和双向链表最多存储 capacity 个节点。

为什么是 O(1)?

  • 哈希表查找:O(1)
  • 双向链表插入/删除:O(1)(已知节点指针)
  • remove + push_front:都是 O(1)

七、面试追问

问题 回答要点
为什么不只用哈希表? 哈希表无法维护「最近使用」的访问顺序,无法实现 LRU 淘汰策略
为什么不只用双向链表? 链表按值查找是 O(n),无法满足题目 O(1) 的要求
为什么要用双向链表而不是单向链表? 删除节点需要知道前驱节点,单向链表无法 O(1) 完成删除
循环链表有什么好处? dummy 哑节点使得插入和删除操作不需要特殊判断空链表的情况
删除最久未使用的节点一定是 dummy->prev 吗? 是的,链表从头(dummy->next)到尾(dummy->prev)是「最近到最久」的顺序
Node 结构体中为什么存 key? 删除尾部节点时需要从哈希表中擦除条目,哈希表的 key 通过 node->key 获取
容量为 1 的边界情况如何处理? put 时新建节点然后删除尾部,get 时正常,无特殊处理
哈希表和链表的对应关系是什么? key_to_node[key] 存储的是该 key 对应的链表节点指针,链表节点中存有完整的 key 和 value

八、相关题目

题号 题目 关键点
146 LRU 缓存 本题
460 LFU 缓存 多重哈希表,比 LRU 更复杂
432 全 O(1) 的数据结构 计数器,支持 inc 和 dec
380 O(1) 时间插入、删除和获取随机元素 哈希表 + 数组
381 O(1) 时间插入、删除和获取随机元素(可重复) 数组 + 哈希表(位置列表)

九、总结

要点 内容
核心数据结构 哈希表 + 双向循环链表
哈希表作用 存储 key -> 节点的映射,O(1) 查找
双向链表作用 维护访问顺序,O(1) 插入/删除
dummy 节点 哑节点,省去空链表的边界判断
get 操作 哈希表查找 + 移动到头部
put 操作 哈希表查找 + 更新或插入 + 超容量时删除尾部
时间复杂度 O(1) for both get and put
空间复杂度 O(capacity)
记忆口诀 哈希表加速,双向循环定顺序,哑节点省边界,最近在前,最久在后
相关推荐
凯瑟琳.奥古斯特1 小时前
丑数II C++三指针解法(力扣264)
数据结构·c++·算法·leetcode·职场和发展
YYYing.1 小时前
【C++项目之高并发内存池 (四)】三层缓存的空间回收流程详解
c++·笔记·缓存·高并发·内存池
福大大架构师每日一题1 小时前
ollama v0.23.2 更新:/api/show 缓存提升 6.7 倍,Claude Desktop 集成调整
缓存·ollama
j_xxx404_1 小时前
力扣算法:用栈消消乐,巧解相邻重复与退格字符串
c++·算法·leetcode
lightqjx1 小时前
【Linux】第一个小程序:进度条
linux·服务器·学习·缓存·c·进度条实现
eggrall1 小时前
找到字符串中所有字母异位词(medium)
算法·leetcode·职场和发展
l软件定制开发工作室1 小时前
Spring开发系列教程(37)——使用Conditional
java·后端·spring
RemainderTime2 小时前
基于Spring AI + 阿里百炼 DashScope:构建 AI Agent RAG 企业级知识助手
人工智能·后端·spring·ai·es
Zephyr_02 小时前
SQL,MyBatis-Plus,maven,Spring与VUE3
sql·spring·vue·maven·mybatis