LeetCode 146 LRU缓存|哈希表+双向链表标准实现(JS完整注释版)

0 阅读3分钟

前言

LRU 是面试高频算法题,要求 get/put 均摊 O(1)。单纯哈希无法维护时序,单向链表删除节点要遍历;哈希表+带虚拟哨兵的双向链表是标准最优解。 链表头 = 最近使用,链表尾 = 最久未使用,满容量淘汰尾部节点。

整体设计思路

  1. 双向链表节点 DNode:存 key、val、prev、next,必须存 key,淘汰时同步删除 Map 键值;
  2. 虚拟头 head、虚拟尾 tail:省去判空边界逻辑;
  3. Map:key 映射链表节点,实现 O(1) 定位节点;
  4. 4个内部工具函数:移除节点、头部插入、节点移至头部、弹出尾节点;
  5. 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. 四个私有工具函数(复用逻辑,代码干净)

  1. _removeNode:纯粹断开节点前后指针,不删除、不移位;
  2. _addHead:固定在 head 后插入,代表最新访问数据;
  3. _moveToHead:先摘节点,再插入头部,复用上面两个函数;
  4. _popTail:取出尾前真实节点,断开链表,返回给外层删除 Map 记录。

4. get 方法

  • key 不存在返回 -1;
  • 存在则取出节点,调用 _moveToHead 刷新访问时序,返回值。

5. put 方法两种分支

  1. key 已存在:仅更新 val,移动至表头;
  2. key 不存在:创建节点、存入 Map、插入表头; 若 Map 长度超过容量,执行淘汰:弹出尾节点,同时从 Map 删除对应 key。

复杂度总结

  • 时间复杂度:getput 均摊 O(1),哈希读写、链表头尾操作都是常数时间;
  • 空间复杂度:O(capacity),最多存储 capacity 个节点。

补充说明

刷题可以用 JS Map 简写版本,但面试手写必须掌握双向链表哨兵版,面试官考察底层原理,简写只能作为辅助思路。