LEETCODE 146Medium

LRU 缓存机制

哈希表负责“找得快”,双向链表负责“挪得快”,两个 O(1) 的结构各管一半,拼出完整的 O(1)。

问题拆解

设计一个容量固定的缓存:get 读取,put 写入,容量满时淘汰最久未使用的那条,且两个操作都必须是 O(1)

只用哈希表,查找是 O(1),但“谁最久没被用过”得线性扫描;给每条记录挂时间戳再排序,又变成 O(log n)。换个思路:“最近使用”本质是一种顺序,把所有 key 按使用时间串成一条链——每次访问就把节点挪到链头,淘汰时摘掉链尾。要在 O(1) 内完成“从链中间摘下一个节点”,必须是双向链表;要在 O(1) 内“根据 key 找到链上的节点”,必须靠哈希表指向节点。

哈希表解决“在哪”,双向链表解决“多久没用”——单独哪个都做不到,组合起来正好互补。

哈希表 + 双向链表

两个实现细节值得先说:一是给链表加头尾两个哨兵节点,摘除和插入就不用判空;二是链表节点里除了 value 还要存 key,因为淘汰链尾节点时,需要拿它的 key 反过来删掉哈希表里的条目——这是最容易漏的一步。

class LRUCache {
    // 手写双向链表节点,head/tail 为哨兵
    private static class Node {
        int key, value;
        Node prev, next;
        Node(int key, int value) { this.key = key; this.value = value; }
    }

    private final int capacity;
    private final Map<Integer, Node> map = new HashMap<>();
    private final Node head = new Node(0, 0);
    private final Node tail = new Node(0, 0);

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

    public int get(int key) {
        Node node = map.get(key);
        if (node == null) return -1;
        moveToFront(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node node = map.get(key);
        if (node != null) {
            node.value = value;
            moveToFront(node);
            return;
        }
        if (map.size() == capacity) {
            Node last = tail.prev; // 最久未使用的在链尾
            detach(last);
            map.remove(last.key);  // 节点存 key 就是为了这一步
        }
        node = new Node(key, value);
        map.put(key, node);
        attachFront(node);
    }

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

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

    private void moveToFront(Node node) {
        detach(node);
        attachFront(node);
    }
}
class Node:
    __slots__ = ("key", "value", "prev", "next")

    def __init__(self, key=0, value=0):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None


class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.map = {}
        self.head = Node()  # 哨兵
        self.tail = Node()
        self.head.next = self.tail
        self.tail.prev = self.head

    def get(self, key: int) -> int:
        if key not in self.map:
            return -1
        node = self.map[key]
        self._move_to_front(node)
        return node.value

    def put(self, key: int, value: int) -> None:
        if key in self.map:
            node = self.map[key]
            node.value = value
            self._move_to_front(node)
            return
        if len(self.map) == self.capacity:
            last = self.tail.prev  # 最久未使用的在链尾
            self._detach(last)
            del self.map[last.key]  # 节点存 key 就是为了这一步
        node = Node(key, value)
        self.map[key] = node
        self._attach_front(node)

    def _detach(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev

    def _attach_front(self, node):
        node.next = self.head.next
        node.prev = self.head
        self.head.next.prev = node
        self.head.next = node

    def _move_to_front(self, node):
        self._detach(node)
        self._attach_front(node)
type node struct {
    key, value int
    prev, next *node
}

type LRUCache struct {
    capacity   int
    m          map[int]*node
    head, tail *node // 哨兵
}

func Constructor(capacity int) LRUCache {
    head, tail := &node{}, &node{}
    head.next = tail
    tail.prev = head
    return LRUCache{capacity: capacity, m: make(map[int]*node), head: head, tail: tail}
}

func (c *LRUCache) Get(key int) int {
    n, ok := c.m[key]
    if !ok {
        return -1
    }
    c.moveToFront(n)
    return n.value
}

func (c *LRUCache) Put(key int, value int) {
    if n, ok := c.m[key]; ok {
        n.value = value
        c.moveToFront(n)
        return
    }
    if len(c.m) == c.capacity {
        last := c.tail.prev // 最久未使用的在链尾
        c.detach(last)
        delete(c.m, last.key) // 节点存 key 就是为了这一步
    }
    n := &node{key: key, value: value}
    c.m[key] = n
    c.attachFront(n)
}

func (c *LRUCache) detach(n *node) {
    n.prev.next = n.next
    n.next.prev = n.prev
}

func (c *LRUCache) attachFront(n *node) {
    n.next = c.head.next
    n.prev = c.head
    c.head.next.prev = n
    c.head.next = n
}

func (c *LRUCache) moveToFront(n *node) {
    c.detach(n)
    c.attachFront(n)
}
use std::collections::HashMap;

// Rust 里用指针手写双向链表会陷入所有权与借用的泥潭(节点互相引用)。
// 这里换一种等价实现:所有节点放进 Vec 当“节点池”,prev/next 存下标,
// 下标 0、1 是头尾哨兵。逻辑和指针版完全一致,只是把指针换成了下标。
struct LRUCache {
    capacity: usize,
    map: HashMap<i32, usize>, // key -> 节点下标
    keys: Vec<i32>,
    vals: Vec<i32>,
    prev: Vec<usize>,
    next: Vec<usize>,
}

impl LRUCache {
    fn new(capacity: i32) -> Self {
        LRUCache {
            capacity: capacity as usize,
            map: HashMap::new(),
            keys: vec![0, 0], // 0 是头哨兵,1 是尾哨兵
            vals: vec![0, 0],
            prev: vec![0, 0],
            next: vec![1, 1],
        }
    }

    fn detach(&mut self, i: usize) {
        let (p, n) = (self.prev[i], self.next[i]);
        self.next[p] = n;
        self.prev[n] = p;
    }

    fn attach_front(&mut self, i: usize) {
        let first = self.next[0];
        self.prev[i] = 0;
        self.next[i] = first;
        self.next[0] = i;
        self.prev[first] = i;
    }

    fn get(&mut self, key: i32) -> i32 {
        match self.map.get(&key) {
            Some(&i) => {
                self.detach(i);
                self.attach_front(i);
                self.vals[i]
            }
            None => -1,
        }
    }

    fn put(&mut self, key: i32, value: i32) {
        if let Some(&i) = self.map.get(&key) {
            self.vals[i] = value;
            self.detach(i);
            self.attach_front(i);
            return;
        }
        let i = if self.map.len() == self.capacity {
            // 容量满:直接复用最久未使用的节点(尾哨兵的前驱)
            let last = self.prev[1];
            self.detach(last);
            self.map.remove(&self.keys[last]);
            self.keys[last] = key;
            self.vals[last] = value;
            last
        } else {
            self.keys.push(key);
            self.vals.push(value);
            self.prev.push(0);
            self.next.push(0);
            self.keys.len() - 1
        };
        self.map.insert(key, i);
        self.attach_front(i);
    }
}

getput 的公共动作被抽成 detach(摘下)和 attachFront(插到链头)两个原子操作,所有场景——命中后提前、更新后提前、新增、淘汰——都由它们拼出来,指针改写只出现在这两处,不容易写错。Rust 版的“节点池”是数组式双向链表:淘汰时直接复用旧节点的槽位,Vec 的长度永远不超过 capacity + 2,行为与指针版逐条对应。

复杂度

指标 复杂度 原因
时间 O(1) get/put 各是一次哈希查找加常数次指针(下标)改写
空间 O(capacity) 哈希表和链表各存至多 capacity 条记录

可以迁移的模式

  • 需要“O(1) 查找 + O(1) 调整顺序”时,哈希表指向链表节点是标准组合,LFU(460)是它的进阶版;
  • 头尾哨兵让插入删除不必讨论空链、头尾等边界,凡手写链表都值得加上;
  • 反向删除要留“回程票”:链表节点存 key,才能在淘汰时清掉哈希表条目。

设计题考的不是算法有多巧,而是把每个操作拆成几个原子步骤后,验证每一步都在预算之内。