手写 LRU Cache

LRU(Least Recently Used)缓存淘汰最久未被访问的数据。面试通常要求 getput 都达到平均 O(1)

Map 实现

JavaScript 的 Map 会维护插入顺序。访问键时先删除再插入,即可把它移动到“最新”位置。

class LRUCache {
  constructor(capacity) {
    if (!Number.isInteger(capacity) || capacity <= 0) {
      throw new RangeError('capacity must be a positive integer');
    }
    this.capacity = capacity;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) return -1;
    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  put(key, value) {
    if (this.cache.has(key)) this.cache.delete(key);
    this.cache.set(key, value);

    if (this.cache.size > this.capacity) {
      const oldestKey = this.cache.keys().next().value;
      this.cache.delete(oldestKey);
    }
  }
}

其中:

this.cache.keys().next().value

可以拆成三步理解:

  1. this.cache.keys() 返回一个只遍历 key 的迭代器。Map 会按照插入顺序保存 key。
  2. .next() 取出迭代器的第一个元素,返回形如 { value, done } 的对象。
  3. .value 取出真正的 key。

因此,如果缓存中的 key 顺序是:

['a', 'b', 'c']

那么:

this.cache.keys().next().value; // 'a'

在这个 LRU 实现中,最久未使用的 key 始终位于 Map 的最前面,所以可以用它完成淘汰。Map 为空时,next().value 会是 undefined,但这里因为外层已经判断了 this.cache.size > this.capacity,正常情况下不会出现空 Map 淘汰。

const cache = new LRUCache(2);
cache.put('a', 1);
cache.put('b', 2);
cache.get('a');
cache.put('c', 3); // 淘汰 b

console.log(cache.get('b')); // -1
console.log(cache.get('a')); // 1

复杂度与经典实现

  • getput:平均 O(1)
  • 空间:O(capacity)

不依赖有序 Map 时,经典答案是哈希表加双向链表:哈希表负责定位节点,双向链表负责在 O(1) 内删除和移动节点,链表尾部保存最久未使用项。单链表无法在 O(1) 内删除任意节点,因为还需寻找前驱。

生产缓存通常还需要 TTL、容量统计和淘汰回调。LRU 只按最近访问淘汰,不保证命中率最优,也不适合对象体积差异极大的缓存。

带 TTL 的 LRU Cache

TTL(Time To Live)表示缓存项的存活时间。下面的实现约定:

  • ttl 使用毫秒表示,从 put 写入或更新时开始计时。
  • get 命中过期数据时,先删除数据,再返回 -1
  • get 命中未过期数据时,会刷新 LRU 顺序,但不会刷新 TTL。
  • 采用惰性删除:只有访问到过期数据,或主动调用 clearExpired 时才删除过期项。
class TTLCache {
  constructor(capacity, ttl) {
    if (!Number.isInteger(capacity) || capacity <= 0) {
      throw new RangeError('capacity must be a positive integer');
    }
    if (!Number.isFinite(ttl) || ttl <= 0) {
      throw new RangeError('ttl must be a positive number');
    }

    this.capacity = capacity;
    this.ttl = ttl;
    this.cache = new Map();
  }

  get(key) {
    const entry = this.cache.get(key);
    if (!entry) return -1;

    if (entry.expiresAt <= Date.now()) {
      this.cache.delete(key);
      return -1;
    }

    // 删除后重新插入,使 key 进入最新位置。
    this.cache.delete(key);
    this.cache.set(key, entry);
    return entry.value;
  }

  put(key, value) {
    // 更新已有 key 时,重新计算 TTL,并将它移动到最新位置。
    this.cache.delete(key);
    this.cache.set(key, {
      value,
      expiresAt: Date.now() + this.ttl,
    });

    if (this.cache.size > this.capacity) {
      const oldestKey = this.cache.keys().next().value;
      this.cache.delete(oldestKey);
    }
  }

  delete(key) {
    return this.cache.delete(key);
  }

  clearExpired() {
    const now = Date.now();

    for (const [key, entry] of this.cache) {
      if (entry.expiresAt <= now) {
        this.cache.delete(key);
      }
    }
  }
}

使用示例:

const cache = new TTLCache(2, 1000);

cache.put('a', 1);
console.log(cache.get('a')); // 1

setTimeout(() => {
  console.log(cache.get('a')); // -1,已超过 1000ms
}, 1100);

复杂度

  • getputdelete:平均 O(1)
  • clearExpiredO(n),其中 n 是当前缓存项数量。
  • 空间:O(capacity)

惰性删除不会启动后台定时器,适合简单面试实现。如果要求过期数据必须在 TTL 到期的瞬间自动清除,可以为每个缓存项增加定时器,或者使用“哈希表 + 过期时间最小堆”;后者能更稳定地控制清理成本,但实现复杂度更高。

保护热 key 的分段 LRU

普通 LRU 只看“最近一次访问时间”。一个 key 即使被访问过很多次,只要一段时间没有访问,也可能和普通冷数据一样被淘汰。

可以把缓存分成两个区域:

  • probation:普通区,新写入的 key 先放这里。
  • protected:保护区,key 在普通区再次命中后晋升到这里。

淘汰时先淘汰普通区,只有普通区为空时才淘汰保护区。这样,偶尔访问一次的冷数据不会挤掉已经多次命中的热 key。

class HotKeyLRUCache {
  constructor(capacity, protectedCapacity = Math.ceil(capacity / 2)) {
    if (!Number.isInteger(capacity) || capacity <= 0) {
      throw new RangeError('capacity must be a positive integer');
    }
    if (
      !Number.isInteger(protectedCapacity) ||
      protectedCapacity <= 0 ||
      protectedCapacity > capacity
    ) {
      throw new RangeError('protectedCapacity must be between 1 and capacity');
    }

    this.capacity = capacity;
    this.protectedCapacity = protectedCapacity;
    this.probation = new Map();
    this.protected = new Map();
  }

  get(key) {
    if (this.protected.has(key)) {
      const value = this.protected.get(key);
      this.protected.delete(key);
      this.protected.set(key, value);
      return value;
    }

    if (!this.probation.has(key)) return -1;

    const value = this.probation.get(key);
    this.probation.delete(key);
    this.promote(key, value);
    return value;
  }

  put(key, value) {
    if (this.protected.has(key)) {
      this.protected.delete(key);
      this.protected.set(key, value);
      return;
    }

    // 更新普通区中的 key 也视为一次命中,直接晋升到保护区。
    if (this.probation.has(key)) {
      this.probation.delete(key);
      this.promote(key, value);
      return;
    }

    this.probation.set(key, value);
    this.evictIfNeeded();
  }

  promote(key, value) {
    if (this.protected.size >= this.protectedCapacity) {
      const oldestProtectedKey = this.protected.keys().next().value;
      const oldestProtectedValue = this.protected.get(oldestProtectedKey);
      this.protected.delete(oldestProtectedKey);
      this.probation.set(oldestProtectedKey, oldestProtectedValue);
    }

    this.protected.set(key, value);
    this.evictIfNeeded();
  }

  evictIfNeeded() {
    while (this.probation.size + this.protected.size > this.capacity) {
      if (this.probation.size > 0) {
        const oldestKey = this.probation.keys().next().value;
        this.probation.delete(oldestKey);
      } else {
        const oldestKey = this.protected.keys().next().value;
        this.protected.delete(oldestKey);
      }
    }
  }
}

使用示例:

const cache = new HotKeyLRUCache(3, 2);

cache.put('hot', 'A');
cache.get('hot'); // hot 从普通区晋升到保护区
cache.put('cold-1', 'B');
cache.put('cold-2', 'C');
cache.put('cold-3', 'D'); // 优先淘汰普通区中的 cold-1

console.log(cache.get('hot')); // 'A'

这里的“保护”不是永久保留:保护区容量有限。当保护区已满,晋升新热 key 时,保护区中最久未访问的 key 会降级到普通区,并可能在下一次淘汰时被删除。

复杂度

  • getput、晋升和淘汰:平均 O(1)
  • 空间:O(capacity)

这个版本适合表达“访问频率优先、时间顺序次之”的缓存策略;如果要严格按照访问次数淘汰,则应改用 LFU,并维护访问频率和频率桶。