前言
LRU 是面试高频算法题,要求 get/put 均摊 O(1)。单纯哈希无法维护时序,单向链表删除节点要遍历;哈希表+带虚拟哨兵的双向链表是标准最优解。
链表头 = 最近使用,链表尾 = 最久未使用,满容量淘汰尾部节点。
整体设计思路
- 双向链表节点
DNode:存key、val、prev、next,必须存 key,淘汰时同步删除 Map 键值; - 虚拟头 head、虚拟尾 tail:省去判空边界逻辑;
- Map:key 映射链表节点,实现 O(1) 定位节点;
- 4个内部工具函数:移除节点、头部插入、节点移至头部、弹出尾节点;
- get:查到节点就挪到表头;put:存在则更新挪表头,不存在新建表头,超容删尾。
完整可运行代码
// 双向链表节点
class DNode {
constructor(key, val) {
this.key = key;
this.val = val;
this.prev = null;
this.next = null;
}
}
var LRUCache = function(capacity) {
this.cap = capacity; // 缓存最大容量
this.map = new Map(); // key -> DNode,O(1)查找节点
// 虚拟头尾哨兵,简化边界判断
this.head = new DNode();
this.tail = new DNode();
this.head.next = this.tail;
this.tail.prev = this.head;
};
// 内部工具:将已有节点移到链表最头部(标记为最近使用)
LRUCache.prototype._moveToHead = function(node) {
this._removeNode(node);
this._addHead(node);
};
// 内部工具:头部插入新节点
LRUCache.prototype._addHead = function(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
};
// 内部工具:断开链表中指定节点
LRUCache.prototype._removeNode = function(node) {
const prev = node.prev;
const nxt = node.next;
prev.next = nxt;
nxt.prev = prev;
};
// 内部工具:弹出尾部最少使用节点,用于容量溢出淘汰
LRUCache.prototype._popTail = function() {
const del = this.tail.prev;
this._removeNode(del);
return del;
};
// 查询缓存
LRUCache.prototype.get = function(key) {
if (!this.map.has(key)) return -1;
const node = this.map.get(key);
this._moveToHead(node); // 使用后更新为最新
return node.val;
};
// 写入/更新缓存
LRUCache.prototype.put = function(key, value) {
// key已存在:更新值,挪到表头
if (this.map.has(key)) {
const node = this.map.get(key);
node.val = value;
this._moveToHead(node);
} else {
// 新建节点存入Map,插入表头
const newNode = new DNode(key, value);
this.map.set(key, newNode);
this._addHead(newNode);
// 超出容量,淘汰尾部节点并同步清理Map
if (this.map.size > this.cap) {
const delNode = this._popTail();
this.map.delete(delNode.key);
}
}
};
分段代码解析
1. DNode 节点类
每个节点保存 key 是关键:链表淘汰尾部时,需要用节点上的 key 删除 Map 中的映射,只存 val 会丢失对应 key。
2. 构造函数 LRUCache
cap:缓存上限;map:哈希映射,快速找到目标节点;head/tail虚拟哨兵,链表永远不会出现prev/next为 null 的特殊情况,插入、删除无需额外判断空节点。
3. 四个私有工具函数(复用逻辑,代码干净)
_removeNode:纯粹断开节点前后指针,不删除、不移位;_addHead:固定在 head 后插入,代表最新访问数据;_moveToHead:先摘节点,再插入头部,复用上面两个函数;_popTail:取出尾前真实节点,断开链表,返回给外层删除 Map 记录。
4. get 方法
- key 不存在返回 -1;
- 存在则取出节点,调用
_moveToHead刷新访问时序,返回值。
5. put 方法两种分支
- key 已存在:仅更新 val,移动至表头;
- key 不存在:创建节点、存入 Map、插入表头; 若 Map 长度超过容量,执行淘汰:弹出尾节点,同时从 Map 删除对应 key。
复杂度总结
- 时间复杂度:
get、put均摊 O(1),哈希读写、链表头尾操作都是常数时间; - 空间复杂度:O(capacity),最多存储 capacity 个节点。
补充说明
刷题可以用 JS Map 简写版本,但面试手写必须掌握双向链表哨兵版,面试官考察底层原理,简写只能作为辅助思路。