3. 哈希表与双向链表实现LRU核心
LRU 缓存的高效性依赖于两种数据结构的配合:哈希表提供 O(1) 的键查找,双向链表维护访问顺序。本章节将详细拆解节点结构、链表头尾操作、哈希表快速查找以及将访问节点移动到链表头部的核心逻辑。
3.1 节点结构设计
每个缓存项在链表中对应一个节点,节点内部需要存储键、值以及前后指针。哈希表则存储键到节点的映射。
| 成员 | 类型 | 用途 |
|---|---|---|
key | int | 缓存键,用于哈希表查找与 LRU 淘汰时的键删除 |
value | int | 缓存值,实际存储的数据 |
prev | DLinkedNode* | 指向前驱节点,用于链表删除与移动操作 |
next | DLinkedNode* | 指向后继节点,用于链表遍历与插入 |
struct DLinkedNode {
int key;
int value;
DLinkedNode* prev;
DLinkedNode* next;
DLinkedNode() : key(0), value(0), prev(nullptr), next(nullptr) {}
DLinkedNode(int _key, int _value) : key(_key), value(_value), prev(nullptr), next(nullptr) {}
};
3.2 双向链表头尾操作
LRU 算法将最近访问的节点置于链表头部,尾部节点即为最久未使用的候选淘汰项。需要维护两个哨兵节点:head 和 tail(伪头、伪尾),避免空指针判断。
- 初始化:头尾互指:
head->next = tail; tail->prev = head; - 头插法:将节点插入到 head 之后,即成为第一个有效节点。
- 尾删法:删除 tail 之前的节点(即真正最后一个有效节点),得到待淘汰的缓存项。
- 移除节点:通过节点指针直接将其从链表中断开:
node->prev->next = node->next; node->next->prev = node->prev;
// 移除任意节点(内部方法)
void removeNode(DLinkedNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
// 将节点添加到头部
void addToHead(DLinkedNode* node) {
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node;
}
// 移除尾部节点并返回(用于淘汰)
DLinkedNode* removeTail() {
DLinkedNode* node = tail->prev;
removeNode(node);
return node;
}
设计要点:哨兵节点不存储实际数据,它们的作用是统一代码逻辑,避免在头尾插入/删除时编写条件分支。始终保证 head->next 是第一个有效节点,tail->prev 是最后一个有效节点。
3.3 哈希表快速查找
使用 C++ STL 的 unordered_map 存储 key 到节点指针的映射,实现 O(1) 查找。哈希表本身不参与顺序维护,仅用于定位链表节点。
unordered_map<int, DLinkedNode*> cache;
当用户 get(key) 时,先查哈希表:
int get(int key) {
if (cache.find(key) == cache.end()) {
return -1; // 不存在
}
DLinkedNode* node = cache[key];
// 将该节点移动到链表头部(表示最近使用)
moveToHead(node);
return node->value;
}
3.4 移动节点到头部
移动操作是 LRU 算法的核心步骤:每次访问一个节点(get 或 put 已存在的 key),都需要将其从原位置断开,再插入到链表头部。将前述两个基本操作组合即可:
void moveToHead(DLinkedNode* node) {
removeNode(node); // 从原位置移除
addToHead(node); // 插入头部
}
移动后,该节点成为最新使用的节点,不会因后续容量满而被立即淘汰。
3.5 整体结构数据流
下图展示了哈希表与双向链表如何协作完成一次 get 命中操作:
3.6 关键复杂度总结
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 哈希表查找 | O(1) | 依赖良好散列函数 |
| 链表头插 | O(1) | 通过哨兵头节点 |
| 链表移除 | O(1) | 直接操作前后指针 |
| 移动节点到头部 | O(1) | 移除+头插两步各 O(1) |
| 淘汰尾部节点 | O(1) | 通过 tail->prev 直接获取 |
常见误区:不少初学实现将链表头当作最早访问(类似队列),但在 LRU 中头部应是最新访问项。务必确认链表方向:头部 ← 最新访问,尾部 ← 最久未访问。哈希表仅负责定位,顺序完全由链表头尾维护。
3.7 完整 LRUCache 类骨架
class LRUCache {
private:
unordered_map<int, DLinkedNode*> cache;
DLinkedNode* head;
DLinkedNode* tail;
int capacity;
int size;
public:
LRUCache(int _capacity) : capacity(_capacity), size(0) {
head = new DLinkedNode();
tail = new DLinkedNode();
head->next = tail;
tail->prev = head;
}
~LRUCache() {
// 清理所有节点(略)
}
int get(int key) { /* 见上文 */ }
void put(int key, int value) { /* 第4章详细讲解 */ }
private:
void addToHead(DLinkedNode* node) { /* ... */ }
void removeNode(DLinkedNode* node) { /* ... */ }
void moveToHead(DLinkedNode* node) { /* ... */ }
DLinkedNode* removeTail() { /* ... */ }
};
本章核心:三种基础操作(移除、头插、tail->prev 获取尾节点)构成了 LRU 的全部链表动作,结合哈希表实现 O(1) 查找,为后续容量控制与防泄漏打下坚实基础。