前言
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 规则:
- Map 是有序的,插入顺序即遍历顺序,新插入的键值对会放在 Map 末尾(标记为「最近使用」)。
- 对已存在的键执行
delete + set操作,会将该键移到 Map 末尾(重置为「最近使用」)。 - 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 的核心是「快速查找」和「有序维护」,两种结构各司其职:
- 哈希表(Map) :负责快速定位键值对,时间复杂度 O(1),存储 key 到双向链表节点的映射。
- 双向链表:负责维护键值对的使用顺序,头部是「最近使用」的节点,尾部是「最久未使用」的节点;插入、删除、移动节点的时间复杂度均为 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)
*/
代码解析
- 节点定义:ListNode 包含 key、value、prev、next 四个属性,key 用于删除尾部节点时同步删除哈希表中的项。
- 虚拟头/尾节点:初始化时连接 head 和 tail,避免处理边界情况(比如插入第一个节点、删除最后一个节点)。
- 辅助方法:封装 moveToHead、removeNode、addToHead、removeTail 四个方法,复用链表操作逻辑,使代码更简洁。
- get/put 方法:严格遵循 LRU 规则,通过哈希表快速定位节点,通过双向链表维护使用顺序,确保所有操作都是 O(1) 时间复杂度。
优缺点
✅ 优点:底层逻辑清晰,严格保证 O(1) 时间复杂度,面试加分项,能体现对数据结构的掌握。
❌ 缺点:代码量较多(约100行),需注意链表节点的指针操作,容易出现边界错误。
四、关键补充:Map 有序性的底层原理(面试必问)
很多人用 Map 写 LRU,但说不清楚 Map 为什么有序——这也是面试中常追问的点,这里用通俗的语言拆解:
Map 底层结构:哈希表 + 双向链表
Map 并不是单纯的哈希表,而是「哈希表 + 双向链表」的组合(也叫“有序哈希表”):
- 哈希表:负责快速查找,根据 key 计算哈希值,直接定位到对应的链表节点,时间复杂度 O(1)。
- 双向链表:负责维护插入顺序,所有节点按插入顺序串联,新节点追加到链表尾部,遍历 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(加分项)
- 面试时,优先写出「经典版(哈希表+双向链表)」,再补充「Map简洁版」,说明两种写法的优缺点和适用场景。
- 被问 Map 为什么有序时,能说出「底层是哈希表+双向链表,哈希表负责查找,双向链表负责维护顺序」。
- 注意边界情况:容量为1、get不存在的key、put已存在的key、容量满时插入新key。
- 原型链写法注意:构造函数中定义的属性和方法,需通过 this 访问,避免全局污染。
七、总结
力扣146题的核心是理解 LRU 规则,两种解法各有侧重:
- Map 简洁版:适合快速解题,代码量少,依赖 Map 有序特性,适合力扣提交和面试基础版本。
- 哈希表+双向链表经典版:适合面试进阶,体现底层逻辑,严格保证 O(1) 时间复杂度,是面试加分项。
掌握这两种写法,不仅能轻松解决力扣146题,还能应对面试中各类 LRU 变种问题(比如带过期时间的 LRU、LRU-K 等)。建议先理解 Map 简洁版,再手动实现经典版,吃透底层原理,面试时才能从容应对。