LRU 缓存

实现一个容量受限的缓存,支持 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) 移动/删除"的结构是什么?

把问题拆开两问:

  1. 我需要 O(1) 根据 key 找到对应项 → unordered_map 满足。

  2. 我需要在 O(1) 时间内把某个已存在的项移到"最近使用"位置,或删除最旧项 → 需要一种能在 O(1) 删除任意节点并 O(1) 插入头部/尾部的数据结构。

答案合并后就出来了:哈希 + 双向链表

  • 哈希表:负责从 key 快速定位到链表节点(存的是节点指针 / iterator)。

  • 双向链表:负责维护访问顺序,支持常数时间的插入与删除(给定指针)。

PS:Python 的 OrderedDict 正是这种设计思想的一个现成实现。


实现尝试与常见错误(我踩过的坑)

我按自己风格手写了双向链表 + unordered_map<int, Node*>。过程中出现了不少问题,列几个代表性的坑和修正思路:

1. 在链表内部 remove() 执行 delete node; ------ 危险!

  • 如果 List::remove(node) 在内部 delete 节点,而高层(LRUCache)尚未从 maperase,就有窗口期:map 中仍然存着一个指向已释放内存的指针(野指针)。

  • 正确做法:链表只负责结构性变更(detach/insert),不负责内存释放。 内存释放(delete)由高层统一负责,且在 mp.erase(key) 之后执行。

这是职责分离(Separation of Concerns)的体现:List 负责链表操作,LRUCache 负责内存与缓存策略。

2. 单节点边界处理错误

手写链表时对"单节点"的头/尾更新处理非常容易出错(忘记同时更新 head 和 tail),导致野指针或 double delete。解决方案有两种:

  • 使用 dummy head/tail(哨兵节点),可以极大简化边界判断;

  • 或者在每次 detach/insert 时严格更新 headtail 的指向,写清楚单节点分支。

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 删除最久未使用。detachdelete 节点。put 中统一进行 mp.erasedelete。所有这些都遵从"职责分离"和内存安全原则。


复杂度与性质回顾

  • get:O(1)(哈希查找 + 链表常数操作)

  • put:O(1)(哈希更新 + 链表常数操作 + 在满时删除头)

  • 内存:O(capacity)


面试沟通建议(如何把你的演进过程说得既诚实又加分)

在面试中,直接写出最终正确实现是最直接的。但如果面试官问"你最初是怎么想的?",你可以这样说:

  1. "起初我想到用队列维护顺序,但 queue 无法 O(1) 删除中间元素。"

  2. "于是想到用 unordered_map 做快速查找,但它没有维持使用顺序。"

  3. "把两者结合:unordered_map 存 key → 链表节点指针,链表维护顺序。这样既 O(1) 查找,又能 O(1) 移动和删除。"

  4. "实现时把删除内存的步骤放到高层统一管理,链表仅负责结构变动;这样能避免野指针 / 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;

任何一条忘了都会导致链表残缺。

相关推荐
曼巴UE514 小时前
UE5 C++ 动态多播
java·开发语言
热心市民蟹不肉15 小时前
黑盒漏洞扫描(三)
数据库·redis·安全·缓存
steins_甲乙15 小时前
C++并发编程(3)——资源竞争下的安全栈
开发语言·c++·安全
请一直在路上15 小时前
python文件打包成exe(虚拟环境打包,减少体积)
开发语言·python
luguocaoyuan15 小时前
JavaScript性能优化实战技术学习大纲
开发语言·javascript·性能优化
禁默15 小时前
“零消耗”调用优质模型:AI Ping结合Cline助我快速开发SVG工具,性能与官网无异
开发语言·php
CSDN_RTKLIB16 小时前
代码指令与属性配置
开发语言·c++
上不如老下不如小16 小时前
2025年第七届全国高校计算机能力挑战赛 决赛 C++组 编程题汇总
开发语言·c++
雍凉明月夜16 小时前
c++ 精学笔记记录Ⅱ
开发语言·c++·笔记·vscode
木鹅.16 小时前
接入其他大模型
数据库·redis·缓存