每日一题
LRU:(lasted recently used)最近最少使用算法
LRU算法可以被视为一种内存淘汰策略,将最近最少访问的数据从缓存中删除出去。在redis数据库中,由于内存资源的限制一般需要一种淘汰策略来将长时间不使用的内存进行卸载从而为新数据提供存储空间,因此需要一种内存淘汰策略,而lru由于查询速度快,删除和增加只需要O(1)的时间复杂度并且缓存命中率高得特点被应用到redis中。
lru的操作包括:
访问数据:当缓存存在目标数据时,将其移动到链表头部,表示其为最近访问
添加数据:当新的数据被添加到缓存的时候,它将被插入到链表的头部,表示最近访问
淘汰数据:当缓存已经满了,并且需要添加新的数据的时候,链表的尾部数据就会被删除,表示最久未被访问的会被淘汰
lru的优点:逻辑简单,易于实现,时间复杂度低,算法执行效率高
缺点:以空间换时间,需要同时维护hash表和双向链表,当高并发场景下数据的频繁移动会造成额外的性能开销
具体实施细节
既然要保证查询的时间复杂度为O(1),就要使用hashmap来记录节点数值和对应的位置;而要保证删除和增加节点为O(1)就要使用双向链表,可以从头或者尾部去进行删除
而最近最少使用,就要包含频率,也就是链表节点内部不仅要有前后指针,还要有key ,value 来存放数值和访问频率
这个时候就明晰乐数据结构,分别是
public class node {
public node pre;
public node next;
public int key;
public int value;
public node(int key,int value) {
this.key = key;
this.value=value;
}
public node () {}
Map<Integer, Node> cache;
}
介绍完数据之后就要说明如何初始化LRU结构以及get, put, delete的具体实现逻辑
import java.util.HashMap;
import java.util.Map;
// 1. 定义节点类,类名首字母大写,与引用保持一致
class Node {
public Node pre; // 前驱节点
public Node next; // 后继节点
public int key; // 键
public int value; // 值
public Node(int key, int value) {
this.key = key;
this.value = value;
}
// 无参构造函数,用于创建头尾哨兵节点
public Node() {}
}
public class LRUCache {
private Node head; // 伪头部
private Node tail; // 伪尾部
private int capacity; // 缓存容量
private int size; // 当前缓存大小
private Map<Integer, Node> cache; // 哈希表用于O(1)查找
// 初始化
public LRUCache(int capacity) {
this.capacity = capacity;
this.size = 0;
this.cache = new HashMap<>(capacity + 1); // 初始容量稍微大一点避免扩容
// 初始化双向链表的哨兵节点
initLinkedList();
}
// 初始化链表操作:创建伪头 and 伪尾,并互相连接
private void initLinkedList() {
head = new Node();
tail = new Node();
head.next = tail;
tail.pre = head;
}
// 获取数据
public int get(int key) {
Node node = cache.get(key);
if (node == null) {
return -1;
}
// 如果存在,将其移动到头部(表示最近使用)
moveToHead(node);
return node.value;
}
// 写入数据
public void put(int key, int value) {
Node node = cache.get(key);
// 情况1: Key已存在,更新值并移到头部
if (node != null) {
node.value = value;
moveToHead(node);
return;
}
// 情况2: Key不存在
// 先判断容量是否已满
if (size == capacity) {
// 满了,删除尾部节点(最久未使用)
Node lastNode = tail.pre;
deleteNode(lastNode);
cache.remove(lastNode.key); // 同时从Map中移除
size--;
}
// 创建新节点
Node newNode = new Node(key, value);
// 添加到头部
addNode(newNode);
// 加入Map
cache.put(key, newNode);
size++;
}
// 添加节点到头部 (head 之后)
private void addNode(Node node) {
// 原头部后的第一个节点
Node firstRealNode = head.next;
// 1. 新节点指向原第一个节点
node.next = firstRealNode;
// 2. 原第一个节点的前驱指向新节点
firstRealNode.pre = node;
// 3. 头节点指向新节点
head.next = node;
// 4. 新节点的前驱指向头节点
node.pre = head;
}
// 删除指定节点
private void deleteNode(Node node) {
Node prevNode = node.pre;
Node nextNode = node.next;
// 跳过当前节点,直接连接前后节点
prevNode.next = nextNode;
nextNode.pre = prevNode;
// 帮助GC(可选)
node.pre = null;
node.next = null;
}
// 将节点移动到头部
private void moveToHead(Node node) {
// 先从原位置删除
deleteNode(node);
// 再添加到头部
addNode(node);
}
}
子集II
回溯算法:当一个问题需要尝试所有可能的候选解,并且可以通过逐步构建候选集来寻找答案的时候,就可以使用回溯(带有剪枝功能的深度优先搜索)
回溯算法用于求解的问题有组合问题,子集问题,分割和划分子字符串问题,全排列问题
剪枝方式有for循环遍历时剪枝,探索候选集时的非法路径剪枝以及同层避免重复剪枝
这里的子集II就是对应的同层剪枝,因为数组中有重复元素,为了避免组合中出现重复就需要在当前层for循环中避免遍历重复元素如[1,2,2] 得到 [1,2] 当到下一层的时候
就还有可能是[1,2] 两个出现重复(回溯可以抽象为一个树结构,每次做选择的时候就是一次分支)
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
# 使用回溯算法,子集问题与组合问题的区别在于 子集问题需要将沿途的中间结果也要收集,而组合和分割都是只收集叶子节点即可
# 这道题存在重复元素,如果还是按照之前的解法肯定还是会遇到问题,因此要进行层间去重,避免重复组合问题
#
result = []
path = []
nums.sort()
def dfs(startIndex,nums):
result.append(path[:]) # 子集就是要将沿途的中间结果也要收集
# 递归终止条件,已经遍历到了最后一个元素
if startIndex > len(nums):
return
for i in range(startIndex,len(nums)):
# 避免遍历到同层元素
if i > startIndex and nums[i] == nums[i-1]:
continue
path.append(nums[i])
dfs(i + 1,nums)
path.pop()
dfs(0,nums)
return result
浙公网安备 33010602011771号