以下是针对LRU缓存题目的系统整理笔记,结合题目要求、实现原理、代码实现和关键技巧,帮助你高效掌握该数据结构的设计要点。
一、题目核心要求
-
功能需求:
LRUCache(capacity)
:以正整数初始化缓存容量。get(key)
:返回键对应的值(若存在),否则返回-1
。put(key, value)
:- 键存在 → 更新值;
- 键不存在 → 插入键值对;
- 插入后超容 → 淘汰最久未使用的键值对。
- 时间复杂度 :
get
和put
操作必须满足 O(1) 平均时间复杂度。
-
示例:
plaintext输入:["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]
操作流程
get(key)
:- 哈希表查找
key
→ 不存在则返回-1
。 - 存在则将节点移到链表头部(表示最近使用),返回节点值。
- 哈希表查找
put(key, value)
:- 键存在:更新值,节点移到链表头部。
- 键不存在 :
- 创建新节点,插入链表头部。
- 若容量超限 → 删除链表尾部节点,并移除哈希表中对应键。
- 更新哈希表映射。
基于双向链表和哈希表的 JavaScript LRU 缓存实现
以下是满足 O(1) 时间复杂度要求的 LRU 缓存实现方案,采用双向链表 + 哈希表结构(不依赖 JS 语言特性),并附详细解析:
javascript
class LRUCache {
constructor(capacity) {
this.capacity = capacity; // 缓存容量上限
this.map = new Map(); // 哈希表:存储键到节点的映射
this.head = new Node(); // 哑头节点(哨兵节点)
this.tail = new Node(); // 哑尾节点(哨兵节点)
this.head.next = this.tail;
this.tail.prev = this.head;
}
// 节点类(双向链表)
static Node = class {
constructor(key = 0, value = 0) {
this.key = key; // 键(用于删除哈希表映射)
this.value = value;// 值
this.prev = null; // 前驱节点指针
this.next = null; // 后继节点指针
}
};
// 核心方法:获取缓存值(O(1))
get(key) {
if (!this.map.has(key)) return -1;
const node = this.map.get(key);
this._moveToHead(node); // 移动到头部表示最近使用
return node.value;
}
// 核心方法:插入/更新缓存(O(1))
put(key, value) {
if (this.map.has(key)) {
// 更新已存在节点
const node = this.map.get(key);
node.value = value;
this._moveToHead(node);
} else {
// 创建新节点
const newNode = new LRUCache.Node(key, value);
this.map.set(key, newNode);
this._addToHead(newNode);
// 容量超限时删除最久未使用节点
if (this.map.size > this.capacity) {
const tailNode = this._removeTail();
this.map.delete(tailNode.key);
}
}
}
// 私有方法:添加节点到链表头部(O(1))
_addToHead(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
}
// 私有方法:移除指定节点(O(1))
_removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 私有方法:移动节点到头部(O(1))
_moveToHead(node) {
this._removeNode(node);
this._addToHead(node);
}
// 私有方法:移除尾部节点(最久未使用)(O(1))
_removeTail() {
const tailNode = this.tail.prev;
this._removeNode(tailNode);
return tailNode;
}
}
🔍 实现原理解析
1. 数据结构设计
- 双向链表 :按访问顺序存储节点,头部存放最近使用 的节点,尾部存放最久未使用的节点。
- 哈希表(Map):存储键到链表节点的映射,实现 O(1) 的键值查询。
- 哑头尾节点:简化链表边界操作(如插入/删除首尾节点)。
2. 核心操作复杂度
操作 | 实现逻辑 | 时间复杂度 |
---|---|---|
get(key) |
通过哈希表定位节点 → 移动到链表头部 | O(1) |
put(key, value) |
存在则更新并移到头部;不存在则创建新节点 → 插入头部 → 超容时删除尾部节点 | O(1) |
3. 关键步骤图解
less
插入新节点 "D"(容量=3):
Step 1: [Head] → A → B → C → [Tail]
Step 2: 插入 D → [Head] → D → A → B → C → [Tail]
Step 3: 超容删除 C → [Head] → D → A → B → [Tail]
访问节点 "B" 后:
Step 1: [Head] → D → A → B → [Tail]
Step 2: 移动 B → [Head] → B → D → A → [Tail]
🌐 应用场景
LRU 缓存广泛应用于高频访问系统优化:
- 前端路由缓存:Vue/React 中缓存最近访问的组件实例
- API 响应缓存:减少重复网络请求(如 Axios 拦截器)
- 浏览器资源缓存:存储最近加载的 JS/CSS 文件
- 数据库查询缓存:MySQL/Redis 的缓冲池管理
💡 方案优势
- 严格 O(1) 时间复杂度:链表操作(插入/删除)与哈希表查询均为常数时间
- 内存高效 :仅存储必要指针(每个节点含
prev/next
) - 规避哈希冲突:链表操作不受 Map 哈希冲突影响(对比纯 Map 实现)
以下是双向链表和哈希表在 JavaScript 中的实现原理及核心代码解析:
🔗 一、双向链表(Doubly Linked List)
1. 基本原理
-
节点结构 :每个节点包含三个属性:
data
:存储数据值prev
:指向前一个节点的指针next
:指向后一个节点的指针
-
双向遍历支持 :可以从头节点(
head
)向尾节点(tail
)正向遍历,或从尾节点向头节点反向遍历。 -
与单向链表的区别 :
操作 单向链表 双向链表 插入/删除 仅需修改 next
指针需同时修改 prev
和next
指针反向遍历 不支持(需从头遍历) 支持(时间复杂度 O(1)) 内存占用 较低(每个节点 1 个指针) 较高(每个节点 2 个指针)
2. JavaScript 实现关键步骤
javascript
class Node {
constructor(data) {
this.data = data;
this.prev = null;
this.next = null;
}
}
class DoublyLinkedList {
constructor() {
this.head = null; // 链表头
this.tail = null; // 链表尾
this.size = 0; // 链表长度
}
// 尾部追加节点
append(data) {
const newNode = new Node(data);
if (!this.head) {
this.head = this.tail = newNode; // 空链表时头尾指向同一节点
} else {
newNode.prev = this.tail; // 新节点的 prev 指向原尾节点
this.tail.next = newNode; // 原尾节点的 next 指向新节点
this.tail = newNode; // 更新尾节点为新节点
}
this.size++;
}
// 删除指定位置节点
removeAt(index) {
if (index < 0 || index >= this.size) return null;
let current = this.head;
// 删除头节点
if (index === 0) {
this.head = current.next;
if (this.head) this.head.prev = null;
else this.tail = null; // 链表为空时更新 tail
}
// 删除尾节点
else if (index === this.size - 1) {
current = this.tail;
this.tail = current.prev;
this.tail.next = null;
}
// 删除中间节点
else {
let count = 0;
while (count++ < index) current = current.next;
current.prev.next = current.next; // 前驱节点的 next 指向后继
current.next.prev = current.prev; // 后继节点的 prev 指向前驱
}
this.size--;
return current.data;
}
}
操作复杂度:
- 插入/删除头尾节点:O(1)
- 插入/删除中间节点:O(n)(需遍历定位)
🗂️ 二、哈希表(Hash Table)
1. 基本原理
- 核心组件 :
- 哈希函数 :将任意键(Key)转换为固定范围的整数索引(如
hash = key.charCodeAt(i) % size
)。 - 存储桶(Buckets):数组 + 链表(解决冲突)。
- 哈希函数 :将任意键(Key)转换为固定范围的整数索引(如
- 冲突解决策略 :
- 链地址法:同一索引的键值对存储在链表中(如 JavaScript 对象的属性冲突处理)。
- 开放定址法:冲突时寻找下一个空闲位置(未在基础实现中广泛使用)。
2. JavaScript 实现关键步骤
javascript
class HashTable {
constructor(size = 100) {
this.size = size;
this.buckets = new Array(size).fill(null).map(() => []); // 初始化桶为二维数组
}
// 哈希函数(简单示例)
_hash(key) {
let hash = 0;
for (let char of key) hash += char.charCodeAt(0);
return hash % this.size; // 取模限制索引范围
}
// 插入键值对
set(key, value) {
const index = this._hash(key);
const bucket = this.buckets[index];
// 检查是否已存在相同 Key
for (let i = 0; i < bucket.length; i++) {
if (bucket[i][0] === key) {
bucket[i][1] = value; // 更新值
return;
}
}
bucket.push([key, value]); // 无冲突时直接添加
}
// 获取值
get(key) {
const index = this._hash(key);
const bucket = this.buckets[index];
for (let [k, v] of bucket) {
if (k === key) return v;
}
return undefined;
}
}
操作复杂度:
- 理想情况(无冲突):O(1)
- 最坏情况(所有键冲突):O(n)(退化为链表遍历)
💡 三、应用场景对比
数据结构 | 适用场景 | JS 原生对应 |
---|---|---|
双向链表 | 历史记录(如浏览器前进/后退)、LRU 缓存 | 无直接对应,需手动实现 |
哈希表 | 快速检索(如字典、缓存系统、数据库索引) | Object / Map |
⚙️ 四、性能优化方向
- 双向链表 :
- 使用哑节点(Dummy Nodes) 简化边界操作(如头尾插入)。
- 维护
tail
指针避免反向遍历时的全链表扫描。
- 哈希表 :
- 动态扩容:当负载因子(元素数/桶数) > 0.7 时,倍增桶数并重哈希。
- 优化哈希函数:改用更均匀的算法(如 DJB2、MurmurHash)减少冲突。
以下是一个结合浏览器历史记录管理场景的双向链表 JavaScript 实现详解。我将从基础结构开始,逐行解析代码设计思路,配合图示和实际应用逻辑,助你透彻理解双向链表的原理。
一、双向链表核心:节点类设计
javascript
class HistoryNode {
constructor(url, title) {
this.url = url; // 存储数据(如访问的URL)
this.title = title; // 附加数据(如网页标题)
this.prev = null; // 指向前驱节点(后退功能)
this.next = null; // 指向后继节点(前进功能)
}
}
设计解析:
- 数据分离 :
url
和title
分离存储,符合单一职责原则。 - 双向指针 :
prev
:指向上一次访问的页面(实现后退)next
:指向下一次访问的页面(实现前进)
- 独立实体:每个节点封装完整的历史记录项,不依赖外部状态。
二、双向链表类:浏览器历史管理器
javascript
class BrowserHistory {
constructor() {
this.head = null; // 链表头(最早访问的页面)
this.tail = null; // 链表尾(最新访问的页面)
this.current = null; // 当前展示的页面
this.size = 0; // 历史记录数量
}
// 访问新页面(尾插法)
visit(url, title) {
const newNode = new HistoryNode(url, title);
// 场景1:首次访问
if (this.size === 0) {
this.head = newNode;
this.tail = newNode;
this.current = newNode;
}
// 场景2:已有历史记录
else {
newNode.prev = this.tail; // 新节点前驱指向旧尾节点
this.tail.next = newNode; // 旧尾节点后继指向新节点
this.tail = newNode; // 更新尾节点
this.current = newNode; // 跳转到新页面
}
this.size++;
}
// 后退功能
back() {
if (this.current.prev) {
this.current = this.current.prev; // 移动到前驱节点
return this.current.title; // 返回页面标题
}
return "Already at earliest page!";
}
// 前进功能
forward() {
if (this.current.next) {
this.current = this.current.next; // 移动到后继节点
return this.current.title;
}
return "Already at latest page!";
}
// 删除当前记录(如关闭敏感页面)
removeCurrent() {
if (!this.current) return;
// 场景1:删除唯一节点
if (this.size === 1) {
this.head = this.tail = this.current = null;
}
// 场景2:删除头节点
else if (this.current === this.head) {
this.head = this.head.next;
this.head.prev = null;
this.current = this.head; // 后退后删除时自动跳转到下一页
}
// 场景3:删除尾节点
else if (this.current === this.tail) {
this.tail = this.tail.prev;
this.tail.next = null;
this.current = this.tail; // 前进后删除时自动跳转到上一页
}
// 场景4:删除中间节点
else {
const prevNode = this.current.prev;
const nextNode = this.current.next;
prevNode.next = nextNode;
nextNode.prev = prevNode;
this.current = prevNode; // 默认后退到前一页
}
this.size--;
}
}
三、核心操作图解与代码解析
1. 访问新页面 (visit()
)
-
操作流程 :
graph LR A[旧尾节点] -->|next| B[新节点] B -->|prev| A C[新节点] -->|成为| D[新尾节点] -
代码逻辑 :
- 第14行:将新节点的
prev
指向当前尾节点,建立反向链接 - 第15行:当前尾节点的
next
指向新节点,建立正向链接 - 第16行:更新尾指针到新节点(确保链表完整性)
- 第14行:将新节点的
2. 删除节点 (removeCurrent()
)
-
中间节点删除流程 :
graph LR A[前驱节点] -->|原指向| B[待删节点] B -->|原指向| C[后继节点] 调整后:A -->|next| C C -->|prev| A -
关键代码 :
-
第49-50行:
javascriptprevNode.next = nextNode; // 前驱跳过当前节点 nextNode.prev = prevNode; // 后继跳过当前节点
-
通过重定向相邻节点的指针,物理摘除目标节点。
-
四、应用演示:模拟浏览器操作
javascript
const history = new BrowserHistory();
history.visit("home.com", "Home");
history.visit("about.com", "About");
history.visit("contact.com", "Contact");
console.log(history.back()); // 输出:"About"(回到上一页)
console.log(history.forward()); // 输出:"Contact"(进入下一页)
history.removeCurrent(); // 删除Contact页
console.log(history.current.title); // 输出:"About"(自动跳转回上一页)
五、双向链表 vs 单向链表:设计哲学
特性 | 双向链表 | 单向链表 |
---|---|---|
指针开销 | 每个节点多1个指针(空间换时间) | 仅1个指针(空间高效) |
删除效率 | O(1) (直接访问前驱) | O(n) (需遍历找前驱) |
适用场景 | 需双向遍历(如浏览器历史) | 单向操作(如消息队列) |
六、深入理解:为什么双向链表适合LRU缓存?
在LRU缓存中,频繁移动节点是核心操作:
- 访问节点时需将其移到链表头(表示最新使用)
- 淘汰节点时删除链表尾(表示最旧数据)
双向链表的优势:
- 移动节点 :通过
prev
直接定位相邻节点,O(1)完成摘除和插入 - 删除尾节点 :通过
tail
指针直接访问,无需遍历
此设计思想同样适用于:
- 音乐播放列表(上一曲/下一曲)
- 文档撤销/重做栈(如Photoshop历史记录)
附:完整可运行代码
javascript
// 节点类与链表类完整代码(包含注释)
class HistoryNode { /* 见上文 */ }
class BrowserHistory { /* 见上文 */ }
// 测试用例
const history = new BrowserHistory();
history.visit("https://example.com", "Example");
history.visit("https://openai.com", "OpenAI");
console.log("当前页面:", history.current.title);
console.log("后退:", history.back());
console.log("前进:", history.forward());
通过逐行剖析与实际场景结合,双向链表的设计从抽象概念转化为解决具体问题的工具。重点理解指针重定向的逻辑 与边界条件处理,即可掌握其本质。