从API调用到手写LRU:力扣146「LRU缓存」的哈希表+双向链表终极进化之路

从API调用到手写LRU:力扣146「LRU缓存」的哈希表+双向链表终极进化之路

当面试官说"不要用LinkedHashMap"时,你就知道,真正的挑战来了。这道题,是对你数据结构组合能力的终极考验。

前言

在完成了链表的各种翻转(206/24/25)、合并(21/23)、排序(148)和复制(138)之后,今天我们要攻克的是一道在面试中"如雷贯耳"的经典设计题------力扣146. LRU缓存(LRU Cache)

这道题在LeetCode上标记为中等(Medium) ,但它的江湖地位远超其难度标签。在字节跳动、腾讯、Google、Meta 的面试中,它几乎是"必刷清单"上的TOP 3。为什么?因为它完美地融合了哈希表双向链表 两种数据结构,考察的是你能否在 O(1) 的时间复杂度内,同时完成"查找"和"更新顺序"这两个核心操作。

LRU(Least Recently Used,最近最少使用)缓存机制,不仅在面试中高频出现,更是Redis、MySQL等中间件内存淘汰策略的核心思想。你可能会想:"Java有LinkedHashMap,Python有OrderedDict,几行代码不就搞定了吗?"------但面试官真正想看的是,当你不依赖这些现成的"轮子"时,如何用最基础的数组/链表+哈希表,精准地实现这一机制。

今天,我们就从最直观的"时间戳排序"思路出发,再到利用LinkedHashMap的"作弊"写法,最后抵达那个让无数人头皮发麻、但一旦理解就酣畅淋漓的手写双向链表+哈希表 的终极实现。当你亲手把 prevnext 指针一根根接上时,LRU将不再是你的噩梦,而是你的肌肉记忆。

题目回顾

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

实现 LRUCache 类:

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

函数 getput 必须以 O(1) 的平均时间复杂度运行。

示例:

css 复制代码
输入:
["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]

核心难点:同时维护"访问顺序"与"快速查找"

为什么这道题让无数人栽跟头?因为它有两个相互冲突的要求:

  1. 快速查找 :要能在 O(1) 时间内通过 key 找到 value。------这显然是哈希表的专长。
  2. 快速更新顺序 :每次 getput 时,这个 key 必须变成"最近使用"的。当缓存满了,我们要快速找到"最久未使用"的那个并删除。------这需要一种能快速调整顺序的数据结构。

数组 能快速查找(二分或哈希映射),但调整顺序要移动大量元素,O(n) 不行。 单链表 能快速调整(插入/删除节点),但要找某个节点必须从头遍历,O(n) 也不行。

因此,唯一的出路就是哈希表 + 双向链表 的结合体。哈希表负责 key 到节点的映射(实现 O(1) 查找),双向链表负责维护节点的访问顺序(实现 O(1) 的删除和插入)。

第一层:偷懒法(利用语言内置特性) ------ 面试前的"热身"

很多高级语言提供了现成的有序字典。在面试中,如果你能直接说出这个方案,说明你懂思路;但如果面试官让你"禁止使用",你就得拿出真本事了。

Python(OrderedDict) / Java(LinkedHashMap)

java 复制代码
class LRUCache extends LinkedHashMap<Integer, Integer> {
    private int capacity;
    
    public LRUCache(int capacity) {
        super(capacity, 0.75f, true); // accessOrder=true 表示按访问顺序排序
        this.capacity = capacity;
    }
    
    public int get(int key) {
        return super.getOrDefault(key, -1);
    }
    
    public void put(int key, int value) {
        super.put(key, value);
    }
    
    @Override
    protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
        return size() > capacity;
    }
}

点评: 这种写法在工程上没有任何问题,但它完全屏蔽了底层原理。如果面试官想考的是"设计数据结构",直接甩出 LinkedHashMap 相当于"作弊"。我们只把它作为引子,真正的核心还在后面。

第二层:手写双向链表 + 哈希表 ------ 终极奥义

这是本题最正统、最硬核的解法。我们需要自己构建一个双向链表,并搭配哈希表使用。

数据结构设计

首先,我们定义一个双向链表的节点类 DLinkedNode。它不仅要存 value,还要存 key(因为删除尾节点时,我们需要通过 key 去哈希表中删除记录)。

java 复制代码
class DLinkedNode {
    int key;
    int value;
    DLinkedNode prev;
    DLinkedNode next;
    
    public DLinkedNode() {}
    public DLinkedNode(int key, int value) {
        this.key = key;
        this.value = value;
    }
}

然后,在 LRUCache 类中维护:

  • Map<Integer, DLinkedNode> cache:快速定位节点。
  • DLinkedNode head, tail:虚拟头尾节点(哨兵),简化边界操作。
  • int capacityint size:当前容量和大小。

核心操作:定义"原子"动作

为了避免 putget 逻辑混乱,我们封装几个辅助函数:

  1. addToHead(Node node):将节点添加到虚拟头节点之后(标记为最新使用)。
  2. removeNode(Node node):从链表中移除一个节点(不删除哈希映射)。
  3. moveToHead(Node node) :将某个节点移动到头部。removeNode(node) + addToHead(node)
  4. popTail():移除真正的尾节点(虚拟尾的前一个),并返回它,供删除使用。

为什么使用虚拟头尾节点? 如果不使用虚拟节点,我们在插入第一个节点、删除最后一个节点时,都要特殊判断 head == nulltail == null。引入 headtail 作为固定的哨兵,让所有操作都在"非空"节点间进行,极大地减少了 if (null) 的烦恼。

代码实现(Java 完整版)

java 复制代码
class LRUCache {
    private Map<Integer, DLinkedNode> cache;
    private DLinkedNode head, tail;
    private int capacity;
    private int size;

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.size = 0;
        cache = new HashMap<>();
        // 初始化虚拟头尾节点,并让它们互相指向
        head = new DLinkedNode();
        tail = new DLinkedNode();
        head.next = tail;
        tail.prev = head;
    }
    
    public int get(int key) {
        DLinkedNode node = cache.get(key);
        if (node == null) return -1;
        // 只要访问过,就移到头部
        moveToHead(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);
        if (node != null) {
            // 存在:更新值,并移到头部
            node.value = value;
            moveToHead(node);
        } else {
            // 不存在:创建新节点
            DLinkedNode newNode = new DLinkedNode(key, value);
            cache.put(key, newNode);
            addToHead(newNode);
            size++;
            
            // 检查是否超出容量
            if (size > capacity) {
                // 删除尾部节点
                DLinkedNode tailNode = popTail();
                cache.remove(tailNode.key);
                size--;
            }
        }
    }
    
    // ---------- 辅助函数 ----------
    private void addToHead(DLinkedNode node) {
        node.prev = head;
        node.next = head.next;
        head.next.prev = node;
        head.next = node;
    }
    
    private void removeNode(DLinkedNode node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    private void moveToHead(DLinkedNode node) {
        removeNode(node);
        addToHead(node);
    }
    
    private DLinkedNode popTail() {
        DLinkedNode res = tail.prev;
        removeNode(res);
        return res;
    }
}
python 复制代码
class DLinkedNode:
    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.size = 0
        self.cache = {}
        self.head = DLinkedNode()
        self.tail = DLinkedNode()
        self.head.next = self.tail
        self.tail.prev = self.head

    def get(self, key: int) -> int:
        node = self.cache.get(key)
        if not node:
            return -1
        self._move_to_head(node)
        return node.value

    def put(self, key: int, value: int) -> None:
        node = self.cache.get(key)
        if not node:
            new_node = DLinkedNode(key, value)
            self.cache[key] = new_node
            self._add_to_head(new_node)
            self.size += 1
            if self.size > self.capacity:
                removed = self._pop_tail()
                del self.cache[removed.key]
                self.size -= 1
        else:
            node.value = value
            self._move_to_head(node)
    
    def _add_to_head(self, node):
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node
    
    def _remove_node(self, node):
        node.prev.next = node.next
        node.next.prev = node.prev
    
    def _move_to_head(self, node):
        self._remove_node(node)
        self._add_to_head(node)
    
    def _pop_tail(self):
        node = self.tail.prev
        self._remove_node(node)
        return node

流程演示

capacity = 2 为例,执行 put(1,1)put(2,2)get(1)put(3,3)

  1. put(1,1):链表 head <-> 1 <-> tail
  2. put(2,2):链表 head <-> 2 <-> 1 <-> tail(2最新)。
  3. get(1):将1移到头部,链表 head <-> 1 <-> 2 <-> tail
  4. put(3,3):发现容量满了,popTail() 移除尾节点(即2),删除映射。链表 head <-> 3 <-> 1 <-> tail。完美淘汰了最久未使用的2。

第三层:深度剖析 ------ 为什么 Node 里要存 key?

很多同学写代码时会在节点里存 key,但没想过为什么。答案就藏在 popTail() 中:

当我们删除尾节点时,我们拿到了这个 DLinkedNode 对象。此时我们需要从哈希表 cache 中删除这个映射。哈希表是 Map<Integer, DLinkedNode>,要从哈希表中删除,必须提供 key

如果节点中不存 key,我们就无法知道这个尾节点对应的 key 是什么,也就无法删除哈希表条目,最终导致内存泄漏或逻辑错误。这就是节点必须存 key 的根本原因。

第四层(拓展):为什么不用单链表?为什么不用数组?

  1. 单链表 :删除节点时,我们需要找到它的前驱节点。在单链表中,这需要从头遍历(O(n))。双向链表则通过 prev 指针直接获取前驱,实现 O(1) 删除。
  2. 数组 + 时间戳:每次访问更新数组中的时间戳,删除时遍历找最小时间戳。查找和删除都是 O(n),不满足要求。
  3. 单独用 HashMap:无法维护顺序;单独用 LinkedList:查找 O(n)。

所以,"哈希表 + 双向链表"是这个命题下的唯一标准解。

深度总结:从设计模式到细节实现

组件 数据结构 职责
查找引擎 HashMap(哈希表) 通过 key 在 O(1) 时间内定位到链表节点
顺序维护引擎 双向链表(Doubly Linked List) 维护节点的访问顺序,头部为最新,尾部为最旧
辅助节点 虚拟头尾节点(Dummy Head/Tail) 简化边界操作,避免空指针判断
删除机制 popTail() + cache.remove(key) 当容量超限时,删除尾节点并清理哈希表

从这道题中我们学到了什么?

  1. 数据结构的组合(Composition)胜于继承 。我们不是扩展某个类,而是将 Map 和自定义链表组合起来。这种设计模式(组合模式)在工程中极其常见。

  2. 双向链表 + 哈希表 = 万能钥匙。凡是涉及到"快速查找 + 快速调整顺序"的问题(如 LFU 缓存、某些调度算法),都可以考虑这个组合。

  3. 虚拟节点是链表编程的"护身符" 。在力扣的链表题中,我们见过很多 dummy 节点。在 LRU 的实现中,虚拟头尾节点更是把插入和删除的边界逻辑减少到了极致。

  4. O(1) 复杂度的实现必然伴随着细节的严谨。少改一个指针,链表就可能断裂形成环;少删一个 map 映射,内存就会泄露。手写 LRU 是对"代码严谨性"的极好训练。

最后的一些心里话

力扣146是一道"看答案觉得简单,自己写全是坑"的题。很多同学在面试中因为指针顺序写反(比如 head.next.prev = nodenode.next = head.next 的顺序搞反)而功亏一篑。

我建议你在写这道题时,addToHeadremoveNode 的指针操作当成"四行咒语"背下来,并在纸上画出箭头指向的变化。 当你画过三遍之后,这四行代码就会变成你的条件反射。

现在的你,已经掌握了哈希表、单链表、双向链表、反转链表、合并链表、排序链表、复制链表,再加上今天的 LRU 设计。链表这个数据结构,你已经从"使用者"变成了"设计者"。

记住今天的口诀:

LRU 设计巧,哈希链表结合好。 虚拟头尾当哨兵,增删改查 O(1) 保。 节点记得存 key 值,淘汰尾部才找得到。


如果你觉得这篇题解帮你彻底搞懂了 LRU 缓存,欢迎点赞、收藏、转发!接下来我们将开启全新的篇章------二叉树,你准备好了吗? 🚀

相关推荐
科技道人1 小时前
记录 默认置灰/禁用 app ‘Search Engine Selector‘ 的disable按钮
开发语言·前端·javascript
天疆说1 小时前
Zotero Connector 在 Edge 里找不到桌面 Zotero(Linux / Zotero 9.0.6 / Edge 150)
linux·前端·edge
ffqws_1 小时前
Redis 分布式锁:基于 Redisson 的实现与面试高频问答
redis·分布式·面试
李伟_Li慢慢3 小时前
02-从硬件说起WebGL
前端·three.js
kyriewen3 小时前
看了微软几万人用AI编程的数据——效率涨24%的代价没人提
前端·ai编程·claude
2601_963771373 小时前
How to Scale Your WordPress Store Traffic in 2026
前端·php·plugin
亿元程序员4 小时前
老板说我的3D箭头游戏用来做试玩太普通了,没人想玩,让我变点花样...
前端
李伟_Li慢慢4 小时前
01-threejs架构原理-课程简介
前端·three.js
_瑞6 小时前
深入理解 iOS 渲染原理
前端·ios