力扣146. LRU缓存|原型链写法+底层原理,面试必掌握

0 阅读9分钟

前言

LRU(Least Recently Used,最近最少使用)缓存,是前端/后端面试高频考点,也是力扣中等难度里的经典题——力扣146题,要求我们实现一个LRU缓存结构,支持get和put操作,且时间复杂度需尽可能优化。

本文将完全贴合力扣题目给出的「原型链写法」(非Class类写法),实现两种主流解法:ES6 Map简洁版(快速解题)和哈希表+双向链表经典版(面试标准),同时拆解Map有序性的底层原理,帮你吃透LRU的核心逻辑,面试时既能快速写题,又能讲清底层。

一、题目回顾(力扣146. LRU缓存)

题目要求

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

实现 LRUCache 类:

  • LRUCache(int capacity) 以正整数作为容量 capacity 初始化LRU缓存。
  • int get(int key) 如果关键字 key 存在于缓存中,则获取关键字的值,否则返回 -1。获取后,该key需标记为「最近使用」。
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value;如果不存在,则向缓存中插入该组key-value。若缓存容量达到上限,则在插入新数据前删除「最久未使用」的数据值,从而为新数据留出空间。插入/更新后,该key需标记为「最近使用」。

核心约束

  • 进阶:get 和 put 操作的时间复杂度必须为 O(1)

  • 题目给定代码格式(原型链写法),不可修改结构:

/**
 * @param {number} capacity
 */
var LRUCache = function(capacity) {
  
};

/** 
 * @param {number} key
 * @return {number}
 */
LRUCache.prototype.get = function(key) {
    
};

/** 
 * @param {number} key 
 * @param {number} value
 * @return {void}
 */
LRUCache.prototype.put = function(key, value) {
    
};

/**
 * Your LRUCache object will be instantiated and called as such:
 * var obj = new LRUCache(capacity)
 * var param_1 = obj.get(key)
 * obj.put(key,value)
 */

二、解法一:ES6 Map 简洁版(快速解题,力扣直接通过)

这是最简洁的解法,利用 ES6 Map 的「有序特性」,无需手动实现双向链表,代码量极少,适合力扣快速提交,也适合面试时快速写出基础版本。

核心思路

Map 的两个关键特性,完美契合 LRU 规则:

  1. Map 是有序的,插入顺序即遍历顺序,新插入的键值对会放在 Map 末尾(标记为「最近使用」)。
  2. 对已存在的键执行 delete + set 操作,会将该键移到 Map 末尾(重置为「最近使用」)。
  3. Map 的 keys().next().value 可快速获取第一个键(即「最久未使用」的键)。

完整代码(原型链写法)

/**
 * @param {number} capacity
 */
var LRUCache = function(capacity) {
    this.capacity = capacity; // 缓存最大容量
    this.cache = new Map();   // 有序Map:最新的键在末尾,最旧的在开头
};

/** 
 * @param {number} key
 * @return {number}
 */
LRUCache.prototype.get = function(key) {
    // 1. 不存在该key,返回-1
    if (!this.cache.has(key)) return -1;
    
    // 2. 存在该key:先删除,再重新set(将key移到末尾,标记为最近使用)
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    
    // 3. 返回对应值
    return value;
};

/** 
 * @param {number} key 
 * @param {number} value
 * @return {void}
 */
LRUCache.prototype.put = function(key, value) {
    // 1. 已存在该key:先删除(后续重新set,标记为最近使用)
    if (this.cache.has(key)) {
        this.cache.delete(key);
    }
    
    // 2. 容量超限:删除最久未使用的key(Map的第一个键)
    if (this.cache.size >= this.capacity) {
        const oldestKey = this.cache.keys().next().value;
        this.cache.delete(oldestKey);
    }
    
    // 3. 插入新键值对(移到末尾,标记为最近使用)
    this.cache.set(key, value);
};

/**
 * Your LRUCache object will be instantiated and called as such:
 * var obj = new LRUCache(capacity)
 * var param_1 = obj.get(key)
 * obj.put(key,value)
 */

代码解析

  • 初始化:在构造函数中定义缓存容量 capacity 和 Map 实例 cache,用于存储键值对。
  • get 方法:先判断 key 是否存在,不存在返回 -1;存在则通过「删除+重新插入」重置其位置,保证最近使用的键在末尾。
  • put 方法:先处理已存在的 key(删除后重新插入);再判断容量是否超限,超限则删除最久未使用的键;最后插入新键值对。

优缺点

✅ 优点:代码极简(不到50行),易写易调试,力扣提交通过率100%,时间复杂度 O(1)(Map 底层是哈希表)。

❌ 缺点:依赖 ES6 Map 特性,面试时若只写这个版本,需能讲清 Map 有序性的底层原理(否则会显得对 LRU 底层理解不深)。

三、解法二:哈希表 + 双向链表(经典版,面试标准写法)

这是面试中最推荐的写法,严格遵循「O(1) 时间复杂度」要求,手动实现「哈希表 + 双向链表」的组合结构,能充分体现对 LRU 底层逻辑的理解。

核心思路

LRU 的核心是「快速查找」和「有序维护」,两种结构各司其职:

  1. 哈希表(Map) :负责快速定位键值对,时间复杂度 O(1),存储 key 到双向链表节点的映射。
  2. 双向链表:负责维护键值对的使用顺序,头部是「最近使用」的节点,尾部是「最久未使用」的节点;插入、删除、移动节点的时间复杂度均为 O(1)。

补充:设置「虚拟头/尾节点」,避免处理「头节点为空」「尾节点为空」的边界情况,简化代码。

完整代码(原型链写法)

/**
 * @param {number} capacity
 */
var LRUCache = function(capacity) {
    this.capacity = capacity; // 缓存最大容量
    this.size = 0;            // 当前缓存大小
    this.cache = new Map();   // 哈希表:key -> 双向链表节点
    
    // 1. 定义双向链表节点结构(内部函数)
    const ListNode = function(key, value) {
        this.key = key;       // 存key:删除尾部节点时,需同步删除哈希表中的项
        this.value = value;
        this.prev = null;
        this.next = null;
    };
    
    // 2. 虚拟头/尾节点(简化边界处理)
    this.head = new ListNode(-1, -1);
    this.tail = new ListNode(-1, -1);
    this.head.next = this.tail;
    this.tail.prev = this.head;

    // 3. 辅助方法:将节点移到头部(标记为最近使用)
    this.moveToHead = function(node) {
        this.removeNode(node); // 先移除节点
        this.addToHead(node);  // 再插入到头部
    };

    // 4. 辅助方法:移除指定节点
    this.removeNode = function(node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    };

    // 5. 辅助方法:将节点添加到头部(虚拟头节点之后)
    this.addToHead = function(node) {
        node.next = this.head.next;
        node.prev = this.head;
        this.head.next.prev = node;
        this.head.next = node;
    };

    // 6. 辅助方法:移除尾部节点(最久未使用,虚拟尾节点之前)
    this.removeTail = function() {
        const tailNode = this.tail.prev;
        this.removeNode(tailNode);
        return tailNode; // 返回被删除的节点,用于同步删除哈希表
    };
};

/** 
 * @param {number} key
 * @return {number}
 */
LRUCache.prototype.get = function(key) {
    // 1. 不存在该key,返回-1
    if (!this.cache.has(key)) return -1;
    
    // 2. 存在该key:找到节点,移到头部(标记为最近使用)
    const node = this.cache.get(key);
    this.moveToHead(node);
    
    // 3. 返回节点值
    return node.value;
};

/** 
 * @param {number} key 
 * @param {number} value
 * @return {void}
 */
LRUCache.prototype.put = function(key, value) {
    if (this.cache.has(key)) {
        // 1. 已存在该key:更新值 + 移到头部
        const node = this.cache.get(key);
        node.value = value;
        this.moveToHead(node);
    } else {
        // 2. 不存在该key:新建节点
        const ListNode = function(k, v) {
            this.key = k;
            this.value = v;
            this.prev = null;
            this.next = null;
        };
        const newNode = new ListNode(key, value);
        
        // 3. 同步到哈希表和双向链表
        this.cache.set(key, newNode);
        this.addToHead(newNode);
        this.size++;

        // 4. 容量超限:删除尾部节点(最久未使用),同步删除哈希表
        if (this.size > this.capacity) {
            const tailNode = this.removeTail();
            this.cache.delete(tailNode.key);
            this.size--;
        }
    }
};

/**
 * Your LRUCache object will be instantiated and called as such:
 * var obj = new LRUCache(capacity)
 * var param_1 = obj.get(key)
 * obj.put(key,value)
 */

代码解析

  1. 节点定义:ListNode 包含 key、value、prev、next 四个属性,key 用于删除尾部节点时同步删除哈希表中的项。
  2. 虚拟头/尾节点:初始化时连接 head 和 tail,避免处理边界情况(比如插入第一个节点、删除最后一个节点)。
  3. 辅助方法:封装 moveToHead、removeNode、addToHead、removeTail 四个方法,复用链表操作逻辑,使代码更简洁。
  4. get/put 方法:严格遵循 LRU 规则,通过哈希表快速定位节点,通过双向链表维护使用顺序,确保所有操作都是 O(1) 时间复杂度。

优缺点

✅ 优点:底层逻辑清晰,严格保证 O(1) 时间复杂度,面试加分项,能体现对数据结构的掌握。

❌ 缺点:代码量较多(约100行),需注意链表节点的指针操作,容易出现边界错误。

四、关键补充:Map 有序性的底层原理(面试必问)

很多人用 Map 写 LRU,但说不清楚 Map 为什么有序——这也是面试中常追问的点,这里用通俗的语言拆解:

Map 底层结构:哈希表 + 双向链表

Map 并不是单纯的哈希表,而是「哈希表 + 双向链表」的组合(也叫“有序哈希表”):

  1. 哈希表:负责快速查找,根据 key 计算哈希值,直接定位到对应的链表节点,时间复杂度 O(1)。
  2. 双向链表:负责维护插入顺序,所有节点按插入顺序串联,新节点追加到链表尾部,遍历 Map 时按链表顺序遍历。

和普通对象的核心区别

特性Map普通对象({})
插入顺序严格保留插入顺序不保证(数字键会按升序排序)
键类型支持任意类型(数字、对象、函数等)仅支持字符串、Symbol
大小获取map.size(O(1))Object.keys(obj).length(O(n))

LRU 中 Map 的核心操作对应底层行为

  • map.set(key, value):底层先判断 key 是否存在,存在则移动节点到链表尾部,不存在则追加节点到尾部。
  • map.delete(key):底层通过哈希表定位节点,再从双向链表中移除该节点。
  • map.keys().next().value:底层获取双向链表的头部节点(最久未使用)的 key。

五、测试用例(验证正确性)

两种解法均可通过以下测试用例,可直接在力扣调试:

// 测试代码
var lru = new LRUCache(2);
lru.put(1, 1); // 缓存:{1:1}
lru.put(2, 2); // 缓存:{1:1, 2:2}
console.log(lru.get(1)); // 输出 1 → 缓存变为 {2:2, 1:1}
lru.put(3, 3); // 容量满,删除最久未使用的 2 → 缓存:{1:1, 3:3}
console.log(lru.get(2)); // 输出 -1
lru.put(4, 4); // 容量满,删除最久未使用的 1 → 缓存:{3:3, 4:4}
console.log(lru.get(1)); // 输出 -1
console.log(lru.get(3)); // 输出 3 → 缓存变为 {4:4, 3:3}
console.log(lru.get(4)); // 输出 4 → 缓存变为 {3:3, 4:4}

六、面试Tips(加分项)

  1. 面试时,优先写出「经典版(哈希表+双向链表)」,再补充「Map简洁版」,说明两种写法的优缺点和适用场景。
  2. 被问 Map 为什么有序时,能说出「底层是哈希表+双向链表,哈希表负责查找,双向链表负责维护顺序」。
  3. 注意边界情况:容量为1、get不存在的key、put已存在的key、容量满时插入新key。
  4. 原型链写法注意:构造函数中定义的属性和方法,需通过 this 访问,避免全局污染。

七、总结

力扣146题的核心是理解 LRU 规则,两种解法各有侧重:

  • Map 简洁版:适合快速解题,代码量少,依赖 Map 有序特性,适合力扣提交和面试基础版本。
  • 哈希表+双向链表经典版:适合面试进阶,体现底层逻辑,严格保证 O(1) 时间复杂度,是面试加分项。

掌握这两种写法,不仅能轻松解决力扣146题,还能应对面试中各类 LRU 变种问题(比如带过期时间的 LRU、LRU-K 等)。建议先理解 Map 简洁版,再手动实现经典版,吃透底层原理,面试时才能从容应对。