LFU缓存
什么是 LFU 缓存?
最少使用频率 (LFU) 是一种缓存管理算法,在这种算法中,系统会记录缓存中每个数据被访问的次数。当缓存空间不足时,系统会删除访问频率最低的数据项,以便为新的数据腾出空间。
LFU 缓存问题描述
我们的目标是设计一个LFU 缓存,需要支持以下操作:
- LFUCache(int capacity): 初始化LFU缓存,并设定缓存的容量。
- int get(int key): 如果键存在于缓存中,则返回该键的值;否则,返回 -1。
- void put(int key, int value): 如果键存在,则更新其值;如果键不存在,将新的key-value对加入缓存。如果缓存满了,应在插入新数据删除最少使用频率的数据。如果存在多个访问频率相同的数据(平局情况),则在这些数据中将最近最少使用的数据删除,这种情况相当于将LFU和LRU结合,在删除元素时,先比较使用频率,再根据时间戳决定删除哪个元素。
当对缓存中某个数据执行 get 或 put 操作时,该数据的频率将增加。get 和 put 操作的时间复杂度应为 O(1)。
LFU 缓存实现
使用暴力方法实现 LFU 缓存
我们初始化一个大小等于缓存大小的数组。每个元素存储键、值、频率以及该键被访问的时间戳。
class Element {
int key;
int val;
int frequency;
long timeStamp;
public Element(int k, int v) {
key = k;
val = v;
frequency = 1;
timeStamp = System.currentTimeMillis(); // 使用系统时间戳
}
@Override
public String toString() {
return "{" + key + "=" + val + ", freq=" + frequency + ", timeStamp=" + timeStamp + "}";
}
}
public class LFUCache {
private final Element[] cache;
private final int capacity;
public LFUCache(int capacity) {
this.capacity = capacity;
this.cache = new Element[capacity];
}
public int get(int key) {
for (Element element : cache) {
if (element != null && element.key == key) {
element.frequency++; // 增加频率
element.timeStamp = System.currentTimeMillis(); // 更新时间戳
return element.val;
}
}
return -1; // 如果没找到,返回 -1
}
public void put(int key, int value) {
// 检查缓存中是否已经有这个元素
for (Element element : cache) {
if (element != null && element.key == key) {
element.val = value;
element.frequency++; // 增加频率
element.timeStamp = System.currentTimeMillis(); // 更新时间戳
return;
}
}
// 如果缓存未满,直接插入新的元素
for (int i = 0; i < capacity; i++) {
if (cache[i] == null) {
cache[i] = new Element(key, value);
return;
}
}
// 如果缓存已满,找到频率最低的元素(LFU),如果频率相同则选择最久未使用的(LRU)
int lfuIndex = 0;
int minFrequency = cache[0].frequency;
long oldestTimeStamp = cache[0].timeStamp;
for (int i = 1; i < capacity; i++) {
if (cache[i].frequency < minFrequency ||
(cache[i].frequency == minFrequency && cache[i].timeStamp < oldestTimeStamp)) {
lfuIndex = i;
minFrequency = cache[i].frequency;
oldestTimeStamp = cache[i].timeStamp;
}
}
// 替换最少使用的元素
cache[lfuIndex] = new Element(key, value);
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("[");
for (int i = 0; i < capacity; i++) {
if (cache[i] != null) {
sb.append(cache[i].toString());
if (i < capacity - 1) sb.append(", ");
}
}
sb.append("]");
return sb.toString();
}
public static void main(String[] args) {
LFUCache cache = new LFUCache(3);
cache.put(1, 10);
cache.put(2, 20);
cache.put(3, 30);
System.out.println(cache); // 打印缓存内容
cache.get(1); // 访问 key 1,增加其频率
cache.get(2); // 访问 key 2,增加其频率
cache.put(4, 40); // 缓存已满,删除频率最低的 key 3
System.out.println(cache); // 打印缓存内容
cache.put(5, 50); // 缓存已满,删除频率最低的 key 4
System.out.println(cache); // 打印缓存内容
}
}
//输出
[{1=10, freq=1, timeStamp=1722865924851}, {2=20, freq=1, timeStamp=1722865924851}, {3=30, freq=1, timeStamp=1722865924852}]
[{1=10, freq=2, timeStamp=1722865924875}, {2=20, freq=2, timeStamp=1722865924875}, {4=40, freq=1, timeStamp=1722865924875}]
[{1=10, freq=2, timeStamp=1722865924875}, {2=20, freq=2, timeStamp=1722865924875}, {5=50, freq=1, timeStamp=1722865924875}]
int get(int key): 我们遍历数组,并将缓存中每个元素的键与给定键进行比较。如果找到相等的键,则增加该元素的频率并更新时间戳。如果未找到,则返回 -1。时间复杂度为 O(n)。
void put(int key, int value): 如果数组未满,我们创建一个频率为 1 且时间戳为当前时间的新元素插入到数组中,如果数组已满,我们删除访问频率最少的元素。为此,我们遍历数组并找到频率最低的元素。如果频率相同,则选择最近最少使用的元素(最旧的时间戳)。然后再插入新元素。时间复杂度为 O(n)。
使用更高效的方法实现 LFU 缓存
首先,我们将实现 O(1) 时间复杂度的插入和访问操作。为此,我们需要两个Map,一个存储键=>值,另一个存储键=>访问次数/频率。
public class LFUCache {
private Map<Integer, Integer> valueMap = new HashMap<>();
private Map<Integer, Integer> frequencyMap = new HashMap<>();
private final int size;
public LFUCache(int capacity) {
size = capacity;
}
public int get(int key) {
if (!valueMap.containsKey(key)) {
return -1;
}
frequencyMap.put(key, frequencyMap.get(key) + 1);
return valueMap.get(key);
}
public void put(int key, int value) {
if (!valueMap.containsKey(key)) {
valueMap.put(key, value);
frequencyMap.put(key, 1);
} else {
valueMap.put(key, value);
frequencyMap.put(key, frequencyMap.get(key) + 1);
}
}
}
在上述代码中,我们还没有实现缓存淘汰策略:当缓存大小达到最大容量时,我们需要找到访问频率最低的数据。
在当前实现中,我们必须遍历 frequencyMap 的所有元素才能找到访问频率最低的元素,这需要 O(n) 时间。
此外,如果缓存中有多个数据的访问频率相同时,在当前实现中我们无法找到最近最少使用的数据。
使用单链表实现 LFU 缓存
为了解决上面的问题,我们添加了一个新的数据结构,即一个有序映射,其中键是数据访问频率,值是具有相同频率的元素列表。
现在,新的数据可以添加到频率为 1 的链表末尾。由于映射按频率排序,我们可以在 O(1) 时间内找到最低频率的列表。此外,我们可以在 O(1) 时间内删除列表的第一个元素(访问频率最低的元素),因为它是最近最少使用的。
代码如下:
public class LFUCache {
private Map<Integer, Integer> valueMap = new HashMap<>();
private Map<Integer, Integer> countMap = new HashMap<>();
//有序的映射,键是频率,值是一个按访问顺序排列的键的列表。它用于记录每个访问频率下有哪些键。
private TreeMap<Integer, List<Integer>> frequencyMap = new TreeMap<>();
private final int size;
public LFUCache(int capacity) {
size = capacity;
}
public int get(int key) {
//缓存中不存在指定key
if (!valueMap.containsKey(key) || size == 0) {
return -1;
}
//从 frequencyMap 中移除该频率列表中的该键。非O(1)时间复杂度度
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(new Integer(key));
if (frequencyMap.get(frequency).size() == 0) {
frequencyMap.remove(frequency);
}
frequencyMap.computeIfAbsent(frequency + 1, k -> new LinkedList<>()).add(key);
countMap.put(key, frequency + 1);
return valueMap.get(key);
}
public void put(int key, int value) {
if (!valueMap.containsKey(key) && size > 0) {
if (valueMap.size() == size) {
int lowestCount = frequencyMap.firstKey();
int keyToDelete = frequencyMap.get(lowestCount).remove(0);
if (frequencyMap.get(lowestCount).size() == 0) {
frequencyMap.remove(lowestCount);
}
valueMap.remove(keyToDelete);
countMap.remove(keyToDelete);
}
valueMap.put(key, value);
countMap.put(key, 1);
frequencyMap.computeIfAbsent(1, k -> new LinkedList<>()).add(key);
} else if (size > 0) {
valueMap.put(key, value);
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(new Integer(key));
if (frequencyMap.get(frequency).size() == 0) {
frequencyMap.remove(frequency);
}
frequencyMap.computeIfAbsent(frequency + 1, k -> new LinkedList<>()).add(key);
countMap.put(key, frequency + 1);
}
}
}
因此,这种实现插入和删除操作都是 O(1)时间复杂度,即常量时间操作。
使用双链表实现 LFU 缓存 (Java)
在单链表实现 LFU 缓存淘汰算法时,我们将访问操作的时间复杂度增加到了 O(n)。所有具有相同频率的数据元素都在一个链表中。如果其中一个元素被访问,我们需要将其移动到下一个频率的链表中。我们必须先遍历链表找到该元素,这在最坏情况下需要 O(n) 操作。
为了解决这个问题,我们需要以某种方式直接在链表中访问该数据,如果我们能做到这一点,就可以在 O(1) 时间内从当前频率链表中删除该元素,并在 O(1) 时间内将其移动到下一个频率链表的末尾。
为此,我们需要一个双链表。我们将创建一个节点,存储元素的键、值和在链表中的位置。我们将把链表转换为双链表。
public class LFUCache {
private Map<Integer, Node> valueMap = new HashMap<>();
private Map<Integer, Integer> countMap = new HashMap<>();
private TreeMap<Integer, DoubleLinkedList> frequencyMap = new TreeMap<>();
private final int size;
public LFUCache(int n) {
size = n;
}
public int get(int key) {
if (!valueMap.containsKey(key) || size == 0) {
return -1;
}
Node nodeToDelete = valueMap.get(key);
Node node = new Node(key, nodeToDelete.value());
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(nodeToDelete);
removeIfListEmpty(frequency);
valueMap.remove(key);
countMap.remove(key);
valueMap.put(key, node);
countMap.put(key, frequency + 1);
frequencyMap.computeIfAbsent(frequency + 1, k -> new DoubleLinkedList()).add(node);
return valueMap.get(key).value;
}
public void put(int key, int value) {
if (!valueMap.containsKey(key) && size > 0) {
Node node = new Node(key, value);
if (valueMap.size() == size) {
int lowestCount = frequencyMap.firstKey();
Node nodeToDelete = frequencyMap.get(lowestCount).head();
frequencyMap.get(lowestCount).remove(nodeToDelete);
removeIfListEmpty(lowestCount);
int keyToDelete = nodeToDelete.key();
valueMap.remove(keyToDelete);
countMap.remove(keyToDelete);
}
frequencyMap.computeIfAbsent(1, k -> new DoubleLinkedList()).add(node);
valueMap.put(key, node);
countMap.put(key, 1);
} else if (size > 0) {
Node node = valueMap.get(key);
Node nodeToInsert = new Node(key, value);
int frequency = countMap.get(key);
frequencyMap.get(frequency).remove(node);
removeIfListEmpty(frequency);
valueMap.remove(key);
countMap.remove(key);
valueMap.put(key, nodeToInsert);
countMap.put(key, frequency + 1);
frequencyMap.computeIfAbsent(frequency + 1, k -> new DoubleLinkedList()).add(nodeToInsert);
}
}
private void removeIfListEmpty(int frequency) {
if (frequencyMap.get(frequency).size() == 0) {
frequencyMap.remove(frequency);
}
}
}
class Node {
private final int key;
private final int value;
private Node previous;
private Node next;
public Node(int k, int v) {
key = k;
value = v;
}
public int key() {
return key;
}
public int value() {
return value;
}
}
class DoubleLinkedList {
private Node head = new Node(0, 0);
private Node tail = new Node(0, 0);
private int size = 0;
public DoubleLinkedList() {
head.next = tail;
tail.previous = head;
}
public void add(Node node) {
node.previous = tail.previous;
node.previous.next = node;
node.next = tail;
tail.previous = node;
size++;
}
public void remove(Node node) {
node.previous.next = node.next;
node.next.previous = node.previous;
size--;
}
public Node head() {
return head.next;
}
public int size() {
return size;
}
}
双链表确保我们可以在常数时间内删除节点和插入节点。总的来说,插入和访问操作都是 O(1) 时间复杂度。
LRU缓存的实际应用
LFU算法在以下场景中通常比 LRU算法更有效:
访问频率明显偏向某些数据的场景:
在一些应用中,某些数据或资源的访问频率远高于其他数据,形成了所谓的“热点数据”。例如,在电商网站中,主页、爆款商品页面,或者内容平台上的热门文章,通常会被频繁访问。
LFU 能更好地识别并保留这些频繁访问的数据,即使在这些数据最近没有被访问的情况下,仍然会将它们保留在缓存中。而 LRU 则可能因为这些数据暂时未被访问而将其移出缓存,导致后续访问时出现缓存未命中(cache miss)的情况。
短期内访问模式多变,长期内存在固定热点的场景:
例如,在一个社交媒体平台上,某些用户或内容在短期内可能突然变得非常热门,但从长远来看,只有那些优质的内容始终受到用户的关注。
LFU 在这种情况下会优先保留长期热点数据,不容易被那些短期流行数据“冲出”缓存。,LRU 更注重最近的访问情况,可能因为短期的访问高峰而替换掉长期热门的内容,进而降低缓存命中率。