资讯详情

手写LRU缓存:哈希表+双向链表为何是O(1)淘汰的黄金组合

📅 2026/9/28 8:29:18 | 华诺云谱 👁 阅读
手写LRU缓存:哈希表+双向链表为何是O(1)淘汰的黄金组合
1. 这道题到底在考什么力扣 hot100 里挂着“146. LRU 缓存”这个题号看起来只是“设计一个数据结构”实际上它考的是你对“哈希表 双向链表”这套组合的熟练度以及你能不能在生产级别的缓存场景里讲清楚“淘汰”到底是怎么发生的。我先说结论这道题刷一遍不难但把它刷到能应对面试追问、能迁移到真实系统的程度是另一回事。LRULeast Recently Used翻译过来就是“最近最少使用”。每次访问一个 key就把它标记为“最近刚用过的”缓存满了要腾位置时优先淘汰“最久没被用过的”。这个概念你随便搜一下 Redis、操作系统页面置换、CPU Cache 都能看到它的身影但力扣把这个概念浓缩成了一个 146 行的题目要求你实现两个操作 get 和 put两者时间复杂度都必须是 O(1)。这道题适合谁认真刷准备校招社招的应届生、想补数据结构的后端开发还有那些已经工作了但总觉得自己对缓存原理“会概念、不会设计”的人。题解网上多的是但大多数只给代码不讲为什么。这篇东西我把所有“为什么”拆开讲清楚。关于题目的三个硬性要求先摆在这里get(key)如果 key 存在返回 value并把该 key 标记为最近使用不存在则返回 -1。put(key, value)如果 key 存在更新 value并标记为最近使用如果不存在则插入。插入前若缓存已满先淘汰最久未使用的 key再插入。所有操作的时间复杂度必须达到 O(1)。前两条是 LRU 的语义第三条把题目从一个“模拟题”抬升成了“结构设计题”。难点就在这个 O(1) 上。2. 为什么朴素的方案会挂拿到这道题第一反应往往是“这有什么难的”。用一个 HashMap 存 key 和 value记录每个 key 的“最近访问时间”put 的时候遍历找最旧的那个删掉。这个思路没错但问题在于“遍历找最旧”这一步。只要缓存容量是 N这一步的时间复杂度就是 O(N)get 还能勉强做到 O(1)put 直接被打回原形和题目要求的 O(1) 相去甚远。顺着这个思路往下推你会发现核心矛盾变成了能不能维护好“访问顺序”让“哪个 key 最久没被用过”这个问题不用遍历就能知道这里有一个自然联想链表。链表天生擅长表示顺序关系。如果把每个 key 对应的节点按“最近访问”的顺序串成一个链表那么链表头部就是最近使用的链表尾部就是最久未使用的。淘汰的时候只需要删尾节点O(1) 搞定。但光有链表还不够get 的时候你需要在链表里找到这个 key 对应的节点这一步如果靠遍历又是 O(N)。于是第二个结构登场了哈希表。哈希表负责“从 key 到链表节点”的映射让 get 可以先在哈希表里 O(1) 找到节点再 O(1) 把它移动到链表头部。两个结构各干各擅长的活组合起来才能满足全部要求。所以这道题的设计本质不是“你会不会 LRU 概念”而是“你能不能识别出单靠哈希表或链表都无法同时满足 O(1) 查找和 O(1) 淘汰从而想到组合结构”。这个识别过程就是面试官想看到的思维链路。3. 核心细节为什么必须是哈希表 双向链表3.1 双向链表到底“双向”在哪很多人写得出来代码但被追问“为什么用双向而不用单向”的时候卡住了。单向链表的节点只有 next 指针它只能往后走。删除一个节点必须知道它的前驱节点不然没法把前驱的 next 指向当前节点的 next。问题在于单向链表里找前驱只能从头遍历这就引入了 O(N) 的删除开销。双向链表每个节点多存一个 prev 指针删除时 O(1) 就能拿到前驱同时改掉前驱的 next 和当前节点的前后引用这就是“双向”的全部意义。你可以在草稿纸上画一下一个链表里有 A、B、C 三个节点想删掉 B。单向链表需要从 head 开始走到 A然后 A.next C。双向链表则直接通过 B.prev 拿到 A再改 A.next 和 C.prev不需要任何遍历。这个差距在小规模数据里看不出来但在容量上万的缓存里就是天壤之别。3.2 哨兵节点把边界情况全部干掉处理链表最烦的是什么头尾边界。在头部插入、在尾部删除、链表为空时操作这些情况都得分支判断写出来又丑又容易漏。解决办法是引入哨兵节点一个永远存在的 dummy head 和一个永远存在的 dummy tail。做一个很形象的比喻哨兵节点就像旋转门两侧的门柱人进进出出时永远有东西撑着结构不会出现“法式大餐吃到最后发现没盘子了”的尴尬。有了它们链表为空时head.next 依然指向 dummy tailtail.prev 依然指向 dummy head指针引用始终合法。插入头部、插入尾部、删除节点这些操作的代码是统一的不用判断链表是否为空、操作的是不是头尾节点。循环引用的垃圾回收问题在 C 等语言里需要你手动处理而在有 GC 的语言里反而省心你只需要把引用断开就行。哨兵节点虽然会多占两个节点的内存但它换来的代码简洁性和无边界分支逻辑绝对值。3.3 哈希表里存的是节点不是值这是新手最容易踩的坑。哈希表的作用是“从 key 快速定位到链表节点”所以 map 的 value 应该存节点对象而不是直接存 value。为什么考虑淘汰的场景当链表尾部节点要被删掉时你需要同步删除哈希表里对应的 key。如果哈希表里存的是 value那此时你拿不到 key只知道“某个 key 对应的值是这个 value”根本没法删哈希表条目。所以链表节点必须同时保存 key 和 value作为哈希表键和值的中转站。删除一个节点时先从 node.key 拿到 key再从哈希表里删掉这组映射。这个设计在任何一门语言里都是一样的逻辑Python 里就是self.cache[key] nodeJava 里就是map.put(key, node)C 里就是 key 关联 list 迭代器。3.4 更新顺序先改链表还是先改哈希表put 一个已存在的 key 时要更新 value 并把它移到链表头部。这涉及两个结构的更新。正确的顺序是先通过哈希表找到旧节点修改节点里的 value再把这个节点从原位置移到头部。哈希表本身不需要动因为 key 没变映射关系没变。如果反过来先删哈希表再操作链表中间一旦出错整个 map 就处在脏状态。类似的细节还出现在删除尾部节点时应该先从哈希表移除 key再从链表摘除节点还是反过来我的习惯是先拿到 tail从哈希表删除 tail.key再执行链表删除操作。理由很简单链表删除是纯指针操作一旦执行完节点对象可能已经失去引用趁节点还“活着”先把它的 key 拿出来用是最稳妥的。4. 从零手写一套能直接跑通的 Python 实现4.1 手写版哈希表 双向链表直接上代码这份代码我在本地跑过力扣的示例用例和几个边界用例没有任何问题class ListNode: def __init__(self, key0, value0): self.key key self.value value self.prev None self.next None class LRUCache: def __init__(self, capacity: int): self.capacity capacity self.cache {} self.head ListNode() self.tail ListNode() self.head.next self.tail self.tail.prev self.head def _remove_node(self, node): node.prev.next node.next node.next.prev node.prev def _add_to_head(self, node): 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) return if len(self.cache) self.capacity: tail self._pop_tail() del self.cache[tail.key] new_node ListNode(key, value) self.cache[key] new_node self._add_to_head(new_node)建议你把这个实现逐行读一遍然后闭着眼睛自己写三遍。每一遍都会发现对指针操作的理解又深了一点。4.2 关键环节逐段拆解_remove_node四行代码奏效原理来自双向链表的“指针互相指”特性。node.prev.next node.next和node.next.prev node.prev两条赋值把节点从链表中摘出去。顺序不重要两条都执行完节点就被孤立了。注意这里没有让 node 自己的 prev 和 next 变成 None这在我们的场景里没关系因为后续马上要把它重新接入头部。如果你写的代码里节点还挂在某个哈希表引用里务必要确认它确实从链表中断开了。_add_to_head新节点插到 head 和原第一个节点之间。先处理新节点自己prev 指向 headnext 指向原 head.next。然后把原 head.next 的 prev 改成指向新节点最后让 head.next 指向新节点。代码写起来有一个容易错的地方把self.head.next.prev node这行漏掉导致原头节点的 prev 没更新链表就坏了。每写一次链表操作我建议都画一下图再对照而不是靠背。_move_to_head先删再加两步操作合起来等于把节点从任意位置挪到头部。为什么要这么拆因为删除和插入是最原子的两个操作组合起来可读性最高也最容易排查问题。_pop_tail返回值是tail.prev因为这个节点才是真正的尾节点dummy tail 只是个哨兵。拿到节点后先_remove_node再从哈希表删 key调用顺序我在 3.4 里讲过了回到这里就是del self.cache[tail.key]在_pop_tail()之后执行。get三步查哈希表判断存在性、取节点移动到头、返回值。注意 get 一个不存在的 key 返回 -1这里不能抛异常。put核心分支是“key 已存在”还是“key 不存在”。已存在更新 value、移动节点结束。不存在判断缓存是否已满满了先淘汰尾节点然后新建节点加入哈希表加入链表头。这里有个隐藏细节新建节点后再把整个东西加入哈希表和链表而不是先加链表再加哈希表是为了在操作失败时不留下挂了一半的数据。4.3 用 OrderedDict 的偷懒版Python 标准库里的collections.OrderedDict本身就是“哈希表 双向链表”的实现它天然支持把某个键移到末尾并且保持插入顺序。用它来实现 LRU 会短得多from collections import OrderedDict class LRUCache: def __init__(self, capacity: int): self.capacity capacity self.cache OrderedDict() def get(self, key: int) - int: if key not in self.cache: return -1 self.cache.move_to_end(key) return self.cache[key] def put(self, key: int, value: int) - None: if key in self.cache: self.cache.move_to_end(key) self.cache[key] value if len(self.cache) self.capacity: self.cache.popitem(lastFalse)这里move_to_end(key)把键移到末尾死亡淘汰时popitem(lastFalse)弹出最前面的键。整体代码量只有手写版的三分之一。但面试的时候我一般建议先亮出手写版。因为OrderedDict是语言库的封装产物面试官想听到的是你“手写哈希表 双向链表”的能力。你可以用这个方法做对照验证写完手写版之后用 OrderedDict 版跑同样用例两边比对输出作为自测手段。4.4 复杂度与边界验证时间上get 和 put 的每一步基本都是“哈希查找 O(1) 指针调整 O(1)”所以总体 O(1)。空间上是 O(capacity)因为哈希表最多存 capacity 个键链表也最多 capacity 个节点。边界情况建议自己构造以下用例去跑场景操作序列预期结果容量为 1put(1,1)、put(2,2)、get(1)返回 -1因为第一个插入的已被淘汰重复 put 已有 keyput(1,1)、put(1,2)、get(1)返回 2且容量不变get 不存在 keyget(999)返回 -1容量为 0put(1,1)缓存根本不该插入数据缓存满后连续 get 旧 key先填满再 get 最早那个再 put 新 key最早那个虽然 get 过但可能仍被淘汰取决于它是否被移到了头部容量为 0 这个用例最常被忽略。__init__里 capacity 是 0哈希表为空任何 put 都应该直接返回不然_pop_tail会拿tail.prev而这个值可能是 head删掉一个哨兵问题就大了。所以严谨一点应该在 put 开头加一句if self.capacity 0: return我在刷题时一般不会加因为力扣的约束是 capacity 为正整数但写生产代码时必须考虑。5. 刷题翻车现场这些坑我全踩过5.1 变量名混乱导致指针引用错乱头节点、新节点、当前节点的 prev 和 next 互相赋值变量多了以后很容易把自己绕进去。建议从写第一遍开始就别用 a、b、c 这种名字老老实实写 node、prev、next。画图比看代码更有用每个节点的 prev 和 next 都标清楚操作前线和操作后线各画一张。别嫌麻烦链表这东西靠脑内模拟极易出错。5.2 更新已存在 key 时忘了更新 valueput(1, 1)之后执行put(1, 2)如果只把节点移到头部、不改节点里的 value那么下次 get 返回的就是旧值 1。这个 bug 很隐蔽因为链表结构没问题哈希表映射也没问题只有值错了。我的经验是所有“修改”操作都先把 node 取出来看一遍手动过一遍“改值-删节点-加头部”三步曲。5.3 淘汰时删了链表节点却忘了删哈希表这样会导致哈希表里保存了一个“已经不在链表中的节点”的引用。第一次 get 它会从哈希表里命中但_move_to_head时因为节点不在链表中指针操作会把链表搞成一团乱。调试的时候你会看到很奇怪的现象链表节点数量不变但逻辑上已经有节点丢了。建议每次_pop_tail之后立即检查哈希表长度和链表长度是否一致不一致就是哪里漏删了。5.4 用对象当 key 时的哈希变动问题力扣测试里 key 都是整数没问题。但如果把这道题直接搬到生产场景key 是自定义对象而对象的 hashCode 又在变化那么哈希表就找不到原来的键了。这是面试官喜欢追问的扩展点。工程上一般用不可变对象当 key或者把变量对象序列化成字符串再塞进 LRU 结构。6. 从力扣题到真实系统LRU 在缓存世界里到底长什么样6.1 那些“带缓存”的系统本质上都在做同一件事我在日常开发里接触过的缓存组件从内存缓存到进程级缓存再到分布式缓存底层原理无一例外都在做同一件事拿空间换时间用淘汰策略控制空间。Redis 里有内存淘汰策略其中就包含近似 LRU操作系统的页面置换有 LRU 变体MyBatis 的二级缓存默认也支持 LRU 策略Spring 里用Cacheable配合各种 CacheManager也涉及容量和淘汰的概念。结合这次力扣 hot100 的题单你会看到 LRU 缓存和“买股票的最佳时机”“最长递增子序列”这类题并列就是因为这种“经典结构题”和“经典动态规划题”一样都是面试中最高频的题型。你现在刷到这一题说明你已经在 hot100 的路上走到中段了这题的收益会在后续的系统设计面试里反复兑现。6.2 Redis 用的不是严格 LRU工程里要的是近似严格 LRU 要求每次访问都精确移动链表节点而在 Redis 这种高吞吐场景为每个 key 维护精确的访问顺序内存和时间成本都不可忽略。Redis 默认的allkeys-lru策略其实是“采样淘汰”从所有 key 里随机采样一批默认 5 个淘汰其中最久未使用的。这是一个典型的“把 O(1) 严格结构换成近似统计”的工程取舍牺牲一点命中率换来极低的维护成本。这类近似思路和力扣的严格 LRU 对比是面试官的进阶问题。回答得好说明不仅要会写题还理解“精确的代价”和“工程上的妥协”。6.3 缓存命中率、穿透、雪崩和 LRU 的边界我们在生产里花最多时间调优的三件事缓存命中率、缓存穿透、缓存一致。LRU 解决的是“空间有限时让命中率尽量高”但它不解决“本来就不存在的 key 被反复查询”的穿透问题也不解决“数据源更新了但缓存里还是旧值”的一致性问题。穿透的常见解法是布隆过滤器加在 LRU 前面一致性的常见解法是写更新时主动失效缓存、设置合理的 TTL、或者引入消息通知去删缓存。你在刷 LRU 缓存这道题时如果能把这个边界想清楚面试时就能很自然地把话题从“算法题”过渡到“你项目里的缓存设计”这是加分项。6.4 多线程环境下怎么改造 LRU力扣不问并发但生产环境一定会问。最粗暴的方案是给 get 和 put 加互斥锁例如 Python 的threading.Lock。这个做法的优点是正确性没问题但所有读操作都会被串行化缓存这层反而成了性能瓶颈。更细的方案是读写锁读多写少的场景用RWLock让多个 get 并行put 时独占。再进一步是分段锁在不同哈希桶上各自加锁降低锁竞争。手写版在改造时还有一个天然难点双向链表的指针操作必须整体加锁不然两个线程同时移动节点链表就乱了。所以工程实现里往往不做“严格 LRU 无锁”而是“近似 LRU 采样 允许少量误差”代价可控复杂度可接受。这个方向非常值得在面试追问环节认真聊一聊。7. 实操总结我把这套刷题思路沉淀成了三步先把这题刷到“默写”的程度再用它去理解工业级缓存最后把它融入自己的项目经验里。我的体会是大多数刷题人卡在第二步因为手里没有一套“从题到系统”的迁移框架。这里给你一个可复用的三步法第一步把力扣的 LRU 当模板题来练。手写版和 OrderedDict 版都实现一遍用示例用例把两个版本的结果对齐。第二步打开任何一个你熟悉的技术栈的缓存源码看看它的淘汰策略是不是 LRU 变种。我用过 Redis 和 MyBatis前者是采样近似策略后者是 LinkedHashMap 的 accessOrder 模式的 LRU 实现都有现成代码可读。第三步把 LRU 融进你自己的项目给后端接口加一层容量有限的本地缓存观察命中率和耗时变化。这个小实验比刷几十道题更能让你理解淘汰策略的价值。我个人实际操作中体会最深的一点链表题的精髓不是背代码而是把每一步指针修改画到纸上然后对照图写代码。画图五分钟能省调试两小时。特别是_add_to_head和_remove_node这两个操作你只要画过一次这辈子都忘不了。等你把 LRU 吃透了再看 hot100 里后面的题目会很顺。数据结构组合的思维方式一旦建立二叉树、图、堆那些题也不会觉得难了。缓存这东西今天你在这个题目里用双向链表实现明天在真实系统里可能就是一段写进简历的优化经验这个转化过程值得你认真走一遍。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑