目录

题目描述

LCR 031. LRU 缓存

题意分析

设计一个固定容量的缓存,支持 get(key)put(key, value),容量满时淘汰最久未使用的那个键。题目要求两个操作的时间复杂度都是 $O(1)$。

「最久未使用」需要精确定义:get 命中和 put(无论新增还是覆盖)都算一次使用,都要把该键提升为最近使用;get 未命中不算。所以缓存内部必须维护一个按最近使用时间排序的序列,队首是最新的,队尾是最旧的。

$O(1)$ 这条约束把可选结构压得很窄。逐条拆需求:按键查值要求哈希;把任意一个已存在的元素「提到最前」要求能在常数时间内把它从当前位置摘下来并插到头部;淘汰要求能在常数时间内拿到最尾的元素。

「从中间摘除一个节点」这件事,只有在已知该节点本身且它有前驱指针时才是 $O(1)$。数组做不到(要搬移),单链表也做不到(拿不到前驱),只有双向链表可以。而「已知该节点」这一前提,恰好可以由哈希表提供——让哈希表的值直接存节点引用。

边界:容量为正数,get 未命中返回 -1;put 已存在的键只更新值并提升位置,不增加计数、也不触发淘汰;淘汰时要同时从链表和哈希表里清理,两处漏一处就会内存泄漏或读到脏数据。

解法:哈希表统计状态

核心思路

先看单结构方案的瓶颈。只用哈希表:查值 $O(1)$,但无法知道谁最久未用。哈希表配数组记录使用顺序:提升位置要搬移元素,$O(n)$。哈希表配单链表:能 $O(1)$ 插头,但从中间摘除节点必须先找前驱,仍是 $O(n)$。

于是组合出标准解:哈希表 + 双向链表。链表按最近使用顺序排列,头部最新、尾部最旧;哈希表存「键 → 链表节点引用」,让任意一次查找都能直接跳到对应节点。

核心不变量是:哈希表的键集恰好等于链表中节点的键集,链表长度等于缓存中的元素个数,且链表从头到尾严格按最近使用时间递减排列。每个公开方法返回前都必须恢复这条不变量。

有了这两份结构,三个基本动作都成了常数时间。查找:哈希一次命中拿到节点。提升:靠节点自身的 prevnext 把它从原位置摘下(node.prev.next = node.next; node.next.prev = node.prev;),再接到头部。淘汰:直接取尾部节点,摘除并用它的 key 反查哈希表删除——节点里必须同时存 keyval,只存 val 的话拿到尾节点也不知道该删哈希表里的哪个键,这是本题最容易漏的设计点。

链表两端各放一个哨兵节点 headtail,初始时互相指向。这样任何真实节点都必然有前驱和后继,摘除与插入的四行指针操作不需要任何判空分支,「链表为空」「只有一个元素」「插在头部」全都走同一条路径。

get 的逻辑是:查不到返回 -1;查到就把节点提升到头部再返回值。put 分两种情况:键已存在则更新值并提升;键不存在则新建节点、插到头部、写入哈希表,然后检查是否超容,超了就摘掉尾部节点并从哈希表里删掉它的键。

解题步骤

  • 定义节点Node 同时保存 keyvalprevnext。存 key 是为了淘汰时能反查哈希表,这一步在写代码之前就要想清楚。
  • 构造函数建哨兵head.next = tail; tail.prev = head;。两个哨兵不存任何真实数据,只用来消灭边界判断;size 从 0 开始单独计数(Go 版直接用 len(cache) 等价)。
  • removeNodenode.prev.next = node.next; node.next.prev = node.prev;。两句成对出现,双向链表任何一次改动都必须同时修好正反两个方向。因为有哨兵,node.prevnode.next 永远非空。
  • addToHeadnode.next = head.next; node.prev = head; head.next = node; node.next.prev = node;。四句的顺序必须保证「在覆盖掉 head.next 之前先把它接到 node.next 上」,否则原来的首节点会丢失。
  • moveToHead = 先摘再插:复用上面两个私有方法,避免把八行指针操作重复写两遍——把原子操作拆成小函数,白板上出错概率显著下降。
  • getcache.containsKey(key) 为假直接返回 -1;否则取节点、moveToHead、返回 node.val。返回值必须取自节点,别忘了提升这一步,否则顺序不再反映真实使用情况。
  • put 命中分支:更新 node.valmoveToHead不要新增节点也不要动 size,覆盖不是插入。
  • put 未命中分支:新建节点 → 写哈希 → addToHead++size → 若 size > capacityremoveTail,用返回的节点的 key 从哈希表删除,并 --size。淘汰必须同时清理链表和哈希表两处。
  • removeTail:取 tail.prev(真正的最旧节点,而不是哨兵 tail 本身),摘除后返回它。

以容量 2 的缓存走一遍标准序列。初始链表是 head ↔ tail,哈希为空。

put(1,1):未命中,新建节点插到头部,链表为 head ↔ 1 ↔ tail,哈希 {1}size = 1,未超容。

put(2,2):未命中,插头,链表为 head ↔ 2 ↔ 1 ↔ tail,哈希 {1,2}size = 2,仍未超容。

get(1):命中节点 1,先摘除(此时链表变成 head ↔ 2 ↔ tail),再插到头部得 head ↔ 1 ↔ 2 ↔ tail,返回 1。注意这一步把 2 变成了最旧的。

put(3,3):未命中,插头得 head ↔ 3 ↔ 1 ↔ 2 ↔ tailsize = 3 超过容量 2,触发淘汰:tail.prev 是节点 2,摘除它得 head ↔ 3 ↔ 1 ↔ tail,再用它的 key = 2 删哈希,得 {1,3}size = 2

get(2):哈希里没有 2,返回 -1,链表不动。put(4,4):未命中,插头后超容,淘汰 tail.prev 即节点 1,链表变成 head ↔ 4 ↔ 3 ↔ tail,哈希 {3,4}

get(1) 返回 -1;get(3) 命中,提升后链表为 head ↔ 3 ↔ 4 ↔ tail,返回 3;get(4) 命中,提升后为 head ↔ 4 ↔ 3 ↔ tail,返回 4。全部结果与 LRU 语义一致。

再看覆盖的情形:在 head ↔ 4 ↔ 3 ↔ tail 上执行 put(3, 30),命中分支只更新值并提升,链表变成 head ↔ 3 ↔ 4 ↔ tailsize 仍是 2,不触发淘汰。如果误走了未命中分支,链表里会出现两个 key 为 3 的节点,且会错误淘汰掉 4。

代码实现

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

    Node() {
    }

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

class LRUCache {
    private Map<Integer, Node> cache = new HashMap<>();
    // 头尾哨兵:任何真实节点都有前驱后继,摘除与插入无需判空。
    private Node head = new Node();
    private Node tail = new Node();
    private int capacity;
    private int size;

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

    public int get(int key) {
        if (!cache.containsKey(key)) {
            return -1;
        }
        Node node = cache.get(key);
        // 命中也算一次使用,必须提升。
        moveToHead(node);
        return node.val;
    }

    public void put(int key, int value) {
        if (cache.containsKey(key)) {
            // 覆盖不是插入:不新增节点,也不改 size。
            Node node = cache.get(key);
            node.val = value;
            moveToHead(node);
        } else {
            Node node = new Node(key, value);
            cache.put(key, node);
            addToHead(node);
            ++size;
            if (size > capacity) {
                // 节点里存了 key,才能反查哈希表把它一并删掉。
                node = removeTail();
                cache.remove(node.key);
                --size;
            }
        }
    }

    private void moveToHead(Node node) {
        removeNode(node);
        addToHead(node);
    }

    private void removeNode(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }

    private void addToHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next = node;
        node.next.prev = node;
    }

    private Node removeTail() {
        Node node = tail.prev;
        removeNode(node);
        return node;
    }
}
type node struct {
    key, val   int
    prev, next *node
}

type LRUCache struct {
    capacity   int
    cache      map[int]*node
    head, tail *node
}

func Constructor(capacity int) LRUCache {
    // 头尾哨兵:任何真实节点都有前驱后继,摘除与插入无需判空。
    head := new(node)
    tail := new(node)
    head.next = tail
    tail.prev = head
    return LRUCache{
        capacity: capacity,
        cache:    make(map[int]*node, capacity),
        head:     head,
        tail:     tail,
    }
}

func (this *LRUCache) Get(key int) int {
    n, ok := this.cache[key]
    if !ok {
        return -1
    }
    // 命中也算一次使用,必须提升。
    this.moveToFront(n)
    return n.val
}

func (this *LRUCache) Put(key int, value int) {
    n, ok := this.cache[key]
    if ok {
        // 覆盖不是插入:不新增节点。
        n.val = value
        this.moveToFront(n)
        return
    }
    if len(this.cache) == this.capacity {
        // 节点里存了 key,才能反查哈希表把它一并删掉。
        back := this.tail.prev
        this.remove(back)
        delete(this.cache, back.key)
    }
    n = &node{key: key, val: value}
    this.pushFront(n)
    this.cache[key] = n
}

func (this *LRUCache) moveToFront(n *node) {
    this.remove(n)
    this.pushFront(n)
}

func (this *LRUCache) remove(n *node) {
    n.prev.next = n.next
    n.next.prev = n.prev
    n.prev = nil
    n.next = nil
}

func (this *LRUCache) pushFront(n *node) {
    n.prev = this.head
    n.next = this.head.next
    this.head.next.prev = n
    this.head.next = n
}

复杂度分析

  • 时间复杂度getput 均为平均 $O(1)$。哈希查找是常数时间,摘除与插头各是固定四次指针赋值,淘汰只访问 tail.prev 一个节点,全程没有任何遍历。
  • 空间复杂度:$O(capacity)$。哈希表与双向链表各存不超过 capacity 个元素,且两者存的是同一批节点的引用而非拷贝,因此常数因子很小,与总的访问次数无关。

关键点总结

  • 「$O(1)$ 定位 + $O(1)$ 调整顺序」这对需求,标准答案就是哈希表加双向链表;选双向而非单向的唯一理由是「从中间摘除需要前驱」,面试时要把这句话说出来。
  • 链表节点必须同时保存 keyval,否则淘汰时拿到尾节点却无法反查哈希表,会留下永远清不掉的脏键——这是设计阶段就要定好的细节,写到一半才发现往往要推倒重来。
  • 头尾哨兵把「空链表」「单元素」「插在头部」统一成同一条路径,四行指针操作里一个判空都不需要,这是双向链表题的通用套路。
  • removeNodeaddToHead 拆成独立小函数,moveToHead 直接复用,比在两处各抄一遍八行指针操作可靠得多。
  • 「覆盖已有键」不能走插入分支,它不改变元素个数也不触发淘汰,这条语义区分是判题用例的常客。
  • 面试视角:面试官通常会追问「能不能直接用语言自带的有序哈希结构」。可以答 Java 的 LinkedHashMap 继承后重写 removeEldestEntry 三行就能实现,但那等于跳过考点;本题考的就是手写双向链表,所以现场应当手写,同时点明库实现的内部原理与手写版完全一致。

易错点总结

  • 节点里不存 key:容量 2 的缓存执行 put(1,1)put(2,2)put(3,3) 时,淘汰拿到尾节点却不知道该删哈希表里的哪个键,get(1) 会返回一个已被摘出链表的脏值。
  • get 命中后不提升:容量 2 时 put(1,1)put(2,2)get(1)put(3,3) 会淘汰掉 1 而不是 2,后续 get(1) 错误返回 -1。
  • put 覆盖已有键时走了插入分支:容量 2 时 put(1,1)put(2,2)put(2,20) 会让 size 变成 3 并淘汰掉 1,而正确行为是缓存里仍有 1 和 2。
  • 淘汰时只摘链表不删哈希:哈希里残留旧键,put(1,1)put(2,2)put(3,3)get(1) 会命中一个已经脱离链表的节点,返回旧值而非 -1。
  • 淘汰取的是 tail 而不是 tail.prev:摘掉的是哨兵本身,链表结构被破坏,之后任何一次 addToHeadremoveNode 都会空指针。
  • addToHead 先写 head.next = node 再接 node.next:原首节点丢失,容量 2 时 put(1,1)put(2,2) 之后链表里只剩节点 2。
  • removeNode 只改一个方向:只写 node.prev.next = node.next 而漏掉反向那句,反向遍历时链表断裂,淘汰阶段会取到已被摘除的节点。
  • 构造函数忘记把两个哨兵互相连接:第一次 addToHead 访问 head.next.prev 直接空指针异常。
  • 用单链表加哈希实现moveToHead 必须从头遍历找前驱,get 退化成 $O(n)$,大量操作的用例会超时。
  • size 与实际元素个数不同步:例如淘汰后忘记 --size,容量 2 的缓存会在第三次插入后每次都触发淘汰,缓存实际只留得住一个元素。

相似题目

题目 难度 考察点
146. LRU 缓存 中等 与本题同题,可直接套用哈希加双向链表
面试题 16.25. LRU 缓存 中等 与本题同题,可直接套用
460. LFU 缓存 困难 淘汰依据从「最久未用」变成「使用次数最少」,需要按频次再分一层桶
432. 全 O(1) 的数据结构 困难 同样用哈希加双向链表,但链表按计数值分组,还要支持查最大最小键
707. 设计链表 中等 双向链表增删改的基本功,本题四行指针操作的手感来源
1472. 设计浏览器历史记录 中等 同样用双向链表维护访问序列,但需要前进后退而非淘汰