【LeetCode Hot100 刷题日记(35/100)】146. LRU 缓存 —— 设计、哈希表、链表、双向链表📌

6 阅读7分钟

📌 题目链接:146. LRU 缓存 - 力扣(LeetCode) 🔍 难度:中等 | 🏷️ 标签:设计、哈希表、链表、双向链表
⏱️ 目标时间复杂度:O(1)
💾 空间复杂度:O(capacity)
✅ 本题是面试中的 经典数据结构设计题,常出现在大厂(如字节、腾讯、阿里、Google、Meta)的系统设计或高级算法面试中。
🎯 考察点:如何在 O(1) 时间内实现缓存淘汰策略?
💡 关键在于理解 “最近最少使用” 的语义,并用合适的数据结构支撑。


🔍 题目分析

我们要实现一个 LRU(Least Recently Used)缓存机制,满足以下要求:

  • 支持 get(key)put(key, value) 操作;
  • 所有操作必须在 平均 O(1) 时间复杂度完成;
  • 当容量满时,移除最久未使用的元素(即最后一次访问时间最早的元素);

⚠️ 注意:

  • 如果 key 不存在,get 返回 -1
  • put 时若 key 已存在,则更新值并标记为“最近使用”;
  • 若超出容量,删除最久未使用的节点(尾部),再插入新节点到头部。

📌 核心挑战
我们需要快速定位某个 key 是否存在 → 哈希表
同时要维护访问顺序 → 双向链表
两者结合才能做到 O(1) 的插入、删除和查找。


🔧 核心算法及代码讲解

🔄 核心思想:哈希表 + 双向链表

数据结构功能
unordered_map<int, DLinkedNode*> cache快速查找 key 对应的节点位置(O(1))
DLinkedNode* head, DLinkedNode* tail伪头尾节点,避免边界判断
sizecapacity控制缓存大小

🎯 为什么用双向链表?

  • 单向链表只能向前遍历,无法高效地从中间删除节点;
  • 双向链表支持 O(1) 删除任意节点(只要知道指针);
  • 结合哈希表,我们可以先通过 cache[key] 定位节点,再将其移到头部表示“最近使用”。

🧠 关键操作说明

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

👉 先删后插,保证顺序正确。

DLinkedNode* removeTail() {
    DLinkedNode* node = tail->prev;
    removeNode(node);
    return node;
}

👉 尾部节点是最久未使用的,直接返回即可。


✅ 代码实现(完整版)

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

// 定义双向链表节点
struct DLinkedNode {
    int key, value;
    DLinkedNode* prev;
    DLinkedNode* next;
    DLinkedNode(): key(0), value(0), prev(nullptr), next(nullptr) {}
    DLinkedNode(int _key, int _value): key(_key), value(_value), prev(nullptr), next(nullptr) {}
};

class LRUCache {
private:
    unordered_map<int, DLinkedNode*> cache; // 哈希表:key -> 节点指针
    DLinkedNode* head;                     // 伪头部
    DLinkedNode* tail;                     // 伪尾部
    int size;                              // 当前节点数
    int capacity;                          // 最大容量

public:
    LRUCache(int _capacity): capacity(_capacity), size(0) {
        // 使用伪头部和伪尾部节点,简化边界处理
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head->next = tail;
        tail->prev = head;
    }
    
    int get(int key) {
        if (!cache.count(key)) {
            return -1; // key 不存在
        }
        // 如果 key 存在,先通过哈希表定位,再移到头部
        DLinkedNode* node = cache[key];
        moveToHead(node);
        return node->value;
    }
    
    void put(int key, int value) {
        if (!cache.count(key)) {
            // 如果 key 不存在,创建新节点
            DLinkedNode* node = new DLinkedNode(key, value);
            // 添加进哈希表
            cache[key] = node;
            // 添加至双向链表头部
            addToHead(node);
            ++size;
            // 检查是否超容量
            if (size > capacity) {
                // 删除双向链表尾部节点
                DLinkedNode* removed = removeTail();
                // 删除哈希表中对应项
                cache.erase(removed->key);
                // 防止内存泄漏
                delete removed;
                --size;
            }
        }
        else {
            // 如果 key 存在,更新值并移到头部
            DLinkedNode* node = cache[key];
            node->value = value;
            moveToHead(node);
        }
    }

    // 在链表头部添加节点
    void addToHead(DLinkedNode* node) {
        node->prev = head;
        node->next = head->next;
        head->next->prev = node;
        head->next = node;
    }
    
    // 删除指定节点(已知指针)
    void removeNode(DLinkedNode* node) {
        node->prev->next = node->next;
        node->next->prev = node->prev;
    }

    // 移动节点到头部:先删除,再添加
    void moveToHead(DLinkedNode* node) {
        removeNode(node);
        addToHead(node);
    }

    // 删除尾部节点并返回
    DLinkedNode* removeTail() {
        DLinkedNode* node = tail->prev;
        removeNode(node);
        return node;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    // 示例测试
    LRUCache lRUCache(2);
    lRUCache.put(1, 1);    // {1=1}
    lRUCache.put(2, 2);    // {1=1, 2=2}
    cout << lRUCache.get(1) << endl;   // 输出: 1
    lRUCache.put(3, 3);    // {3=3, 2=2},淘汰 1
    cout << lRUCache.get(2) << endl;   // 输出: 2
    lRUCache.put(4, 4);    // {4=4, 3=3},淘汰 2
    cout << lRUCache.get(1) << endl;   // 输出: -1
    cout << lRUCache.get(3) << endl;   // 输出: 3
    cout << lRUCache.get(4) << endl;   // 输出: 4

    return 0;
}

🧩 解题思路(分步详解)

  1. 初始化结构体

    • 创建 headtail 两个伪节点,构成空链表;
    • 初始化 cache 哈希表,size = 0capacity 由构造函数传入;
  2. get 操作流程

    • 查看 cache 是否包含 key;
    • 若无 → 返回 -1;
    • 若有 → 获取节点,调用 moveToHead(),返回其值;
  3. put 操作流程

    • 若 key 不存在:
      • 新建节点;
      • 插入哈希表;
      • 加入链表头部;
      • 若 size > capacity → 删除尾部节点 + 删除哈希表项 + 释放内存;
    • 若 key 存在:
      • 更新 value;
      • 移动到头部;
  4. 链表操作细节

    • addToHead():将节点插入到 head 后面;
    • removeNode():断开当前节点与前后节点的连接;
    • moveToHead():组合操作,用于刷新“最近使用”状态;
    • removeTail():返回尾部前一个节点(真实最后一个节点);

📊 算法分析

操作时间复杂度空间复杂度说明
get(key)O(1)O(1)哈希表查找 + 链表移动
put(key)O(1)O(1)插入/更新 + 可能删除尾部
构造函数O(1)O(1)只初始化几个变量
总体空间O(capacity)存储最多 capacity 个节点

为什么是 O(1)?

  • 哈希表查找:O(1)
  • 链表增删:O(1),因为有指针直接定位
  • 不需要遍历整个链表!

🔧 优化点

  • 使用伪头尾节点 → 避免对边界条件的判断(如 head == nullptr);
  • 手动管理内存(delete removed)→ 防止内存泄漏(C++ 特性);
  • 哈希表存储的是指针 → 避免拷贝大量数据;

💡 面试常见问题 & 拓展知识

❓ Q1:为什么不直接用 map 或 vector?

  • map:虽然可以按顺序存储,但插入删除不是 O(1),且无法高效维护访问顺序;
  • vector:随机访问快,但插入删除慢(O(n)),不适合频繁变动场景;
  • 哈希表 + 双向链表 是最优解!

❓ Q2:有没有其他方式实现 LRU?

是的!以下是替代方案:

方案优缺点
OrderedDict(Python)内置有序字典,自动维护顺序,简洁易懂
Java LinkedHashMap提供 accessOrder=true 参数,可模拟 LRU
跳表 / 平衡树复杂度高,不推荐用于此场景

但在手写面试中,哈希表 + 双向链表 是标准答案。

❓ Q3:如果要用多线程怎么办?

  • 需要加锁保护共享资源(cache, head, tail);
  • 可以使用 std::mutex
  • 或者使用线程安全容器(如 concurrent_unordered_map);
  • 但这超出了本题范围,属于系统设计范畴。

❓ Q4:LRU 有什么变种?

  • LFU(Least Frequently Used):淘汰使用频率最低的;
  • ARC(Adaptive Replacement Cache):更智能的替换策略;
  • TTL 缓存:基于过期时间淘汰;
  • 这些都是高级缓存系统的核心技术!

🧠 设计模式视角

本题体现了典型的 “组合模式” + “职责分离”

  • cache 负责快速查找;
  • 双向链表负责维护顺序;
  • 二者协同工作,各司其职;

这也是很多高性能缓存系统(如 Redis、Memcached)的底层思想之一。


✅ 实际应用价值

应用场景说明
浏览器缓存页面加载时优先读取最近访问的内容
数据库查询缓存经常执行的 SQL 查询结果缓存
操作系统页面置换内存不足时淘汰最久未使用的页
CDN 缓存服务器用户请求时优先返回热门内容

👉 LRU 是现代计算机系统中最基础也最重要的缓存策略之一!


🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪


📣 下一期预告:LeetCode 热题 100 第36题 —— 4.二叉树的中序遍历(简单)

🔹 题目:给定一个二叉树的根节点,返回其中序遍历的结果。

🔹 核心思路:使用递归或栈实现左→根→右的遍历顺序。

🔹 考点:递归、栈、树的遍历、深度优先搜索(DFS)。

🔹 难度:简单,但却是所有树遍历的基础,务必掌握!

💡 提示:不要只记住递归模板,要理解栈是如何模拟递归过程的!

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!