Implement · LRU Cache · Go
LC 146,手撕率最高的结构组合题——思路一分钟推导(为什么恰好是哈希 + 双向链表),代码两个版本直接可背:container/list 默写版 + 手写双链表版,LC 146 官方用例本机实测通过。
哈希管「点」:O(1) 定位;双向链表管「序」:O(1) 摘下 + 头插——单结构怎么改都不行,逐条否决才能推出这个组合
container/list + map 三十行默写版;手写双向链表 + 双哨兵版——面试官说「别用标准库」就切第二版,两版操作一一对应
Get 命中要前移 · Put 更新也要前移 · 逐出时 delete(map) 的 key 从链表节点里取——写完逐条自查
Theory · Design Derivation
| 需求(LC 146 原题约束) | 候选结构的回答 |
|---|---|
| get(k) O(1) 定位 | 哈希表 ✓;链表 ✗ 要扫 O(n) |
| 维护「最近使用」全序 | 链表 ✓;哈希表 ✗ 无序语义 |
| 摘任意节点 O(1) | 双向链表 ✓;单链表 ✗ 要先找前驱 |
| 头插(变 MRU)O(1) | 双向链表 ✓ |
| 删最旧(tail.prev)O(1) | 双向链表 + 尾哨兵 ✓;数组 ✗ 搬移 O(n) |
平衡树 / 跳表是 O(log n),不达 O(1) 线;数组中间摘除要整体搬移。逐项排除后只剩一个组合。
缓存容量永远小于全量数据,满了必须驱逐;LRU 驱逐最久未被访问的一条——赌时间局部性(刚用过的马上还会用)。LC 146 的硬约束:get / put 均 O(1)。
哈希表给「点」的能力:key → 节点指针,一次定位;双向链表给「序」的能力:任意 O(1) 摘下、O(1) 头插,头 = MRU、尾 = LRU。组合件 map[key] → *node。
凡是「每次访问都要动全局顺序」的方案(数组搬移、遍历找时间戳最小值)都过不了 O(1);摘中间节点要 O(1) 拿前驱、删 LRU 要 O(1) 摘尾——只有双向链表两样都做得到。
get / put 均 O(1)(哈希定位一次 + 常数次指针改写),空间 O(capacity);结构本身非线程安全,工程上外包 mutex 或按 key 分片。
The Composite Structure
Implement I · container/list + map
type entry struct{ key, val int } // 节点存 key:淘汰反查 map type LRUCache struct { cap int l *list.List // Front=MRU · Back=LRU m map[int]*list.Element // O(1) 定位 } func Constructor(capacity int) LRUCache { return LRUCache{cap: capacity, l: list.New(), m: make(map[int]*list.Element, capacity)} } func (c *LRUCache) Get(key int) int { if e, ok := c.m[key]; ok { c.l.MoveToFront(e); return e.Value.(*entry).val // 命中即提升 MRU } return -1 } func (c *LRUCache) Put(key, val int) { if e, ok := c.m[key]; ok { // 已存在:更新 + 前移 e.Value.(*entry).val = val; c.l.MoveToFront(e) return } c.m[key] = c.l.PushFront(&entry{key, val}) if c.l.Len() > c.cap { // 插入后再逐最旧 if e := c.l.Back(); e != nil { c.l.Remove(e); delete(c.m, e.Value.(*entry).key) // key 从节点取 } } }
Get / Put 均 O(1):哈希定位一次 + 链表常数次指针改写;空间 O(capacity)。并发不安全——工程外包 mutex 或分片。
① 白板先画结构图(上一页)② 报复杂度 ③ 再落代码。面试官常说「别用标准库」——那就切下一页的手写骨架,两版要都会。
Implement I · Inside container/list
// src/container/list/list.go(节选注释) // ┌────────────────────────────────┐ // ▼ │ // root ──next──▶ e1 ──next──▶ e2 ────┘ // ▲ ◀──prev──── ◀──prev────── // // root.next = 首元素(Front) · root.prev = 尾元素(Back) // 空表:root.next = root.prev = &root type Element struct { next, prev *Element list *List Value any // 我们存 *entry } type List struct { root Element // 哨兵,不存数据 len int }
&l.root 既是尾节点的 next 又是首节点的 prev——首尾相接成环,没有 nil、没有边界特判:空表、单节点、删头删尾走同一段指针赋值。
PushFront(进 MRU 位)· MoveToFront(命中提升,= Remove+PushFront)· Back+Remove(逐 LRU)。没有一次遍历——上一页三十行就是这么来的。
取值要断言 e.Value.(*entry)(上一页 Get/Put 的写法);不能随机访问第 k 个元素——前者靠「节点存 key」消化,后者 LRU 本就不需要。
Implement II · Hand-Rolled Doubly Linked List
type node struct { key, val int prev, next *node } type LRUCache struct { cap int m map[int]*node head, tail *node // 两个哨兵,不存数据 } func Constructor(capacity int) LRUCache { c := LRUCache{cap: capacity, m: map[int]*node{}, head: &node{}, tail: &node{}} c.head.next, c.tail.prev = c.tail, c.head return c } func (c *LRUCache) remove(n *node) { n.prev.next, n.next.prev = n.next, n.prev } func (c *LRUCache) pushFront(n *node) { n.prev, n.next = c.head, c.head.next c.head.next.prev, c.head.next = n, n }
func (c *LRUCache) Get(key int) int { n, ok := c.m[key] if !ok { return -1 } c.remove(n); c.pushFront(n) // 命中 → 提升 MRU return n.val } func (c *LRUCache) Put(key, val int) { if n, ok := c.m[key]; ok { n.val = val // 更新也要前移! c.remove(n); c.pushFront(n) return } n := &node{key: key, val: val} c.m[key] = n c.pushFront(n) if len(c.m) > c.cap { // 先插入再判超容 last := c.tail.prev // LRU = 尾哨兵前驱 c.remove(last) delete(c.m, last.key) // key 从节点取! } }
*node 指针 · 节点同时存 key 和 val · Get 命中要前移 · Put 更新分支要前移 · 判超容用 > 且放在插入后(cap=1 也无需特殊分支)。
Runnable · LC 146 Official Example
// import "fmt" · 承接上页的手写实现 func main() { c := Constructor(2) c.Put(1, 1) c.Put(2, 2) fmt.Println(c.Get(1)) // 1 c.Put(3, 3) // 容量满 → 逐出 key=2 fmt.Println(c.Get(2)) // -1 c.Put(4, 4) // 逐出 key=1 fmt.Println(c.Get(1)) // -1 fmt.Println(c.Get(3)) // 3 fmt.Println(c.Get(4)) // 4 } // 输出:1 -1 -1 3 4
put(3,3) 时容量满,被逐出的是尾哨兵前驱(最久未使用的 key=2);两次 get 返回 -1 证明逐出后 map 同步删除,不会命中「幽灵节点」——get(1) 从 1 变 -1 则证明 get/put 的前移都在生效。
go1.22.5 实测:container/list 版与手写双链表版对 LC 146 官方序列输出完全一致;另测两组边界——cap=1(插入即逐出,新 key 可正常命中)、put 更新前移(更新过的 key 不被误逐出),全部通过。
Related & References
哈希表 →(「点」能力的底座,本结构的另一半)
数组与链表 →(双链表摘插与哨兵的细节)
堆与优先队列 →(偷懒版淘汰序:O(log n) 的替代方案)
快速排序(手撕)→ 跳表(手撕)→(本分类兄弟 deck)
Go map 底层 →(哈希定位那一半的源码)
Redis 内存策略 →(采样近似 LRU / LFU 逐参数源码级)
OS 页面置换 →(Clock 二次机会:硬件做不起真 LRU)
缓存三大问题 →(LRU 在读路径里的位置)
参考来源(思路与写法可溯源至下列一手材料;本 deck 代码经 go1.22.5 编译运行验证)
| LeetCode 146. LRU Cache / 460. LFU Cache | 题面约束(get/put O(1))、容量语义与官方示例 |
| src/container/list/list.go(Go 官方源码) | 环形双链表 + root 哨兵;MoveToFront / Remove / PushFront 均 O(1) |
| java.util.LinkedHashMap javadoc | accessOrder 访问序与 removeEldestEntry 契约(Java 一行版) |
| redis.io · Key eviction + src/evict.c | 采样近似 LRU / LFU 参数——工程近似的展开在 Redis 内存策略 deck |
| 本机 go1.22.5 实测 | container/list 版 + 手写双链表版:LC 146 官方序列、cap=1、put 更新前移全部一致通过 |