快手面试题:LRU缓存

题目

了解LRU缓存吗?请详细介绍。

解答

LRU(Least Recently Used,最近最少使用)缓存是一种常见的缓存淘汰策略。当缓存空间不足时,它会优先淘汰那些最长时间未被访问的数据,假设"最近被访问过的数据将来更有可能被再次访问"。下面从原理、数据结构、操作实现、代码示例、复杂度分析、应用场景以及扩展等方面详细解释 LRU 缓存。

1. LRU 缓存的核心思想

  • 缓存:存储一部分数据,以加速后续访问。容量有限,当存满时需要淘汰旧数据。

  • 淘汰策略:LRU 认为最近使用过的数据是"热"数据,未来被访问的概率更高;而最久未使用的数据是"冷"数据,应当优先淘汰。

  • 实现要求 :需要在 O(1) 时间内完成 get (读取数据)和 put(写入数据)操作,同时维护数据的访问顺序。

2. 数据结构设计

为了实现 O(1) 的查找和顺序调整,LRU 缓存通常组合使用两种数据结构:

  1. 哈希表(HashMap):存储键(key)到对应节点(Node)的映射,提供 O(1) 的键值查找。

  2. 双向链表(Doubly Linked List):按数据访问时间排序,头部是最近使用的节点,尾部是最久未使用的节点。双向链表允许我们在 O(1) 时间内将任意节点移动到头部,以及删除尾部节点。

节点结构:每个节点包含 key、value、前驱指针 prev 和后继指针 next。

为什么用双向链表而不是单向链表?因为当我们需要将一个节点移动到头部时,需要知道它的前驱节点以完成删除操作,双向链表可以在 O(1) 内完成该操作(单向链表需要遍历找到前驱)。

3. 操作详解

3.1 get(key)
  • 如果 key 存在于哈希表中:

    1. 通过哈希表找到对应的节点。

    2. 将该节点从链表中当前位置移除,并插入到链表头部(表示最近被使用)。

    3. 返回节点的 value。

  • 如果 key 不存在,返回 -1(或 null)。

3.2 put(key, value)
  • 如果 key 已存在:

    1. 更新节点的 value。

    2. 将该节点移动到链表头部(标记为最近使用)。

  • 如果 key 不存在:

    1. 创建新节点,插入到链表头部。

    2. 在哈希表中添加 key 到该节点的映射。

    3. 如果当前缓存容量超过上限,则删除链表尾部的节点(最久未使用),同时从哈希表中移除对应的键。

注意:移动节点到头部 = 删除原位置 + 插入头部。由于是双向链表,这两个操作都是 O(1)。

4. 代码示例(Python)

下面是一个简单的 LRU 缓存实现,使用 dict 作为哈希表,并自定义双向链表。

本节可参考力扣题目:

146. LRU 缓存 - 力扣(LeetCode)https://leetcode.cn/problems/lru-cache/description/?envType=study-plan-v2&envId=top-100-liked

python 复制代码
class DLinkedNode:
    def __init__(self, key=0, value=0):
        self.key = key
        self.value = value
        self.prev = None
        self.next = None

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.cache = {}  # key -> node
        # 使用虚拟头尾节点,避免边界判断
        self.head = DLinkedNode()
        self.tail = DLinkedNode()
        self.head.next = self.tail
        self.tail.prev = self.head
        self.size = 0  # 当前节点数量

    def _remove_node(self, node):
        """从链表中移除节点"""
        prev = node.prev
        nxt = node.next
        prev.next = nxt
        nxt.prev = prev

    def _add_to_head(self, node):
        """将节点插入到头部(head之后)"""
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node

    def _move_to_head(self, node):
        """将节点移动到头部"""
        self._remove_node(node)
        self._add_to_head(node)

    def _pop_tail(self):
        """弹出尾部节点(最久未使用)"""
        node = self.tail.prev
        self._remove_node(node)
        return node

    def get(self, key: int) -> int:
        if key not in self.cache:
            return -1
        node = self.cache[key]
        self._move_to_head(node)  # 更新为最近使用
        return node.value

    def put(self, key: int, value: int) -> None:
        if key in self.cache:
            node = self.cache[key]
            node.value = value
            self._move_to_head(node)
        else:
            new_node = DLinkedNode(key, value)
            self.cache[key] = new_node
            self._add_to_head(new_node)
            self.size += 1
            if self.size > self.capacity:
                # 删除尾部节点
                tail = self._pop_tail()
                del self.cache[tail.key]
                self.size -= 1

5. 时间复杂度分析

  • get:哈希表查找 O(1),链表移动 O(1) → 总 O(1)。

  • put:哈希表查找/插入 O(1),链表插入/删除 O(1) → 总 O(1)。

空间复杂度 O(capacity),存储所有键值对和链表节点。

6. 应用场景

  • 操作系统内存管理:页面置换算法(但实际中可能采用更复杂的近似算法)。

  • 数据库缓存:如 MySQL 的 Buffer Pool、Redis 的内存淘汰(volatile-lru 和 allkeys-lru)。

  • 浏览器历史:最近访问的页面保留,旧的被淘汰。

  • CDN 缓存:边缘节点存储热点内容。

  • 本地缓存库:如 Guava Cache、Caffeine 等支持 LRU 或类似策略。

7. LRU 的优缺点及变种

优点
  • 实现简单,性能高(O(1) 操作)。

  • 对热点数据友好,能快速响应频繁访问的键。

缺点
  • 无法应对偶发性批量访问:例如一次全表扫描会把大量冷数据带入缓存,挤走真正的热点数据(缓存污染)。

  • 只考虑访问时间,不考虑访问频率:一个频繁访问但近期未使用的数据可能会被淘汰(例如周期性任务)。

  • 实现中需要维护双向链表,有一定内存开销

常见变种
  • LRU-K:记录最近 K 次访问的时间戳,淘汰倒数第 K 次访问时间最早的。能更好地抵抗偶发扫描,但实现更复杂。

  • Two Queues (2Q):将数据分为两个队列,一个 FIFO 队列用于初次访问,一个 LRU 队列用于多次访问,减少冷数据冲击。

  • ARC (Adaptive Replacement Cache):动态平衡最近使用和频繁使用的数据,自适应调整。

  • Sliding Window LRU:基于时间窗口的 LRU 近似。

在实际系统中,可能根据业务特点选择或组合这些策略。

8. 总结

LRU 缓存是一种经典且高效的缓存淘汰策略,通过哈希表 + 双向链表可以在 O(1) 时间内完成 get 和 put,同时保持数据按访问时间排序。它广泛应用于需要快速访问且容量有限的场景。理解 LRU 的实现原理不仅有助于面试,也为设计和优化缓存系统打下基础。对于更复杂的访问模式,可以在此基础上改进为 LRU-K 或其他自适应算法。

相关推荐
lqj_本人2 小时前
基于 openYuanrong 的生成式推荐缓存高可用方向验证实践
前端·vue.js·缓存
lhbian2 小时前
redis分页查询
数据库·redis·缓存
We་ct2 小时前
React 中的双缓存 Fiber 树机制
前端·react.js·缓存·前端框架·reactjs·fiber·缓存机制
X-⃢_⃢-X2 小时前
八、Redis之BigKey
数据库·redis·缓存
~莫子2 小时前
Redis
数据库·redis·缓存
jgyzl12 小时前
2026.3.9 Redis内存回收内存淘汰
数据库·redis·缓存
-Da-17 小时前
【操作系统学习日记】《现代处理器性能的三重奏:ISA架构、流水线与缓存系统》
后端·缓存·架构·系统架构
翻斗包菜20 小时前
Nginx 四大核心功能实战:正向代理 + 反向代理 + 缓存 + Rewrite 正则
运维·nginx·缓存
银河麒麟操作系统21 小时前
银河麒麟服务器操作系统IO机制详解
数据库·redis·缓存