
实现一个容量受限的缓存,支持 get(key) 和 put(key, value),并且要在容量满时删除最近最少使用(LRU)的项。要求 get/put 均为 O(1)。
直觉 1:队列(queue)------自然但不够灵活
第一反应很自然:维护一个队列,队头为最近使用、队尾为最久未用,插入与弹出很直观。
但很快就遇到问题:
-
当
get(key)时,需要把该 key 移到最近位置(队头),如果使用普通queue,要把队列"来回倒"以找到该元素并移除,复杂度 O(n)。 -
queue不支持"删除中间项"或"移动中间项至队头"而不遍历。
结论:队列能维持顺序,但不支持 O(1) 的任意删除 / 移动 → 不行。
直觉 2:哈希(unordered_map)------查找瞬时,但没顺序
想到键值映射肯定首选 unordered_map:它能 O(1) 找到 key → value,天然适合缓存场景。
于是产生混合想法:用 unordered_map 做 key→value,同时自己维护"优先级/时间戳/计数"等来判断最久未用项。但问题是:
-
如果只维护时间戳或计数,淘汰最旧项(LRU)通常需要遍历所有项来找最旧的,O(n)。
-
我尝试过两个
unordered_map(一个存 value,一个存优先级),然后遍历 map 找最小优先级,结果 put 操作退化到 O(n)。面试场景下这样会被扣分。
结论:哈希能查但不维持"可高效变动的顺序"。
关键思考:我想要的"既能快速查找,又能维护顺序并支持 O(1) 移动/删除"的结构是什么?
把问题拆开两问:
-
我需要 O(1) 根据 key 找到对应项 →
unordered_map满足。 -
我需要在 O(1) 时间内把某个已存在的项移到"最近使用"位置,或删除最旧项 → 需要一种能在 O(1) 删除任意节点并 O(1) 插入头部/尾部的数据结构。
答案合并后就出来了:哈希 + 双向链表。
-
哈希表:负责从 key 快速定位到链表节点(存的是节点指针 / iterator)。
-
双向链表:负责维护访问顺序,支持常数时间的插入与删除(给定指针)。
PS:Python 的 OrderedDict 正是这种设计思想的一个现成实现。
实现尝试与常见错误(我踩过的坑)
我按自己风格手写了双向链表 + unordered_map<int, Node*>。过程中出现了不少问题,列几个代表性的坑和修正思路:
1. 在链表内部 remove() 执行 delete node; ------ 危险!
-
如果
List::remove(node)在内部delete节点,而高层(LRUCache)尚未从map中erase,就有窗口期:map 中仍然存着一个指向已释放内存的指针(野指针)。 -
正确做法:链表只负责结构性变更(detach/insert),不负责内存释放。 内存释放(
delete)由高层统一负责,且在mp.erase(key)之后执行。
这是职责分离(Separation of Concerns)的体现:List 负责链表操作,LRUCache 负责内存与缓存策略。
2. 单节点边界处理错误
手写链表时对"单节点"的头/尾更新处理非常容易出错(忘记同时更新 head 和 tail),导致野指针或 double delete。解决方案有两种:
-
使用 dummy head/tail(哨兵节点),可以极大简化边界判断;
-
或者在每次 detach/insert 时严格更新
head与tail的指向,写清楚单节点分支。
3. delete 的统一位置
所有 delete 操作应集中在 LRUCache 层,这样能保证删除流程如下安全执行:
node = list.remove_head(); // detach, 不 delete
mp.erase(node->key); // 从 map 移除
delete node; // 释放内存
4. 不要反转头尾逻辑
插入我选择为"尾插(tail 表示最近使用)",这样删除最久的就是删头(head)。这本身没有问题,但实现时容易把 remove_head / remove_tail 写反。写之前在纸上画清楚顺序,再实现。
要点:unordered_map<int, Node*> mp 存节点指针;List 只 detach/insert,不 delete;LRUCache 负责 delete。
class LRUCache {
private:
struct Node {
int key, value;
Node* pre;
Node* next;
Node(int k, int v): key(k), value(v), pre(nullptr), next(nullptr) {}
};
struct List {
Node* head;
Node* tail;
List() : head(nullptr), tail(nullptr) {}
// 在尾部插入 node(== 最新使用)
void insert(Node* node) {
node->next = nullptr;
node->pre = tail;
if (!tail) {
head = tail = node;
} else {
tail->next = node;
tail = node;
}
}
// 删除链表中的任意节点(不 delete,由上层删除)
void detach(Node* node) {
if (node->pre) node->pre->next = node->next;
else head = node->next;
if (node->next) node->next->pre = node->pre;
else tail = node->pre;
}
// 删除头节点(返回被删除节点指针)
Node* remove_head() {
if (!head) return nullptr;
Node* node = head;
detach(node);
return node;
}
};
int _capacity;
unordered_map<int, Node*> mp;
List lst;
public:
LRUCache(int capacity) : _capacity(capacity) {}
int get(int key) {
auto it = mp.find(key);
if (it == mp.end()) return -1;
Node* node = it->second;
// move to tail (most recent)
lst.detach(node);
lst.insert(node);
return node->value;
}
void put(int key, int value) {
auto it = mp.find(key);
if (it != mp.end()) {
Node* node = it->second;
node->value = value;
lst.detach(node);
lst.insert(node);
return;
}
if ((int)mp.size() == _capacity) {
Node* old = lst.remove_head();
mp.erase(old->key);
delete old;
}
Node* node = new Node(key, value);
lst.insert(node);
mp[key] = node;
}
};
说明:上面实现中
insert为尾插(最近使用),remove_head删除最久未使用。detach不delete节点。put中统一进行mp.erase与delete。所有这些都遵从"职责分离"和内存安全原则。
复杂度与性质回顾
-
get:O(1)(哈希查找 + 链表常数操作) -
put:O(1)(哈希更新 + 链表常数操作 + 在满时删除头) -
内存:O(capacity)
面试沟通建议(如何把你的演进过程说得既诚实又加分)
在面试中,直接写出最终正确实现是最直接的。但如果面试官问"你最初是怎么想的?",你可以这样说:
-
"起初我想到用队列维护顺序,但
queue无法 O(1) 删除中间元素。" -
"于是想到用
unordered_map做快速查找,但它没有维持使用顺序。" -
"把两者结合:
unordered_map存 key → 链表节点指针,链表维护顺序。这样既 O(1) 查找,又能 O(1) 移动和删除。" -
"实现时把删除内存的步骤放到高层统一管理,链表仅负责结构变动;这样能避免野指针 / double delete 的风险。"
小结
-
遇到需要"快速定位 + 快速调整顺序"的题,第一反应要想 哈希 + 可随机删除的数据结构。
-
双向链表是能 "O(1) 删除任意节点并 O(1) 插入头/尾" 的首选。
-
unordered_map<key, Node*>+ 双向链表(或list+ iterator)是 LRU 的标准解法。 -
职责分离:链表负责结构,高层负责生命周期与策略;统一 delete,避免野指针。
手写链表注意事项:
手写双向链表是面试里最容易出 bug 的数据结构之一 。
它不像 vector/map 那样有完整的边界保护,一旦你忘了处理一个指针、一个边界,就直接 UAF / 段错误 / 内存破坏。
【黄金法则 1】从链表删除节点后必须"孤立"节点
node->pre = nullptr;
node->next = nullptr;
为什么?
-
不孤立 → 节点保留旧指针 → 下次操作会访问非法结构 → 崩
-
孤立 → 永远不会误用旧链表结构
写链表必须把节点摘干净!
【黄金法则 2】所有操作先考虑"是否是头/尾"
node == head
或者
node == tail
这两个情况必须优先判断,否则边界全错。
示例:
if (node == head) head = head->next;
if (node == tail) tail = tail->pre;
边界不处理就必崩。
【黄金法则 3】绝不要在链表内部 delete 节点
链表负责结构,不负责内存生死。
delete 必须在上层:
❌ 错误:List::remove() 内 delete
✔ 正确:LRUCache::put() 上层 delete
理由:
-
链表永远不知道节点是否还有别的数据结构引用(比如哈希表)
-
内存由最外层管理,链表只是组织结构
你这次的 bug 之一就是链表层仍然 delete 导致 UAF。
【黄金法则 4】写双向链表的顺序不能错
更新 next → 更新 pre
两个方向都要写。
标准模板:
node->pre->next = node->next;
node->next->pre = node->pre;
任何一条忘了都会导致链表残缺。