目录
中等
请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。
实现 LRUCache
类:
-
LRUCache(int capacity)
以 正整数 作为容量capacity
初始化 LRU 缓存 -
int get(int key)
如果关键字key
存在于缓存中,则返回关键字的值,否则返回-1
。 -
void put(int key, int value)
如果关键字key
已经存在,则变更其数据值value
;如果不存在,则向缓存中插入该组key-value
。如果插入操作导致关键字数量超过capacity
,则应该 逐出 最久未使用的关键字。
函数 get
和 put
必须以 O(1)
的平均时间复杂度运行。
示例:
输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]
解释
LRUCache lRUCache = new LRUCache(2); lRUCache.put(1, 1); // 缓存是 {1=1} lRUCache.put(2, 2); // 缓存是 {1=1, 2=2} lRUCache.get(1); // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2); // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1); // 返回 -1 (未找到)
lRUCache.get(3); // 返回 3
lRUCache.get(4); // 返回 4
提示:
-
1 <= capacity <= 3000
-
0 <= key <= 10000
-
0 <= value <= 105
-
最多调用
2 * 105
次get
和put
//方法一:哈希表 + 双向链表
/*
- LRU 缓存机制可以通过哈希表辅以双向链表实现,我们用一个哈希表和一个双向链表维护所有在缓存中的键值对。
- 双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。
- 哈希表即为普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。
- 这样以来,我们首先使用哈希表进行定位,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 O(1) 的时间内完成 get 或者 put 操作。具体的方法如下:
- 对于 get 操作,首先判断 key 是否存在:
- 如果 key 不存在,则返回 −1;
- 如果 key 存在,则 key 对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值。
- 对于 put 操作,首先判断 key 是否存在:
- 如果 key 不存在,使用 key 和 value 创建一个新的节点,在双向链表的头部添加该节点,并将 key 和该节点添加进哈希表中。然后判断双向链表的节点数是否超出容量,如果超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;
- 如果 key 存在,则与 get 操作类似,先通过哈希表定位,再将对应的节点的值更新为 value,并将该节点移到双向链表的头部。
- 上述各项操作中,访问哈希表的时间复杂度为 O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O(1) 时间内完成。
- 在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。
*/
//双向链表节点(DLinkedNode):存储键值对,并维护前后指针,支持 O (1) 时间的插入和删除。
struct DLinkedNode {
// 存储键值对
int key, 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) {}
};
class LRUCache {
private:
// 哈希表
unordered_map<int, DLinkedNode*> cache;
// 双向链表伪头节点
DLinkedNode* head;
// 双向链表伪尾节点
DLinkedNode* tail;
// 当前缓存大小
int size;
// 缓存容量上限
int capacity;
public:
//初始化(构造函数)
LRUCache(int _capacity): capacity(_capacity), size(0) {
// 使用伪头部和伪尾部节点
head = new DLinkedNode();
tail = new DLinkedNode();
// 初始化链表结构:head <-> tail
head->next = tail;
tail->prev = head;
}
int get(int key) {
if (!cache.count(key)) {
return -1;
}
// 如果 key 存在,先通过哈希表定位,再移到头部
DLinkedNode* node = cache[key];
moveToHead(node);
//返回节点值
return node->value;
}
void put(int key, int value) {
if (!cache.count(key)) {
// 如果 key 不存在,创建一个新的节点
DLinkedNode* node = new DLinkedNode(key, value);
// 添加进哈希表
cache[key] = node;
// 添加至双向链表的头部
addToHead(node);
++size;
if (size > capacity) {
// 如果超出容量,删除双向链表的尾部节点
DLinkedNode* removed = removeTail();
// 删除哈希表中对应的项
cache.erase(removed->key);
// 防止内存泄漏
delete removed;
--size;
}
}
else {
// 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
DLinkedNode* node = cache[key];
node->value = value;
moveToHead(node);
}
}
//添加节点到头部(addToHead)
void addToHead(DLinkedNode* node) {
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node;
}
//删除任意节点(removeNode)
void removeNode(DLinkedNode* node) {
node->prev->next = node->next;
node->next->prev = node->prev;
}
//移动节点到头部(moveToHead)
void moveToHead(DLinkedNode* node) {
removeNode(node);
addToHead(node);
}
//删除尾部节点(removeTail)
DLinkedNode* removeTail() {
DLinkedNode* node = tail->prev;
removeNode(node);
return node;
}
};
面试题:
-
为什么用双向链表而不是单向链表?
将某个节点移动到链表头部或者将链表尾部节点删去,都要用到删除链表中某个节点这个操作。你想要删除链表中的某个节点,需要找到该节点的前驱节点和后继节点。对于寻找后继节点,单向链表和双向链表都能通过 next 指针在O(1)时间内完成;对于寻找前驱节点,单向链表需要从头开始找,也就是要O(n)时间,双向链表可以通过前向指针直接找到,需要O(1)时间。综上,要想在O(1)时间内完成该操作,当然需要双向链表,实际上就是用双向链表空间换时间了。
-
为什么链表节点需要同时存储 key 和 value,而不是仅仅只存储 value?
因为删去最近最少使用的键值对时,要删除链表的尾节点,如果节点中没有存储 key,那么怎么知道是哪个 key 被删除,进而在 map 中删去该 key 对应的 key-value 呢?
子串
560. 和为 K 的子数组 中等
给你一个整数数组 nums
和一个整数 k
,请你统计并返回 该数组中和为 k
的子数组的个数 。
子数组是数组中元素的连续非空序列。
示例 1:
输入:nums = [1,1,1], k = 2
输出:2
示例 2:
输入:nums = [1,2,3], k = 3
输出:2
提示:
-
1 <= nums.length <= 2 * 104
-
-1000 <= nums[i] <= 1000
-
-107 <= k <= 107
//方法一:枚举
//通过双重循环生成所有可能的子数组,外层循环控制子数组的结束位置,内层循环控制起始位置。
/*
时间复杂度:O(n2),其中 n 为数组的长度。枚举子数组开头和结尾需要 O(n2) 的时间,其中求和需要 O(1) 的时间复杂度,因此总时间复杂度为 O(n2)。
空间复杂度:O(1)。只需要常数空间存放若干变量。
*/
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
int count = 0; // 记录符合条件的子数组数量
// 外层循环:遍历所有可能的子数组结束位置
for (int start = 0; start < nums.size(); ++start) {
int sum = 0; // 记录从end到start的子数组和
// 内层循环:遍历以start为结束位置的所有子数组
for (int end = start; end >= 0; --end) {
sum += nums[end]; // 累加当前元素到和中
// 如果当前子数组和等于k,计数器加1
if (sum == k) {
count++;
}
}
}
return count; // 返回符合条件的子数组总数
}
};
//方法二:前缀和 + 哈希表优化
//对于当前前缀和 pre,若存在历史前缀和 pre[j-1] = pre - k,则子数组 [j..i] 的和为 k。哈希表中 mp[pre - k] 的值表示这类子数组的数量。
//时间复杂度:O(n),其中 n 为数组的长度。我们遍历数组的时间复杂度为 O(n),中间利用哈希表查询删除的复杂度均为 O(1),因此总时间复杂度为 O(n)。
//空间复杂度:O(n),其中 n 为数组的长度。哈希表在最坏情况下可能有 n 个不同的键值,因此需要 O(n) 的空间复杂度。
class Solution {
public:
int subarraySum(vector<int>& nums, int k) {
// 哈希表:记录前缀和值及其出现次数
unordered_map<int, int> mp;
// 初始化:前缀和值为0的情况出现1次(方便处理从下标0开始的子数组)
mp[0] = 1;
// count:符合条件的子数组数量;pre:当前前缀和
int count = 0, pre = 0;
// 遍历数组
for (auto& x : nums) {
// 计算当前前缀和
pre += x;
// 关键:检查是否存在前缀和为 pre - k 的情况
if (mp.find(pre - k) != mp.end()) {
count += mp[pre - k]; // 若存在,则累加其出现次数
}
// 将当前前缀和及其出现次数存入哈希表
mp[pre]++;
}
return count;
}
};
-
哈希表初始化
mp[0] = 1
: 处理子数组从下标 0 开始的情况。例如,数组[3]
的前缀和为3
,若k=3
,则pre - k = 0
,此时需通过mp[0]
找到对应的子