学习小记—

以下是针对LRU缓存题目的系统整理笔记,结合题目要求、实现原理、代码实现和关键技巧,帮助你高效掌握该数据结构的设计要点。


一、题目核心要求

  1. 功能需求

    • LRUCache(capacity):以正整数初始化缓存容量。
    • get(key):返回键对应的值(若存在),否则返回-1
    • put(key, value)
      • 键存在 → 更新值;
      • 键不存在 → 插入键值对;
      • 插入后超容 → 淘汰最久未使用的键值对。
    • 时间复杂度getput 操作必须满足 O(1) 平均时间复杂度。
  2. 示例

    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]

操作流程

  1. get(key)
    • 哈希表查找 key → 不存在则返回 -1
    • 存在则将节点移到链表头部(表示最近使用),返回节点值。
  2. 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 缓存广泛应用于高频访问系统优化:

  1. 前端路由缓存:Vue/React 中缓存最近访问的组件实例
  2. API 响应缓存:减少重复网络请求(如 Axios 拦截器)
  3. 浏览器资源缓存:存储最近加载的 JS/CSS 文件
  4. 数据库查询缓存:MySQL/Redis 的缓冲池管理

💡 方案优势

  1. 严格 O(1) 时间复杂度:链表操作(插入/删除)与哈希表查询均为常数时间
  2. 内存高效 :仅存储必要指针(每个节点含 prev/next
  3. 规避哈希冲突:链表操作不受 Map 哈希冲突影响(对比纯 Map 实现)

以下是双向链表和哈希表在 JavaScript 中的实现原理及核心代码解析:


🔗 一、双向链表(Doubly Linked List)

1. 基本原理

  • 节点结构 :每个节点包含三个属性:

    • data:存储数据值
    • prev:指向前一个节点的指针
    • next:指向后一个节点的指针
  • 双向遍历支持 :可以从头节点(head)向尾节点(tail)正向遍历,或从尾节点向头节点反向遍历。

  • 与单向链表的区别

    操作 单向链表 双向链表
    插入/删除 仅需修改 next 指针 需同时修改 prevnext 指针
    反向遍历 不支持(需从头遍历) 支持(时间复杂度 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):数组 + 链表(解决冲突)。
  • 冲突解决策略
    • 链地址法:同一索引的键值对存储在链表中(如 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

⚙️ 四、性能优化方向

  1. 双向链表
    • 使用哑节点(Dummy Nodes) 简化边界操作(如头尾插入)。
    • 维护 tail 指针避免反向遍历时的全链表扫描。
  2. 哈希表
    • 动态扩容:当负载因子(元素数/桶数) > 0.7 时,倍增桶数并重哈希。
    • 优化哈希函数:改用更均匀的算法(如 DJB2、MurmurHash)减少冲突。

以下是一个结合浏览器历史记录管理场景的双向链表 JavaScript 实现详解。我将从基础结构开始,逐行解析代码设计思路,配合图示和实际应用逻辑,助你透彻理解双向链表的原理。


一、双向链表核心:节点类设计

javascript 复制代码
class HistoryNode {
    constructor(url, title) {
        this.url = url;         // 存储数据(如访问的URL)
        this.title = title;     // 附加数据(如网页标题)
        this.prev = null;       // 指向前驱节点(后退功能)
        this.next = null;       // 指向后继节点(前进功能)
    }
}

设计解析

  1. 数据分离urltitle分离存储,符合单一职责原则。
  2. 双向指针
    • prev:指向上一次访问的页面(实现后退
    • next:指向下一次访问的页面(实现前进
  3. 独立实体:每个节点封装完整的历史记录项,不依赖外部状态。

二、双向链表类:浏览器历史管理器

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行:更新尾指针到新节点(确保链表完整性)

2. 删除节点 (removeCurrent())

  • 中间节点删除流程

    graph LR A[前驱节点] -->|原指向| B[待删节点] B -->|原指向| C[后继节点] 调整后:A -->|next| C C -->|prev| A
  • 关键代码

    • 第49-50行:

      javascript 复制代码
      prevNode.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缓存中,频繁移动节点是核心操作:

  1. 访问节点时需将其移到链表头(表示最新使用)
  2. 淘汰节点时删除链表尾(表示最旧数据)
    双向链表的优势
  • 移动节点 :通过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());

通过逐行剖析与实际场景结合,双向链表的设计从抽象概念转化为解决具体问题的工具。重点理解指针重定向的逻辑边界条件处理,即可掌握其本质。

相关推荐
路光.9 分钟前
触发事件,按钮loading状态,封装hooks
前端·typescript·vue3hooks
我爱996!12 分钟前
SpringMVC——响应
java·服务器·前端
咔咔一顿操作1 小时前
Vue 3 入门教程7 - 状态管理工具 Pinia
前端·javascript·vue.js·vue3
kk爱闹1 小时前
用el-table实现的可编辑的动态表格组件
前端·vue.js
漂流瓶jz2 小时前
JavaScript语法树简介:AST/CST/词法/语法分析/ESTree/生成工具
前端·javascript·编译原理
换日线°2 小时前
css 不错的按钮动画
前端·css·微信小程序
风象南2 小时前
前端渲染三国杀:SSR、SPA、SSG
前端
90后的晨仔2 小时前
表单输入绑定详解:Vue 中的 v-model 实践指南
前端·vue.js
陈佬昔没带相机3 小时前
围观前后端对接的 TypeScript 最佳实践,我们缺什么?
前端·后端·api
90后的晨仔3 小时前
Vue 事件处理深入指南:从基础到进阶
前端·vue.js