力扣(LRU缓存)

解析 LeetCode 146. LRU 缓存:哈希表与双向链表的协同设计

一、题目分析

(一)问题定义

设计一个缓存结构,支持 get 和 put 操作,所有操作必须在 O(1) 时间内完成。

capacity 表示缓存最多能存几个键值对。

每个 key 对应唯一 value。

get(key) 返回对应 value,key 不存在时返回 -1。

put(key, value) 若 key 已存在,则更新 value 并标记为最近使用。若不存在,则插入新键值对。插入后若总 key 数超过 capacity,必须立即删除最久未使用的那个 key。

例如 capacity = 2 时:

  • put(1,1) → 缓存中只有 (1,1)
  • put(2,2) → 缓存变成 (1,1) (2,2),(1,1) 在前表示更早插入或更久没被访问
  • get(1) → 返回 1,并把 (1,1) 移到最新位置,缓存仍为 (1,1) (2,2),但时序已重排
  • put(3,3) → 要插入新 key,但已满,删最久未用的 (2,2),再加 (3,3),缓存变成 (1,1) (3,3)

(二)核心挑战

O(1) 是硬门槛:不能有任何 for 循环,不能遍历链表找尾,不能扫描哈希表查最老。

既要按 key 快速定位 value,又要按访问时间动态调整顺序------哈希表擅长前者,但无法记录时序。数组或单链表能记顺序,但删最老或插最新都可能 O(n)。

真实场景中,一次 get 就是用户点开商品页,一次 put 就是加入购物车,缓存必须瞬时响应,否则页面卡顿。

二、算法思路:哈希表 + 双向链表

(一)为何选双向链表而非单向

  • 单向链表删 tail 需从 head 开始走到倒数第二个节点,O(n) 直接失败。
  • 双向链表中 tail.prev 就是最久未用节点,一步拿到,O(1) 删除。
  • 把任意节点挪到 head 后,只需断开原连接、重连 head 附近四条指针,全程无遍历。

(二)头尾哨兵节点的设计意义

  • head 和 tail 是虚拟节点,不存业务数据,只起锚点作用。
  • 所有真实节点永远夹在 head 和 tail 中间:head.next 永远指向最新节点,tail.prev 永远指向最久节点。
  • 初始化时 head ↔ tail 连好,后续所有操作都不用判空:removeNode 不检查 node.prev 是否 null,removeTail 直接取 tail.prev 不怕空指针。

三、代码实现与详细解析

java 复制代码
import java.util.HashMap;
import java.util.Map;
public class LRUCache
{
    // 链表节点类:存 key、value 和前后指针
    private class ListNode
    {
        int key;
        int value;
        ListNode prev;
        ListNode next;
        public ListNode(int key, int value)
        {
            this.key = key;
            this.value = value;
        }
    }
    private final int capacity; // 缓存最大容量,定长不改
    private final Map<Integer, ListNode> cache; // key 到节点的映射,保证 get/put 查找 O(1)
    private final ListNode head; // 哨兵头节点,不存数据,只作锚点
    private final ListNode tail; // 哨兵尾节点,不存数据,只作锚点
    public LRUCache(int capacity)
    {
        this.capacity = capacity;
        this.cache = new HashMap<>(); // 初始化哈希表,初始桶数组为空
        this.head = new ListNode(-1, -1); // 哨兵头,key/value 仅占位,无业务含义
        this.tail = new ListNode(-1, -1); // 哨兵尾,同上
        head.next = tail; // 连 head → tail
        tail.prev = head; // 连 tail ← head,形成空双向链表 head ↔ tail
    }
    public int get(int key)
    {
        if (!cache.containsKey(key))
        {
            return -1; // key 不在缓存中,直接返回 -1,不触发任何链表操作
        }
        ListNode node = cache.get(key); // 从哈希表 O(1) 拿到节点引用
        moveToHead(node); // 访问即最新,必须移到 head 后,更新时序
        return node.value; // 返回对应 value
    }
    public void put(int key, int value)
    {
        if (cache.containsKey(key))
        {
            ListNode node = cache.get(key); // key 已存在,拿到旧节点
            node.value = value; // 直接更新 value,不新建节点
            moveToHead(node); // 移到 head 后,标记为最新访问
        }
        else
        {
            ListNode newNode = new ListNode(key, value); // key 不存在,新建节点
            cache.put(key, newNode); // 哈希表登记 key→node 映射
            addToHead(newNode); // 插入链表 head 后,成为最新
            if (cache.size() > capacity)
            {
                ListNode tailNode = removeTail(); // 超容,删最久节点 tail.prev
                cache.remove(tailNode.key); // 哈希表同步删 key,避免 dangling reference
            }
        }
    }
    // 将指定节点移到 head 后,代表它变成最新访问
    private void moveToHead(ListNode node)
    {
        removeNode(node); // 先从当前链表位置摘下该节点
        addToHead(node); // 再插到 head 后,完成时序刷新
    }
    // 从链表中彻底移除指定节点,仅改指针,不涉及哈希表
    private void removeNode(ListNode node)
    {
        node.prev.next = node.next; // 上一节点的 next 指向下一节点
        node.next.prev = node.prev; // 下一节点的 prev 指向上一节点
    }
    // 在 head 后插入节点,使 node 成为最新
    private void addToHead(ListNode node)
    {
        node.prev = head; // node 的 prev 指向 head
        node.next = head.next; // node 的 next 指向原 head.next
        head.next.prev = node; // 原 head.next 的 prev 指回 node
        head.next = node; // head 的 next 指向 node,完成插入
    }
    // 删除 tail.prev 节点并返回它,用于淘汰最久未用项
    private ListNode removeTail()
    {
        ListNode tailNode = tail.prev; // 直接取 tail 的前一个,就是最久节点
        removeNode(tailNode); // 调用通用删除逻辑
        return tailNode; // 返回被删节点,供上层清理哈希表
    }
}

(一)代码流程拆解

  1. 初始化:new LRUCache(2) 时,建 capacity=2、空 HashMap、head 和 tail 两个哨兵,并让 head.next = tail、tail.prev = head,此时链表长度为 0,但结构已就绪。
  2. get 流程:查 cache 无 key → 返回 -1。有 key → 取 node → moveToHead → 返回 value。moveToHead 内部先 removeNode 再 addToHead,两步都是 O(1) 指针操作。
  3. put 流程:key 存在 → 更新 value + moveToHead。key 不存在 → new node + cache.put + addToHead + 检查 size。超容时 removeTail 返回节点,cache.remove 删除映射,双写一致。
  4. 辅助方法闭环:removeNode 断开左右连接。addToHead 四步连指针。removeTail 封装 tail.prev 获取和删除。moveToHead 组合前两者,复用不重复。

(二)关键逻辑解析

head.next 是最新: 每次 addToHead 都把 node 插在 head 后,所以 head.next 永远是刚插入或刚访问的节点。例如 put(1,1) 后 head.next 就是 (1,1)。再 put(2,2),(2,2) 插到 head 后,head.next 变成 (2,2),(1,1) 退为第二新。

tail.prev 是最久: 所有节点都在 head 和 tail 之间,新增和移动只动 head 端,tail 端始终不动,所以 tail.prev 一定是当前链表里最晚被 addToHead 或 moveToHead 的那个,也就是最久未用。

removeNode 不判空: 因为所有调用 removeNode 的地方,node 都来自 cache.get 或 tail.prev,而 cache 中的 node 必然已正确挂入链表(prev/next 非 null),且 tail.prev 在非空链表中一定不为 null ------ 哨兵保证了这一点。

addToHead 四步不能乱序: 必须先设 node.prev = head 和 node.next = head.next,再改 head.next.prev 和 head.next。如果先改 head.next,head.next.prev 就找不到原节点了。

超容判断用 cache.size: 不用链表 length,因为 cache 是唯一权威入口。size() 是 HashMap 的 O(1) 方法,且与实际 key 数严格一致,比遍历链表数节点靠谱。

(三)易错点

  • 误用单向链表删 tail: 单向链表没有 prev 指针,删 tail 时得从 head 一路 next 到倒数第二个节点,再 node.next = null,O(n) 直接超时。双向链表 tail.prev 一步到位,O(1)。

  • moveToHead 里漏掉 removeNode: 如果只做 addToHead 不先摘下 node,node 会同时挂在原位置和 head 后,造成指针混乱,链表断裂,后续操作全错。比如 (1,1)→(2,2) 链表中,直接 addToHead(1,1),不先 remove,就会出现 (1,1) 同时连着 (2,2) 和 head,head.next 也指向 (1,1),但 (1,1).next 还是 (2,2),链表成环或断裂。

  • 超容时先删哈希再删链表: cache.remove(tailNode.key) 放在 removeTail() 前面,会导致 removeTail() 里取 tail.prev 时,该节点虽在链表中但已不在 cache,后续 get 或 put 若命中该 key 会误判为 miss,但更危险的是:如果此时恰好有并发访问,可能拿到已被 remove 的 node 引用,造成 dangling reference,读到脏数据或空指针。

  • addToHead 四步顺序颠倒: 比如先 head.next = node,再 node.next = head.next,那 node.next 就等于自己,链表自环。正确顺序是先固定 node 的 prev 和 next,再更新外围指针。

  • 用链表节点数判断容量: 链表长度需遍历统计,O(n)。而 cache.size() 是 HashMap 内置字段,O(1) 且绝对准确。用链表长度不仅慢,还可能因指针错误导致计数不准。

  • 哨兵节点不初始化双向连接: 初始化时只写 head.next = tail,漏掉 tail.prev = head,那么第一次 addToHead 就会让 head.next.prev 指向 null,触发空指针异常。必须双向连死,形成 head ↔ tail 初始闭环。

四、复杂度分析

(一)时间复杂度

所有操作均为 O(1):HashMap 的 get/put/remove 平均 O(1)。双向链表的 removeNode、addToHead、removeTail 都是固定次数指针赋值,O(1)。

(二)空间复杂度

O(capacity):哈希表最多存 capacity 个键值对,链表最多存 capacity 个节点,两者空间消耗均与 capacity 线性正比,无额外放大。

相关推荐
珂朵莉MM1 小时前
2026国信杯具身智能创新大赛-编程技能赛--本科组国赛解题报告 | 珂学家
算法·深度优先·图论
啊嘞嘞?1 小时前
力扣(下一个排列)
算法·leetcode
疯狂打码的少年1 小时前
【数据结构】复习日:树 + 图 + 排序(整理对比表)
数据结构·笔记·算法
benchmark_cc2 小时前
Claude Code + MCP + QuantDash:打造全自动量化研究流水线的终极指南
人工智能·后端·爬虫·算法·claude·mcp·quantdash
星星.7222 小时前
2026河南萌新联赛第六场(郑州大学)补题B、D、L、J、I
数据结构·c++·算法
朝发如雪2 小时前
快速掌握Linux(2)(时间相关函数)(文件IO)
linux·python·算法
Forever Nore2 小时前
LeetCode 16 最接近的三数之和 - 双指针逼近
算法·leetcode·职场和发展
smj2302_796826522 小时前
解决leetcode第4033题有效K个不同元素子数组I
数据结构·python·算法·leetcode
学linux的QQ蛋3 小时前
Linux 文件 IO:系统调用 open/read/write 完整总结
linux·运维·算法